# standard library importsimportbase64importcopyimportpickleimportuuidfromcollectionsimportnamedtuplefromdash.exceptionsimportPreventUpdate# Holoviews importsimportholoviewsashvfromholoviews.core.decollateimport(expr_to_fn_of_stream_contents,initialize_dynamic,to_expr_extract_streams,)fromholoviews.plotting.plotlyimportDynamicMap,PlotlyRendererfromholoviews.plotting.plotly.callbacksimport(BoundsXCallback,BoundsXYCallback,BoundsYCallback,RangeXCallback,RangeXYCallback,RangeYCallback,Selection1DCallback,)fromholoviews.plotting.plotly.utilimportclean_internal_figure_propertiesfromholoviews.streamsimportDerived,History# Dash importstry:fromdashimportdcc,htmlexceptImportError:importdash_core_componentsasdccimportdash_html_componentsashtml# plotly.py importsimportplotly.graph_objectsasgofromdashimportcallback_contextfromdash.dependenciesimportInput,Output,State# Activate plotly as current HoloViews extensionhv.extension("plotly")# Named tuples definitionsStreamCallback=namedtuple("StreamCallback",["input_ids","fn","output_id"])DashComponents=namedtuple("DashComponents",["graphs","kdims","store","resets","children"])HoloViewsFunctionSpec=namedtuple("HoloViewsFunctionSpec",["fn","kdims","streams"])defget_layout_ranges(plot):layout_ranges={}fig_dict=plot.stateforkinfig_dict['layout']:ifk.startswith(("xaxis","yaxis")):if"range"infig_dict['layout'][k]:layout_ranges[k]={"range":fig_dict['layout'][k]["range"]}ifk.startswith('mapbox'):mapbox_ranges={}if"center"infig_dict['layout'][k]:mapbox_ranges["center"]=fig_dict['layout'][k]["center"]if"zoom"infig_dict['layout'][k]:mapbox_ranges["zoom"]=fig_dict['layout'][k]["zoom"]ifmapbox_ranges:layout_ranges[k]=mapbox_rangesreturnlayout_ranges
[docs]defplot_to_figure(plot,reset_nclicks=0,layout_ranges=None,responsive=True,use_ranges=True):""" Convert a HoloViews plotly plot to a plotly.py Figure. Args: plot: A HoloViews plotly plot object reset_nclicks: Number of times a reset button associated with the plot has been clicked Returns: A plotly.py Figure """fig_dict=plot.stateclean_internal_figure_properties(fig_dict)# Enable uirevision to preserve user-interaction state# Don't use reset_nclicks directly because 0 is treated as no revisionfig_dict['layout']['uirevision']="reset-"+str(reset_nclicks)# Remove range specification so plotly.js autorange + uirevision is in controliflayout_rangesanduse_ranges:forkinfig_dict['layout']:ifk.startswith(("xaxis","yaxis")):fig_dict['layout'][k].pop('range',None)ifk.startswith('mapbox'):fig_dict['layout'][k].pop('zoom',None)fig_dict['layout'][k].pop('center',None)# Remove figure width height, let container decideifresponsive:fig_dict['layout'].pop('autosize',None)ifresponsiveisTrueorresponsive=="width":fig_dict['layout'].pop('width',None)ifresponsiveisTrueorresponsive=="height":fig_dict['layout'].pop('height',None)# Pass to figure constructor to expand magic underscore notationfig=go.Figure(fig_dict)iflayout_rangesanduse_ranges:fig.update_layout(layout_ranges)returnfig
[docs]defto_function_spec(hvobj):""" Convert Dynamic HoloViews object into a pure function that accepts kdim values and stream contents as positional arguments. This borrows the low-level holoviews decollate logic, but instead of returning DynamicMap with cloned streams, returns a HoloViewsFunctionSpec. Args: hvobj: A potentially dynamic Holoviews object Returns: HoloViewsFunctionSpec """kdims_list=[]original_streams=[]streams=[]stream_mapping={}initialize_dynamic(hvobj)expr=to_expr_extract_streams(hvobj,kdims_list,streams,original_streams,stream_mapping)expr_fn=expr_to_fn_of_stream_contents(expr,nkdims=len(kdims_list))# Check for unbounded dimensionsifisinstance(hvobj,DynamicMap)andhvobj.unbounded:dims=', '.join(f'{dim!r}'fordiminhvobj.unbounded)msg=('DynamicMap cannot be displayed without explicit indexing ''as {dims} dimension(s) are unbounded. ''\nSet dimensions bounds with the DynamicMap redim.range ''or redim.values methods.')raiseValueError(msg.format(dims=dims))# Build mapping from kdims to values/rangedimensions_dict={d.name:dfordinhvobj.dimensions()}kdims={}forkinkdims_list:dim=dimensions_dict[k.name]label=dim.labelordim.namekdims[k.name]=label,dim.valuesordim.rangereturnHoloViewsFunctionSpec(fn=expr_fn,kdims=kdims,streams=original_streams)
[docs]defpopulate_store_with_stream_contents(store_data,streams):""" Add contents of streams to the store dictionary Args: store_data: The store dictionary streams: List of streams whose contents should be added to the store Returns: None """forstreaminstreams:# Add streamstore_data["streams"][id(stream)]=copy.deepcopy(stream.contents)ifisinstance(stream,Derived):populate_store_with_stream_contents(store_data,stream.input_streams)elifisinstance(stream,History):populate_store_with_stream_contents(store_data,[stream.input_stream])
[docs]defbuild_derived_callback(derived_stream):""" Build StreamCallback for Derived stream Args: derived_stream: A Derived stream Returns: StreamCallback """input_ids=[id(stream)forstreaminderived_stream.input_streams]constants=copy.copy(derived_stream.constants)transform=derived_stream.transform_functiondefderived_callback(*stream_values):returntransform(stream_values=stream_values,constants=constants)returnStreamCallback(input_ids=input_ids,fn=derived_callback,output_id=id(derived_stream))
[docs]defbuild_history_callback(history_stream):""" Build StreamCallback for History stream Args: history_stream: A History stream Returns: StreamCallback """history_id=id(history_stream)input_stream_id=id(history_stream.input_stream)defhistory_callback(prior_value,input_value):new_value=copy.deepcopy(prior_value)new_value["values"].append(input_value)returnnew_valuereturnStreamCallback(input_ids=[history_id,input_stream_id],fn=history_callback,output_id=history_id)
[docs]defpopulate_stream_callback_graph(stream_callbacks,streams):""" Populate the stream_callbacks dict with StreamCallback instances associated with all of the History and Derived streams in input stream list. Input streams to any History or Derived streams are processed recursively Args: stream_callbacks: dict from id(stream) to StreamCallbacks the should be populated. Order will be a breadth-first traversal of the provided streams list, and any input streams that these depend on. streams: List of streams to build StreamCallbacks from Returns: None """forstreaminstreams:ifisinstance(stream,Derived):cb=build_derived_callback(stream)ifcb.output_idnotinstream_callbacks:stream_callbacks[cb.output_id]=cbpopulate_stream_callback_graph(stream_callbacks,stream.input_streams)elifisinstance(stream,History):cb=build_history_callback(stream)ifcb.output_idnotinstream_callbacks:stream_callbacks[cb.output_id]=cbpopulate_stream_callback_graph(stream_callbacks,[stream.input_stream])
[docs]defencode_store_data(store_data):""" Encode store_data dict into a JSON serializable dict This is currently done by pickling store_data and converting to a base64 encoded string. If HoloViews supports JSON serialization in the future, this method could be updated to use this approach instead Args: store_data: dict potentially containing HoloViews objects Returns: dict that can be JSON serialized """return{"pickled":base64.b64encode(pickle.dumps(store_data)).decode("utf-8")}
[docs]defdecode_store_data(store_data):""" Decode a dict that was encoded by the encode_store_data function. Args: store_data: dict that was encoded by encode_store_data Returns: decoded dict """returnpickle.loads(base64.b64decode(store_data["pickled"]))
[docs]defto_dash(app,hvobjs,reset_button=False,graph_class=dcc.Graph,button_class=html.Button,responsive="width",use_ranges=True,):""" Build Dash components and callbacks from a collection of HoloViews objects Args: app: dash.Dash application instance hvobjs: List of HoloViews objects to build Dash components from reset_button: If True, construct a Button component that, which clicked, will reset the interactive stream values associated with the provided HoloViews objects to their initial values. Defaults to False. graph_class: Class to use when creating Graph components, one of dcc.Graph (default) or ddk.Graph. button_class: Class to use when creating reset button component. E.g. html.Button (default) or dbc.Button responsive: If True graphs will fill their containers width and height responsively. If False, graphs will have a fixed size matching their HoloViews size. If "width" (default), the width is responsive but height matches the HoloViews size. If "height", the height is responsive but the width matches the HoloViews size. use_ranges: If True, initialize graphs with the dimension ranges specified in the HoloViews objects. If False, allow Dash to perform its own auto-range calculations. Returns: DashComponents named tuple with properties: - graphs: List of graph components (with type matching the input graph_class argument) with order corresponding to the order of the input hvobjs list. - resets: List of reset buttons that can be used to reset figure state. List has length 1 if reset_button=True and is empty if reset_button=False. - kdims: Dict from kdim names to Dash Components that can be used to set the corresponding kdim value. - store: dcc.Store the must be included in the app layout - children: Single list of all components above. The order is graphs, kdims, resets, and then the store. """# Number of figuresnum_figs=len(hvobjs)# Initialize component propertiesreset_components=[]graph_components=[]kdim_components={}# Initialize inputs / outputs / states listoutputs=[]inputs=[]states=[]# Initialize otherplots=[]graph_ids=[]initial_fig_dicts=[]all_kdims={}kdims_per_fig=[]# Initialize stream mappingsuid_to_stream_ids={}fig_to_fn_stream={}fig_to_fn_stream_ids={}# Plotly stream typesplotly_stream_types=[RangeXYCallback,RangeXCallback,RangeYCallback,Selection1DCallback,BoundsXYCallback,BoundsXCallback,BoundsYCallback]# Layout rangeslayout_ranges=[]fori,hvobjinenumerate(hvobjs):fn_spec=to_function_spec(hvobj)fig_to_fn_stream[i]=fn_speckdims_per_fig.append(list(fn_spec.kdims))all_kdims.update(fn_spec.kdims)# Convert to figure once so that we can map streams to axesplot=PlotlyRenderer.get_plot(hvobj)plots.append(plot)layout_ranges.append(get_layout_ranges(plot))fig=plot_to_figure(plot,reset_nclicks=0,layout_ranges=layout_ranges[-1],responsive=responsive,use_ranges=use_ranges,).to_dict()initial_fig_dicts.append(fig)# Build graphsgraph_id='graph-'+str(uuid.uuid4())graph_ids.append(graph_id)graph=graph_class(id=graph_id,figure=fig,config={"scrollZoom":True},)graph_components.append(graph)# Build dict from trace uid to plotly callback objectplotly_streams={}forplotly_stream_typeinplotly_stream_types:fortinfig["data"]:ift.get("uid",None)inplotly_stream_type.instances:plotly_streams.setdefault(plotly_stream_type,{})[t["uid"]]= \
plotly_stream_type.instances[t["uid"]]# Build dict from trace uid to list of connected HoloViews streamsforplotly_stream_type,streams_for_typeinplotly_streams.items():foruid,cbinstreams_for_type.items():uid_to_stream_ids.setdefault(plotly_stream_type,{}).setdefault(uid,[]).extend([id(stream)forstreamincb.streams])outputs.append(Output(component_id=graph_id,component_property='figure'))inputs.extend([Input(component_id=graph_id,component_property='selectedData'),Input(component_id=graph_id,component_property='relayoutData')])# Build Store and State liststore_data={"streams":{}}store_id='store-'+str(uuid.uuid4())states.append(State(store_id,'data'))# Store holds mapping from id(stream) -> stream.contents for:# - All extracted streams (including derived)# - All input streams for History and Derived streams.forfn_specinfig_to_fn_stream.values():populate_store_with_stream_contents(store_data,fn_spec.streams)# Initialize empty list of (input_ids, output_id, fn) triples. For each# Derived/History stream, prepend list with triple. Process in# breadth-first order so all inputs to a triple are guaranteed to be earlier# in the list. History streams will input and output their own id, which is# fine.stream_callbacks={}forfn_specinfig_to_fn_stream.values():populate_stream_callback_graph(stream_callbacks,fn_spec.streams)# For each Figure function, save off list of ids for the streams whose contents# should be passed to the function.fori,fn_specinfig_to_fn_stream.items():fig_to_fn_stream_ids[i]=fn_spec.fn,[id(stream)forstreaminfn_spec.streams]# Add store outputstore=dcc.Store(id=store_id,data=encode_store_data(store_data),)outputs.append(Output(store_id,'data'))# Save copy of initial stream contentsinitial_stream_contents=copy.deepcopy(store_data["streams"])# Add kdim sliderskdim_uuids=[]forkdim_name,(kdim_label,kdim_range)inall_kdims.items():slider_uuid=str(uuid.uuid4())slider_id=kdim_name+"-"+slider_uuidslider_label_id=kdim_name+"-label-"+slider_uuidkdim_uuids.append(slider_uuid)html_label=html.Label(id=slider_label_id,children=kdim_label)ifisinstance(kdim_range,list):# list of slider valuesslider=html.Div(children=[html_label,dcc.Slider(id=slider_id,min=kdim_range[0],max=kdim_range[-1],step=None,marks={m:""forminkdim_range},value=kdim_range[0])])else:# Range of slider valuesslider=html.Div(children=[html_label,dcc.Slider(id=slider_id,min=kdim_range[0],max=kdim_range[-1],step=(kdim_range[-1]-kdim_range[0])/11.0,value=kdim_range[0])])kdim_components[kdim_name]=sliderinputs.append(Input(component_id=slider_id,component_property="value"))# Add reset buttonifreset_button:reset_id='reset-'+str(uuid.uuid4())reset_button=button_class(id=reset_id,children="Reset")inputs.append(Input(component_id=reset_id,component_property='n_clicks'))reset_components.append(reset_button)# Register Graphs/Store callback@app.callback(outputs,inputs,states)defupdate_figure(*args):triggered_prop_ids={entry["prop_id"]forentryincallback_context.triggered}# Unpack argsselected_dicts=[args[j]or{}forjinrange(0,num_figs*2,2)]relayout_dicts=[args[j]or{}forjinrange(1,num_figs*2,2)]# Get storeany_change=Falsestore_data=decode_store_data(args[-1])reset_nclicks=0ifreset_button:reset_nclicks=args[-2]or0prior_reset_nclicks=store_data.get("reset_nclicks",0)ifreset_nclicks!=prior_reset_nclicks:store_data["reset_nclicks"]=reset_nclicks# clear stream valuesstore_data["streams"]=copy.deepcopy(initial_stream_contents)selected_dicts=[Nonefor_inselected_dicts]relayout_dicts=[Nonefor_inrelayout_dicts]any_change=True# Init store data if neededifstore_dataisNone:store_data={"streams":{}}# Get kdim valuesstore_data.setdefault("kdims",{})fori,kdiminzip(range(num_figs*2,num_figs*2+len(all_kdims)),all_kdims):ifkdimnotinstore_data["kdims"]orstore_data["kdims"][kdim]!=args[i]:store_data["kdims"][kdim]=args[i]any_change=True# Update store_data with interactive stream valuesforfig_indinrange(len(initial_fig_dicts)):graph_id=graph_ids[fig_ind]# plotly_stream_typesforplotly_stream_type,uid_to_streams_for_typeinuid_to_stream_ids.items():forpanel_propinplotly_stream_type.callback_properties:ifpanel_prop=="selected_data":ifgraph_id+".selectedData"intriggered_prop_ids:# Only update selectedData values that just changed.# This way we don't save values that may have been cleared# from the store above by the reset button.stream_event_data=plotly_stream_type.get_event_data_from_property_update(panel_prop,selected_dicts[fig_ind],initial_fig_dicts[fig_ind])any_change=update_stream_values_for_type(store_data,stream_event_data,uid_to_streams_for_type)orany_changeelifpanel_prop=="viewport":ifgraph_id+".relayoutData"intriggered_prop_ids:stream_event_data=plotly_stream_type.get_event_data_from_property_update(panel_prop,relayout_dicts[fig_ind],initial_fig_dicts[fig_ind])stream_event_data={uid:event_dataforuid,event_datainstream_event_data.items()ifevent_data["x_range"]isnotNoneorevent_data["y_range"]isnotNone}any_change=update_stream_values_for_type(store_data,stream_event_data,uid_to_streams_for_type)orany_changeelifpanel_prop=="relayout_data":ifgraph_id+".relayoutData"intriggered_prop_ids:stream_event_data=plotly_stream_type.get_event_data_from_property_update(panel_prop,relayout_dicts[fig_ind],initial_fig_dicts[fig_ind])any_change=update_stream_values_for_type(store_data,stream_event_data,uid_to_streams_for_type)orany_changeifnotany_change:raisePreventUpdate# Update store with derived/history stream valuesforoutput_idinreversed(stream_callbacks):stream_callback=stream_callbacks[output_id]input_ids=stream_callback.input_idsfn=stream_callback.fnoutput_id=stream_callback.output_idinput_values=[store_data["streams"][input_id]forinput_idininput_ids]output_value=fn(*input_values)store_data["streams"][output_id]=output_valuefigs=[None]*num_figsforfig_ind,(fn,stream_ids)infig_to_fn_stream_ids.items():fig_kdim_values=[store_data["kdims"][kd]forkdinkdims_per_fig[fig_ind]]stream_values=[store_data["streams"][stream_id]forstream_idinstream_ids]hvobj=fn(*(fig_kdim_values+stream_values))plot=PlotlyRenderer.get_plot(hvobj)fig=plot_to_figure(plot,reset_nclicks=reset_nclicks,layout_ranges=layout_ranges[fig_ind],responsive=responsive,use_ranges=use_ranges,).to_dict()figs[fig_ind]=figreturnfigs+[encode_store_data(store_data)]# Register key dimension slider callbacks# Install callbacks to update kdim labels based on slider valuesfori,kdim_nameinenumerate(all_kdims):kdim_label=all_kdims[kdim_name][0]kdim_slider_id=kdim_name+"-"+kdim_uuids[i]kdim_label_id=kdim_name+"-label-"+kdim_uuids[i]@app.callback(Output(component_id=kdim_label_id,component_property="children"),[Input(component_id=kdim_slider_id,component_property="value")])defupdate_kdim_label(value,kdim_label=kdim_label):returnf"{kdim_label}: {value:.2f}"# Collect Dash components into DashComponents namedtuplecomponents=DashComponents(graphs=graph_components,kdims=kdim_components,resets=reset_components,store=store,children=(graph_components+list(kdim_components.values())+reset_components+[store]))returncomponents
[docs]defupdate_stream_values_for_type(store_data,stream_event_data,uid_to_streams_for_type):""" Update the store with values of streams for a single type Args: store_data: Current store dictionary stream_event_data: Potential stream data for current plotly event and traces in figures uid_to_streams_for_type: Mapping from trace UIDs to HoloViews streams of a particular type Returns: any_change: Whether any stream value has been updated """any_change=Falseforuid,event_datainstream_event_data.items():ifuidinuid_to_streams_for_type:forstream_idinuid_to_streams_for_type[uid]:ifstream_idnotinstore_data["streams"]or \
store_data["streams"][stream_id]!=event_data:store_data["streams"][stream_id]=event_dataany_change=Truereturnany_change