diff --git a/packages/python/plotly/js/widget.ts b/packages/python/plotly/js/widget.ts index dc4de6dcd6..6410335b30 100644 --- a/packages/python/plotly/js/widget.ts +++ b/packages/python/plotly/js/widget.ts @@ -13,15 +13,6 @@ type InputDeviceState = { buttons: any; }; -type Js2PyLayoutDeltaMsg = { - layout_delta: any; - layout_edit_id: any; -}; - -type Js2PyMsg = { - source_view_id: string; -}; - type Js2PyPointsCallbackMsg = { event_type: string; points: Points; @@ -29,26 +20,6 @@ type Js2PyPointsCallbackMsg = { selector: Selector; }; -type Js2PyRelayoutMsg = Js2PyMsg & { - relayout_data: any; -}; - -type Js2PyRestyleMsg = Js2PyMsg & { - style_data: any; - style_traces?: null | number | number[]; -}; - -type Js2PyTraceDeltasMsg = { - trace_deltas: any; - trace_edit_id: any; -}; - -type Js2PyUpdateMsg = Js2PyMsg & { - style_data: any; - layout_data: any; - style_traces?: null | number | number[]; -}; - type Points = { trace_indexes: number[]; point_indexes: number[]; @@ -58,13 +29,9 @@ type Points = { }; type Py2JsMsg = { - trace_edit_id?: any; - layout_edit_id?: any; - source_view_id?: any; -}; - -type Py2JsAddTracesMsg = Py2JsMsg & { - trace_data: any; + trace_edit_id?: any; + layout_edit_id?: any; + source_view_id?: any; }; type Py2JsAnimateMsg = Py2JsMsg & { @@ -74,39 +41,6 @@ type Py2JsAnimateMsg = Py2JsMsg & { animation_opts?: any; }; -type Py2JsDeleteTracesMsg = Py2JsMsg & { - delete_inds: number[]; -}; - -type Py2JsMoveTracesMsg = { - current_trace_inds: number[]; - new_trace_inds: number[]; -}; - -type Py2JsRestyleMsg = Py2JsMsg & { - restyle_data: any; - restyle_traces?: null | number | number[]; -}; - -type Py2JsRelayoutMsg = Py2JsMsg & { - relayout_data: any; -}; - -type Py2JsRemoveLayoutPropsMsg = { - remove_props: any; -}; - -type Py2JsRemoveTracePropsMsg = { - remove_props: any; - remove_trace: any; -}; - -type Py2JsUpdateMsg = Py2JsMsg & { - style_data: any; - layout_data: any; - style_traces?: null | number | number[]; -}; - type Selector = { type: "box" | "lasso"; selector_state: @@ -114,695 +48,6 @@ type Selector = { | { xs: number[]; ys: number[] }; }; -// Model -// ===== -/** - * A FigureModel holds a mirror copy of the state of a FigureWidget on - * the Python side. There is a one-to-one relationship between JavaScript - * FigureModels and Python FigureWidgets. The JavaScript FigureModel is - * initialized as soon as a Python FigureWidget initialized, this happens - * even before the widget is first displayed in the Notebook - */ - -type Serializer = { - deserialize(value: Out): In; - serialize(value: In): Out; -} - -export class FigureModel { - model; - serializers: Record - - constructor(model, serializers: Record) { - this.model = model; - this.serializers = serializers; - } - - get(key: string) { - const serializer = this.serializers[key]; - const update = this.model.get(key) - if (serializer?.deserialize) { - return serializer.deserialize(update) - } - return update; - } - - set(key: string, value: unknown) { - let serializer = this.serializers[key]; - if (serializer?.serialize) { - value = serializer.serialize(value) - } - this.model.set(key, value); - } - - on(event: string, cb?: () => void) { - this.model.on(event, cb); - } - - save_changes() { - this.model.save_changes(); - } - - defaults() { - return { - - // Data and Layout - // --------------- - // The _data and _layout properties are synchronized with the - // Python side on initialization only. After initialization, these - // properties are kept in sync through the use of the _py2js_* - // messages - _data: [], - _layout: {}, - _config: {}, - - // Python -> JS messages - // --------------------- - // Messages are implemented using trait properties. This is done so - // that we can take advantage of ipywidget's binary serialization - // protocol. - // - // Messages are sent by the Python side by assigning the message - // contents to the appropriate _py2js_* property, and then immediately - // setting it to None. Messages are received by the JavaScript - // side by registering property change callbacks in the initialize - // methods for FigureModel and FigureView. e.g. (where this is a - // FigureModel): - // - // this.on('change:_py2js_addTraces', this.do_addTraces, this); - // - // Message handling methods, do_addTraces, are responsible for - // performing the appropriate action if the message contents are - // not null - - /** - * @typedef {null|Object} Py2JsAddTracesMsg - * @property {Array.} trace_data - * Array of traces to append to the end of the figure's current traces - * @property {Number} trace_edit_id - * Edit ID to use when returning trace deltas using - * the _js2py_traceDeltas message. - * @property {Number} layout_edit_id - * Edit ID to use when returning layout deltas using - * the _js2py_layoutDelta message. - */ - _py2js_addTraces: null, - - /** - * @typedef {null|Object} Py2JsDeleteTracesMsg - * @property {Array.} delete_inds - * Array of indexes of traces to be deleted, in ascending order - * @property {Number} trace_edit_id - * Edit ID to use when returning trace deltas using - * the _js2py_traceDeltas message. - * @property {Number} layout_edit_id - * Edit ID to use when returning layout deltas using - * the _js2py_layoutDelta message. - */ - _py2js_deleteTraces: null, - - /** - * @typedef {null|Object} Py2JsMoveTracesMsg - * @property {Array.} current_trace_inds - * Array of the current indexes of traces to be moved - * @property {Array.} new_trace_inds - * Array of the new indexes that traces should be moved to. - */ - _py2js_moveTraces: null, - - /** - * @typedef {null|Object} Py2JsRestyleMsg - * @property {Object} restyle_data - * Restyle data as accepted by Plotly.restyle - * @property {null|Array.} restyle_traces - * Array of indexes of the traces that the resytle operation applies - * to, or null to apply the operation to all traces - * @property {Number} trace_edit_id - * Edit ID to use when returning trace deltas using - * the _js2py_traceDeltas message - * @property {Number} layout_edit_id - * Edit ID to use when returning layout deltas using - * the _js2py_layoutDelta message - * @property {null|String} source_view_id - * view_id of the FigureView that triggered the original restyle - * event (e.g. by clicking the legend), or null if the restyle was - * triggered from Python - */ - _py2js_restyle: null, - - /** - * @typedef {null|Object} Py2JsRelayoutMsg - * @property {Object} relayout_data - * Relayout data as accepted by Plotly.relayout - * @property {Number} layout_edit_id - * Edit ID to use when returning layout deltas using - * the _js2py_layoutDelta message - * @property {null|String} source_view_id - * view_id of the FigureView that triggered the original relayout - * event (e.g. by clicking the zoom button), or null if the - * relayout was triggered from Python - */ - _py2js_relayout: null, - - /** - * @typedef {null|Object} Py2JsUpdateMsg - * @property {Object} style_data - * Style data as accepted by Plotly.update - * @property {Object} layout_data - * Layout data as accepted by Plotly.update - * @property {Array.} style_traces - * Array of indexes of the traces that the update operation applies - * to, or null to apply the operation to all traces - * @property {Number} trace_edit_id - * Edit ID to use when returning trace deltas using - * the _js2py_traceDeltas message - * @property {Number} layout_edit_id - * Edit ID to use when returning layout deltas using - * the _js2py_layoutDelta message - * @property {null|String} source_view_id - * view_id of the FigureView that triggered the original update - * event (e.g. by clicking a button), or null if the update was - * triggered from Python - */ - _py2js_update: null, - - /** - * @typedef {null|Object} Py2JsAnimateMsg - * @property {Object} style_data - * Style data as accepted by Plotly.animate - * @property {Object} layout_data - * Layout data as accepted by Plotly.animate - * @property {Array.} style_traces - * Array of indexes of the traces that the animate operation applies - * to, or null to apply the operation to all traces - * @property {Object} animation_opts - * Animation options as accepted by Plotly.animate - * @property {Number} trace_edit_id - * Edit ID to use when returning trace deltas using - * the _js2py_traceDeltas message - * @property {Number} layout_edit_id - * Edit ID to use when returning layout deltas using - * the _js2py_layoutDelta message - * @property {null|String} source_view_id - * view_id of the FigureView that triggered the original animate - * event (e.g. by clicking a button), or null if the update was - * triggered from Python - */ - _py2js_animate: null, - - /** - * @typedef {null|Object} Py2JsRemoveLayoutPropsMsg - * @property {Array.>} remove_props - * Array of property paths to remove. Each propery path is an - * array of property names or array indexes that locate a property - * inside the _layout object - */ - _py2js_removeLayoutProps: null, - - /** - * @typedef {null|Object} Py2JsRemoveTracePropsMsg - * @property {Number} remove_trace - * The index of the trace from which to remove properties - * @property {Array.>} remove_props - * Array of property paths to remove. Each propery path is an - * array of property names or array indexes that locate a property - * inside the _data[remove_trace] object - */ - _py2js_removeTraceProps: null, - - // JS -> Python messages - // --------------------- - // Messages are sent by the JavaScript side by assigning the - // message contents to the appropriate _js2py_* property and then - // calling the `touch` method on the view that triggered the - // change. e.g. (where this is a FigureView): - // - // this.model.set('_js2py_restyle', data); - // this.touch(); - // - // The Python side is responsible for setting the property to None - // after receiving the message. - // - // Message trigger logic is described in the corresponding - // handle_plotly_* methods of FigureView - - /** - * @typedef {null|Object} Js2PyRestyleMsg - * @property {Object} style_data - * Style data that was passed to Plotly.restyle - * @property {Array.} style_traces - * Array of indexes of the traces that the restyle operation - * was applied to, or null if applied to all traces - * @property {String} source_view_id - * view_id of the FigureView that triggered the original restyle - * event (e.g. by clicking the legend) - */ - _js2py_restyle: null, - - /** - * @typedef {null|Object} Js2PyRelayoutMsg - * @property {Object} relayout_data - * Relayout data that was passed to Plotly.relayout - * @property {String} source_view_id - * view_id of the FigureView that triggered the original relayout - * event (e.g. by clicking the zoom button) - */ - _js2py_relayout: null, - - /** - * @typedef {null|Object} Js2PyUpdateMsg - * @property {Object} style_data - * Style data that was passed to Plotly.update - * @property {Object} layout_data - * Layout data that was passed to Plotly.update - * @property {Array.} style_traces - * Array of indexes of the traces that the update operation applied - * to, or null if applied to all traces - * @property {String} source_view_id - * view_id of the FigureView that triggered the original relayout - * event (e.g. by clicking the zoom button) - */ - _js2py_update: null, - - /** - * @typedef {null|Object} Js2PyLayoutDeltaMsg - * @property {Object} layout_delta - * The layout delta object that contains all of the properties of - * _fullLayout that are not identical to those in the - * FigureModel's _layout property - * @property {Number} layout_edit_id - * Edit ID of message that triggered the creation of layout delta - */ - _js2py_layoutDelta: null, - - /** - * @typedef {null|Object} Js2PyTraceDeltasMsg - * @property {Array.} trace_deltas - * Array of trace delta objects. Each trace delta contains the - * trace's uid along with all of the properties of _fullData that - * are not identical to those in the FigureModel's _data property - * @property {Number} trace_edit_id - * Edit ID of message that triggered the creation of trace deltas - */ - _js2py_traceDeltas: null, - - /** - * Object representing a collection of points for use in click, hover, - * and selection events - * @typedef {Object} Points - * @property {Array.} trace_indexes - * Array of the trace index for each point - * @property {Array.} point_indexes - * Array of the index of each point in its own trace - * @property {null|Array.} xs - * Array of the x coordinate of each point (for cartesian trace types) - * or null (for non-cartesian trace types) - * @property {null|Array.} ys - * Array of the y coordinate of each point (for cartesian trace types) - * or null (for non-cartesian trace types - * @property {null|Array.} zs - * Array of the z coordinate of each point (for 3D cartesian - * trace types) - * or null (for non-3D-cartesian trace types) - */ - - /** - * Object representing the state of the input devices during a - * plotly event - * @typedef {Object} InputDeviceState - * @property {boolean} alt - true if alt key pressed, - * false otherwise - * @property {boolean} ctrl - true if ctrl key pressed, - * false otherwise - * @property {boolean} meta - true if meta key pressed, - * false otherwise - * @property {boolean} shift - true if shift key pressed, - * false otherwise - * - * @property {boolean} button - * Indicates which button was pressed on the mouse to trigger the - * event. - * 0: Main button pressed, usually the left button or the - * un-initialized state - * 1: Auxiliary button pressed, usually the wheel button or - * the middle button (if present) - * 2: Secondary button pressed, usually the right button - * 3: Fourth button, typically the Browser Back button - * 4: Fifth button, typically the Browser Forward button - * - * @property {boolean} buttons - * Indicates which buttons were pressed on the mouse when the event - * is triggered. - * 0 : No button or un-initialized - * 1 : Primary button (usually left) - * 2 : Secondary button (usually right) - * 4 : Auxilary button (usually middle or mouse wheel button) - * 8 : 4th button (typically the "Browser Back" button) - * 16 : 5th button (typically the "Browser Forward" button) - * - * Combinations of buttons are represented by the sum of the codes - * above. e.g. a value of 7 indicates buttons 1 (primary), - * 2 (secondary), and 4 (auxilary) were pressed during the event - */ - - /** - * @typedef {Object} BoxSelectorState - * @property {Array.} xrange - * Two element array containing the x-range of the box selection - * @property {Array.} yrange - * Two element array containing the y-range of the box selection - */ - - /** - * @typedef {Object} LassoSelectorState - * @property {Array.} xs - * Array of the x-coordinates of the lasso selection region - * @property {Array.} ys - * Array of the y-coordinates of the lasso selection region - */ - - /** - * Object representing the state of the selection tool during a - * plotly_select event - * @typedef {Object} Selector - * @property {String} type - * Selection type. One of: 'box', or 'lasso' - * @property {BoxSelectorState|LassoSelectorState} selector_state - */ - - /** - * @typedef {null|Object} Js2PyPointsCallbackMsg - * @property {string} event_type - * Name of the triggering event. One of 'plotly_click', - * 'plotly_hover', 'plotly_unhover', or 'plotly_selected' - * @property {null|Points} points - * Points object for event - * @property {null|InputDeviceState} device_state - * InputDeviceState object for event - * @property {null|Selector} selector - * State of the selection tool for 'plotly_selected' events, null - * for other event types - */ - _js2py_pointsCallback: null, - - // Message tracking - // ---------------- - /** - * @type {Number} - * layout_edit_id of the last layout modification operation - * requested by the Python side - */ - _last_layout_edit_id: 0, - - /** - * @type {Number} - * trace_edit_id of the last trace modification operation - * requested by the Python side - */ - _last_trace_edit_id: 0, - }; - } - - /** - * Initialize FigureModel. Called when the Python FigureWidget is first - * constructed - */ - initialize() { - this.model.on("change:_data", () => this.do_data()); - this.model.on("change:_layout", () => this.do_layout()); - this.model.on("change:_py2js_addTraces", () => this.do_addTraces()); - this.model.on("change:_py2js_deleteTraces", () => this.do_deleteTraces()); - this.model.on("change:_py2js_moveTraces", () => this.do_moveTraces()); - this.model.on("change:_py2js_restyle", () => this.do_restyle()); - this.model.on("change:_py2js_relayout", () => this.do_relayout()); - this.model.on("change:_py2js_update", () => this.do_update()); - this.model.on("change:_py2js_animate", () => this.do_animate()); - this.model.on("change:_py2js_removeLayoutProps", () => this.do_removeLayoutProps()); - this.model.on("change:_py2js_removeTraceProps", () => this.do_removeTraceProps()); - } - - /** - * Input a trace index specification and return an Array of trace - * indexes where: - * - * - null|undefined -> Array of all traces - * - Trace index as Number -> Single element array of input index - * - Array of trace indexes -> Input array unchanged - * - * @param {undefined|null|Number|Array.} trace_indexes - * @returns {Array.} - * Array of trace indexes - * @private - */ - _normalize_trace_indexes(trace_indexes?: null | number | number[]): number[] { - if (trace_indexes === null || trace_indexes === undefined) { - var numTraces = this.model.get("_data").length; - trace_indexes = _.range(numTraces); - } - if (!Array.isArray(trace_indexes)) { - // Make sure idx is an array - trace_indexes = [trace_indexes]; - } - return trace_indexes; - } - - /** - * Log changes to the _data trait - * - * This should only happed on FigureModel initialization - */ - do_data() {} - - /** - * Log changes to the _layout trait - * - * This should only happed on FigureModel initialization - */ - do_layout() {} - - /** - * Handle addTraces message - */ - do_addTraces() { - // add trace to plot - /** @type {Py2JsAddTracesMsg} */ - var msgData: Py2JsAddTracesMsg = this.model.get("_py2js_addTraces"); - - if (msgData !== null) { - var currentTraces = this.model.get("_data"); - var newTraces = msgData.trace_data; - _.forEach(newTraces, function (newTrace) { - currentTraces.push(newTrace); - }); - } - } - - /** - * Handle deleteTraces message - */ - do_deleteTraces() { - // remove traces from plot - - /** @type {Py2JsDeleteTracesMsg} */ - var msgData: Py2JsDeleteTracesMsg = this.model.get("_py2js_deleteTraces"); - - if (msgData !== null) { - var delete_inds = msgData.delete_inds; - var tracesData = this.model.get("_data"); - - // Remove del inds in reverse order so indexes remain valid - // throughout loop - delete_inds - .slice() - .reverse() - .forEach(function (del_ind) { - tracesData.splice(del_ind, 1); - }); - } - } - - /** - * Handle moveTraces message - */ - do_moveTraces() { - /** @type {Py2JsMoveTracesMsg} */ - var msgData: Py2JsMoveTracesMsg = this.model.get("_py2js_moveTraces"); - - if (msgData !== null) { - var tracesData = this.model.get("_data"); - var currentInds = msgData.current_trace_inds; - var newInds = msgData.new_trace_inds; - - performMoveTracesLike(tracesData, currentInds, newInds); - } - } - - /** - * Handle restyle message - */ - do_restyle() { - /** @type {Py2JsRestyleMsg} */ - var msgData: Py2JsRestyleMsg = this.model.get("_py2js_restyle"); - if (msgData !== null) { - var restyleData = msgData.restyle_data; - var restyleTraces = this._normalize_trace_indexes(msgData.restyle_traces); - performRestyleLike(this.model.get("_data"), restyleData, restyleTraces); - } - } - - /** - * Handle relayout message - */ - do_relayout() { - /** @type {Py2JsRelayoutMsg} */ - var msgData: Py2JsRelayoutMsg = this.model.get("_py2js_relayout"); - - if (msgData !== null) { - performRelayoutLike(this.model.get("_layout"), msgData.relayout_data); - } - } - - /** - * Handle update message - */ - do_update() { - /** @type {Py2JsUpdateMsg} */ - var msgData: Py2JsUpdateMsg = this.model.get("_py2js_update"); - - if (msgData !== null) { - var style = msgData.style_data; - var layout = msgData.layout_data; - var styleTraces = this._normalize_trace_indexes(msgData.style_traces); - performRestyleLike(this.model.get("_data"), style, styleTraces); - performRelayoutLike(this.model.get("_layout"), layout); - } - } - - /** - * Handle animate message - */ - do_animate() { - /** @type {Py2JsAnimateMsg} */ - var msgData: Py2JsAnimateMsg = this.model.get("_py2js_animate"); - if (msgData !== null) { - var styles = msgData.style_data; - var layout = msgData.layout_data; - var trace_indexes = this._normalize_trace_indexes(msgData.style_traces); - - for (var i = 0; i < styles.length; i++) { - var style = styles[i]; - var trace_index = trace_indexes[i]; - var trace = this.model.get("_data")[trace_index]; - performRelayoutLike(trace, style); - } - - performRelayoutLike(this.model.get("_layout"), layout); - } - } - - /** - * Handle removeLayoutProps message - */ - do_removeLayoutProps() { - /** @type {Py2JsRemoveLayoutPropsMsg} */ - var msgData: Py2JsRemoveLayoutPropsMsg = this.model.get( - "_py2js_removeLayoutProps" - ); - - if (msgData !== null) { - var keyPaths = msgData.remove_props; - var layout = this.model.get("_layout"); - performRemoveProps(layout, keyPaths); - } - } - - /** - * Handle removeTraceProps message - */ - do_removeTraceProps() { - /** @type {Py2JsRemoveTracePropsMsg} */ - var msgData: Py2JsRemoveTracePropsMsg = this.model.get("_py2js_removeTraceProps"); - if (msgData !== null) { - var keyPaths = msgData.remove_props; - var traceIndex = msgData.remove_trace; - var trace = this.model.get("_data")[traceIndex]; - - performRemoveProps(trace, keyPaths); - } - } -} - -const serializers: Record = { - _data: { - deserialize: py2js_deserializer, - serialize: js2py_serializer, - }, - _layout: { - deserialize: py2js_deserializer, - serialize: js2py_serializer, - }, - _py2js_addTraces: { - deserialize: py2js_deserializer, - serialize: js2py_serializer, - }, - _py2js_deleteTraces: { - deserialize: py2js_deserializer, - serialize: js2py_serializer, - }, - _py2js_moveTraces: { - deserialize: py2js_deserializer, - serialize: js2py_serializer, - }, - _py2js_restyle: { - deserialize: py2js_deserializer, - serialize: js2py_serializer, - }, - _py2js_relayout: { - deserialize: py2js_deserializer, - serialize: js2py_serializer, - }, - _py2js_update: { - deserialize: py2js_deserializer, - serialize: js2py_serializer, - }, - _py2js_animate: { - deserialize: py2js_deserializer, - serialize: js2py_serializer, - }, - _py2js_removeLayoutProps: { - deserialize: py2js_deserializer, - serialize: js2py_serializer, - }, - _py2js_removeTraceProps: { - deserialize: py2js_deserializer, - serialize: js2py_serializer, - }, - _js2py_restyle: { - deserialize: py2js_deserializer, - serialize: js2py_serializer, - }, - _js2py_relayout: { - deserialize: py2js_deserializer, - serialize: js2py_serializer, - }, - _js2py_update: { - deserialize: py2js_deserializer, - serialize: js2py_serializer, - }, - _js2py_layoutDelta: { - deserialize: py2js_deserializer, - serialize: js2py_serializer, - }, - _js2py_traceDeltas: { - deserialize: py2js_deserializer, - serialize: js2py_serializer, - }, - _js2py_pointsCallback: { - deserialize: py2js_deserializer, - serialize: js2py_serializer, - }, -}; - // View // ==== /** @@ -815,15 +60,21 @@ const serializers: Record = { * @type {widgets.DOMWidgetView} */ export class FigureView { - viewID: string; resizeEventListener: () => void; - model: FigureModel; + model; el: HTMLElement; - constructor(model: FigureModel, el: HTMLElement) { + constructor(model, el: HTMLElement) { this.model = model; this.el = el; + + model.on('change:_widget_layout', () => { + this.render(); + }); + model.on('change:_widget_data', () => { + this.render(); + }); } /** @@ -833,144 +84,68 @@ export class FigureView { * FigureModel, and it won't happen at all if the Python FigureWidget * is never displayed in a notebook output cell */ - perform_render() { - var that = this; - + render() { // Wire up message property callbacks // ---------------------------------- // Python -> JS event properties - this.model.on("change:_py2js_addTraces", () => this.do_addTraces()); - this.model.on("change:_py2js_deleteTraces", () => this.do_deleteTraces()); - this.model.on("change:_py2js_moveTraces", () => this.do_moveTraces()); - this.model.on("change:_py2js_restyle", () => this.do_restyle()); - this.model.on("change:_py2js_relayout", () => this.do_relayout()); - this.model.on("change:_py2js_update", () => this.do_update()); this.model.on("change:_py2js_animate", () => this.do_animate()); // MathJax v2 configuration // --------------------- (window as any)?.MathJax?.Hub?.Config?.({ SVG: { font: "STIX-Web" } }); - // Get message ids - // --------------------- - var layout_edit_id = this.model.get("_last_layout_edit_id"); - var trace_edit_id = this.model.get("_last_trace_edit_id"); - - // Set view UID - // ------------ - this.viewID = randstr(); - // Initialize Plotly.js figure // --------------------------- // We must clone the model's data and layout properties so that // the model is not directly mutated by the Plotly.js library. - var initialTraces = _.cloneDeep(this.model.get("_data")); - var initialLayout = _.cloneDeep(this.model.get("_layout")); + var initialTraces = _.cloneDeep(this.model.get("_widget_data")); + var initialLayout = _.cloneDeep(this.model.get("_widget_layout")); if (!initialLayout.height) { initialLayout.height = 360; } var config = this.model.get("_config"); config.editSelection = false; - Plotly.newPlot(that.el, initialTraces, initialLayout, config).then( - function () { - // ### Send trace deltas ### - // We create an array of deltas corresponding to the new - // traces. - that._sendTraceDeltas(trace_edit_id); - - // ### Send layout delta ### - that._sendLayoutDelta(layout_edit_id); - + const that = this; + const handlePlotlyEvents = () => { // Wire up plotly event callbacks - (that.el).on("plotly_restyle", function (update: any) { - that.handle_plotly_restyle(update); - }); - (that.el).on("plotly_relayout", function (update: any) { - that.handle_plotly_relayout(update); + (this.el).on("plotly_click", function (update: any) { + that.handle_plotly_click(update); }); - (that.el).on("plotly_update", function (update: any) { - that.handle_plotly_update(update); + (this.el).on("plotly_hover", function (update: any) { + that.handle_plotly_hover(update); }); - (that.el).on("plotly_click", function (update: any) { - that.handle_plotly_click(update); + (this.el).on("plotly_unhover", function (update: any) { + that.handle_plotly_unhover(update); }); - (that.el).on("plotly_hover", function (update: any) { - that.handle_plotly_hover(update); + (this.el).on("plotly_selected", function (update: any) { + that.handle_plotly_selected(update); }); - (that.el).on("plotly_unhover", function (update: any) { - that.handle_plotly_unhover(update); + (this.el).on("plotly_deselect", function () { + that.handle_plotly_deselect({}); }); - (that.el).on("plotly_selected", function (update: any) { - that.handle_plotly_selected(update); - }); - (that.el).on("plotly_deselect", function (update: any) { - that.handle_plotly_deselect(update); - }); - (that.el).on("plotly_doubleclick", function (update: any) { - that.handle_plotly_doubleclick(update); - }); - - // Emit event indicating that the widget has finished - // rendering - var event = new CustomEvent("plotlywidget-after-render", { - detail: { element: that.el, viewID: that.viewID }, + (this.el).on("plotly_doubleclick", function () { + that.handle_plotly_doubleclick({}); }); - + // Dispatch/Trigger/Fire the event document.dispatchEvent(event); } - ); + + + Plotly + .react(this.el, initialTraces, initialLayout, config) + .then(handlePlotlyEvents) } /** - * Respond to Lumino events - */ - _processLuminoMessage(msg: any, _super: any) { - _super.apply(this, arguments); - var that = this; - switch (msg.type) { - case "before-attach": - // Render an initial empty figure. This establishes with - // the page that the element will not be empty, avoiding - // some occasions where the dynamic sizing behavior leads - // to collapsed figure dimensions. - var axisHidden = { - showgrid: false, - showline: false, - tickvals: [] as any[], - }; - - Plotly.newPlot(that.el, [], { - xaxis: axisHidden, - yaxis: axisHidden, - }); - this.resizeEventListener = () => { - this.autosizeFigure(); - } - window.addEventListener("resize", this.resizeEventListener); - break; - case "after-attach": - // Rendering actual figure in the after-attach event allows - // Plotly.js to size the figure to fill the available element - this.perform_render(); - break; - case "after-show": - case "resize": - this.autosizeFigure(); - break; - } - } + * Handle Plotly.js events + */ autosizeFigure() { - var that = this; - var layout = that.model.get("_layout"); + var layout = this.model.get("_widget_layout"); if (_.isNil(layout) || _.isNil(layout.width)) { - // @ts-ignore - Plotly.Plots.resize(that.el).then(function () { - var layout_edit_id = that.model.get("_last_layout_edit_id"); - that._sendLayoutDelta(layout_edit_id); - }); + Plotly.Plots.resize(this.el); } } @@ -983,47 +158,6 @@ export class FigureView { window.removeEventListener("resize", this.resizeEventListener); } - /** - * Return the figure's _fullData array merged with its data array - * - * The merge ensures that for any properties that el._fullData and - * el.data have in common, we return the version from el.data - * - * Named colorscales are one example of why this is needed. The el.data - * array will hold named colorscale strings (e.g. 'Viridis'), while the - * el._fullData array will hold the actual colorscale array. e.g. - * - * el.data[0].marker.colorscale == 'Viridis' but - * el._fullData[0].marker.colorscale = [[..., ...], ...] - * - * Performing the merge allows our FigureModel to retain the 'Viridis' - * string, rather than having it overridded by the colorscale array. - * - */ - getFullData() { - return _.mergeWith( - {}, - (this.el)._fullData, - (this.el).data, - fullMergeCustomizer - ); - } - - /** - * Return the figure's _fullLayout object merged with its layout object - * - * See getFullData documentation for discussion of why the merge is - * necessary - */ - getFullLayout() { - return _.mergeWith( - {}, - (this.el)._fullLayout, - (this.el).layout, - fullMergeCustomizer - ); - } - /** * Build Points data structure from data supplied by the plotly_click, * plotly_hover, or plotly_select events @@ -1172,94 +306,6 @@ export class FigureView { return selectorObject; } - /** - * Handle ploty_restyle events emitted by the Plotly.js library - * @param data - */ - handle_plotly_restyle(data: any) { - if (data === null || data === undefined) { - // No data to report to the Python side - return; - } - - if (data[0] && data[0].hasOwnProperty("_doNotReportToPy")) { - // Restyle originated on the Python side - return; - } - - // Unpack data - var styleData = data[0]; - var styleTraces = data[1]; - - // Construct restyle message to send to the Python side - /** @type {Js2PyRestyleMsg} */ - var restyleMsg: Js2PyRestyleMsg = { - style_data: styleData, - style_traces: styleTraces, - source_view_id: this.viewID, - }; - - this.model.set("_js2py_restyle", restyleMsg); - this.touch(); - } - - touch() { - this.model.save_changes(); - } - - /** - * Handle plotly_relayout events emitted by the Plotly.js library - * @param data - */ - handle_plotly_relayout(data: any) { - if (data === null || data === undefined) { - // No data to report to the Python side - return; - } - - if (data.hasOwnProperty("_doNotReportToPy")) { - // Relayout originated on the Python side - return; - } - - /** @type {Js2PyRelayoutMsg} */ - var relayoutMsg: Js2PyRelayoutMsg = { - relayout_data: data, - source_view_id: this.viewID, - }; - - this.model.set("_js2py_relayout", relayoutMsg); - this.touch(); - } - - /** - * Handle plotly_update events emitted by the Plotly.js library - * @param data - */ - handle_plotly_update(data: any) { - if (data === null || data === undefined) { - // No data to report to the Python side - return; - } - - if (data["data"] && data["data"][0].hasOwnProperty("_doNotReportToPy")) { - // Update originated on the Python side - return; - } - - /** @type {Js2PyUpdateMsg} */ - var updateMsg: Js2PyUpdateMsg = { - style_data: data["data"][0], - style_traces: data["data"][1], - layout_data: data["layout"], - source_view_id: this.viewID, - }; - - // Log message - this.model.set("_js2py_update", updateMsg); - this.touch(); - } - /** * Handle plotly_click events emitted by the Plotly.js library * @param data @@ -1330,7 +376,7 @@ export class FigureView { if (pointsMsg["points"] !== null && pointsMsg["points"] !== undefined) { this.model.set("_js2py_pointsCallback", pointsMsg); - this.touch(); + this.model.save_changes(); } } @@ -1340,141 +386,16 @@ export class FigureView { */ handle_plotly_doubleclick(data: any) {} - /** - * Handle Plotly.addTraces request - */ - do_addTraces() { - /** @type {Py2JsAddTracesMsg} */ - var msgData: Py2JsAddTracesMsg = this.model.get("_py2js_addTraces"); - - if (msgData !== null) { - var that = this; - Plotly.addTraces(this.el, msgData.trace_data).then(function () { - // ### Send trace deltas ### - that._sendTraceDeltas(msgData.trace_edit_id); - - // ### Send layout delta ### - var layout_edit_id = msgData.layout_edit_id; - that._sendLayoutDelta(layout_edit_id); - }); - } - } - - /** - * Handle Plotly.deleteTraces request - */ - do_deleteTraces() { - /** @type {Py2JsDeleteTracesMsg} */ - var msgData: Py2JsDeleteTracesMsg = this.model.get("_py2js_deleteTraces"); - - if (msgData !== null) { - var delete_inds = msgData.delete_inds; - var that = this; - Plotly.deleteTraces(this.el, delete_inds).then(function () { - // ### Send trace deltas ### - var trace_edit_id = msgData.trace_edit_id; - that._sendTraceDeltas(trace_edit_id); - - // ### Send layout delta ### - var layout_edit_id = msgData.layout_edit_id; - that._sendLayoutDelta(layout_edit_id); - }); - } - } - - /** - * Handle Plotly.moveTraces request - */ - do_moveTraces() { - /** @type {Py2JsMoveTracesMsg} */ - var msgData: Py2JsMoveTracesMsg = this.model.get("_py2js_moveTraces"); - - if (msgData !== null) { - // Unpack message - var currentInds = msgData.current_trace_inds; - var newInds = msgData.new_trace_inds; - - // Check if the new trace indexes are actually different than - // the current indexes - var inds_equal = _.isEqual(currentInds, newInds); - - if (!inds_equal) { - Plotly.moveTraces(this.el, currentInds, newInds); - } - } - } - - /** - * Handle Plotly.restyle request - */ - do_restyle() { - /** @type {Py2JsRestyleMsg} */ - var msgData: Py2JsRestyleMsg = this.model.get("_py2js_restyle"); - if (msgData !== null) { - var restyleData = msgData.restyle_data; - var traceIndexes = (this.model as FigureModel)._normalize_trace_indexes( - msgData.restyle_traces - ); - - restyleData["_doNotReportToPy"] = true; - Plotly.restyle(this.el, restyleData, traceIndexes); - - // ### Send trace deltas ### - // We create an array of deltas corresponding to the restyled - // traces. - this._sendTraceDeltas(msgData.trace_edit_id); - - // ### Send layout delta ### - var layout_edit_id = msgData.layout_edit_id; - this._sendLayoutDelta(layout_edit_id); - } - } - - /** - * Handle Plotly.relayout request - */ - do_relayout() { - /** @type {Py2JsRelayoutMsg} */ - var msgData: Py2JsRelayoutMsg = this.model.get("_py2js_relayout"); - if (msgData !== null) { - if (msgData.source_view_id !== this.viewID) { - var relayoutData = msgData.relayout_data; - relayoutData["_doNotReportToPy"] = true; - Plotly.relayout(this.el, msgData.relayout_data); - } - - // ### Send layout delta ### - var layout_edit_id = msgData.layout_edit_id; - this._sendLayoutDelta(layout_edit_id); + _normalize_trace_indexes(trace_indexes?: null | number | number[]): number[] { + if (trace_indexes === null || trace_indexes === undefined) { + var numTraces = this.model.get("_widget_data").length; + trace_indexes = _.range(numTraces); } - } - - /** - * Handle Plotly.update request - */ - do_update() { - /** @type {Py2JsUpdateMsg} */ - var msgData: Py2JsUpdateMsg = this.model.get("_py2js_update"); - - if (msgData !== null) { - var style = msgData.style_data || {}; - var layout = msgData.layout_data || {}; - var traceIndexes = (this.model as FigureModel)._normalize_trace_indexes( - msgData.style_traces - ); - - style["_doNotReportToPy"] = true; - Plotly.update(this.el, style, layout, traceIndexes); - - // ### Send trace deltas ### - // We create an array of deltas corresponding to the updated - // traces. - this._sendTraceDeltas(msgData.trace_edit_id); - - // ### Send layout delta ### - var layout_edit_id = msgData.layout_edit_id; - this._sendLayoutDelta(layout_edit_id); + if (!Array.isArray(trace_indexes)) { + // Make sure idx is an array + trace_indexes = [trace_indexes]; } + return trace_indexes; } /** @@ -1484,6 +405,7 @@ export class FigureView { /** @type {Py2JsAnimateMsg} */ var msgData: Py2JsAnimateMsg = this.model.get("_py2js_animate"); + console.log('do_animate', msgData) if (msgData !== null) { // Unpack params // var animationData = msgData[0]; @@ -1491,586 +413,24 @@ export class FigureView { var styles = msgData.style_data; var layout = msgData.layout_data; - var traceIndexes = (this.model as FigureModel)._normalize_trace_indexes( - msgData.style_traces - ); var animationData: any = { data: styles, layout: layout, - traces: traceIndexes, + traces: this._normalize_trace_indexes(msgData.style_traces), }; animationData["_doNotReportToPy"] = true; - var that = this; - - // @ts-ignore - Plotly.animate(this.el, animationData, animationOpts).then(function () { - // ### Send trace deltas ### - // We create an array of deltas corresponding to the - // animated traces. - that._sendTraceDeltas(msgData.trace_edit_id); - - // ### Send layout delta ### - var layout_edit_id = msgData.layout_edit_id; - that._sendLayoutDelta(layout_edit_id); - }); - } - } - - /** - * Construct layout delta object and send layoutDelta message to the - * Python side - * - * @param layout_edit_id - * Edit ID of message that triggered the creation of the layout delta - * @private - */ - _sendLayoutDelta(layout_edit_id: any) { - // ### Handle layout delta ### - var layout_delta = createDeltaObject( - this.getFullLayout(), - this.model.get("_layout") - ); - - /** @type{Js2PyLayoutDeltaMsg} */ - var layoutDeltaMsg: Js2PyLayoutDeltaMsg = { - layout_delta: layout_delta, - layout_edit_id: layout_edit_id, - }; - - this.model.set("_js2py_layoutDelta", layoutDeltaMsg); - this.touch(); - } - - /** - * Construct trace deltas array for the requested trace indexes and - * send traceDeltas message to the Python side - * Array of indexes of traces for which to compute deltas - * @param trace_edit_id - * Edit ID of message that triggered the creation of trace deltas - * @private - */ - _sendTraceDeltas(trace_edit_id: any) { - var trace_data = this.model.get("_data"); - var traceIndexes = _.range(trace_data.length); - var trace_deltas = new Array(traceIndexes.length); - - var fullData = this.getFullData(); - for (var i = 0; i < traceIndexes.length; i++) { - var traceInd = traceIndexes[i]; - trace_deltas[i] = createDeltaObject( - fullData[traceInd], - trace_data[traceInd] - ); - } - - /** @type{Js2PyTraceDeltasMsg} */ - var traceDeltasMsg: Js2PyTraceDeltasMsg = { - trace_deltas: trace_deltas, - trace_edit_id: trace_edit_id, - }; - - this.model.set("_js2py_traceDeltas", traceDeltasMsg); - this.touch(); - } -} - -// Serialization -/** - * Create a mapping from numpy dtype strings to corresponding typed array - * constructors - */ -const numpy_dtype_to_typedarray_type = { - int8: Int8Array, - int16: Int16Array, - int32: Int32Array, - uint8: Uint8Array, - uint16: Uint16Array, - uint32: Uint32Array, - float32: Float32Array, - float64: Float64Array, -}; - -function serializeTypedArray(v: ArrayConstructor) { - var numpyType; - if (v instanceof Int8Array) { - numpyType = "int8"; - } else if (v instanceof Int16Array) { - numpyType = "int16"; - } else if (v instanceof Int32Array) { - numpyType = "int32"; - } else if (v instanceof Uint8Array) { - numpyType = "uint8"; - } else if (v instanceof Uint16Array) { - numpyType = "uint16"; - } else if (v instanceof Uint32Array) { - numpyType = "uint32"; - } else if (v instanceof Float32Array) { - numpyType = "float32"; - } else if (v instanceof Float64Array) { - numpyType = "float64"; - } else { - // Don't understand it, return as is - return v; - } - var res = { - dtype: numpyType, - shape: [v.length], - value: v.buffer, - }; - return res; -} - -/** - * ipywidget JavaScript -> Python serializer - */ -function js2py_serializer(v: any, widgetManager?: any) { - var res: any; - - if (_.isTypedArray(v)) { - res = serializeTypedArray(v); - } else if (Array.isArray(v)) { - // Serialize array elements recursively - res = new Array(v.length); - for (var i = 0; i < v.length; i++) { - res[i] = js2py_serializer(v[i]); - } - } else if (_.isObject(v)) { - // Serialize object properties recursively - res = {}; - for (var p in v) { - if (v.hasOwnProperty(p)) { - res[p] = js2py_serializer(v[p]); - } - } - } else if (v === undefined) { - // Translate undefined into '_undefined_' sentinal string. The - // Python _js_to_py deserializer will convert this into an - // Undefined object - res = "_undefined_"; - } else { - // Primitive value to transfer directly - res = v; - } - return res; -} - -/** - * ipywidget Python -> Javascript deserializer - */ -function py2js_deserializer(v: any, widgetManager?: any) { - var res: any; - - if (Array.isArray(v)) { - // Deserialize array elements recursively - res = new Array(v.length); - for (var i = 0; i < v.length; i++) { - res[i] = py2js_deserializer(v[i]); - } - } else if (_.isObject(v)) { - if ( - (_.has(v, "value") || _.has(v, "buffer")) && - _.has(v, "dtype") && - _.has(v, "shape") - ) { - // Deserialize special buffer/dtype/shape objects into typed arrays - // These objects correspond to numpy arrays on the Python side - // - // Note plotly.py<=3.1.1 called the buffer object `buffer` - // This was renamed `value` in 3.2 to work around a naming conflict - // when saving widget state to a notebook. // @ts-ignore - var typedarray_type = numpy_dtype_to_typedarray_type[v.dtype]; - var buffer = _.has(v, "value") ? v.value.buffer : v.buffer.buffer; - res = new typedarray_type(buffer); - } else { - // Deserialize object properties recursively - res = {}; - for (var p in v) { - if (v.hasOwnProperty(p)) { - res[p] = py2js_deserializer(v[p]); - } - } + Plotly.animate(this.el, animationData, animationOpts); } - } else if (v === "_undefined_") { - // Convert the _undefined_ sentinal into undefined - res = undefined; - } else { - // Accept primitive value directly - res = v; - } - return res; -} - -/** - * Return whether the input value is a typed array - * @param potentialTypedArray - * Value to examine - * @returns {boolean} - */ -function isTypedArray(potentialTypedArray: any): boolean { - return ( - ArrayBuffer.isView(potentialTypedArray) && - !(potentialTypedArray instanceof DataView) - ); -} - -/** - * Customizer for use with lodash's mergeWith function - * - * The customizer ensures that typed arrays are not converted into standard - * arrays during the recursive merge - * - * See: https://lodash.com/docs/latest#mergeWith - */ -function fullMergeCustomizer(objValue: any, srcValue: any, key: string) { - if (key[0] === "_") { - // Don't recurse into private properties - return null; - } else if (isTypedArray(srcValue)) { - // Return typed arrays directly, don't recurse inside - return srcValue; } } -/** - * Reform a Plotly.relayout like operation on an input object - * - * @param {Object} parentObj - * The object that the relayout operation should be applied to - * @param {Object} relayoutData - * An relayout object as accepted by Plotly.relayout - * - * Examples: - * var d = {foo {bar [5, 10]}}; - * performRelayoutLike(d, {'foo.bar': [0, 1]}); - * d -> {foo: {bar: [0, 1]}} - * - * var d = {foo {bar [5, 10]}}; - * performRelayoutLike(d, {'baz': 34}); - * d -> {foo: {bar: [5, 10]}, baz: 34} - * - * var d = {foo: {bar: [5, 10]}; - * performRelayoutLike(d, {'foo.baz[1]': 17}); - * d -> {foo: {bar: [5, 17]}} - * - */ -function performRelayoutLike(parentObj: any, relayoutData: any) { - // Perform a relayout style operation on a given parent object - for (var rawKey in relayoutData) { - if (!relayoutData.hasOwnProperty(rawKey)) { - continue; - } - - // Extract value for this key - var relayoutVal = relayoutData[rawKey]; - - // Set property value - if (relayoutVal === null) { - _.unset(parentObj, rawKey); - } else { - _.set(parentObj, rawKey, relayoutVal); - } - } +function render ({ el, model }) { + const view = new FigureView(model, el); + view.render() + return () => view.remove(); } -/** - * Perform a Plotly.restyle like operation on an input object array - * - * @param {Array.} parentArray - * The object that the restyle operation should be applied to - * @param {Object} restyleData - * A restyle object as accepted by Plotly.restyle - * @param {Array.} restyleTraces - * Array of indexes of the traces that the resytle operation applies to - * - * Examples: - * var d = [{foo: {bar: 1}}, {}, {}] - * performRestyleLike(d, {'foo.bar': 2}, [0]) - * d -> [{foo: {bar: 2}}, {}, {}] - * - * var d = [{foo: {bar: 1}}, {}, {}] - * performRestyleLike(d, {'foo.bar': 2}, [0, 1, 2]) - * d -> [{foo: {bar: 2}}, {foo: {bar: 2}}, {foo: {bar: 2}}] - * - * var d = [{foo: {bar: 1}}, {}, {}] - * performRestyleLike(d, {'foo.bar': [2, 3, 4]}, [0, 1, 2]) - * d -> [{foo: {bar: 2}}, {foo: {bar: 3}}, {foo: {bar: 4}}] - * - */ -function performRestyleLike( - parentArray: any[], - restyleData: any, - restyleTraces: number[] -) { - // Loop over the properties of restyleData - for (var rawKey in restyleData) { - if (!restyleData.hasOwnProperty(rawKey)) { - continue; - } - - // Extract value for property and normalize into a value list - var valArray = restyleData[rawKey]; - if (!Array.isArray(valArray)) { - valArray = [valArray]; - } - - // Loop over the indexes of the traces being restyled - for (var i = 0; i < restyleTraces.length; i++) { - // Get trace object - var traceInd = restyleTraces[i]; - var trace = parentArray[traceInd]; - - // Extract value for this trace - var singleVal = valArray[i % valArray.length]; - - // Set property value - if (singleVal === null) { - _.unset(trace, rawKey); - } else if (singleVal !== undefined) { - _.set(trace, rawKey, singleVal); - } - } - } -} - -/** - * Perform a Plotly.moveTraces like operation on an input object array - * @param parentArray - * The object that the moveTraces operation should be applied to - * @param currentInds - * Array of the current indexes of traces to be moved - * @param newInds - * Array of the new indexes that traces selected by currentInds should be - * moved to. - * - * Examples: - * var d = [{foo: 0}, {foo: 1}, {foo: 2}] - * performMoveTracesLike(d, [0, 1], [2, 0]) - * d -> [{foo: 1}, {foo: 2}, {foo: 0}] - * - * var d = [{foo: 0}, {foo: 1}, {foo: 2}] - * performMoveTracesLike(d, [0, 2], [1, 2]) - * d -> [{foo: 1}, {foo: 0}, {foo: 2}] - */ -function performMoveTracesLike( - parentArray: any[], - currentInds: number[], - newInds: number[] -) { - // ### Remove by currentInds in reverse order ### - var movingTracesData: any[] = []; - for (var ci = currentInds.length - 1; ci >= 0; ci--) { - // Insert moving parentArray at beginning of the list - movingTracesData.splice(0, 0, parentArray[currentInds[ci]]); - parentArray.splice(currentInds[ci], 1); - } - - // ### Sort newInds and movingTracesData by newInds ### - var newIndexSortedArrays = _(newInds) - .zip(movingTracesData) - .sortBy(0) - .unzip() - .value(); - - newInds = newIndexSortedArrays[0]; - movingTracesData = newIndexSortedArrays[1]; - - // ### Insert by newInds in forward order ### - for (var ni = 0; ni < newInds.length; ni++) { - parentArray.splice(newInds[ni], 0, movingTracesData[ni]); - } -} - -/** - * Remove nested properties from a parent object - * @param {Object} parentObj - * Parent object from which properties or nested properties should be removed - * @param {Array.>} keyPaths - * Array of key paths for properties that should be removed. Each key path - * is an array of properties names or array indexes that reference a - * property to be removed - * - * Examples: - * var d = {foo: [{bar: 0}, {bar: 1}], baz: 32} - * performRemoveProps(d, ['baz']) - * d -> {foo: [{bar: 0}, {bar: 1}]} - * - * var d = {foo: [{bar: 0}, {bar: 1}], baz: 32} - * performRemoveProps(d, ['foo[1].bar', 'baz']) - * d -> {foo: [{bar: 0}, {}]} - * - */ -function performRemoveProps( - parentObj: object, - keyPaths: Array> -) { - for (var i = 0; i < keyPaths.length; i++) { - var keyPath = keyPaths[i]; - _.unset(parentObj, keyPath); - } -} - -/** - * Return object that contains all properties in fullObj that are not - * identical to the corresponding properties in removeObj - * - * Properties of fullObj and removeObj may be objects or arrays of objects - * - * Returned object is a deep clone of the properties of the input objects - * - * @param {Object} fullObj - * @param {Object} removeObj - * - * Examples: - * var fullD = {foo: [{bar: 0}, {bar: 1}], baz: 32} - * var removeD = {baz: 32} - * createDeltaObject(fullD, removeD) - * -> {foo: [{bar: 0}, {bar: 1}]} - * - * var fullD = {foo: [{bar: 0}, {bar: 1}], baz: 32} - * var removeD = {baz: 45} - * createDeltaObject(fullD, removeD) - * -> {foo: [{bar: 0}, {bar: 1}], baz: 32} - * - * var fullD = {foo: [{bar: 0}, {bar: 1}], baz: 32} - * var removeD = {foo: [{bar: 0}, {bar: 1}]} - * createDeltaObject(fullD, removeD) - * -> {baz: 32} - * - */ -function createDeltaObject(fullObj: any, removeObj: any) { - // Initialize result as object or array - var res: any; - if (Array.isArray(fullObj)) { - res = new Array(fullObj.length); - } else { - res = {}; - } - - // Initialize removeObj to empty object if not specified - if (removeObj === null || removeObj === undefined) { - removeObj = {}; - } - - // Iterate over object properties or array indices - for (var p in fullObj) { - if ( - p[0] !== "_" && // Don't consider private properties - fullObj.hasOwnProperty(p) && // Exclude parent properties - fullObj[p] !== null // Exclude cases where fullObj doesn't - // have the property - ) { - // Compute object equality - var props_equal; - props_equal = _.isEqual(fullObj[p], removeObj[p]); - - // Perform recursive comparison if props are not equal - if (!props_equal || p === "uid") { - // Let uids through - - // property has non-null value in fullObj that doesn't - // match the value in removeObj - var fullVal = fullObj[p]; - if (removeObj.hasOwnProperty(p) && typeof fullVal === "object") { - // Recurse over object properties - if (Array.isArray(fullVal)) { - if (fullVal.length > 0 && typeof fullVal[0] === "object") { - // We have an object array - res[p] = new Array(fullVal.length); - for (var i = 0; i < fullVal.length; i++) { - if (!Array.isArray(removeObj[p]) || removeObj[p].length <= i) { - res[p][i] = fullVal[i]; - } else { - res[p][i] = createDeltaObject(fullVal[i], removeObj[p][i]); - } - } - } else { - // We have a primitive array or typed array - res[p] = fullVal; - } - } else { - // object - var full_obj = createDeltaObject(fullVal, removeObj[p]); - if (Object.keys(full_obj).length > 0) { - // new object is not empty - res[p] = full_obj; - } - } - } else if (typeof fullVal === "object" && !Array.isArray(fullVal)) { - // Return 'clone' of fullVal - // We don't use a standard clone method so that we keep - // the special case handling of this method - res[p] = createDeltaObject(fullVal, {}); - } else if (fullVal !== undefined && typeof fullVal !== "function") { - // No recursion necessary, Just keep value from fullObj. - // But skip values with function type - res[p] = fullVal; - } - } - } - } - return res; -} - -function randstr( - existing?: { [k: string]: any }, - bits?: number, - base?: number, - _recursion?: number -): string { - if (!base) base = 16; - if (bits === undefined) bits = 24; - if (bits <= 0) return "0"; - - var digits = Math.log(Math.pow(2, bits)) / Math.log(base); - var res = ""; - var i, b, x; - - for (i = 2; digits === Infinity; i *= 2) { - digits = (Math.log(Math.pow(2, bits / i)) / Math.log(base)) * i; - } - - var rem = digits - Math.floor(digits); - - for (i = 0; i < Math.floor(digits); i++) { - x = Math.floor(Math.random() * base).toString(base); - res = x + res; - } - - if (rem) { - b = Math.pow(base, rem); - x = Math.floor(Math.random() * b).toString(base); - res = x + res; - } - - var parsed = parseInt(res, base); - if ( - (existing && existing[res]) || - (parsed !== Infinity && parsed >= Math.pow(2, bits)) - ) { - if (_recursion > 10) { - console.warn("randstr failed uniqueness"); - return res; - } - return randstr(existing, bits, base, (_recursion || 0) + 1); - } else return res; -} - -export default () => { - let model; - return { - /** @type {import('anywidget/types').Initialize} */ - initialize(ctx) { - model = new FigureModel(ctx.model, serializers); - model.initialize(); - }, - /** @type {import('anywidget/types').Render} */ - render({ el }) { - const view = new FigureView(model, el); - view.perform_render() - return () => view.remove(); - } - } -} +export default { render } \ No newline at end of file diff --git a/packages/python/plotly/plotly/basedatatypes.py b/packages/python/plotly/plotly/basedatatypes.py index a3044f6763..1b0dee56f9 100644 --- a/packages/python/plotly/plotly/basedatatypes.py +++ b/packages/python/plotly/plotly/basedatatypes.py @@ -1,5 +1,6 @@ import collections from collections import OrderedDict +import pdb import re import warnings from contextlib import contextmanager @@ -557,6 +558,11 @@ class is a subclass of both BaseFigure and widgets.DOMWidget. layout, skip_invalid=skip_invalid, _validate=self._validate ) + # Template + # -------- + # ### Check for default template ### + self._initialize_layout_template() + # ### Import clone of layout properties ### self._layout = deepcopy(self._layout_obj._props) @@ -624,11 +630,6 @@ class is a subclass of both BaseFigure and widgets.DOMWidget. self._animation_duration_validator = animation.DurationValidator() self._animation_easing_validator = animation.EasingValidator() - # Template - # -------- - # ### Check for default template ### - self._initialize_layout_template() - # Process kwargs # -------------- for k, v in kwargs.items(): @@ -1030,10 +1031,6 @@ def data(self, new_data): # Modify in-place so we don't trigger serialization del self._data[i] - if delete_inds: - # Update widget, if any - self._send_deleteTraces_msg(delete_inds) - # Move traces # ----------- @@ -1042,37 +1039,8 @@ def data(self, new_data): for uid in uids_post_removal: new_inds.append(new_uids.index(uid)) - # ### Compute current index for each remaining trace ### - current_inds = list(range(len(traces_props_post_removal))) - - # ### Check whether a move is needed ### - if not all([i1 == i2 for i1, i2 in zip(new_inds, current_inds)]): - - # #### Save off index lists for moveTraces message #### - msg_current_inds = current_inds - msg_new_inds = new_inds - - # #### Reorder trace elements #### - # We do so in-place so we don't trigger traitlet property - # serialization for the FigureWidget case - # ##### Remove by curr_inds in reverse order ##### - moving_traces_data = [] - for ci in reversed(current_inds): - # Push moving traces data to front of list - moving_traces_data.insert(0, self._data[ci]) - del self._data[ci] - - # #### Sort new_inds and moving_traces_data by new_inds #### - new_inds, moving_traces_data = zip( - *sorted(zip(new_inds, moving_traces_data)) - ) - - # #### Insert by new_inds in forward order #### - for ni, trace_data in zip(new_inds, moving_traces_data): - self._data.insert(ni, trace_data) - - # #### Update widget, if any #### - self._send_moveTraces_msg(msg_current_inds, msg_new_inds) + # #### Update widget, if any #### + self._send_react_msg() # ### Update data defaults ### # There is to front-end syncronization to worry about so this @@ -1657,29 +1625,11 @@ def plotly_restyle(self, restyle_data, trace_indexes=None, **kwargs): # ----------------------- trace_indexes = self._normalize_trace_indexes(trace_indexes) - # Handle source_view_id - # --------------------- - # If not None, the source_view_id is the UID of the frontend - # Plotly.js view that initially triggered this restyle operation - # (e.g. the user clicked on the legend to hide a trace). We pass - # this UID along so that the frontend views can determine whether - # they need to apply the restyle operation on themselves. - source_view_id = kwargs.get("source_view_id", None) - # Perform restyle on trace dicts # ------------------------------ restyle_changes = self._perform_plotly_restyle(restyle_data, trace_indexes) if restyle_changes: - # The restyle operation resulted in a change to some trace - # properties, so we dispatch change callbacks and send the - # restyle message to the frontend (if any) - msg_kwargs = ( - {"source_view_id": source_view_id} if source_view_id is not None else {} - ) - - self._send_restyle_msg( - restyle_changes, trace_indexes=trace_indexes, **msg_kwargs - ) + self._send_react_msg() self._dispatch_trace_change_callbacks(restyle_changes, trace_indexes) @@ -1787,7 +1737,7 @@ def _restyle_child(self, child, key_path_str, val): if not self._in_batch_mode: send_val = [val] restyle = {key_path_str: send_val} - self._send_restyle_msg(restyle, trace_indexes=trace_index) + self._send_react_msg() self._dispatch_trace_change_callbacks(restyle, [trace_index]) # In batch mode @@ -2261,7 +2211,7 @@ def add_traces( self._data_objs = self._data_objs + data # Update messages - self._send_addTraces_msg(new_traces_data) + self._send_react_msg() return self @@ -2559,6 +2509,11 @@ def layout(self, new_layout): self._layout_obj._orphan_props.update(old_layout_data) self._layout_obj._parent = None + print('layout setter') + # Initialize template object + # -------------------------- + self._initialize_layout_template() + # Parent new layout # ----------------- self._layout = new_layout_data @@ -2566,12 +2521,8 @@ def layout(self, new_layout): new_layout._orphan_props.clear() self._layout_obj = new_layout - # Initialize template object - # -------------------------- - self._initialize_layout_template() - # Notify JS side - self._send_relayout_msg(new_layout_data) + self._send_react_msg() def plotly_relayout(self, relayout_data, **kwargs): """ @@ -2593,19 +2544,6 @@ def plotly_relayout(self, relayout_data, **kwargs): None """ - # Handle source_view_id - # --------------------- - # If not None, the source_view_id is the UID of the frontend - # Plotly.js view that initially triggered this relayout operation - # (e.g. the user clicked on the toolbar to change the drag mode - # from zoom to pan). We pass this UID along so that the frontend - # views can determine whether they need to apply the relayout - # operation on themselves. - if "source_view_id" in kwargs: - msg_kwargs = {"source_view_id": kwargs["source_view_id"]} - else: - msg_kwargs = {} - # Perform relayout operation on layout dict # ----------------------------------------- relayout_changes = self._perform_plotly_relayout(relayout_data) @@ -2613,7 +2551,7 @@ def plotly_relayout(self, relayout_data, **kwargs): # The relayout operation resulted in a change to some layout # properties, so we dispatch change callbacks and send the # relayout message to the frontend (if any) - self._send_relayout_msg(relayout_changes, **msg_kwargs) + self._send_react_msg() self._dispatch_layout_change_callbacks(relayout_changes) @@ -2709,7 +2647,7 @@ def _relayout_child(self, child, key_path_str, val): # Dispatch change callbacks and send relayout message if not self._in_batch_mode: relayout_msg = {key_path_str: val} - self._send_relayout_msg(relayout_msg) + self._send_react_msg() self._dispatch_layout_change_callbacks(relayout_msg) # In batch mode @@ -2923,12 +2861,7 @@ def plotly_update( # ------------------- # Send a plotly_update message to the frontend (if any) if restyle_changes or relayout_changes: - self._send_update_msg( - restyle_data=restyle_changes, - relayout_data=relayout_changes, - trace_indexes=trace_indexes, - **msg_kwargs, - ) + self._send_react_msg() # Dispatch changes # ---------------- @@ -2974,26 +2907,9 @@ def _perform_plotly_update( # Plotly message stubs # -------------------- # send-message stubs that may be overridden by the widget subclass - def _send_addTraces_msg(self, new_traces_data): + def _send_react_msg(self): pass - - def _send_moveTraces_msg(self, current_inds, new_inds): - pass - - def _send_deleteTraces_msg(self, delete_inds): - pass - - def _send_restyle_msg(self, style, trace_indexes=None, source_view_id=None): - pass - - def _send_relayout_msg(self, layout, source_view_id=None): - pass - - def _send_update_msg( - self, restyle_data, relayout_data, trace_indexes=None, source_view_id=None - ): - pass - + def _send_animate_msg( self, styles_data, relayout_data, trace_indexes, animation_opts ): @@ -3066,6 +2982,8 @@ def batch_update(self): trace_indexes=trace_indexes, ) + self._send_react_msg() + # ### Clear out saved batch edits ### self._batch_layout_edits.clear() self._batch_trace_edits.clear() @@ -3228,6 +3146,7 @@ def _perform_batch_animate(self, animation_opts): ------- None """ + print('perform batch animate') # Apply commands to internal dictionaries as an update # ---------------------------------------------------- ( diff --git a/packages/python/plotly/plotly/basewidget.py b/packages/python/plotly/plotly/basewidget.py index 42fa8cdb89..43a62c7032 100644 --- a/packages/python/plotly/plotly/basewidget.py +++ b/packages/python/plotly/plotly/basewidget.py @@ -1,4 +1,6 @@ +from copy import deepcopy import pathlib +import pdb from traitlets import List, Dict, observe, Integer from plotly.io._renderers import display_jupyter_version_warnings @@ -21,69 +23,12 @@ class BaseFigureWidget(BaseFigure, anywidget.AnyWidget): # These properties store the current state of the traces and # layout as JSON-style dicts. These dicts do not store any subclasses of # `BasePlotlyType` - # - # Note: These are only automatically synced with the frontend on full - # assignment, not on mutation. We use this fact to only directly sync - # them to the front-end on FigureWidget construction. All other updates - # are made using mutation, and they are manually synced to the frontend - # using the relayout/restyle/update/etc. messages. - _layout = Dict().tag(sync=True, **custom_serializers) - _data = List().tag(sync=True, **custom_serializers) + _widget_layout = Dict().tag(sync=True, **custom_serializers) + _widget_data = List().tag(sync=True, **custom_serializers) _config = Dict().tag(sync=True, **custom_serializers) - - # ### Python -> JS message properties ### - # These properties are used to send messages from Python to the - # frontend. Messages are sent by assigning the message contents to the - # appropriate _py2js_* property and then immediatly assigning None to the - # property. - # - # See JSDoc comments in the FigureModel class in js/src/Figure.js for - # detailed descriptions of the messages. - _py2js_addTraces = Dict(allow_none=True).tag(sync=True, **custom_serializers) - _py2js_restyle = Dict(allow_none=True).tag(sync=True, **custom_serializers) - _py2js_relayout = Dict(allow_none=True).tag(sync=True, **custom_serializers) - _py2js_update = Dict(allow_none=True).tag(sync=True, **custom_serializers) - _py2js_animate = Dict(allow_none=True).tag(sync=True, **custom_serializers) - - _py2js_deleteTraces = Dict(allow_none=True).tag(sync=True, **custom_serializers) - _py2js_moveTraces = Dict(allow_none=True).tag(sync=True, **custom_serializers) - - _py2js_removeLayoutProps = Dict(allow_none=True).tag( - sync=True, **custom_serializers - ) - _py2js_removeTraceProps = Dict(allow_none=True).tag(sync=True, **custom_serializers) - - # ### JS -> Python message properties ### - # These properties are used to receive messages from the frontend. - # Messages are received by defining methods that observe changes to these - # properties. Receive methods are named `_handler_js2py_*` where '*' is - # the name of the corresponding message property. Receive methods are - # responsible for setting the message property to None after retreiving - # the message data. - # - # See JSDoc comments in the FigureModel class in js/src/Figure.js for - # detailed descriptions of the messages. - _js2py_traceDeltas = Dict(allow_none=True).tag(sync=True, **custom_serializers) - _js2py_layoutDelta = Dict(allow_none=True).tag(sync=True, **custom_serializers) - _js2py_restyle = Dict(allow_none=True).tag(sync=True, **custom_serializers) - _js2py_relayout = Dict(allow_none=True).tag(sync=True, **custom_serializers) - _js2py_update = Dict(allow_none=True).tag(sync=True, **custom_serializers) _js2py_pointsCallback = Dict(allow_none=True).tag(sync=True, **custom_serializers) - # ### Message tracking properties ### - # The _last_layout_edit_id and _last_trace_edit_id properties are used - # to keep track of the edit id of the message that most recently - # requested an update to the Figures layout or traces respectively. - # - # We track this information because we don't want to update the Figure's - # default layout/trace properties (_layout_defaults, _data_defaults) - # while edits are in process. This can lead to inconsistent property - # states. - _last_layout_edit_id = Integer(0).tag(sync=True) - _last_trace_edit_id = Integer(0).tag(sync=True) - - _set_trace_uid = True - _allow_disable_validation = False + _frame_objs = [] # Constructor # ----------- @@ -93,7 +38,7 @@ def __init__( # Call superclass constructors # ---------------------------- - # Note: We rename layout to layout_plotly because to deconflict it + # Note: We rename layout to layout_plotly to deconflict it # with the `layout` constructor parameter of the `widgets.DOMWidget` # ipywidgets class super(BaseFigureWidget, self).__init__( @@ -104,240 +49,55 @@ def __init__( **kwargs, ) - # Validate Frames - # --------------- - # Frames are not supported by figure widget - if self._frame_objs: - BaseFigureWidget._display_frames_error() - - # Message States - # -------------- - # ### Layout ### - - # _last_layout_edit_id is described above - self._last_layout_edit_id = 0 - - # _layout_edit_in_process is set to True if there are layout edit - # operations that have been sent to the frontend that haven't - # completed yet. - self._layout_edit_in_process = False - - # _waiting_edit_callbacks is a list of callback functions that - # should be executed as soon as all pending edit operations are - # completed - self._waiting_edit_callbacks = [] - - # ### Trace ### - # _last_trace_edit_id: described above - self._last_trace_edit_id = 0 - - # _trace_edit_in_process is set to True if there are trace edit - # operations that have been sent to the frontend that haven't - # completed yet. - self._trace_edit_in_process = False - - # View count - # ---------- - # ipywidget property that stores the number of active frontend - # views of this widget - self._view_count = 0 + self._widget_layout = deepcopy(self._layout_obj._props) + self._widget_data = deepcopy(self._data) def show(self, *args, **kwargs): return self - # Python -> JavaScript Messages - # ----------------------------- - def _send_relayout_msg(self, layout_data, source_view_id=None): - """ - Send Plotly.relayout message to the frontend - - Parameters - ---------- - layout_data : dict - Plotly.relayout layout data - source_view_id : str - UID of view that triggered this relayout operation - (e.g. By the user clicking 'zoom' in the toolbar). None if the - operation was not triggered by a frontend view - """ - # Increment layout edit messages IDs - # ---------------------------------- - layout_edit_id = self._last_layout_edit_id + 1 - self._last_layout_edit_id = layout_edit_id - self._layout_edit_in_process = True - - # Build message - # ------------- - msg_data = { - "relayout_data": layout_data, - "layout_edit_id": layout_edit_id, - "source_view_id": source_view_id, - } - - # Send message - # ------------ - self._py2js_relayout = msg_data - self._py2js_relayout = None - - def _send_restyle_msg(self, restyle_data, trace_indexes=None, source_view_id=None): + # Display + # ------- + def _repr_html_(self): """ - Send Plotly.restyle message to the frontend - - Parameters - ---------- - restyle_data : dict - Plotly.restyle restyle data - trace_indexes : list[int] - List of trace indexes that the restyle operation - applies to - source_view_id : str - UID of view that triggered this restyle operation - (e.g. By the user clicking the legend to hide a trace). - None if the operation was not triggered by a frontend view + Customize html representation """ + raise NotImplementedError # Prefer _repr_mimebundle_ - # Validate / normalize inputs - # --------------------------- - trace_indexes = self._normalize_trace_indexes(trace_indexes) - - # Increment layout/trace edit message IDs - # --------------------------------------- - layout_edit_id = self._last_layout_edit_id + 1 - self._last_layout_edit_id = layout_edit_id - self._layout_edit_in_process = True - - trace_edit_id = self._last_trace_edit_id + 1 - self._last_trace_edit_id = trace_edit_id - self._trace_edit_in_process = True - - # Build message - # ------------- - restyle_msg = { - "restyle_data": restyle_data, - "restyle_traces": trace_indexes, - "trace_edit_id": trace_edit_id, - "layout_edit_id": layout_edit_id, - "source_view_id": source_view_id, - } - - # Send message - # ------------ - self._py2js_restyle = restyle_msg - self._py2js_restyle = None - - def _send_addTraces_msg(self, new_traces_data): + def _repr_mimebundle_(self, include=None, exclude=None, validate=True, **kwargs): """ - Send Plotly.addTraces message to the frontend - - Parameters - ---------- - new_traces_data : list[dict] - List of trace data for new traces as accepted by Plotly.addTraces + Return mimebundle corresponding to default renderer. """ - - # Increment layout/trace edit message IDs - # --------------------------------------- - layout_edit_id = self._last_layout_edit_id + 1 - self._last_layout_edit_id = layout_edit_id - self._layout_edit_in_process = True - - trace_edit_id = self._last_trace_edit_id + 1 - self._last_trace_edit_id = trace_edit_id - self._trace_edit_in_process = True - - # Build message - # ------------- - add_traces_msg = { - "trace_data": new_traces_data, - "trace_edit_id": trace_edit_id, - "layout_edit_id": layout_edit_id, + display_jupyter_version_warnings() + return { + "application/vnd.jupyter.widget-view+json": { + "version_major": 2, + "version_minor": 0, + "model_id": self._model_id, + }, } - # Send message - # ------------ - self._py2js_addTraces = add_traces_msg - self._py2js_addTraces = None - - def _send_moveTraces_msg(self, current_inds, new_inds): - """ - Send Plotly.moveTraces message to the frontend - - Parameters - ---------- - current_inds : list[int] - List of current trace indexes - new_inds : list[int] - List of new trace indexes - """ - - # Build message - # ------------- - move_msg = {"current_trace_inds": current_inds, "new_trace_inds": new_inds} - - # Send message - # ------------ - self._py2js_moveTraces = move_msg - self._py2js_moveTraces = None - - def _send_update_msg( - self, restyle_data, relayout_data, trace_indexes=None, source_view_id=None - ): + def _ipython_display_(self): """ - Send Plotly.update message to the frontend - - Parameters - ---------- - restyle_data : dict - Plotly.update restyle data - relayout_data : dict - Plotly.update relayout data - trace_indexes : list[int] - List of trace indexes that the update operation applies to - source_view_id : str - UID of view that triggered this update operation - (e.g. By the user clicking a button). - None if the operation was not triggered by a frontend view + Handle rich display of figures in ipython contexts """ + raise NotImplementedError # Prefer _repr_mimebundle_ - # Validate / normalize inputs - # --------------------------- - trace_indexes = self._normalize_trace_indexes(trace_indexes) - - # Increment layout/trace edit message IDs - # --------------------------------------- - trace_edit_id = self._last_trace_edit_id + 1 - self._last_trace_edit_id = trace_edit_id - self._trace_edit_in_process = True - - layout_edit_id = self._last_layout_edit_id + 1 - self._last_layout_edit_id = layout_edit_id - self._layout_edit_in_process = True + def notify_change(self, change): + # print('notify_change', change) + return super().notify_change(change) - # Build message - # ------------- - update_msg = { - "style_data": restyle_data, - "layout_data": relayout_data, - "style_traces": trace_indexes, - "trace_edit_id": trace_edit_id, - "layout_edit_id": layout_edit_id, - "source_view_id": source_view_id, - } + def _send_react_msg(self): + self._widget_layout = deepcopy(self._layout_obj._props) + self._widget_data = deepcopy(self._data) - # Send message - # ------------ - self._py2js_update = update_msg - self._py2js_update = None def _send_animate_msg( self, styles_data, relayout_data, trace_indexes, animation_opts ): """ Send Plotly.update message to the frontend - Note: there is no source_view_id parameter because animations triggered by the fontend are not currently supported - Parameters ---------- styles_data : list[dict] @@ -347,6 +107,7 @@ def _send_animate_msg( trace_indexes : list[int] List of trace indexes that the animate operation applies to """ + print('sending animate message') # Validate / normalize inputs # --------------------------- @@ -379,263 +140,11 @@ def _send_animate_msg( self._py2js_animate = animate_msg self._py2js_animate = None - def _send_deleteTraces_msg(self, delete_inds): - """ - Send Plotly.deleteTraces message to the frontend - - Parameters - ---------- - delete_inds : list[int] - List of trace indexes of traces to delete - """ - - # Increment layout/trace edit message IDs - # --------------------------------------- - trace_edit_id = self._last_trace_edit_id + 1 - self._last_trace_edit_id = trace_edit_id - self._trace_edit_in_process = True - - layout_edit_id = self._last_layout_edit_id + 1 - self._last_layout_edit_id = layout_edit_id - self._layout_edit_in_process = True - - # Build message - # ------------- - delete_msg = { - "delete_inds": delete_inds, - "layout_edit_id": layout_edit_id, - "trace_edit_id": trace_edit_id, - } - - # Send message - # ------------ - self._py2js_deleteTraces = delete_msg - self._py2js_deleteTraces = None - - # JavaScript -> Python Messages - # ----------------------------- - @observe("_js2py_traceDeltas") - def _handler_js2py_traceDeltas(self, change): - """ - Process trace deltas message from the frontend - """ - - # Receive message - # --------------- - msg_data = change["new"] - if not msg_data: - self._js2py_traceDeltas = None - return - - trace_deltas = msg_data["trace_deltas"] - trace_edit_id = msg_data["trace_edit_id"] - - # Apply deltas - # ------------ - # We only apply the deltas if this message corresponds to the most - # recent trace edit operation - if trace_edit_id == self._last_trace_edit_id: - - # ### Loop over deltas ### - for delta in trace_deltas: - - # #### Find existing trace for uid ### - trace_uid = delta["uid"] - trace_uids = [trace.uid for trace in self.data] - trace_index = trace_uids.index(trace_uid) - uid_trace = self.data[trace_index] - - # #### Transform defaults to delta #### - delta_transform = BaseFigureWidget._transform_data( - uid_trace._prop_defaults, delta - ) - - # #### Remove overlapping properties #### - # If a property is present in both _props and _prop_defaults - # then we remove the copy from _props - remove_props = self._remove_overlapping_props( - uid_trace._props, uid_trace._prop_defaults - ) - - # #### Notify frontend model of property removal #### - if remove_props: - remove_trace_props_msg = { - "remove_trace": trace_index, - "remove_props": remove_props, - } - self._py2js_removeTraceProps = remove_trace_props_msg - self._py2js_removeTraceProps = None - - # #### Dispatch change callbacks #### - self._dispatch_trace_change_callbacks(delta_transform, [trace_index]) - - # ### Trace edits no longer in process ### - self._trace_edit_in_process = False - - # ### Call any waiting trace edit callbacks ### - if not self._layout_edit_in_process: - while self._waiting_edit_callbacks: - self._waiting_edit_callbacks.pop()() - - self._js2py_traceDeltas = None - - @observe("_js2py_layoutDelta") - def _handler_js2py_layoutDelta(self, change): - """ - Process layout delta message from the frontend - """ - - # Receive message - # --------------- - msg_data = change["new"] - if not msg_data: - self._js2py_layoutDelta = None - return - - layout_delta = msg_data["layout_delta"] - layout_edit_id = msg_data["layout_edit_id"] - - # Apply delta - # ----------- - # We only apply the delta if this message corresponds to the most - # recent layout edit operation - if layout_edit_id == self._last_layout_edit_id: - - # ### Transform defaults to delta ### - delta_transform = BaseFigureWidget._transform_data( - self._layout_defaults, layout_delta - ) - - # ### Remove overlapping properties ### - # If a property is present in both _layout and _layout_defaults - # then we remove the copy from _layout - removed_props = self._remove_overlapping_props( - self._layout, self._layout_defaults - ) - - # ### Notify frontend model of property removal ### - if removed_props: - remove_props_msg = {"remove_props": removed_props} - - self._py2js_removeLayoutProps = remove_props_msg - self._py2js_removeLayoutProps = None - - # ### Create axis objects ### - # For example, when a SPLOM trace is created the layout defaults - # may include axes that weren't explicitly defined by the user. - for proppath in delta_transform: - prop = proppath[0] - match = self.layout._subplot_re_match(prop) - if match and prop not in self.layout: - # We need to create a subplotid object - self.layout[prop] = {} - - # ### Dispatch change callbacks ### - self._dispatch_layout_change_callbacks(delta_transform) - - # ### Layout edits no longer in process ### - self._layout_edit_in_process = False - - # ### Call any waiting layout edit callbacks ### - if not self._trace_edit_in_process: - while self._waiting_edit_callbacks: - self._waiting_edit_callbacks.pop()() - - self._js2py_layoutDelta = None - - @observe("_js2py_restyle") - def _handler_js2py_restyle(self, change): - """ - Process Plotly.restyle message from the frontend - """ - - # Receive message - # --------------- - restyle_msg = change["new"] - - if not restyle_msg: - self._js2py_restyle = None - return - - style_data = restyle_msg["style_data"] - style_traces = restyle_msg["style_traces"] - source_view_id = restyle_msg["source_view_id"] - - # Perform restyle - # --------------- - self.plotly_restyle( - restyle_data=style_data, - trace_indexes=style_traces, - source_view_id=source_view_id, - ) - - self._js2py_restyle = None - - @observe("_js2py_update") - def _handler_js2py_update(self, change): - """ - Process Plotly.update message from the frontend - """ - - # Receive message - # --------------- - update_msg = change["new"] - - if not update_msg: - self._js2py_update = None - return - - style = update_msg["style_data"] - trace_indexes = update_msg["style_traces"] - layout = update_msg["layout_data"] - source_view_id = update_msg["source_view_id"] - - # Perform update - # -------------- - self.plotly_update( - restyle_data=style, - relayout_data=layout, - trace_indexes=trace_indexes, - source_view_id=source_view_id, - ) - - self._js2py_update = None - - @observe("_js2py_relayout") - def _handler_js2py_relayout(self, change): - """ - Process Plotly.relayout message from the frontend - """ - - # Receive message - # --------------- - relayout_msg = change["new"] - - if not relayout_msg: - self._js2py_relayout = None - return - - relayout_data = relayout_msg["relayout_data"] - source_view_id = relayout_msg["source_view_id"] - - if "lastInputTime" in relayout_data: - # Remove 'lastInputTime'. Seems to be an internal plotly - # property that is introduced for some plot types, but it is not - # actually a property in the schema - relayout_data.pop("lastInputTime") - - # Perform relayout - # ---------------- - self.plotly_relayout(relayout_data=relayout_data, source_view_id=source_view_id) - - self._js2py_relayout = None - @observe("_js2py_pointsCallback") def _handler_js2py_pointsCallback(self, change): """ Process points callback message from the frontend """ - # Receive message # --------------- callback_data = change["new"] @@ -716,53 +225,6 @@ def _handler_js2py_pointsCallback(self, change): self._js2py_pointsCallback = None - # Display - # ------- - def _repr_html_(self): - """ - Customize html representation - """ - raise NotImplementedError # Prefer _repr_mimebundle_ - - def _repr_mimebundle_(self, include=None, exclude=None, validate=True, **kwargs): - """ - Return mimebundle corresponding to default renderer. - """ - display_jupyter_version_warnings() - return { - "application/vnd.jupyter.widget-view+json": { - "version_major": 2, - "version_minor": 0, - "model_id": self._model_id, - }, - } - - def _ipython_display_(self): - """ - Handle rich display of figures in ipython contexts - """ - raise NotImplementedError # Prefer _repr_mimebundle_ - - # Callbacks - # --------- - def on_edits_completed(self, fn): - """ - Register a function to be called after all pending trace and layout - edit operations have completed - - If there are no pending edit operations then function is called - immediately - - Parameters - ---------- - fn : callable - Function of zero arguments to be called when all pending edit - operations have completed - """ - if self._layout_edit_in_process or self._trace_edit_in_process: - self._waiting_edit_callbacks.append(fn) - else: - fn() # Validate No Frames # ------------------ @@ -793,199 +255,3 @@ def _display_frames_error(): Frames are not supported by the plotly.graph_objs.FigureWidget class. Note: Frames are supported by the plotly.graph_objs.Figure class""" raise ValueError(msg) - - # Static Helpers - # -------------- - @staticmethod - def _remove_overlapping_props(input_data, delta_data, prop_path=()): - """ - Remove properties in input_data that are also in delta_data, and do so - recursively. - - Exception: Never remove 'uid' from input_data, this property is used - to align traces - - Parameters - ---------- - input_data : dict|list - delta_data : dict|list - - Returns - ------- - list[tuple[str|int]] - List of removed property path tuples - """ - - # Initialize removed - # ------------------ - # This is the list of path tuples to the properties that were - # removed from input_data - removed = [] - - # Handle dict - # ----------- - if isinstance(input_data, dict): - assert isinstance(delta_data, dict) - - for p, delta_val in delta_data.items(): - if isinstance(delta_val, dict) or BaseFigure._is_dict_list(delta_val): - if p in input_data: - # ### Recurse ### - input_val = input_data[p] - recur_prop_path = prop_path + (p,) - recur_removed = BaseFigureWidget._remove_overlapping_props( - input_val, delta_val, recur_prop_path - ) - removed.extend(recur_removed) - - # Check whether the last property in input_val - # has been removed. If so, remove it entirely - if not input_val: - input_data.pop(p) - removed.append(recur_prop_path) - - elif p in input_data and p != "uid": - # ### Remove property ### - input_data.pop(p) - removed.append(prop_path + (p,)) - - # Handle list - # ----------- - elif isinstance(input_data, list): - assert isinstance(delta_data, list) - - for i, delta_val in enumerate(delta_data): - if i >= len(input_data): - break - - input_val = input_data[i] - if ( - input_val is not None - and isinstance(delta_val, dict) - or BaseFigure._is_dict_list(delta_val) - ): - - # ### Recurse ### - recur_prop_path = prop_path + (i,) - recur_removed = BaseFigureWidget._remove_overlapping_props( - input_val, delta_val, recur_prop_path - ) - - removed.extend(recur_removed) - - return removed - - @staticmethod - def _transform_data(to_data, from_data, should_remove=True, relayout_path=()): - """ - Transform to_data into from_data and return relayout-style - description of the transformation - - Parameters - ---------- - to_data : dict|list - from_data : dict|list - - Returns - ------- - dict - relayout-style description of the transformation - """ - - # Initialize relayout data - # ------------------------ - relayout_data = {} - - # Handle dict - # ----------- - if isinstance(to_data, dict): - - # ### Validate from_data ### - if not isinstance(from_data, dict): - raise ValueError( - "Mismatched data types: {to_dict} {from_data}".format( - to_dict=to_data, from_data=from_data - ) - ) - - # ### Add/modify properties ### - # Loop over props/vals - for from_prop, from_val in from_data.items(): - - # #### Handle compound vals recursively #### - if isinstance(from_val, dict) or BaseFigure._is_dict_list(from_val): - - # ##### Init property value if needed ##### - if from_prop not in to_data: - to_data[from_prop] = {} if isinstance(from_val, dict) else [] - - # ##### Transform property val recursively ##### - input_val = to_data[from_prop] - relayout_data.update( - BaseFigureWidget._transform_data( - input_val, - from_val, - should_remove=should_remove, - relayout_path=relayout_path + (from_prop,), - ) - ) - - # #### Handle simple vals directly #### - else: - if from_prop not in to_data or not BasePlotlyType._vals_equal( - to_data[from_prop], from_val - ): - - to_data[from_prop] = from_val - relayout_path_prop = relayout_path + (from_prop,) - relayout_data[relayout_path_prop] = from_val - - # ### Remove properties ### - if should_remove: - for remove_prop in set(to_data.keys()).difference( - set(from_data.keys()) - ): - to_data.pop(remove_prop) - - # Handle list - # ----------- - elif isinstance(to_data, list): - - # ### Validate from_data ### - if not isinstance(from_data, list): - raise ValueError( - "Mismatched data types: to_data: {to_data} {from_data}".format( - to_data=to_data, from_data=from_data - ) - ) - - # ### Add/modify properties ### - # Loop over indexes / elements - for i, from_val in enumerate(from_data): - - # #### Initialize element if needed #### - if i >= len(to_data): - to_data.append(None) - input_val = to_data[i] - - # #### Handle compound element recursively #### - if input_val is not None and ( - isinstance(from_val, dict) or BaseFigure._is_dict_list(from_val) - ): - - relayout_data.update( - BaseFigureWidget._transform_data( - input_val, - from_val, - should_remove=should_remove, - relayout_path=relayout_path + (i,), - ) - ) - - # #### Handle simple elements directly #### - else: - if not BasePlotlyType._vals_equal(to_data[i], from_val): - to_data[i] = from_val - relayout_data[relayout_path + (i,)] = from_val - - return relayout_data diff --git a/packages/python/plotly/plotly/graph_objs/_layout.py b/packages/python/plotly/plotly/graph_objs/_layout.py index c6e9887954..c121f30beb 100644 --- a/packages/python/plotly/plotly/graph_objs/_layout.py +++ b/packages/python/plotly/plotly/graph_objs/_layout.py @@ -1,3 +1,4 @@ +import pdb from plotly.basedatatypes import BaseLayoutType as _BaseLayoutType import copy as _copy diff --git a/packages/python/plotly/plotly/serializers.py b/packages/python/plotly/plotly/serializers.py index 3f02f7db45..ac8cf25b4b 100644 --- a/packages/python/plotly/plotly/serializers.py +++ b/packages/python/plotly/plotly/serializers.py @@ -1,3 +1,4 @@ +from _plotly_utils.utils import to_typed_array_spec from .basedatatypes import Undefined from .optional_imports import get_module @@ -24,7 +25,6 @@ def _py_to_js(v, widget_manager): any Value that the ipywidget library can serialize natively """ - # Handle dict recursively # ----------------------- if isinstance(v, dict): @@ -38,6 +38,7 @@ def _py_to_js(v, widget_manager): # Handle numpy array # ------------------ elif np is not None and isinstance(v, np.ndarray): + return to_typed_array_spec(v) # Convert 1D numpy arrays with numeric types to memoryviews with # datatype and shape metadata. if (