From 86c9504c0911c01d5bde5ea3ce510238f336b38a Mon Sep 17 00:00:00 2001 From: martinRenou Date: Tue, 2 Jul 2024 14:05:40 +0100 Subject: [PATCH] Add raster layer gallery (#16) * Add raster layer gallery * Add layout browser to toolbar * Create layer browser * Add raster layer gallery * Change thumbnail to use string * Use real gallery instead of dummy data * Add layer to map on click * Add atrribution * Add search icon * Add display for added layers * Rework JSON * Create categories * Use shared model as source of layers on map * Start working on category tabs * Fix up provider category css * Category filter wip * Filter by category * Add more attributes to raste source json * Add some attributes to source - wip * Add new layers to layer tree * Move layer browser css * Make layer browser a plugin * Add webpack-env types to clear ts error * Move layer browser css * Create thumbnails at build time and add build:dev option to skip it * Fix build stuff * Add script dependecies to build action * Remove layer tree stuff * Add some tests * Small fixes * Add remove/clear methods to registry * Add script dependecies to requirements * Fix tests * Rename registry functions * Remove raster layer gallery * Make attribution optional * Use models addLayer() * Add some jsdoc * CI test * CI fix maybe * Rework tests * Resize grid for smaller windows --------- Co-authored-by: Greg --- .github/workflows/build.yml | 3 + .github/workflows/check-release.yml | 3 + .gitignore | 1 + package.json | 7 + packages/base/package.json | 4 +- .../base/rasterlayer_gallery_generator.py | 238 ++++++++++++++++++ packages/base/src/commands.ts | 48 +++- .../src/layerBrowser/layerBrowserDialog.tsx | 206 +++++++++++++++ packages/base/src/toolbar/widget.tsx | 13 +- packages/base/src/tools.ts | 83 ++++++ packages/base/src/types.ts | 6 +- packages/base/style/base.css | 1 + packages/base/style/layerBrowser.css | 237 +++++++++++++++++ packages/schema/package.json | 1 + packages/schema/src/interfaces.ts | 21 ++ packages/schema/src/schema/rastersource.json | 46 ++++ packages/schema/src/token.ts | 8 +- python/jupytergis_core/package.json | 1 + python/jupytergis_core/src/index.ts | 4 +- .../src/layerBrowserRegistry.ts | 53 ++++ python/jupytergis_core/src/plugin.ts | 19 +- python/jupytergis_lab/package.json | 1 + python/jupytergis_lab/src/index.ts | 23 +- requirements-build.txt | 3 + tsconfigbase.json | 2 +- ui-tests/tests/layer-browser.spec.ts | 159 ++++++++++++ yarn.lock | 48 ++++ 27 files changed, 1220 insertions(+), 19 deletions(-) create mode 100644 packages/base/rasterlayer_gallery_generator.py create mode 100644 packages/base/src/layerBrowser/layerBrowserDialog.tsx create mode 100644 packages/base/style/layerBrowser.css create mode 100644 python/jupytergis_core/src/layerBrowserRegistry.ts create mode 100644 ui-tests/tests/layer-browser.spec.ts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b298992f..3b0ef91a 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,6 +23,9 @@ jobs: python=3.9 jupyterlab=4 yarn=3 + pillow + mercantile + xyzservices - name: Setup pip cache uses: actions/cache@v2 diff --git a/.github/workflows/check-release.yml b/.github/workflows/check-release.yml index a91c2891..a8300d30 100644 --- a/.github/workflows/check-release.yml +++ b/.github/workflows/check-release.yml @@ -22,6 +22,9 @@ jobs: yarn=3 pip jupyter-releaser + pillow + mercantile + xyzservices - name: Check Release uses: jupyter-server/jupyter_releaser/.github/actions/check-release@v2 diff --git a/.gitignore b/.gitignore index c2084136..496aeb54 100644 --- a/.gitignore +++ b/.gitignore @@ -129,5 +129,6 @@ jupytergis/_version.py python/jupytergis_lab/jupytergis_lab/labextension/ python/jupytergis_lab/jupytergis_lab/notebook/objects/_schema +packages/base/rasterlayer_gallery/* untitled* diff --git a/package.json b/package.json index f5c5ea97..01652255 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "build": "lerna run build", "build:prod": "lerna run build:prod", "build:test": "lerna run build:test", + "build:dev": "lerna run build:dev", "bump:js:version": "lerna version --no-push --force-publish --no-git-tag-version --yes", "clean": "lerna run clean", "clean:all": "lerna run clean:all", @@ -56,6 +57,7 @@ "@jupyterlab/services": " ^7.0.0" }, "devDependencies": { + "@types/webpack-env": "^1.18.5", "@typescript-eslint/eslint-plugin": "5.55.0", "@typescript-eslint/parser": "5.55.0", "copy-webpack-plugin": "^10.0.0", @@ -68,5 +70,10 @@ "rimraf": "^3.0.2", "typescript": "^5", "webpack": "^5.76.3" + }, + "dependencies": { + "@fortawesome/fontawesome-svg-core": "^6.5.2", + "@fortawesome/free-solid-svg-icons": "^6.5.2", + "@fortawesome/react-fontawesome": "latest" } } diff --git a/packages/base/package.json b/packages/base/package.json index 3a4f9fa0..1cdb4c23 100644 --- a/packages/base/package.json +++ b/packages/base/package.json @@ -27,8 +27,10 @@ "url": "https://github.com/QuantStack/jupytergis.git" }, "scripts": { - "build": "tsc -b", + "build": "jlpm run build:gallery && tsc -b", + "build:gallery": "python rasterlayer_gallery_generator.py", "build:prod": "jlpm run clean && jlpm run build", + "build:dev": "tsc -b", "clean": "rimraf tsconfig.tsbuildinfo", "clean:lib": "rimraf lib tsconfig.tsbuildinfo", "clean:all": "jlpm run clean:lib", diff --git a/packages/base/rasterlayer_gallery_generator.py b/packages/base/rasterlayer_gallery_generator.py new file mode 100644 index 00000000..3fa4c396 --- /dev/null +++ b/packages/base/rasterlayer_gallery_generator.py @@ -0,0 +1,238 @@ +from datetime import date, timedelta +import json +from io import BytesIO +import os + +import requests +from PIL import Image +import mercantile +from xyzservices import providers + +THUMBNAILS_LOCATION = "rasterlayer_gallery" + + +def fetch_tile(url_template, x, y, z, s='a'): + """ + Fetch a tile from the given URL template. + """ + url = url_template.format(x=x, y=y, z=z, s=s) + print(f' Fetch {url}') + response = requests.get(url, headers={ + "Content-Type": "application/json", + "User-Agent": "JupyterGIS" + }) + response.raise_for_status() + return Image.open(BytesIO(response.content)) + +def latlng_to_tile(lat, lng, zoom): + """ + Convert latitude/longitude to tile coordinates. + """ + tile = mercantile.tile(lng, lat, zoom, True) + return tile.x, tile.y + +def create_thumbnail(url_template, lat, lng, zoom, tile_size=256, thumbnail_size=(512, 512)): + """ + Create a thumbnail for the specified location and zoom level. + """ + x, y = latlng_to_tile(lat, lng, zoom) + + # Fetch the tiles (2x2 grid for the thumbnail) + tiles = [] + for dy in range(2): + row = [] + for dx in range(2): + tile_x, tile_y = x + dx, y + dy + tile = fetch_tile(url_template, tile_x, tile_y, zoom) + row.append(tile) + tiles.append(row) + + # Create a blank image for the thumbnail + thumbnail = Image.new('RGB', (2 * tile_size, 2 * tile_size)) + + # Paste the tiles into the thumbnail image + for dy, row in enumerate(tiles): + for dx, tile in enumerate(row): + thumbnail.paste(tile, (dx * tile_size, dy * tile_size)) + + # Resize to the desired thumbnail size + thumbnail = thumbnail.resize(thumbnail_size, Image.LANCZOS) + return thumbnail + + +yesterday = (date.today() - timedelta(days=1)).strftime("%Y-%m-%d") + +# San Francisco +san_francisco = { + 'lat': 37.7749, + 'lng': -122.4194, + 'zoom': 5 +} + +middle_europe = { + 'lat': 48.63290858589535, + 'lng': -350.068359375, + 'zoom': 4 +} + +# Default +france = { + 'lat': 47.040182144806664, + 'lng': 1.2963867187500002, + 'zoom': 5 +} + +thumbnails_providers_positions = { + 'OpenStreetMap': { + 'Special Rules': { + 'BZH': { + 'lat': 47.76702233051035, + 'lng': -3.4675598144531254, + 'zoom': 8 + }, + 'CH': { + 'lat': 46.8182, + 'lng': 8.2275, + 'zoom': 8 + }, + 'DE': { + 'lat': 51.1657, + 'lng': 10.4515, + 'zoom': 8 + }, + 'France': france, + 'HOT': france + }, + 'Default': france + }, + 'NASAGIBS': { + 'Special Rules': {}, + 'Default': france + }, + # 'JusticeMap': { + # 'Special Rules': {}, + # 'Default': san_francisco, + # }, + 'USGS': { + 'Special Rules': {}, + 'Default': san_francisco, + }, + 'WaymarkedTrails': { + 'Special Rules': {}, + 'Default': france, + }, + 'Gaode': { + 'Special Rules': {}, + 'Default': san_francisco, + }, + 'Strava': { + 'Special Rules': {}, + 'Default': france, + 'TileSize': 512 + }, + 'OPNVKarte': { + 'Special Rules': {}, + 'Default': san_francisco, + }, + 'OpenTopoMap': { + 'Special Rules': {}, + 'Default': san_francisco, + }, + 'OpenRailwayMap': { + 'Special Rules': {}, + 'Default': san_francisco, + 'TileSize': 512 + }, + # 'OpenFireMap': { + # 'Special Rules': {}, + # 'Default': san_francisco, + # }, + # 'SafeCast': { + # 'Special Rules': {}, + # 'Default': san_francisco, + # } +} + +def download_thumbnail(url_template, name, position, tile_size): + file_path = f'{THUMBNAILS_LOCATION}/{name}.png' + thumbnail = create_thumbnail( + url_template, + position['lat'], + position['lng'], + position['zoom'], + tile_size + ) + thumbnail.save(file_path) + return file_path + + +# This is the JSON we'll generate for the raster gallery +raster_provider_gallery = {} + +# Create thumbnail dir if needed +if not os.path.exists(THUMBNAILS_LOCATION): + os.makedirs(THUMBNAILS_LOCATION) + +# Fetch thumbnails and populate the dictionary +for provider in thumbnails_providers_positions.keys(): + xyzprovider = providers[provider] + + if 'url' in xyzprovider.keys(): + print(f"Process {provider}") + + name = provider + url_template = xyzprovider["url"] + + if name in thumbnails_providers_positions[provider]['Special Rules'].keys(): + position = thumbnails_providers_positions[provider]['Special Rules'][name] + else: + position = thumbnails_providers_positions[provider]['Default'] + + tile_size = thumbnails_providers_positions[provider].get('TileSize', 256) + + # file_path = download_thumbnail(url_template, name, position, tile_size) + raster_provider_gallery[name] = dict( + # jgisname=name, + thumbnailPath='file_path', + **xyzprovider + ) + + continue + + providers_maps = {} + for map_name in xyzprovider.keys(): + print(f"Process {provider} {map_name}") + + + try: + if map_name in thumbnails_providers_positions[provider]['Special Rules'].keys(): + position = thumbnails_providers_positions[provider]['Special Rules'][map_name] + else: + position = thumbnails_providers_positions[provider]['Default'] + + tile_provider = xyzprovider[map_name] + + if 'crs' in tile_provider or 'apikey' in tile_provider: + # TODO Support other projections once we have another viewer than maplibre + # TODO Support api keys + continue + + name = tile_provider["name"].replace(".", "-") + url_template = tile_provider.build_url(time=yesterday) + tile_size = thumbnails_providers_positions[provider].get('TileSize', 256) + + # file_path = download_thumbnail(url_template, name, position, tile_size) + providers_maps[map_name] = dict( + # jgisname=name, + thumbnailPath='file_path', + **tile_provider + ) + + raster_provider_gallery[provider] = providers_maps + + except Exception as e: + print('Failed...', e) + +# Save JSON repr +with open(f'{THUMBNAILS_LOCATION}/raster_layer_gallery.json', 'w') as f: + json.dump(raster_provider_gallery, f) diff --git a/packages/base/src/commands.ts b/packages/base/src/commands.ts index 4b5939d8..11c584f6 100644 --- a/packages/base/src/commands.ts +++ b/packages/base/src/commands.ts @@ -1,18 +1,21 @@ import { JupyterFrontEnd } from '@jupyterlab/application'; -import { WidgetTracker } from '@jupyterlab/apputils'; +import { Dialog, WidgetTracker } from '@jupyterlab/apputils'; import { ITranslator } from '@jupyterlab/translation'; import { redoIcon, undoIcon } from '@jupyterlab/ui-components'; -import { JupyterGISWidget } from './widget'; import { IDict, IJGISFormSchemaRegistry, IJGISLayer, + IJGISLayerBrowserRegistry, IJGISSource, IJupyterGISModel } from '@jupytergis/schema'; -import { FormDialog } from './formdialog'; import { UUID } from '@lumino/coreutils'; +import { FormDialog } from './formdialog'; + +import { LayerBrowserWidget } from './layerBrowser/layerBrowserDialog'; +import { JupyterGISWidget } from './widget'; /** * Add the commands to the application's command registry. @@ -21,11 +24,13 @@ export function addCommands( app: JupyterFrontEnd, tracker: WidgetTracker, translator: ITranslator, - formSchemaRegistry: IJGISFormSchemaRegistry + formSchemaRegistry: IJGISFormSchemaRegistry, + layerBrowserRegistry: IJGISLayerBrowserRegistry ): void { Private.updateFormSchema(formSchemaRegistry); const trans = translator.load('jupyterlab'); const { commands } = app; + commands.addCommand(CommandIDs.redo, { label: trans.__('Redo'), isEnabled: () => { @@ -70,6 +75,17 @@ export function addCommands( iconClass: 'fa fa-map', execute: Private.createRasterSourceAndLayer(tracker) }); + + commands.addCommand(CommandIDs.openLayerBrowser, { + label: trans.__('Open Layer Browser'), + isEnabled: () => { + return tracker.currentWidget + ? tracker.currentWidget.context.model.sharedModel.editable + : false; + }, + iconClass: 'fa fa-book-open', + execute: Private.createLayerBrowser(tracker, layerBrowserRegistry) + }); } /** @@ -80,6 +96,7 @@ export namespace CommandIDs { export const undo = 'jupytergis:undo'; export const newRasterLayer = 'jupytergis:newRasterLayer'; + export const openLayerBrowser = 'jupytergis:openLayerBrowser'; } namespace Private { @@ -102,9 +119,30 @@ namespace Private { }); } + export function createLayerBrowser( + tracker: WidgetTracker, + layerBrowserRegistry: IJGISLayerBrowserRegistry + ) { + return async () => { + const current = tracker.currentWidget; + + if (!current) { + return; + } + + const dialog = new Dialog({ + body: new LayerBrowserWidget( + current.context.model, + layerBrowserRegistry.getRegistryLayers() + ), + buttons: [Dialog.cancelButton(), Dialog.okButton()] + }); + await dialog.launch(); + }; + } + // TODO Allow for creating only a source (e.g. loading a CSV file) // TODO Allow for creating only a layer (e.g. creating a vector layer given a source selected from a dropdown) - export function createRasterSourceAndLayer( tracker: WidgetTracker ) { diff --git a/packages/base/src/layerBrowser/layerBrowserDialog.tsx b/packages/base/src/layerBrowser/layerBrowserDialog.tsx new file mode 100644 index 00000000..da3e153b --- /dev/null +++ b/packages/base/src/layerBrowser/layerBrowserDialog.tsx @@ -0,0 +1,206 @@ +import { + faCheck, + faMagnifyingGlass, + faPlus +} from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { + IJGISLayer, + IJGISLayerDocChange, + IJGISSource, + IJupyterGISModel, + IRasterLayerGalleryEntry +} from '@jupytergis/schema'; +import { ReactWidget } from '@jupyterlab/ui-components'; +import { UUID } from '@lumino/coreutils'; +import React, { ChangeEvent, MouseEvent, useEffect, useState } from 'react'; + +interface ILayerBrowserDialogProps { + model: IJupyterGISModel; + registry: IRasterLayerGalleryEntry[]; +} + +export const LayerBrowserComponent = ({ + model, + registry +}: ILayerBrowserDialogProps) => { + const [searchTerm, setSearchTerm] = useState(''); + const [activeLayers, setActiveLayers] = useState([]); + const [selectedCategory, setSelectedCategory] = + useState(); + + const [galleryWithCategory, setGalleryWithCategory] = + useState(registry); + + const providers = [...new Set(registry.map(item => item.source.provider))]; + + const filteredGallery = galleryWithCategory.filter(item => + item.name.toLowerCase().includes(searchTerm) + ); + + useEffect(() => { + // Override default dialog style + const dialog = document.getElementsByClassName('jp-Dialog-content'); + const dialogHeader = document.getElementsByClassName('jp-Dialog-header'); + dialogHeader[0].setAttribute('style', 'padding: 0'); + dialog[0].classList.add('jgis-dialog-override'); + + model.sharedModel.layersChanged.connect(handleLayerChange); + + return () => { + model.sharedModel.layersChanged.disconnect(handleLayerChange); + }; + }, []); + + /** + * Track which layers are currently added to the map + */ + const handleLayerChange = (_, change: IJGISLayerDocChange) => { + // The split is to get rid of the 'Layer' part of the name to match the names in the gallery + setActiveLayers( + Object.values(model.sharedModel.layers).map( + layer => layer.name.split(' ')[0] + ) + ); + }; + + const handleSearchInput = (event: ChangeEvent) => { + setSearchTerm(event.target.value.toLowerCase()); + }; + + const handleCategoryClick = (event: MouseEvent) => { + const categoryTab = event.target as HTMLElement; + const sameAsOld = categoryTab.innerText === selectedCategory?.innerText; + + categoryTab.classList.toggle('jgis-layer-browser-category-selected'); + selectedCategory?.classList.remove('jgis-layer-browser-category-selected'); + + const filteredGallery = sameAsOld + ? registry + : registry.filter(item => + item.source.provider?.includes(categoryTab.innerText) + ); + + setGalleryWithCategory(filteredGallery); + setSearchTerm(''); + setSelectedCategory(sameAsOld ? null : categoryTab); + }; + + /** + * Add tile layer and source to model + * @param tile Tile to add + */ + const handleTileClick = (tile: IRasterLayerGalleryEntry) => { + const sourceId = UUID.uuid4(); + + const sourceModel: IJGISSource = { + type: 'RasterSource', + name: tile.name, + parameters: { + url: tile.source.url, + minZoom: tile.source.minZoom, + maxZoom: tile.source.maxZoom + } + }; + + const layerModel: IJGISLayer = { + type: 'RasterLayer', + parameters: { + source: sourceId + }, + visible: true, + name: tile.name + ' Layer' + }; + + model.sharedModel.addSource(sourceId, sourceModel); + model.addLayer(UUID.uuid4(), layerModel); + }; + + return ( +
+
+
+

Layer Browser

+
+ + +
+
+
+ {providers.map(provider => ( + + {provider} + + ))} +
+
+
+ {filteredGallery.map(tile => ( +
handleTileClick(tile)} + > +
+ + {activeLayers.indexOf(tile.name) === -1 ? ( +
+ +
+ ) : ( +
+ +

Added!

+
+ )} +
+
+
+

+ {tile.name} +

+ {/*

+ {tile.description} + placeholder +

*/} +
+

+ {tile.source.attribution} +

+
+
+ ))} +
+
+ ); +}; + +export class LayerBrowserWidget extends ReactWidget { + private _model: IJupyterGISModel; + private _registry: IRasterLayerGalleryEntry[]; + + constructor(model: IJupyterGISModel, registry: IRasterLayerGalleryEntry[]) { + super(); + this.id = 'jupytergis::layerBrowser'; + this._model = model; + this._registry = registry; + } + + render() { + return ( + + ); + } +} diff --git a/packages/base/src/toolbar/widget.tsx b/packages/base/src/toolbar/widget.tsx index 4e632925..c8f180a1 100644 --- a/packages/base/src/toolbar/widget.tsx +++ b/packages/base/src/toolbar/widget.tsx @@ -2,16 +2,16 @@ import { IJGISExternalCommand, JupyterGISModel } from '@jupytergis/schema'; import { CommandToolbarButton } from '@jupyterlab/apputils'; import { ReactWidget, - redoIcon, Toolbar, + redoIcon, undoIcon } from '@jupyterlab/ui-components'; import { CommandRegistry } from '@lumino/commands'; import { Widget } from '@lumino/widgets'; +import * as React from 'react'; import { CommandIDs } from '../commands'; import { UsersItem } from './usertoolbaritem'; -import * as React from 'react'; export const TOOLBAR_SEPARATOR_CLASS = 'jGIS-Toolbar-Separator'; @@ -63,6 +63,15 @@ export class ToolbarWidget extends Toolbar { }) ); + this.addItem( + 'openLayerBrowser', + new CommandToolbarButton({ + id: CommandIDs.openLayerBrowser, + label: '', + commands: options.commands + }) + ); + // Add more commands here this.addItem('spacer', Toolbar.createSpacerItem()); diff --git a/packages/base/src/tools.ts b/packages/base/src/tools.ts index 38389c41..a58e7028 100644 --- a/packages/base/src/tools.ts +++ b/packages/base/src/tools.ts @@ -2,6 +2,9 @@ import { URLExt } from '@jupyterlab/coreutils'; import { ServerConnection } from '@jupyterlab/services'; import * as d3Color from 'd3-color'; +import { IJGISLayerBrowserRegistry } from '@jupytergis/schema'; +import RASTER_LAYER_GALLERY from '../rasterlayer_gallery/raster_layer_gallery.json'; + export const debounce = ( func: CallableFunction, timeout = 100 @@ -158,3 +161,83 @@ export async function requestAPI( export function isLightTheme(): boolean { return document.body.getAttribute('data-jp-theme-light') === 'true'; } + +/** + * Create a default layer registry + * + * @param layerBrowserRegistry Registry to add layers to + */ +export function createDefaultLayerRegistry( + layerBrowserRegistry: IJGISLayerBrowserRegistry +): void { + const RASTER_THUMBNAILS: { [key: string]: string } = {}; + + /** + * Generate object to hold thumbnail URLs + */ + const importAll = (r: __WebpackModuleApi.RequireContext) => { + r.keys().forEach(key => { + const imageName = key.replace('./', '').replace(/\.\w+$/, ''); + RASTER_THUMBNAILS[imageName] = r(key); + }); + }; + + const context = require.context( + '../rasterlayer_gallery', + false, + /\.(png|jpe?g|gif|svg)$/ + ); + importAll(context); + + for (const entry of Object.keys(RASTER_LAYER_GALLERY)) { + const xyzprovider = RASTER_LAYER_GALLERY[entry]; + + if ('url' in xyzprovider) { + const tile = convertToRegistryEntry(entry, xyzprovider); + layerBrowserRegistry.addRegistryLayer(tile); + } else { + Object.keys(xyzprovider).forEach(mapName => { + const tile = convertToRegistryEntry( + xyzprovider[mapName]['name'], + xyzprovider[mapName], + entry + ); + + layerBrowserRegistry.addRegistryLayer(tile); + }); + } + + console.log('register', layerBrowserRegistry.getRegistryLayers()); + } + + // TODO: These need better names + /** + * Parse tile information from providers to be useable in the layer registry + * + * @param entry - The name of the entry, which may also serve as the default provider name if none is specified. + * @param xyzprovider - An object containing the XYZ provider's details, including name, URL, zoom levels, attribution, and possibly other properties relevant to the provider. + * @param provider - Optional. Specifies the provider name. If not provided, the `entry` parameter is used as the default provider name. + * @returns - An object representing the registry entry + */ + function convertToRegistryEntry( + entry: string, + xyzprovider: { [x: string]: any }, + provider?: string | undefined + ) { + return { + name: entry, + thumbnail: RASTER_THUMBNAILS[xyzprovider['name'].replace('.', '-')], + source: { + url: xyzprovider['url'], + minZoom: xyzprovider['min_zoom'] || 0, + maxZoom: xyzprovider['max_zoom'] || 24, + attribution: xyzprovider['attribution'] || '', + provider: provider ?? entry, + time: encodeURIComponent(new Date().toISOString()), + variant: xyzprovider['variant'] || '', + tileMatrixSet: xyzprovider['tilematrixset'] || '', + format: xyzprovider['format'] || '' + } + }; + } +} diff --git a/packages/base/src/types.ts b/packages/base/src/types.ts index bdb6c0e9..f86154dd 100644 --- a/packages/base/src/types.ts +++ b/packages/base/src/types.ts @@ -1,11 +1,11 @@ -import { ISignal } from '@lumino/signaling'; import { - IJupyterGISModel, - IJupyterGISDoc, IDict, + IJupyterGISDoc, + IJupyterGISModel, IJupyterGISTracker, IJupyterGISWidget } from '@jupytergis/schema'; +import { ISignal } from '@lumino/signaling'; export { IDict }; export type ValueOf = T[keyof T]; diff --git a/packages/base/style/base.css b/packages/base/style/base.css index d2cd03dd..edb67e86 100644 --- a/packages/base/style/base.css +++ b/packages/base/style/base.css @@ -4,3 +4,4 @@ |---------------------------------------------------------------------------- */ @import url('./leftPanel.css'); +@import url('./layerBrowser.css'); diff --git a/packages/base/style/layerBrowser.css b/packages/base/style/layerBrowser.css new file mode 100644 index 00000000..e77d60ae --- /dev/null +++ b/packages/base/style/layerBrowser.css @@ -0,0 +1,237 @@ +.jgis-layer-browser-container * { + box-sizing: border-box; +} + +.jgis-layer-browser-container { + display: flex; + flex-direction: column; +} + +.jgis-layer-browser-header-container { + position: sticky; + top: 0; + z-index: 40; + background-color: var(--jp-layout-color1); +} + +.jgis-layer-browser-header { + display: flex; + justify-content: space-between; + align-items: center; + gap: 20px; + padding: 1rem 2rem; +} + +.jgis-layer-browser-header-text { + padding-right: 1rem; + font-weight: bold; +} + +.jgis-layer-browser-header-search-container { + display: flex; + align-items: center; + flex-grow: 1; + position: relative; +} + +.jgis-layer-browser-header-search-icon { + position: absolute; + top: 50%; + left: 0.75rem; + transform: translateY(-50%); +} + +.jgis-layer-browser-header-search { + box-shadow: none; + flex: 1 1 0%; + height: 40px; + padding: 1rem 2rem; + padding-left: 2.5rem; + background-color: transparent; + border: 1px solid var(--jp-border-color1); + border-radius: 8px; + color: var(--jp-ui-font-color1); +} + +.jgis-layer-browser-grid { + display: grid; + gap: 1.25rem; + grid-template-rows: 1fr; + grid-template-columns: repeat(3, minmax(0px, 1fr)); + padding: 1rem 2rem; +} + +@media (min-width: 1400px) { + .jgis-layer-browser-grid { + grid-template-columns: repeat(5, minmax(0px, 1fr)); + } +} + +.jgis-dialog-override { + width: calc(100% - 4rem); + max-width: 100%; + height: calc(100% - 2rem); + max-height: 100%; +} + +.jgis-layer-browser-categories { + display: flex; + gap: 2rem; + padding: 0 2rem; + border-bottom: 2px solid var(--jp-border-color1); +} + +.jgis-layer-browser-category { + position: relative; + opacity: 0.7; + padding: 4px 0 14px; + cursor: pointer; + text-decoration: none; +} + +.jgis-layer-browser-category::after { + content: ''; + position: absolute; + bottom: -2px; + left: 0; + width: 0px; + height: 3px; + transition: + background-color 300ms ease-in, + width 300ms ease-in, + opacity 300ms ease-in; + background-color: transparent; +} + +.jgis-layer-browser-category.jgis-layer-browser-category-selected::after { + width: 100%; + background-color: var(--jp-inverse-layout-color2); +} + +.jgis-layer-browser-category.jgis-layer-browser-category-selected { + opacity: 1; + font-weight: bold; +} + +.jgis-layer-browser-tile { + display: flex; + flex-direction: column; + gap: 0.75rem; + position: relative; + margin-top: 4px; + padding: 8px; + transition: transform 150ms ease-out; + cursor: pointer; + border: 1px solid var(--jp-border-color1); + border-radius: 8px; +} + +.jgis-layer-browser-tile:hover { + box-shadow: var(--jp-layout-color2); + transform: translateY(-4px); + background-color: var(--jp-brand-color2); +} + +.jgis-layer-browser-tile-img-container { + /* isolation: isolate; */ + /* border: medium; */ + /* background: transparent; */ + /* padding: 0px; */ + /* width: 100%; */ + aspect-ratio: 16 / 10; + display: flex; + justify-content: center; + align-items: center; + position: relative; + overflow: hidden; + border-radius: 8px; +} + +.jgis-layer-browser-img { + /* position: absolute; */ + /* height: 100%; */ + /* object-fit: cover; */ + /* box-sizing: border-box; */ + width: 100%; +} + +.jgis-layer-browser-icon { + padding-inline: 8px; + display: inline-flex; + justify-content: center; + align-items: center; + position: absolute; + overflow: hidden; + box-sizing: border-box; + width: 32px; + height: 32px; + transition: + width 125ms ease-in-out, + background-color 125ms ease-in-out, + opacity 150ms ease-out; + background-color: var(--jp-accent-color1); + border-radius: 20px; +} + +.jgis-layer-browser-tile:not(.jgis-layer-browser-tile:hover) + .jgis-layer-browser-icon { + opacity: 0; +} + +.jgis-layer-browser-added { + display: inline-flex; + gap: 0.35rem; + opacity: 1 !important; + width: 7rem; + color: var(--jp-ui-inverse-font-color1); +} + +.jgis-layer-browser-text-container { + padding-inline: 4px; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 8px; + flex: 1 1 0%; + box-sizing: border-box; +} + +.jgis-layer-browser-text-info { + display: flex; + flex-direction: column; + gap: 8px; + box-sizing: border-box; +} + +.jgis-layer-browser-text-header { + overflow: hidden; + font-weight: bold; + text-transform: capitalize; + text-overflow: ellipsis; + white-space: nowrap; +} + +.jgis-layer-browser-text-p { + overflow: hidden; + text-overflow: ellipsis; +} + +.jgis-layer-browser-text-source { + overflow: hidden; + font-size: 0.75rem; + font-weight: lighter; + text-overflow: ellipsis; + white-space: nowrap; +} + +.jgis-layer-browser-text-description { + -webkit-line-clamp: 2; + -moz-box-orient: vertical; + display: -webkit-box; + overflow: hidden; + text-overflow: ellipsis; +} + +.jgis-layer-browser-text-general { + margin: 0; +} diff --git a/packages/schema/package.json b/packages/schema/package.json index 045c673f..9ec7b7e1 100644 --- a/packages/schema/package.json +++ b/packages/schema/package.json @@ -31,6 +31,7 @@ "build:schema:py": "datamodel-codegen --input ./src/schema --output ../../python/jupytergis_lab/jupytergis_lab/notebook/objects/_schema --output-model-type pydantic_v2.BaseModel --input-file-type jsonschema", "build:prod": "jlpm run clean && jlpm build:schema && jlpm run build:lib", "build:lib": "tsc", + "build:dev": "jlpm run build", "clean": "rimraf tsconfig.tsbuildinfo", "clean:lib": "rimraf lib tsconfig.tsbuildinfo", "clean:all": "jlpm run clean:lib", diff --git a/packages/schema/src/interfaces.ts b/packages/schema/src/interfaces.ts index 15c6e909..5b1ddb1c 100644 --- a/packages/schema/src/interfaces.ts +++ b/packages/schema/src/interfaces.ts @@ -22,6 +22,7 @@ import { IJGISSource, IJGISSources } from './_interface/jgis'; +import { IRasterSource } from './_interface/rastersource'; export interface IDict { [key: string]: T; @@ -211,3 +212,23 @@ export interface IJGISExternalCommandRegistry { getCommands(): IJGISExternalCommand[]; registerCommand(command: IJGISExternalCommand): void; } + +/** + * Defines the structure for entries in a raster layer gallery. + * Each entry consists of a name, a thumbnail URL, and source information. + * The source information is expected to conform to the IRasterSource interface. + * + * @interface IRasterLayerGalleryEntry + */ +export interface IRasterLayerGalleryEntry { + name: string; + thumbnail: string; + source: IRasterSource; +} + +export interface IJGISLayerBrowserRegistry { + getRegistryLayers(): IRasterLayerGalleryEntry[]; + addRegistryLayer(data: IRasterLayerGalleryEntry): void; + removeRegistryLayer(name: string): void; + clearRegistry(): void; +} diff --git a/packages/schema/src/schema/rastersource.json b/packages/schema/src/schema/rastersource.json index d5f19d61..549ddc22 100644 --- a/packages/schema/src/schema/rastersource.json +++ b/packages/schema/src/schema/rastersource.json @@ -20,6 +20,52 @@ "minimum": 0, "maximum": 24, "description": "The minimum zoom level for the raster source" + }, + "attribution": { + "type": "string", + "description": "The attribution for the raster source" + }, + "htmlAttribution": { + "type": "string", + "description": "The html attribution for the raster source" + }, + "provider": { + "type": "string", + "description": "The map provider" + }, + "bounds": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "number" + } + }, + "description": "The bounds of the source" + }, + "format": { + "type": "string", + "description": "The file format of the source" + }, + "time": { + "type": "string", + "description": "Time to use for source" + }, + "tileMatrixSet": { + "type": "string", + "description": "The tile matrix set" + }, + "variant": { + "type": "string", + "description": "The variant of the source" + }, + "opacity": { + "type": "number", + "description": "The opcaity of the source" + }, + "status": { + "type": "string", + "descriptions": "The status of the source" } } } diff --git a/packages/schema/src/token.ts b/packages/schema/src/token.ts index 57dd1911..1979b036 100644 --- a/packages/schema/src/token.ts +++ b/packages/schema/src/token.ts @@ -1,9 +1,10 @@ import { Token } from '@lumino/coreutils'; import { - IJupyterGISTracker, + IJGISExternalCommandRegistry, IJGISFormSchemaRegistry, - IJGISExternalCommandRegistry + IJGISLayerBrowserRegistry, + IJupyterGISTracker } from './interfaces'; export const IJupyterGISDocTracker = new Token( @@ -16,3 +17,6 @@ export const IJGISFormSchemaRegistryToken = new Token( export const IJGISExternalCommandRegistryToken = new Token('jupytergisExternalCommandRegistry'); + +export const IJGISLayerBrowserRegistryToken = + new Token('jupytergisExternalCommandRegistry'); diff --git a/python/jupytergis_core/package.json b/python/jupytergis_core/package.json index c7bf712d..5c48bfcb 100644 --- a/python/jupytergis_core/package.json +++ b/python/jupytergis_core/package.json @@ -31,6 +31,7 @@ "build:labextension:dev": "jupyter labextension build --development True .", "build:lib": "tsc --sourceMap", "build:lib:prod": "tsc", + "build:dev": "jlpm run build", "clean": "jlpm clean:lib", "clean:lib": "rimraf lib tsconfig.tsbuildinfo", "clean:lintcache": "rimraf .eslintcache .stylelintcache", diff --git a/python/jupytergis_core/src/index.ts b/python/jupytergis_core/src/index.ts index af1de7cd..bc29524d 100644 --- a/python/jupytergis_core/src/index.ts +++ b/python/jupytergis_core/src/index.ts @@ -2,6 +2,7 @@ import jgisPlugin from './jgisplugin/plugins'; import { externalCommandRegistryPlugin, formSchemaRegistryPlugin, + layerBrowserRegistryPlugin, trackerPlugin } from './plugin'; @@ -10,5 +11,6 @@ export default [ trackerPlugin, jgisPlugin, formSchemaRegistryPlugin, - externalCommandRegistryPlugin + externalCommandRegistryPlugin, + layerBrowserRegistryPlugin ]; diff --git a/python/jupytergis_core/src/layerBrowserRegistry.ts b/python/jupytergis_core/src/layerBrowserRegistry.ts new file mode 100644 index 00000000..0c92b4cf --- /dev/null +++ b/python/jupytergis_core/src/layerBrowserRegistry.ts @@ -0,0 +1,53 @@ +import { + IJGISLayerBrowserRegistry, + IRasterLayerGalleryEntry +} from '@jupytergis/schema'; + +/** + * Manages a registry of raster layer gallery entries for a Jupyter GIS Layer Browser. + * Implements the {@link IJGISLayerBrowserRegistry} interface, allowing for the addition, removal, and retrieval of raster layer entries. + * + * @class JupyterGISLayerBrowserRegistry + * @implements IJGISLayerBrowserRegistry + */ +export class JupyterGISLayerBrowserRegistry + implements IJGISLayerBrowserRegistry +{ + private _registry: IRasterLayerGalleryEntry[]; + + constructor() { + this._registry = []; + } + + /** + * Retrieves the current state of the registry layers. + * Returns a copy of the internal registry array to prevent external modifications. + * @returns The current state of the registry layers. + */ + getRegistryLayers(): IRasterLayerGalleryEntry[] { + return [...this._registry]; + } + + /** + * Adds a new raster layer gallery entry to the registry. + * @param data - The raster layer gallery entry to add. + */ + addRegistryLayer(data: IRasterLayerGalleryEntry): void { + this._registry.push(data); + } + + /** + * Removes a raster layer gallery entry from the registry by its name. + * @param name - The name of the raster layer gallery entry to remove. + */ + removeRegistryLayer(name: string): void { + this._registry = this._registry.filter(item => item.name !== name); + } + + /** + * Clears the entire registry of raster layer gallery entries. + */ + clearRegistry(): void { + this._registry = []; + } +} diff --git a/python/jupytergis_core/src/plugin.ts b/python/jupytergis_core/src/plugin.ts index 90263d91..67a42c11 100644 --- a/python/jupytergis_core/src/plugin.ts +++ b/python/jupytergis_core/src/plugin.ts @@ -4,6 +4,8 @@ import { IJGISExternalCommandRegistryToken, IJGISFormSchemaRegistry, IJGISFormSchemaRegistryToken, + IJGISLayerBrowserRegistry, + IJGISLayerBrowserRegistryToken, IJupyterGISDocTracker, IJupyterGISTracker } from '@jupytergis/schema'; @@ -15,8 +17,9 @@ import { WidgetTracker } from '@jupyterlab/apputils'; import { IMainMenu } from '@jupyterlab/mainmenu'; import { ITranslator } from '@jupyterlab/translation'; -import { JupyterGISFormSchemaRegistry } from './schemaregistry'; import { JupyterGISExternalCommandRegistry } from './externalcommand'; +import { JupyterGISLayerBrowserRegistry } from './layerBrowserRegistry'; +import { JupyterGISFormSchemaRegistry } from './schemaregistry'; const NAME_SPACE = 'jupytergis'; @@ -62,3 +65,17 @@ export const externalCommandRegistryPlugin: JupyterFrontEndPlugin = + { + id: 'jupytergis:core:layer-browser-registry', + autoStart: true, + requires: [], + provides: IJGISLayerBrowserRegistryToken, + activate: (app: JupyterFrontEnd) => { + console.log('jupytergis:core:layer-browser-registry is activated'); + + const registry = new JupyterGISLayerBrowserRegistry(); + return registry; + } + }; diff --git a/python/jupytergis_lab/package.json b/python/jupytergis_lab/package.json index 7f4f31b4..4c2dc5ab 100644 --- a/python/jupytergis_lab/package.json +++ b/python/jupytergis_lab/package.json @@ -31,6 +31,7 @@ "build:labextension:dev": "jupyter labextension build --development True .", "build:lib": "tsc --sourceMap", "build:lib:prod": "tsc", + "build:dev": "jlpm run build", "clean": "jlpm clean:lib", "clean:lib": "rimraf lib tsconfig.tsbuildinfo", "clean:lintcache": "rimraf .eslintcache .stylelintcache", diff --git a/python/jupytergis_lab/src/index.ts b/python/jupytergis_lab/src/index.ts index fb0f84af..f77c2274 100644 --- a/python/jupytergis_lab/src/index.ts +++ b/python/jupytergis_lab/src/index.ts @@ -1,14 +1,17 @@ import { + CommandIDs, ControlPanelModel, JupyterGISWidget, LeftPanelWidget, RightPanelWidget, addCommands, - CommandIDs + createDefaultLayerRegistry } from '@jupytergis/base'; import { IJGISFormSchemaRegistry, IJGISFormSchemaRegistryToken, + IJGISLayerBrowserRegistry, + IJGISLayerBrowserRegistryToken, IJupyterGISDocTracker, IJupyterGISTracker } from '@jupytergis/schema'; @@ -28,12 +31,17 @@ const NAME_SPACE = 'jupytergis'; const plugin: JupyterFrontEndPlugin = { id: 'jupytergis:lab:main-menu', autoStart: true, - requires: [IJupyterGISDocTracker, IJGISFormSchemaRegistryToken], + requires: [ + IJupyterGISDocTracker, + IJGISFormSchemaRegistryToken, + IJGISLayerBrowserRegistryToken + ], optional: [IMainMenu, ITranslator], activate: ( app: JupyterFrontEnd, tracker: WidgetTracker, formSchemaRegistry: IJGISFormSchemaRegistry, + layerBrowserRegistry: IJGISLayerBrowserRegistry, mainMenu?: IMainMenu, translator?: ITranslator ): void => { @@ -46,7 +54,16 @@ const plugin: JupyterFrontEndPlugin = { ); }; - addCommands(app, tracker, translator, formSchemaRegistry); + createDefaultLayerRegistry(layerBrowserRegistry); + + addCommands( + app, + tracker, + translator, + formSchemaRegistry, + layerBrowserRegistry + ); + if (mainMenu) { populateMenus(mainMenu, isEnabled); } diff --git a/requirements-build.txt b/requirements-build.txt index ca273a94..8bebf89c 100644 --- a/requirements-build.txt +++ b/requirements-build.txt @@ -3,3 +3,6 @@ datamodel-code-generator>=0.23.0 hatchling>=1.5.0,<2 jupyterlab>=4,<5 pydantic==2.4.2 +pillow>=10 +mercantile>=1.2 +xyzservices>=2024.6.0 \ No newline at end of file diff --git a/tsconfigbase.json b/tsconfigbase.json index a77e61af..af3810af 100644 --- a/tsconfigbase.json +++ b/tsconfigbase.json @@ -17,7 +17,7 @@ "strictNullChecks": true, "strictPropertyInitialization": false, "target": "es2017", - "types": ["node"], + "types": ["node", "webpack-env"], "skipLibCheck": true, "lib": ["ES2019", "WebWorker", "DOM"] } diff --git a/ui-tests/tests/layer-browser.spec.ts b/ui-tests/tests/layer-browser.spec.ts new file mode 100644 index 00000000..96d5a18f --- /dev/null +++ b/ui-tests/tests/layer-browser.spec.ts @@ -0,0 +1,159 @@ +import { + IJupyterLabPageFixture, + expect, + galata, + test +} from '@jupyterlab/galata'; +import { Locator } from '@playwright/test'; +import path from 'path'; + +const TEST_REGISTRY = { + OpenStreetMap: { + Mapnik: { + thumbnailPath: 'rasterlayer_gallery/OpenStreetMap-Mapnik.png', + url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + max_zoom: 19, + html_attribution: + '© OpenStreetMap contributors', + attribution: '(C) OpenStreetMap contributors', + name: 'OpenStreetMap.Mapnik' + } + }, + Strava: { + All: { + thumbnailPath: 'rasterlayer_gallery/Strava-All.png', + url: 'https://heatmap-external-a.strava.com/tiles/all/hot/{z}/{x}/{y}.png', + max_zoom: 15, + attribution: + 'Map tiles by Strava 2021', + html_attribution: + 'Map tiles by Strava 2021', + name: 'Strava.All' + }, + Ride: { + thumbnailPath: 'rasterlayer_gallery/Strava-Ride.png', + url: 'https://heatmap-external-a.strava.com/tiles/ride/hot/{z}/{x}/{y}.png', + max_zoom: 15, + attribution: + 'Map tiles by Strava 2021', + html_attribution: + 'Map tiles by Strava 2021', + name: 'Strava.Ride' + }, + Run: { + thumbnailPath: 'rasterlayer_gallery/Strava-Run.png', + url: 'https://heatmap-external-a.strava.com/tiles/run/bluered/{z}/{x}/{y}.png', + max_zoom: 15, + attribution: + 'Map tiles by Strava 2021', + html_attribution: + 'Map tiles by Strava 2021', + name: 'Strava.Run' + }, + Water: { + thumbnailPath: 'rasterlayer_gallery/Strava-Water.png', + url: 'https://heatmap-external-a.strava.com/tiles/water/blue/{z}/{x}/{y}.png', + max_zoom: 15, + attribution: + 'Map tiles by Strava 2021', + html_attribution: + 'Map tiles by Strava 2021', + name: 'Strava.Water' + }, + Winter: { + thumbnailPath: 'rasterlayer_gallery/Strava-Winter.png', + url: 'https://heatmap-external-a.strava.com/tiles/winter/hot/{z}/{x}/{y}.png', + max_zoom: 15, + attribution: + 'Map tiles by Strava 2021', + html_attribution: + 'Map tiles by Strava 2021', + name: 'Strava.Winter' + } + } +}; + +async function openLayerBrowser( + page: IJupyterLabPageFixture +): Promise { + const layerBrowser = page.locator('#jupytergis\\:\\:layerBrowser'); + + if (!(await layerBrowser.isVisible())) { + await page.getByTitle('Open Layer Browser').locator('div').click(); + await page.waitForCondition(async () => await layerBrowser.isVisible()); + } + return layerBrowser; +} + +async function getGridTiles(page: IJupyterLabPageFixture): Promise { + const layerBrowser = await openLayerBrowser(page); + + const gridTiles = layerBrowser.locator( + '.jgis-layer-browser-container .jgis-layer-browser-grid .jgis-layer-browser-tile' + ); + + return gridTiles; +} + +test.describe('#layerBrowser', () => { + test.beforeAll(async ({ request }) => { + const content = galata.newContentsHelper(request); + await content.deleteDirectory('/examples'); + await content.uploadDirectory( + path.resolve(__dirname, '../../examples'), + '/examples' + ); + }); + + test.beforeEach(async ({ page }) => { + await page.filebrowser.open('examples/test.jGIS'); + }); + + test.afterEach(async ({ page }) => { + await page.activity.closeAll(); + }); + + test('toolbar should have layer browser icon', async ({ page }) => { + const toolbarIcon = page.getByTitle('Open Layer Browser').locator('div'); + await expect(toolbarIcon).toBeVisible(); + }); + + test('layer browser should open when clicked', async ({ page }) => { + const layerBrowser = await openLayerBrowser(page); + await expect(layerBrowser).toBeVisible(); + }); + + test('layer browser should be populated', async ({ page }) => { + const layerBrowser = await openLayerBrowser(page); + + const gridTiles = layerBrowser.locator( + '.jgis-layer-browser-container .jgis-layer-browser-grid .jgis-layer-browser-tile' + ); + const numberOfTiles = await gridTiles.count(); + + expect(numberOfTiles).toBeGreaterThan(0); + }); + + test('search bar should filter tiles', async ({ page }) => { + const gridTiles = await getGridTiles(page); + await page.getByPlaceholder('Search...').click(); + await page.getByPlaceholder('Search...').fill('mapnik'); + await expect(gridTiles).toHaveCount(1); + }); + + test('category filters should work', async ({ page }) => { + const gridTiles = await getGridTiles(page); + await page.getByText('Strava', { exact: true }).click(); + await expect(gridTiles).toHaveCount(5); + }); + + test('clicking category filter twice should clear filter', async ({ + page + }) => { + const gridTiles = await getGridTiles(page); + const numberOfTiles = await gridTiles.count(); + await page.getByText('WaymarkedTrails', { exact: true }).click(); + await page.getByText('WaymarkedTrails', { exact: true }).click(); + await expect(gridTiles).toHaveCount(numberOfTiles); + }); +}); diff --git a/yarn.lock b/yarn.lock index db146540..e0644475 100644 --- a/yarn.lock +++ b/yarn.lock @@ -614,6 +614,13 @@ __metadata: languageName: node linkType: hard +"@fortawesome/fontawesome-common-types@npm:6.5.2": + version: 6.5.2 + resolution: "@fortawesome/fontawesome-common-types@npm:6.5.2" + checksum: 8164f3e16683db5125634a4fbf3db83a5a7366bb830111ffe8538e1b8f98f8fe6dc35609cf2c595a7d6840e27d3fb45b57faf7340e40e98f0d76207fe8f94e79 + languageName: node + linkType: hard + "@fortawesome/fontawesome-free@npm:^5.12.0": version: 5.15.4 resolution: "@fortawesome/fontawesome-free@npm:5.15.4" @@ -621,6 +628,36 @@ __metadata: languageName: node linkType: hard +"@fortawesome/fontawesome-svg-core@npm:^6.5.2": + version: 6.5.2 + resolution: "@fortawesome/fontawesome-svg-core@npm:6.5.2" + dependencies: + "@fortawesome/fontawesome-common-types": 6.5.2 + checksum: f0c2a0800074c5bbc143631b9f3f818b94bd14b8590153058eecc9f548ae0ac78cfca61196880f9b3b79b5d5b5afdb140d05da75542da2087701614c9c043905 + languageName: node + linkType: hard + +"@fortawesome/free-solid-svg-icons@npm:^6.5.2": + version: 6.5.2 + resolution: "@fortawesome/free-solid-svg-icons@npm:6.5.2" + dependencies: + "@fortawesome/fontawesome-common-types": 6.5.2 + checksum: f23964434ccbab5114c05bcdabb79d8e801b5be534618db7947d40d4841a3e52177e6145ae5fe59c941d864f70ffcffd0f1e4f0983dfd0048a1f5f3430a00c8c + languageName: node + linkType: hard + +"@fortawesome/react-fontawesome@npm:latest": + version: 0.2.2 + resolution: "@fortawesome/react-fontawesome@npm:0.2.2" + dependencies: + prop-types: ^15.8.1 + peerDependencies: + "@fortawesome/fontawesome-svg-core": ~1 || ~6 + react: ">=16.3" + checksum: e4bed35bfb7fc88b5bcf2305d08ee1835b82fa7705945c4d310df33bb747b05ef07a33ac9db643c8870ca4f835228978290d84d82f2c6c6a70b9ab4c886731a6 + languageName: node + linkType: hard + "@gar/promisify@npm:^1.1.3": version: 1.1.3 resolution: "@gar/promisify@npm:1.1.3" @@ -973,6 +1010,10 @@ __metadata: version: 0.0.0-use.local resolution: "@jupytergis/jupytergis-root@workspace:." dependencies: + "@fortawesome/fontawesome-svg-core": ^6.5.2 + "@fortawesome/free-solid-svg-icons": ^6.5.2 + "@fortawesome/react-fontawesome": latest + "@types/webpack-env": ^1.18.5 "@typescript-eslint/eslint-plugin": 5.55.0 "@typescript-eslint/parser": 5.55.0 copy-webpack-plugin: ^10.0.0 @@ -3316,6 +3357,13 @@ __metadata: languageName: node linkType: hard +"@types/webpack-env@npm:^1.18.5": + version: 1.18.5 + resolution: "@types/webpack-env@npm:1.18.5" + checksum: 4ca8eb4c44e1e1807c3e245442fce7aaf2816a163056de9436bbac44cc47c8bc5b1c9a330dc05748d6616431b1fb5bd5379733fb1da0b78d03c59f4ec824c184 + languageName: node + linkType: hard + "@types/webpack-sources@npm:^0.1.5": version: 0.1.12 resolution: "@types/webpack-sources@npm:0.1.12"