In [1]:
import numpy as np
import holoviews as hv
import geoviews as gv
import cartopy.crs as ccrs
import cesiumpy

Declaring a Renderer

In [28]:
import param
from holoviews.plotting.renderer import Renderer, MIME_TYPES

import cesiumpy.util.html as cesium_html 

NOTEBOOK_DIV = """
{plot_div}
<script type="text/javascript">
  {plot_script}
</script>
"""

class CesiumRenderer(Renderer):

    backend = param.String(default='cesium', doc="The backend name.")

    fig = param.ObjectSelector(default='auto', objects=['html', 'auto'], doc="""
        Output render format for static figures. If None, no figure
        rendering will occur. """)

    holomap = param.ObjectSelector(default='auto',
                                   objects=['widgets', 'scrubber',
                                            None, 'auto'], doc="""
        Output render multi-frame (typically animated) format. If
        None, no multi-frame rendering will occur.""")

    mode = param.ObjectSelector(default='default',
                                objects=['default', 'server'], doc="""
        Whether to render the object in regular or server mode. In server
        mode a bokeh Document will be returned which can be served as a
        bokeh server app. By default renders all output is rendered to HTML.""")

    token = param.String(default=None)
    
    # Defines the valid output formats for each mode.
    mode_formats = {'fig': {'default': ['html', 'auto'],
                            'server': ['html', 'auto']},
                    'holomap': {'default': ['widgets', 'scrubber', 'auto', None],
                                'server': ['server', 'auto', None]}}

    backend_dependencies = {'js': ['https://cesiumjs.org/Cesium/Build/Cesium/Cesium.js'],
                            'css': ['https://cesiumjs.org/Cesium/Build/Cesium/Widgets/widgets.css']}

    _loaded = False

    def __call__(self, obj, fmt=None, doc=None):
        """
        Render the supplied HoloViews component using the appropriate
        backend. The output is not a file format but a suitable,
        in-memory byte stream together with any suitable metadata.
        """
        plot, fmt =  self._validate(obj, fmt)
        info = {'file-ext': fmt, 'mime_type': MIME_TYPES[fmt]}

        if fmt == 'html':
            html = self._figure_data(plot)
            html = "<div style='display: table; margin: 0 auto;'>%s</div>" % html
            return self._apply_post_render_hooks(html, obj, fmt), info
        
    def _figure_data(self, plot, fmt='html', as_script=False, **kwargs):
        obj = plot.state
        div = obj.container
        if False: #self.token:
            token = "Cesium.Ion.DefaultAccessToken = %r;" % self.token
        else:
            token = ''
        raw_js = '\n'.join([token]+obj.script)
        js = ('function load_{id}() {{ if ((window.Cesium == undefined) || (document.readyState != "complete"))'
              '{{ setTimeout(load_{id}, 50);  return;}};  %s }}; setTimeout(load_{id}, 50)'.format(id=plot.id) % raw_js)
        if as_script:
            return div, js
        return NOTEBOOK_DIV.format(plot_div=div, plot_script=js)

    @classmethod
    def load_nb(cls, inline=True):
        """
        Loads the bokeh notebook resources.
        """
        from bokeh.core.templates import AUTOLOAD_NB_JS
        from IPython.display import publish_display_data
        load_type = MIME_TYPES['jlab-hv-load']
        JS = AUTOLOAD_NB_JS.render(
            elementid = '',
            js_urls   = cls.backend_dependencies['js'],
            css_urls  = cls.backend_dependencies['css'],
            js_raw    = '',
            css_raw   = '',
            force     = True,
            timeout   = 5000,
            register_mime = False
        )
        mime_bundle = {load_type: JS, 'application/javascript': JS}
        publish_display_data(data=mime_bundle)
        
    def plot_options(cls, obj, percent_size):
        return {}
        

hv.Store.renderers['cesium'] = CesiumRenderer.instance()

Custom Elements

In [22]:
from geoviews.element.geo import _GeoFeature

class Terrain(_GeoFeature):
    def __init__(self, data=None, **params):
        super(Terrain, self).__init__(data, **params)

class Model3d(_GeoFeature):

    def __init__(self, data, position, scale, **params):
        m = cesiumpy.Model(url=data, modelMatrix=position, scale=scale)
        super(Model3d, self).__init__(m, **params)

Plot implementations

In [23]:
import uuid
from holoviews.core import util
from holoviews.plotting.plot import DimensionedPlot, GenericElementPlot, GenericOverlayPlot
from holoviews.core import CompositeOverlay
from holoviews.plotting.util import hex2rgb

def preprocess_style(style, rename={}):
    new_style = {}
    for k, v in style.items():
        if ('color' in k and v.startswith('#')):
            v = cesiumpy.color.Color(*(c/255. for c in hex2rgb(v)))
        k = rename.get(k, k)
        new_style[k] = v
    return new_style


class CesiumPlot(DimensionedPlot):

    width = param.Integer(default=900, doc="""
        Width of the plot in pixels""")

    height = param.Integer(default=500, doc="""
        Height of the plot in pixels""")

    backend = 'cesium'
    
    def __init__(self, obj, **kwargs):
        self._id = uuid.uuid4().hex
        super(CesiumPlot, self).__init__(obj, **kwargs)
        
    
    @property
    def id(self):
        return self._id

    @property
    def state(self):
        """
        The plotting state that gets updated via the update method and
        used by the renderer to generate output.
        """
        return self.handles['plot']

    
class ElementPlot(CesiumPlot, GenericElementPlot):
        
    finalize_hooks = param.HookList(default=[], doc="""
        Optional list of hooks called when finalizing an axis.
        The hook is passed the plot object and the displayed
        object, other plotting handles can be accessed via plot.handles.""")

    def initialize_plot(self, ranges=None, plot=None):
        """
        Initializes a new plot object with the last available frame.
        """
        # Get element key and ranges for frame
        if self.batched:
            element = [el for el in self.hmap.data.values() if el][-1]
        else:
            element = self.hmap.last
        key = util.wrap_tuple(self.hmap.last_key)
        ranges = self.compute_ranges(self.hmap, key, ranges)
        self.current_ranges = ranges
        self.current_frame = element
        self.current_key = key
        style_element = element.last if self.batched else element
        ranges = util.match_spec(style_element, ranges)

        # Initialize plot, source and glyph
        if plot is None:
            plot = self._init_plot(key, style_element, ranges=ranges)
            self._init_axes(plot)
        self.handles['plot'] = plot

        self._init_glyphs(plot, element, ranges)
        if not self.overlaid:
            self._update_plot(key, plot, style_element)
            self._update_ranges(style_element, ranges)

        self._execute_hooks(element)

        self.drawn = True

        return plot
    
    def _init_plot(self, key, element, ranges):
        width = '%dpx' % self.width
        height = '%dpx' % self.height
        divid = uuid.uuid4().hex
        return cesiumpy.Viewer(width=width, height=height, divid=divid)
    
    def _init_axes(self, plot):
        pass
    
    def _init_glyphs(self, plot, element, ranges):
        pass
        
    def _update_plot(self, key, plot, element):
        pass
    
    def _update_ranges(self, element, ranges):
        pass
    
    
class OverlayPlot(GenericOverlayPlot, ElementPlot):
    _propagate_options = ['width', 'height']

    def initialize_plot(self, ranges=None, plot=None, plots=None):
        key = util.wrap_tuple(self.hmap.last_key)
        nonempty = [el for el in self.hmap.data.values() if el]
        if not nonempty:
            raise SkipRendering('All Overlays empty, cannot initialize plot.')
        element = nonempty[-1]
        ranges = self.compute_ranges(self.hmap, key, ranges)
        if plot is None and not self.batched:
            plot = self._init_plot(key, element, ranges=ranges)
            self._init_axes(plot)
        self.handles['plot'] = plot

        if plot and not self.overlaid:
            self._update_plot(key, plot, element)
            self._update_ranges(element, ranges)

        panels = []
        for key, subplot in self.subplots.items():
            frame = None
            child = subplot.initialize_plot(ranges, plot)
            if isinstance(element, CompositeOverlay):
                frame = element.get(key, None)
                subplot.current_frame = frame
            if self.batched:
                self.handles['plot'] = child

        self.drawn = True
        self.handles['plots'] = plots
        self._execute_hooks(element)

        return self.handles['plot']
    
    
class WMTSPlot(ElementPlot):
    
    def _init_glyphs(self, plot, element, ranges):
        index = element.data.index('{Z}')-1
        url = element.data[:index]
        extension = element.data.split('.')[-1]
        imagery = cesiumpy.createOpenStreetMapImageryProvider(url=url, fileExtension=extension)
        plot.imageryProvider = imagery
        plot.baseLayerPicker = False
        
        
class TerrainPlot(ElementPlot):
    
    def _init_glyphs(self, plot, element, ranges):
        url = element.data
        if url is None:
            imagery = cesiumpy.provider.createWorldTerrain()
        else:
            imagery = cesiumpy.provider.TerrainProvider(url=url)
        plot.terrainProvider = imagery
        plot.baseLayerPicker = False


class PointPlot(ElementPlot):
    
    style_opts = ['color']
    
    def _init_glyphs(self, plot, element, ranges):
        style = preprocess_style(self.style[self.cyclic_index])
        points = gv.project(element, projection=ccrs.PlateCarree())
        for x, y in points.array([0, 1]):
            point = cesiumpy.Point(position=[x, y, 0], **style)
            plot.entities.add(point)
            
class Model3dPlot(ElementPlot):
    
    def _init_glyphs(self, plot, element, ranges):
        plot.scene.primitives.add(element.data)

            
class PathPlot(ElementPlot):
    
    style_opts = ['color', 'line_width']
    
    def _init_glyphs(self, plot, element, ranges):
        rename = {'line_width': 'width', 'color': 'material'}
        style = preprocess_style(self.style[self.cyclic_index], rename)
        path = gv.project(element, projection=ccrs.PlateCarree())
        for p in path.split(datatype='array'):
            polyline = cesiumpy.Polyline(positions=p.flatten(), **style)
            plot.entities.add(polyline)
            
class PolygonPlot(ElementPlot):
    
    style_opts = ['color', 'line_width']
            
    def _init_glyphs(self, plot, element, ranges):
        rename = {'line_width': 'width', 'color': 'material'}
        style = preprocess_style(self.style[self.cyclic_index], rename)
        path = gv.project(element, projection=ccrs.PlateCarree())
        for p in path.split():
            polygon = cesiumpy.Polygon(hierarchy=list(p.array([0, 1]).flatten()), **style)
            plot.entities.add(polygon)


class GraphPlot(ElementPlot):
    
    style_opts = ['color', 'line_width']
    
    def _init_glyphs(self, plot, element, ranges):
        rename = {'line_width': 'width', 'color': 'material'}
        style = preprocess_style(self.style[self.cyclic_index], rename)
        graph = gv.project(element, projection=ccrs.PlateCarree())
        for p in graph.edgepaths.split(datatype='array'):
            polyline = cesiumpy.Polyline(positions=p.flatten(), **style)
            plot.entities.add(polyline)
        for x, y in graph.nodes.array([0, 1]):
            point = cesiumpy.Point(position=[x, y, 0])
            plot.entities.add(point)
            
            
#class ImagePlot()
    
hv.Store.register({hv.Element: ElementPlot, hv.Overlay: OverlayPlot,
                   gv.WMTS: WMTSPlot,
                   gv.Points: PointPlot,
                   gv.Path: PathPlot,
                   gv.Graph: GraphPlot,
                   gv.Polygons: PolygonPlot,
                   Terrain: TerrainPlot, Model3d: Model3dPlot}, 'cesium')

options = hv.Store.options('cesium')
options.Points = hv.Options('style', color=hv.Cycle())
options.Path = hv.Options('style', color=hv.Cycle())

Testing

In [24]:
hv.extension('cesium', 'bokeh')
hv.Store.current_backend = 'cesium'
In [25]:
from bokeh.sampledata.airport_routes import airports, routes

Plotting some data

Points

In [29]:
gv.tile_sources.Wikipedia *\
gv.Points(airports, ['Longitude', 'Latitude']).iloc[:100] *\
gv.Points(airports, ['Longitude', 'Latitude']).iloc[200:300]
Out[29]:
In [30]:
%%output backend='bokeh'
gv.tile_sources.Wikipedia *\
gv.Points(airports, ['Longitude', 'Latitude']).iloc[:100] *\
gv.Points(airports, ['Longitude', 'Latitude']).iloc[200:300].options(width=800, height=400)
Out[30]:

Path

In [31]:
Terrain() * gv.Path([[(0, 0), (-70, 30)]])
Out[31]:

Graph

In [32]:
paths = []
honolulu = (-157.9219970703125, 21.318700790405273)
routes = routes[routes.SourceID==3728]
airports = airports[airports.AirportID.isin(list(routes.DestinationID)+[3728])]

points = gv.Nodes(airports, ['Longitude', 'Latitude', 'AirportID'], ['Name', 'City'])
graph = gv.Graph((routes, points), ['SourceID', 'DestinationID'])

gv.tile_sources.OSM * graph
Out[32]:
In [33]:
%%output backend='bokeh'
gv.tile_sources.OSM * graph.options(width=800, height=400)
Out[33]:

Polygons

Plot two polygons from a geopandas dataframe:

In [34]:
import geopandas as gpd
gv.tile_sources.OSM * gv.Polygons(gpd.read_file(gpd.datasets.get_path('naturalearth_lowres')).iloc[[0, 5]], vdims=['pop_est', ('name', 'Country')])
Out[34]:

3d Models

In [35]:
duck = 'https://assets.holoviews.org/temp/Duck/glTF/Duck.gltf'
lantern = 'https://assets.holoviews.org/temp/Lantern/glTF/Lantern.gltf'

def random_position(lon, lat):
    return np.random.rand()*40+lon, np.random.rand()*20+lat, 0

Randomly positions lantern and duck 3d models on a terrain map:

In [36]:
Terrain() *\
hv.Overlay([Model3d(duck, random_position(-60, 30), 100000) for i in range(50)]) *\
hv.Overlay([Model3d(lantern, random_position(-110, 30), 10000) for i in range(50)])
Out[36]:

General notes

  • Overall CesiumJS is quite powerful and Cesiumpy is well designed
  • CesiumPy development has stalled and has been inactive for >1 year
  • The CesiumJS API has changed a little bit since Cesiumpy was written so Cesiumpy needs some updates

Risks:

  • Unclear how easy it will be to update an existing CesiumJS plot
  • Need to take over maintenance of CesiumPy
  • Unclear how general we can make it, e.g. recti-linear and curvi-linear

Time estimates:

  • Two days to take over maintenance of CesiumPy and update API
  • Two days to investigate whether we can easily CesiumJS plots
  • One month to build fully featured CesiumJS backend