diff --git a/examples/example_deckgl_map.py b/examples/example_deckgl_map.py index b005d9832..265138a69 100644 --- a/examples/example_deckgl_map.py +++ b/examples/example_deckgl_map.py @@ -6,15 +6,212 @@ import io import base64 +import copy +import re import dash import dash_html_components as html +import jsonpatch +import jsonpointer import numpy as np from PIL import Image import webviz_subsurface_components +class MapSpec: + def __init__(self, initialSpec=None): + self._spec = initialSpec + + # Warning: modifying the spec directly might result in missing patches, + # and getting out of sync with the frontend state. + def get_spec(self): + return self._spec + + def get_spec_clone(self): + return copy.deepcopy(self._spec) + + def update(self, new_spec): + updated_spec = new_spec + if callable(new_spec): + updated_spec = new_spec(self.get_spec_clone()) + + patch = jsonpatch.make_patch(self._spec, updated_spec).patch + + self._spec = updated_spec + return patch + + def create_patch(self, new_spec=None): + if new_spec is None: + return jsonpatch.make_patch(None, self._spec).patch + + comp_with = new_spec + if callable(new_spec): + comp_with = new_spec(self.get_spec_clone()) + + return jsonpatch.make_patch(self._spec, comp_with).patch + + def apply_patch(self, patch): + jsonpatch.apply_patch(self._spec, self.normalize_patch(patch), True) + + # Replace ids with indices in the patch paths + def normalize_patch(self, in_patch, inplace=False): + def replace_path_id(matched): + parent = matched.group(1) + obj_id = matched.group(2) + parent_array = jsonpointer.resolve_pointer(self._spec, parent) + matched_id = -1 + for (i, elem) in enumerate(parent_array): + if elem["id"] == obj_id: + matched_id = i + break + if matched_id < 0: + raise f"Id {obj_id} not found" + return f"{parent}/{matched_id}" + + out_patch = in_patch if inplace else copy.deepcopy(in_patch) + for patch in out_patch: + patch["path"] = re.sub( + r"([\w\/-]*)\/\[([\w-]+)\]", replace_path_id, patch["path"] + ) + + return out_patch + + +class LeftMapSpec(MapSpec): + def __init__(self, min_val, max_val): + bounds = [432205, 6475078, 437720, 6481113] # left, bottom, right, top + width = bounds[2] - bounds[0] # right - left + height = bounds[3] - bounds[1] # top - bottom + + super().__init__( + { + "initialViewState": { + "target": [bounds[0] + width / 2, bounds[1] + height / 2, 0], + "zoom": -3, + }, + "layers": [ + { + "@@type": "ColormapLayer", + "id": "colormap-layer", + "bounds": bounds, + "image": "@@#resources.propertyMap", + "colormap": "@@#resources.colormap", + "valueRange": [min_val, max_val], + "pickable": True, + }, + { + "@@type": "Hillshading2DLayer", + "id": "hillshading-layer", + "bounds": bounds, + "opacity": 1.0, + "valueRange": [min_val, max_val], + "image": "@@#resources.propertyMap", + "pickable": True, + }, + { + "@@type": "DrawingLayer", + "id": "drawing-layer", + "mode": "drawLineString", + "data": {"type": "FeatureCollection", "features": []}, + }, + ], + "views": [ + { + "@@type": "OrthographicView", + "id": "main", + "controller": {"doubleClickZoom": False}, + "x": "0%", + "y": "0%", + "width": "100%", + "height": "100%", + "flipY": False, + } + ], + } + ) + + def update_drawing_mode(self, mode): + spec = self.get_spec_clone() + spec["layers"][2]["mode"] = mode + return self.update(spec) + + +class RightMapSpec(MapSpec): + def __init__(self, min_val, max_val): + bounds = [432205, 6475078, 437720, 6481113] # left, bottom, right, top + width = bounds[2] - bounds[0] # right - left + height = bounds[3] - bounds[1] # top - bottom + + super().__init__( + { + "initialViewState": { + "target": [bounds[0] + width / 2, bounds[1] + height / 2, 0], + "zoom": -3, + }, + "layers": [ + { + "@@type": "ColormapLayer", + "id": "colormap-layer", + "bounds": bounds, + "image": "@@#resources.propertyMap", + "colormap": "@@#resources.colormap", + "valueRange": [min_val, max_val], + "pickable": True, + }, + { + "@@type": "Hillshading2DLayer", + "id": "hillshading-layer", + "bounds": bounds, + "opacity": 1.0, + "valueRange": [min_val, max_val], + "image": "@@#resources.propertyMap", + "pickable": True, + }, + { + "@@type": "DrawingLayer", + "id": "drawing-layer", + "mode": "view", + "data": {"type": "FeatureCollection", "features": []}, + }, + { + "@@type": "WellsLayer", + "id": "wells-layer", + "data": "@@#resources.wells", + "opacity": 1.0, + "lineWidthScale": 5, + "pointRadiusScale": 8, + "outline": True, + }, + ], + "views": [ + { + "@@type": "OrthographicView", + "id": "main", + "controller": {"doubleClickZoom": False}, + "x": "0%", + "y": "0%", + "width": "100%", + "height": "100%", + "flipY": False, + } + ], + } + ) + + def sync_drawing(self, in_patch): + drawing_layer_patches = list( + filter( + lambda patch: patch["path"].startswith("/layers/[drawing-layer]/data") + or patch["path"].startswith("/layers/2/data"), + in_patch, + ) + ) + self.apply_patch(drawing_layer_patches) + + return drawing_layer_patches + + def array2d_to_png(z_array): """The DeckGL map dash component takes in pictures as base64 data (or as a link to an existing hosted image). I.e. for containers wanting @@ -56,7 +253,6 @@ def array2d_to_png(z_array): if __name__ == "__main__": - # The data below is a modified version of one of the surfaces # taken from the Volve data set provided by Equinor and the former # Volve Licence partners under CC BY-NC-SA 4.0 license, and only @@ -74,138 +270,31 @@ def array2d_to_png(z_array): map_data = (map_data - min_value) * scale_factor map_data = array2d_to_png(map_data) - COLORMAP = "https://cdn.jsdelivr.net/gh/kylebarron/deck.gl-raster/assets/colormaps/plasma.png" - + COLOR_MAP = "https://cdn.jsdelivr.net/gh/kylebarron/deck.gl-raster/assets/colormaps/plasma.png" WELLS = ( - "https://raw.githubusercontent.com/equinor/webviz-subsurface-components/master/src" - "/demo/example-data/volve_wells.json" + "https://raw.githubusercontent.com/equinor/webviz-subsurface-components/" + "master/src/demo/example-data/volve_wells.json" ) - - bounds = [432205, 6475078, 437720, 6481113] # left, bottom, right, top - width = bounds[2] - bounds[0] # right - left - height = bounds[3] - bounds[1] # top - bottom - - deckgl_map_left = webviz_subsurface_components.DeckGLMap( + left_map_spec = LeftMapSpec(min_value, max_value) + left_map = webviz_subsurface_components.DeckGLMap( id="DeckGL-Map-Left", resources={ "propertyMap": map_data, + "colormap": COLOR_MAP, + "wells": WELLS, }, - deckglSpecPatch=[ - { - "op": "replace", - "path": "", - "value": { - "initialViewState": { - "target": [bounds[0] + width / 2, bounds[1] + height / 2, 0], - "zoom": -3, - }, - "layers": [ - { - "@@type": "ColormapLayer", - "id": "colormap-layer", - "bounds": bounds, - "image": "@@#resources.propertyMap", - "colormap": COLORMAP, - "valueRange": [min_value, max_value], - "pickable": True, - }, - { - "@@type": "Hillshading2DLayer", - "id": "hillshading-layer", - "bounds": bounds, - "opacity": 1.0, - "valueRange": [min_value, max_value], - "image": "@@#resources.propertyMap", - "pickable": True, - }, - { - "@@type": "DrawingLayer", - "id": "drawing-layer", - "mode": "drawLineString", - "data": {"type": "FeatureCollection", "features": []}, - }, - ], - "views": [ - { - "@@type": "OrthographicView", - "id": "main", - "controller": True, - "x": "0%", - "y": "0%", - "width": "100%", - "height": "100%", - "flipY": False, - } - ], - }, - }, - ], + deckglSpecPatch=left_map_spec.create_patch(), ) - deckgl_map_right = webviz_subsurface_components.DeckGLMap( + right_map_spec = RightMapSpec(min_value, max_value) + right_map = webviz_subsurface_components.DeckGLMap( id="DeckGL-Map-Right", resources={ "propertyMap": map_data, + "colormap": COLOR_MAP, + "wells": WELLS, }, - deckglSpecPatch=[ - { - "op": "replace", - "path": "", - "value": { - "initialViewState": { - "target": [bounds[0] + width / 2, bounds[1] + height / 2, 0], - "zoom": -3, - }, - "layers": [ - { - "@@type": "ColormapLayer", - "id": "colormap-layer", - "bounds": bounds, - "image": "@@#resources.propertyMap", - "colormap": COLORMAP, - "valueRange": [min_value, max_value], - "pickable": True, - }, - { - "@@type": "Hillshading2DLayer", - "id": "hillshading-layer", - "bounds": bounds, - "opacity": 1.0, - "valueRange": [min_value, max_value], - "image": "@@#resources.propertyMap", - "pickable": True, - }, - { - "@@type": "DrawingLayer", - "id": "drawing-layer", - "mode": "view", - "data": {"type": "FeatureCollection", "features": []}, - }, - { - "@@type": "WellsLayer", - "id": "wells-layer", - "data": WELLS, - "opacity": 1.0, - "lineWidthScale": 5, - "pointRadiusScale": 8, - "outline": True, - }, - ], - "views": [ - { - "@@type": "OrthographicView", - "id": "main", - "controller": True, - "x": "0%", - "y": "0%", - "width": "100%", - "height": "100%", - "flipY": False, - } - ], - }, - }, - ], + deckglSpecPatch=right_map_spec.create_patch(), ) app = dash.Dash(__name__) @@ -214,7 +303,7 @@ def array2d_to_png(z_array): children=[ html.Div( style={"float": "left", "width": "50%", "height": "95vh"}, - children=[deckgl_map_left], + children=[left_map], ), html.Button( id="toggle-drawing", @@ -222,7 +311,7 @@ def array2d_to_png(z_array): ), html.Div( style={"float": "right", "width": "50%", "height": "95vh"}, - children=[deckgl_map_right], + children=[right_map], ), ] ) @@ -233,19 +322,20 @@ def array2d_to_png(z_array): ) def toggle_drawing(n_clicks): mode = "view" if n_clicks is None or n_clicks % 2 == 0 else "drawLineString" - return [ - {"op": "replace", "path": "/layers/[drawing-layer]/mode", "value": mode} - ] + patch = left_map_spec.update_drawing_mode(mode) + return patch @app.callback( dash.dependencies.Output("DeckGL-Map-Right", "deckglSpecPatch"), dash.dependencies.Input("DeckGL-Map-Left", "deckglSpecPatch"), ) def sync_drawing(in_patch): - drawing_layer_patches = filter( - lambda patch: patch["path"].startswith("/layers/[drawing-layer]/data"), - in_patch, - ) - return list(drawing_layer_patches) + if not in_patch: + return None + # Update internal state of the left map + left_map_spec.apply_patch(in_patch) + + # Update the right map + return right_map_spec.sync_drawing(in_patch) app.run_server(debug=True) diff --git a/setup.py b/setup.py index 738702961..1014f43d3 100644 --- a/setup.py +++ b/setup.py @@ -29,6 +29,8 @@ "pylint>=2.4", "scipy>=1.2", "selenium>=3.141", + "jsonpatch>=1.32", + "jsonpointer>=2.1", ] # 'dash[testing]' to be added in TEST_REQUIRE when diff --git a/src/lib/components/DeckGLMap/DeckGLMap.jsx b/src/lib/components/DeckGLMap/DeckGLMap.jsx index 4c1409c8f..3ef28b354 100644 --- a/src/lib/components/DeckGLMap/DeckGLMap.jsx +++ b/src/lib/components/DeckGLMap/DeckGLMap.jsx @@ -31,40 +31,58 @@ function _idsToIndices(doc, path) { return path; } -DeckGLMap.defaultProps = { - coords: { - visible: true, - multiPicking: true, - pickDepth: 10, - }, -}; +function useStateWithPatches(initialState) { + const [state, setState] = React.useState(initialState); -function DeckGLMap({ id, resources, deckglSpecPatch, coords, setProps }) { - const [deckglSpec, setDeckglSpec] = React.useState(null); - React.useEffect(() => { - if (!deckglSpecPatch) { - return; + const getPatch = (newState) => { + if (typeof newState === "function") { + const currState = cloneDeep(state); + return jsonpatch.compare(state, newState(currState)); } + return jsonpatch.compare(state, newState); + }; - let newSpec = deckglSpec; + const setPatch = (patch) => { + let newSpec = state; try { - const patch = deckglSpecPatch.map((patch) => { + const normalizedPatch = patch.map((patch) => { return { ...patch, - path: _idsToIndices(deckglSpec, patch.path), + path: _idsToIndices(state, patch.path), }; }); newSpec = jsonpatch.applyPatch( - deckglSpec, - patch, + state, + normalizedPatch, true, false ).newDocument; } catch (error) { console.error("Unable to apply patch: " + error); } + setState(newSpec); + }; - setDeckglSpec(newSpec); + return [state, getPatch, setPatch]; +} + +DeckGLMap.defaultProps = { + coords: { + visible: true, + multiPicking: true, + pickDepth: 10, + }, +}; + +function DeckGLMap({ id, resources, deckglSpecPatch, coords, setProps }) { + const [deckglSpec, getDeckglSpecPatch, setDeckglSpecPatch] = + useStateWithPatches(null); + + React.useEffect(() => { + if (!deckglSpecPatch) { + return; + } + setDeckglSpecPatch(deckglSpecPatch); }, [deckglSpecPatch]); React.useEffect(() => { @@ -74,14 +92,15 @@ function DeckGLMap({ id, resources, deckglSpecPatch, coords, setProps }) { return layer["@@type"] == "DrawingLayer" && layer["mode"] != "view"; }); - const newSpec = cloneDeep(deckglSpec); - newSpec.layers.forEach((layer) => { - if (layer["@@type"] == "WellsLayer") { - layer.selectionEnabled = !drawingEnabled; - } + const patch = getDeckglSpecPatch((newSpec) => { + newSpec.layers.forEach((layer) => { + if (layer["@@type"] == "WellsLayer") { + layer.selectionEnabled = !drawingEnabled; + } + }); + return newSpec; }); - const patch = jsonpatch.compare(deckglSpec, newSpec); if (patch.length !== 0) { setProps({ deckglSpecPatch: patch,