Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

pydeck and @deck.gl/jupyter-widget: Add support for dynamic deck.gl custom Layer registration #4233

Merged
merged 20 commits into from
Feb 20, 2020

Conversation

ajduberstein
Copy link
Collaborator

@ajduberstein ajduberstein commented Feb 4, 2020

For #4103

Background

Change List

  • Load custom scripts using XMLHttpRequest (works in Notebook, JupyterLab, and pure HTML environments)
  • Layer registration

@coveralls
Copy link

coveralls commented Feb 4, 2020

Coverage Status

Coverage decreased (-0.2%) to 80.701% when pulling 4c98bd0 on ajd/custom-layer-loader into 3b45d23 on master.

@ajduberstein ajduberstein changed the title [WIP] Add support for dynamic deck.gl custom Layer registration pydeck: Add support for dynamic deck.gl custom Layer registration Feb 6, 2020
@ajduberstein
Copy link
Collaborator Author

This PR is currently working with the code from this repo, which produces the bundle that I'm hosting here.

@ajduberstein ajduberstein changed the title pydeck: Add support for dynamic deck.gl custom Layer registration pydeck and @deck.gl/jupyter-widget: Add support for dynamic deck.gl custom Layer registration Feb 6, 2020
Copy link
Collaborator

@ibgreen ibgreen left a comment

Choose a reason for hiding this comment

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

@ajdubersteing This looks great, thank you very much for putting this in place.

I was assuming that that we would only support adding layers at initialization time, but this actually allows additional layer modules to be imported dynamically at run time, which certainly seems very flexible!

This seems to take care of the javascript side. Is there a corresponding "command" or function to be added on the pydeck/python side?

modules/jupyter-widget/src/dynamic-registration.js Outdated Show resolved Hide resolved
@@ -125,9 +126,21 @@ export class DeckGLView extends DOMWidgetView {

this.model.on('change:json_input', this.valueChanged.bind(this), this);
this.model.on('change:data_buffer', this.dataBufferChanged.bind(this), this);
this.model.on('change:custom_layers', this.addNewCustomLayers.bind(this), this);
Copy link
Collaborator

Choose a reason for hiding this comment

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

change:custom_layers - We only support layers right now, not other types of configuration objects (e.g. constants).

I think it is fine to only support layers for now, though I wouldn't mind if we designed it with more generality/expandability in mind, e.g. change:add-configuration, which takes a JSON configuration object, and renamed the method to this.add<Custom>JSONConfiguration().

(Completely generalizing things would seem to require conventions for how to bundle a module so the various types of exports are discoverable on dynamic import, and I don't think that would need to be solved in this PR, i.e. for now it is fine to only send configuration objects with a classes field.)

// Opinionated choice, requires that the user load only one layer at a time
// and that layer must be the sole default export of the library
// TODO better choice here?
jsonConverter.configuration.classes[className] = window[className].default;
Copy link
Collaborator

Choose a reason for hiding this comment

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

You are updating an existing configuration. This code is a little hacky, I don't think it would be unreasonable to provide a JSONConverter.mergeConfiguration(newConfiguration) API for merging in a new configuration object.

this.dataBufferChanged();
}

addNewCustomLayers() {
if (this.model.get('custom_layers')) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we use the same naming as JSON config objects?

this.model.get('classes`) ...

modules/jupyter-widget/src/dynamic-registration.js Outdated Show resolved Hide resolved
// Opinionated choice, requires that the user load only one layer at a time
// and that layer must be the sole default export of the library
// TODO better choice here?
const classConstructor = window[className].default;
Copy link
Collaborator

Choose a reason for hiding this comment

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

How is the user supposed to bundle their code? Add an example/test.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

The test is addressed in #4267 and I'll add documentation/an example directory elsewhere

modules/jupyter-widget/src/dynamic-registration.js Outdated Show resolved Hide resolved
@@ -41,8 +42,13 @@ const jsonConverter = new deck.JSONConverter({
configuration: jsonConverterConfiguration
});

export function updateDeck(inputJSON, deckgl) {
const results = jsonConverter.convert(inputJSON);
export function updateClasses({className, resourceUri, onComplete}) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Why do we need a function that directly calls another? Where is onComplete used?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

onComplete is used in the test associated with this function

@@ -9,4 +9,4 @@
module_name = "@deck.gl/jupyter-widget"
# module_version is the current version of the module of the JS portion of the widget
# It appears to be important only for JupyterLab and ignored for Jupyter Notebooks
module_version = "^8.0.0"
module_version = "8.1.0-alpha.1"
Copy link
Collaborator

Choose a reason for hiding this comment

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

Can we not hardcode any version?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Handled in a separate PR, this is only for development purposes, I'll change it back

modules/jupyter-widget/src/create-deck.js Outdated Show resolved Hide resolved
modules/jupyter-widget/src/create-deck.js Outdated Show resolved Hide resolved
modules/jupyter-widget/src/create-deck.js Outdated Show resolved Hide resolved
modules/jupyter-widget/src/index.js Outdated Show resolved Hide resolved
modules/jupyter-widget/src/widget.js Outdated Show resolved Hide resolved
modules/jupyter-widget/webpack.config.js Outdated Show resolved Hide resolved
test/modules/jupyter-widget/create-deck.spec.js Outdated Show resolved Hide resolved
});
}

function loadExternalClasses({libraryName, resourceUri, onComplete}, loadResource = loadScript) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Add a comment: loadResource is for testing only

Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: I personally try to avoid mixing positional and named parameters.

const customLibraries = this.model.get('classes');
for (const obj of customLibraries) {
// obj is an object of {libraryName: <string>, url: <string>}
const [libraryName, resourceUri] = Object.entries(obj)[0];
Copy link
Collaborator

Choose a reason for hiding this comment

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

obj.libraryName,
obj.url

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

They're arriving as a list of dictionaries from the server in something like this format:

[{"libraryName1": "url1"}, {"libraryName2": "url2"}]

Copy link
Collaborator

Choose a reason for hiding this comment

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

Don’t you have control of the format on the server side?

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 do but I'm personally partial to the more concise form of

[{"libraryName1": "url1"}, {"libraryName2": "url2"}]

instead of

[{"libraryName": "libraryName1", "resourceUri": "url1"}, {"libraryName": "libraryName2", "resourceUri": "url2"}]

It's anyone's choice, however–happy to switch it over.

Copy link
Collaborator

@Pessimistress Pessimistress Feb 13, 2020

Choose a reason for hiding this comment

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

Think of each item as an entry in a database. Predictable data structured makes your code a lot more readable. Using the key name as the library name is implicit, and requires additional documentation in the source. Explicitly calling out libraryName leaves no ambiguity.

If you insist on using keys as library names, which is fine, then there's no reason to have an array. All entries can be merged into one dictionary. Object.entries(obj)[0] is a bad pattern because you are relying on the existence and uniqueness of the object's content. It will easily crash if the input is malformed.

Copy link
Collaborator

@ibgreen ibgreen left a comment

Choose a reason for hiding this comment

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

See inline comment, this seems to only support importing a single layer per module, (and no other types of symbols, such as enums etc).

From our use case, even with this limitation, this is a big step up. That said we will probably soon want to import custom modules with multiple layers, and perhaps also enums, constants etc.

For longer-term, we also have requests around being able to import alternative base map modules into pydeck, and doing that using a generalization of this dynamic injection mechanism could be very elegant. Of course we would need to solve a number of questions around how those are integrated (a discussion partially started in your pydeck multiview PR).

For a possible follow-up PR: long-term would it make sense to route all deck.gl layer module loading through this function, not just custom modules? That would make this code into a primary part of the library, instead of a "fragile" edge case.

});
}

function loadExternalClasses({libraryName, resourceUri, onComplete}, loadResource = loadScript) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Nit: I personally try to avoid mixing positional and named parameters.

one could load it into pydeck by doing the following:

```
pydeck.settings.custom_classes = [{'TagmapLayer': 'https://demourl.libpath/bundle.js'}]
Copy link
Collaborator

Choose a reason for hiding this comment

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

I suppose that we since we haven't yet implemented the configuration "back-channel" message back to Python (which would let Python about what JS classes are available) the user has to duplicate that information here.

It would be valuable to be able to import multiple classes per module (in fact it would be valuable to be able to import also constants etc).

Therefore I am wondering if a class-name-to-url scheme is sufficient, or if it should be

pydeck.settings.custom_classes = {
  'https://demourl.libpath/bundle1.js': [
     classes: ['TagmapLayer', 'Layer2'],
     enums: ...
  ],
  'https://demourl.libpath/bundle2.js': [
     classes: ['MyCustomLayer'],
  ]
};

Again, ideally long-term we should have the back-channel message and not require listing the contents of the packages on the JS side.

Copy link
Collaborator Author

@ajduberstein ajduberstein Feb 12, 2020

Choose a reason for hiding this comment

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

The latest implementation of this allows for multiple classes. I'll be updating the docstring here to read {"tagmapLibrary": ...url...} instead.

@@ -10,19 +10,19 @@ import * as deck from './deck-bundle';

import GL from '@luma.gl/constants';

function extractClasses() {
function extractClasses(library) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

Please add a more comprehensive header describing what this function does.

// Get classes for registration from standalone deck.gl

this makes it sound like this is for the top-level deck.gl module only

modules/jupyter-widget/src/widget.js Outdated Show resolved Hide resolved
@ajduberstein
Copy link
Collaborator Author

Will merge after #4267 is merged

@ajduberstein ajduberstein merged commit 9fc27f9 into master Feb 20, 2020
@ajduberstein ajduberstein deleted the ajd/custom-layer-loader branch February 20, 2020 21:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants