Files
Automaaval/dist/zacatraz/_internal/panel/models/plotly.ts
T
2026-03-14 21:48:05 +00:00

500 lines
15 KiB
TypeScript

import {ModelEvent} from "@bokehjs/core/bokeh_events"
import type {StyleSheetLike} from "@bokehjs/core/dom"
import {div} from "@bokehjs/core/dom"
import type * as p from "@bokehjs/core/properties"
import {isPlainObject} from "@bokehjs/core/util/types"
import {clone} from "@bokehjs/core/util/object"
import {is_equal} from "@bokehjs/core/util/eq"
import type {Attrs} from "@bokehjs/core/types"
import {ColumnDataSource} from "@bokehjs/models/sources/column_data_source"
import {debounce} from "debounce"
import {HTMLBox, HTMLBoxView, set_size} from "./layout"
import {convertUndefined, deepCopy, get, reshape, throttle} from "./util"
import plotly_css from "styles/models/plotly.css"
export class PlotlyEvent extends ModelEvent {
constructor(readonly data: any) {
super()
}
protected override get event_values(): Attrs {
return {model: this.origin, data: this.data}
}
static {
this.prototype.event_name = "plotly_event"
}
}
interface PlotlyHTMLElement extends HTMLDivElement {
_fullLayout: any
_hoverdata: any
layout: any
on(event: "plotly_relayout", callback: (eventData: any) => void): void
on(event: "plotly_relayouting", callback: (eventData: any) => void): void
on(event: "plotly_restyle", callback: (eventData: any) => void): void
on(event: "plotly_click", callback: (eventData: any) => void): void
on(event: "plotly_hover", callback: (eventData: any) => void): void
on(event: "plotly_clickannotation", callback: (eventData: any) => void): void
on(event: "plotly_selected", callback: (eventData: any) => void): void
on(event: "plotly_deselect", callback: () => void): void
on(event: "plotly_unhover", callback: () => void): void
}
const filterEventData = (gd: any, eventData: any, event: string) => {
// Ported from dash-core-components/src/components/Graph.react.js
const filteredEventData: {[k: string]: any} = Array.isArray(eventData)? []: {}
if (event === "click" || event === "hover" || event === "selected") {
const points = []
if (eventData === undefined || eventData === null) {
return null
}
/*
* remove `data`, `layout`, `xaxis`, etc
* objects from the event data since they're so big
* and cause JSON stringify circular structure errors.
*
* also, pull down the `customdata` point from the data array
* into the event object
*/
const data = gd.data
for (let i = 0; i < eventData.points.length; i++) {
const fullPoint = eventData.points[i]
const pointData: {[k: string]: any} = {}
for (const property in fullPoint) {
const val = fullPoint[property]
if (fullPoint.hasOwnProperty(property) &&
!Array.isArray(val) && !isPlainObject(val) &&
val !== undefined) {
pointData[property] = val
}
}
if (fullPoint !== undefined && fullPoint !== null) {
if (fullPoint.hasOwnProperty("curveNumber") &&
fullPoint.hasOwnProperty("pointNumber") &&
data[fullPoint.curveNumber].hasOwnProperty("customdata")) {
pointData.customdata =
data[fullPoint.curveNumber].customdata[
fullPoint.pointNumber
]
}
// specific to histogram. see https://github.com/plotly/plotly.js/pull/2113/
if (fullPoint.hasOwnProperty("pointNumbers")) {
pointData.pointNumbers = fullPoint.pointNumbers
}
}
points[i] = pointData
}
filteredEventData.points = points
} else if (event === "relayout" || event === "restyle") {
/*
* relayout shouldn't include any big objects
* it will usually just contain the ranges of the axes like
* "xaxis.range[0]": 0.7715822247381828,
* "xaxis.range[1]": 3.0095292008680063`
*/
for (const property in eventData) {
if (eventData.hasOwnProperty(property)) {
filteredEventData[property] = eventData[property]
}
}
}
if (eventData.hasOwnProperty("range")) {
filteredEventData.range = eventData.range
}
if (eventData.hasOwnProperty("lassoPoints")) {
filteredEventData.lassoPoints = eventData.lassoPoints
}
return convertUndefined(filteredEventData)
}
const _isHidden = (gd: any) => {
const display = window.getComputedStyle(gd).display
return !display || display === "none"
}
export class PlotlyPlotView extends HTMLBoxView {
declare model: PlotlyPlot
_setViewport: Function
_settingViewport: boolean = false
_plotInitialized: boolean = false
_rendered: boolean = false
_reacting: boolean = false
_relayouting: boolean = false
_hoverdata: any = null
container: PlotlyHTMLElement
_watched_sources: string[]
_end_relayouting = debounce(() => {
this._relayouting = false
}, 2000, false)
override connect_signals(): void {
super.connect_signals()
const {
data, data_sources, layout, relayout, restyle, viewport_update_policy,
viewport_update_throttle, _render_count, frames, viewport,
} = this.model.properties
this.on_change([data, data_sources, layout], () => {
const render_count = this.model._render_count
setTimeout(() => {
if (this.model._render_count === render_count) {
this.model._render_count += 1
}
}, 250)
})
this.on_change(relayout, () => {
if (this.model.relayout == null) {
return
}
(window as any).Plotly.relayout(this.container, this.model.relayout)
this.model.relayout = null
})
this.on_change(restyle, () => {
if (this.model.restyle == null) {
return
}
(window as any).Plotly.restyle(this.container, this.model.restyle.data, this.model.restyle.traces)
this.model.restyle = null
})
this.on_change(viewport_update_policy, () => {
this._updateSetViewportFunction()
})
this.on_change(viewport_update_throttle, () => {
this._updateSetViewportFunction()
})
this.on_change(_render_count, () => {
this.plot()
})
this.on_change(frames, () => {
this.plot(true)
})
this.on_change(viewport, () => {
this._updateViewportFromProperty()
})
}
override stylesheets(): StyleSheetLike[] {
return [...super.stylesheets(), plotly_css]
}
override remove(): void {
if (this.container != null) {
(window as any).Plotly.purge(this.container)
}
super.remove()
}
override render(): void {
super.render()
this.container = div() as PlotlyHTMLElement
set_size(this.container, this.model)
this._rendered = false
this.shadow_el.appendChild(this.container)
this.watch_stylesheets()
this.plot().then(() => {
this._rendered = true
if (this.model.relayout != null) {
(window as any).Plotly.relayout(this.container, this.model.relayout)
}
(window as any).Plotly.Plots.resize(this.container)
})
}
override style_redraw(): void {
if (this._rendered && this.container != null) {
(window as any).Plotly.Plots.resize(this.container)
}
}
override after_layout(): void {
super.after_layout()
if (this._rendered && this.container != null) {
(window as any).Plotly.Plots.resize(this.container)
}
}
_trace_data(): any {
const data = []
for (let i = 0; i < this.model.data.length; i++) {
data.push(this._get_trace(i, false))
}
return data
}
_layout_data(): any {
const newLayout = deepCopy(this.model.layout)
if (this._relayouting) {
const {layout} = this.container
// For each xaxis* and yaxis* property of layout, if the value has a 'range'
// property then use this in newLayout
Object.keys(layout).reduce((value: any, key: string) => {
if (key.slice(1, 5) === "axis" && "range" in value) {
newLayout[key].range = value.range
}
}, {})
}
return newLayout
}
_install_callbacks(): void {
// - plotly_relayout
this.container.on("plotly_relayout", (eventData: any) => {
if (eventData._update_from_property !== true) {
this.model.relayout_data = filterEventData(
this.container, eventData, "relayout")
this._updateViewportProperty()
this._end_relayouting()
}
})
// - plotly_relayouting
this.container.on("plotly_relayouting", () => {
if (this.model.viewport_update_policy !== "mouseup") {
this._relayouting = true
this._updateViewportProperty()
}
})
// - plotly_restyle
this.container.on("plotly_restyle", (eventData: any) => {
this.model.restyle_data = filterEventData(
this.container, eventData, "restyle")
this._updateViewportProperty()
})
// - plotly_click
this.container.on("plotly_click", (eventData: any) => {
const data = filterEventData(this.container, eventData, "click")
this.model.trigger_event(new PlotlyEvent({type: "click", data}))
})
// - plotly_hover
this.container.on("plotly_hover", (eventData: any) => {
const data = filterEventData(this.container, eventData, "hover")
this.model.trigger_event(new PlotlyEvent({type: "hover", data}))
// Override hoverdata to ensure click event has context
// see https://github.com/holoviz/panel/pull/6753
this._hoverdata = this.container._hoverdata = eventData.points
})
// - plotly_selected
this.container.on("plotly_selected", (eventData: any) => {
if (eventData === undefined || eventData === null) {
// filter out the empty events that come from single-click
return
}
const data = filterEventData(this.container, eventData, "selected")
this.model.trigger_event(new PlotlyEvent({type: "selected", data}))
})
// - plotly_clickannotation
this.container.on("plotly_clickannotation", (eventData: any) => {
delete eventData.event
delete eventData.fullAnnotation
this.model.trigger_event(new PlotlyEvent({type: "clickannotation", data: eventData}))
})
// - plotly_deselect
this.container.on("plotly_deselect", () => {
this.model.trigger_event(new PlotlyEvent({type: "selected", data: null}))
})
// - plotly_unhover
this.container.on("plotly_unhover", () => {
// Override hoverdata to ensure click event has context
this.container._hoverdata = this._hoverdata
this.model.trigger_event(new PlotlyEvent({type: "hover", data: null}))
setTimeout(() => {
// Remove hoverdata once events have been processed
delete this.container._hoverdata
}, 0)
})
}
async plot(new_plot: boolean=false): Promise<void> {
if (!(window as any).Plotly) {
return
}
const data = this._trace_data()
const newLayout = this._layout_data()
this._reacting = true
if (new_plot) {
const obj = {data, layout: newLayout, config: this.model.config, frames: this.model.frames}
await (window as any).Plotly.newPlot(this.container, obj)
} else {
await (window as any).Plotly.react(this.container, data, newLayout, this.model.config)
if (this.model.frames != null) {
await (window as any).Plotly.addFrames(this.container, this.model.frames)
}
}
this._updateSetViewportFunction()
this._updateViewportProperty()
if (!this._plotInitialized) {
this._install_callbacks()
} else if (!_isHidden(this.container)) {
(window as any).Plotly.Plots.resize(this.container)
}
this._reacting = false
this._plotInitialized = true
}
_get_trace(index: number, update: boolean): any {
const trace = clone(this.model.data[index]) as any
const cds = this.model.data_sources[index]
for (const column of cds.columns()) {
let array = cds.get_array(column)[0]
if (array.shape != null && array.shape.length > 1) {
array = reshape(array, array.shape)
}
const prop_path = column.split(".")
const prop = prop_path[prop_path.length - 1]
let prop_parent = trace
for (const k of prop_path.slice(0, -1)) {
prop_parent = (prop_parent[k])
}
if (update && prop_path.length == 1) {
prop_parent[prop] = [array]
} else {
prop_parent[prop] = array
}
}
return trace
}
_updateViewportFromProperty(): void {
if (!(window as any).Plotly || this._settingViewport || this._reacting || !this.model.viewport) {
return
}
const fullLayout = this.container._fullLayout
// Call relayout if viewport differs from fullLayout
Object.keys(this.model.viewport).reduce((value: any, key: string) => {
if (!is_equal(get(fullLayout, key), value)) {
const clonedViewport = deepCopy(this.model.viewport)
clonedViewport._update_from_property = true
this._settingViewport = true;
(window as any).Plotly.relayout(this.el, clonedViewport).then(() => {
this._settingViewport = false
})
return false
} else {
return true
}
}, {})
}
_updateViewportProperty(): void {
const fullLayout = this.container._fullLayout
const viewport: any = {}
// Get range for all xaxis and yaxis properties
for (const prop in fullLayout) {
if (!fullLayout.hasOwnProperty(prop)) {
continue
}
const maybe_axis = prop.slice(0, 5)
if (maybe_axis === "xaxis" || maybe_axis === "yaxis") {
viewport[`${prop }.range`] = deepCopy(fullLayout[prop].range)
}
}
if (!is_equal(viewport, this.model.viewport)) {
this._setViewport(viewport)
}
}
_updateSetViewportFunction(): void {
if (this.model.viewport_update_policy === "continuous" ||
this.model.viewport_update_policy === "mouseup") {
this._setViewport = (viewport: any) => {
if (!this._settingViewport) {
this._settingViewport = true
this.model.viewport = viewport
this._settingViewport = false
}
}
} else {
this._setViewport = throttle((viewport: any) => {
if (!this._settingViewport) {
this._settingViewport = true
this.model.viewport = viewport
this._settingViewport = false
}
}, this.model.viewport_update_throttle)
}
}
}
export namespace PlotlyPlot {
export type Attrs = p.AttrsOf<Props>
export type Props = HTMLBox.Props & {
data: p.Property<any[]>
frames: p.Property<any[] | null>
layout: p.Property<any>
config: p.Property<any>
data_sources: p.Property<any[]>
relayout: p.Property<any>
restyle: p.Property<any>
relayout_data: p.Property<any>
restyle_data: p.Property<any>
viewport: p.Property<any>
viewport_update_policy: p.Property<string>
viewport_update_throttle: p.Property<number>
_render_count: p.Property<number>
}
}
export interface PlotlyPlot extends PlotlyPlot.Attrs {}
export class PlotlyPlot extends HTMLBox {
declare properties: PlotlyPlot.Props
constructor(attrs?: Partial<PlotlyPlot.Attrs>) {
super(attrs)
}
static override __module__ = "panel.models.plotly"
static {
this.prototype.default_view = PlotlyPlotView
this.define<PlotlyPlot.Props>(({List, Any, Nullable, Float, Ref, Str}) => ({
data: [ List(Any), [] ],
layout: [ Any, {} ],
config: [ Any, {} ],
frames: [ Nullable(List(Any)), null ],
data_sources: [ List(Ref(ColumnDataSource)), [] ],
relayout: [ Nullable(Any), {} ],
restyle: [ Nullable(Any), {} ],
relayout_data: [ Any, {} ],
restyle_data: [ List(Any), [] ],
viewport: [ Any, {} ],
viewport_update_policy: [ Str, "mouseup" ],
viewport_update_throttle: [ Float, 200 ],
_render_count: [ Float, 0 ],
}))
}
}