UNPKG

@bokeh/bokehjs

Version:

Interactive, novel data visualization

552 lines 20.1 kB
import { Pane, PaneView } from "../ui/pane"; import { logger } from "../../core/logging"; import { Signal } from "../../core/signaling"; import { Align, Dimensions, FlowMode, SizingMode } from "../../core/enums"; import { px } from "../../core/dom"; import { isNumber, isArray } from "../../core/util/types"; import { enumerate } from "../../core/util/iterator"; import { build_views } from "../../core/build_views"; import { SizingPolicy } from "../../core/layout"; import { CanvasLayer } from "../../core/util/canvas"; import { unreachable } from "../../core/util/assert"; export class LayoutDOMView extends PaneView { static __name__ = "LayoutDOMView"; _child_views = new Map(); layout; mouseenter = new Signal(this, "mouseenter"); mouseleave = new Signal(this, "mouseleave"); disabled = new Signal(this, "disabled"); get is_layout_root() { return this.is_root || !(this.parent instanceof LayoutDOMView); } _after_resize() { super._after_resize(); if (this.is_layout_root && !this._was_built) { // This can happen only in pathological cases primarily in tests. logger.warn(`${this} wasn't built properly`); this.render(); this.r_after_render(); } else { this.compute_layout(); } } async lazy_initialize() { await super.lazy_initialize(); await this.build_child_views(); } remove() { for (const child_view of this.child_views) { child_view.remove(); } this._child_views.clear(); super.remove(); } connect_signals() { super.connect_signals(); this.el.addEventListener("mouseenter", (event) => { this.mouseenter.emit(event); }); this.el.addEventListener("mouseleave", (event) => { this.mouseleave.emit(event); }); if (this.parent instanceof LayoutDOMView) { this.connect(this.parent.disabled, (disabled) => { this.disabled.emit(disabled || this.model.disabled); }); } const p = this.model.properties; this.on_change(p.disabled, () => { this.disabled.emit(this.model.disabled); }); this.on_change([ p.css_classes, p.stylesheets, p.width, p.height, p.min_width, p.min_height, p.max_width, p.max_height, p.margin, p.width_policy, p.height_policy, p.flow_mode, p.sizing_mode, p.aspect_ratio, p.visible, ], () => this.invalidate_layout()); } children_views() { return [...super.children_views(), ...this.child_views]; } get child_views() { // TODO In case of a race condition somewhere between layout, resize and children updates, // child_models and _child_views may be temporarily inconsistent, resulting in undefined // values. Eventually this shouldn't happen and undefined should be treated as a bug. return this.child_models.map((child) => this._child_views.get(child)).filter((view) => view != null); } get layoutable_views() { return this.child_views.filter((c) => c instanceof LayoutDOMView); } async build_child_views() { const { created, removed } = await build_views(this._child_views, this.child_models, { parent: this }); for (const view of removed) { this._resize_observer.unobserve(view.el); } for (const view of created) { this._resize_observer.observe(view.el, { box: "border-box" }); } return created; } render() { super.render(); for (const child_view of this.child_views) { const target = child_view.rendering_target() ?? this.shadow_el; child_view.render_to(target); } } rerender() { super.rerender(); this.update_layout(); this.compute_layout(); } _update_children() { } async update_children() { const created = await this.build_child_views(); const created_views = new Set(created); // Find index up to which the order of the existing views // matches the order of the new views. This allows us to // skip re-inserting the views up to this point const current_views = Array.from(this.shadow_el.children).flatMap(el => { const view = this.child_views.find(view => view.el === el); return view === undefined ? [] : [view]; }); let matching_index = null; for (let i = 0; i < current_views.length; i++) { if (current_views[i] === this.child_views[i]) { matching_index = i; } else { break; } } // Since appending to a DOM node will move the node to the end if it has // already been added appending all the children in order will result in // correct ordering. for (const [view, i] of enumerate(this.child_views)) { const is_new = created_views.has(view); const target = view.rendering_target() ?? this.self_target; if (is_new) { view.render_to(target); } else if (matching_index === null || i > matching_index) { target.append(view.el); } } this.r_after_render(); this._update_children(); this.invalidate_layout(); } _auto_width = "fit-content"; _auto_height = "fit-content"; _intrinsic_display() { return { inner: this.model.flow_mode, outer: "flow" }; } _update_layout() { function css_sizing(policy, size, auto_size, margin) { switch (policy) { case "auto": return size != null ? px(size) : auto_size; case "fixed": return size != null ? px(size) : "fit-content"; case "fit": return "fit-content"; case "min": return "min-content"; case "max": return margin == null ? "100%" : `calc(100% - ${margin})`; } } function css_display(display) { // Convert to legacy values due to limited browser support. const { inner, outer } = display; switch (`${inner} ${outer}`) { case "block flow": return "block"; case "inline flow": return "inline"; case "block flow-root": return "flow-root"; case "inline flow-root": return "inline-block"; case "block flex": return "flex"; case "inline flex": return "inline-flex"; case "block grid": return "grid"; case "inline grid": return "inline-grid"; case "block table": return "table"; case "inline table": return "inline-table"; default: unreachable(); } } function to_css(value) { return isNumber(value) ? px(value) : `${value.percent}%`; } const styles = {}; const display = this._intrinsic_display(); styles.display = css_display(display); const sizing = this.box_sizing(); const { width_policy, height_policy, width, height, aspect_ratio } = sizing; const computed_aspect = (() => { if (aspect_ratio == "auto") { if (width != null && height != null) { return width / height; } } else if (isNumber(aspect_ratio)) { return aspect_ratio; } return null; })(); if (aspect_ratio == "auto") { if (width != null && height != null) { styles.aspect_ratio = `${width} / ${height}`; } else { styles.aspect_ratio = "auto"; } } else if (isNumber(aspect_ratio)) { styles.aspect_ratio = `${aspect_ratio}`; } const { margin } = this.model; const margins = (() => { if (margin != null) { if (isNumber(margin)) { styles.margin = px(margin); return { width: px(2 * margin), height: px(2 * margin) }; } else if (margin.length == 2) { const [vertical, horizontal] = margin; styles.margin = `${px(vertical)} ${px(horizontal)}`; return { width: px(2 * horizontal), height: px(2 * vertical) }; } else { const [top, right, bottom, left] = margin; styles.margin = `${px(top)} ${px(right)} ${px(bottom)} ${px(left)}`; return { width: px(left + right), height: px(top + bottom) }; } } else { return { width: null, height: null }; } })(); const [css_width, css_height] = (() => { const css_width = css_sizing(width_policy, width, this._auto_width, margins.width); const css_height = css_sizing(height_policy, height, this._auto_height, margins.height); if (aspect_ratio != null) { if (width_policy != height_policy) { if (width_policy == "fixed") { return [css_width, "auto"]; } if (height_policy == "fixed") { return ["auto", css_height]; } if (width_policy == "max") { return [css_width, "auto"]; } if (height_policy == "max") { return ["auto", css_height]; } return ["auto", "auto"]; } else { if (width_policy != "fixed" && height_policy != "fixed") { if (computed_aspect != null) { if (computed_aspect >= 1) { return [css_width, "auto"]; } else { return ["auto", css_height]; } } } } } return [css_width, css_height]; })(); styles.width = css_width; styles.height = css_height; const { min_width, max_width } = this.model; const { min_height, max_height } = this.model; if (min_width != null) { styles.min_width = to_css(min_width); } if (min_height != null) { styles.min_height = to_css(min_height); } if (this.is_layout_root) { if (max_width != null) { styles.max_width = to_css(max_width); } if (max_height != null) { styles.max_height = to_css(max_height); } } else { if (max_width != null) { styles.max_width = `min(${to_css(max_width)}, 100%)`; } else if (width_policy != "fixed") { styles.max_width = "100%"; } if (max_height != null) { styles.max_height = `min(${to_css(max_height)}, 100%)`; } else if (height_policy != "fixed") { styles.max_height = "100%"; } } const { resizable } = this.model; if (resizable !== false) { const resize = (() => { switch (resizable) { case "width": return "horizontal"; case "height": return "vertical"; case true: case "both": return "both"; } })(); styles.resize = resize; styles.overflow = "auto"; } this.style.append(":host", styles); } update_layout() { this.update_style(); for (const child_view of this.child_views) { child_view.parent_style.clear(); } for (const child_view of this.layoutable_views) { child_view.update_layout(); } this._update_layout(); } get is_managed() { return this.parent instanceof LayoutDOMView; } /** * Update CSS layout with computed values from canvas layout. * This can be done more frequently than `_update_layout()`. */ _measure_layout() { } measure_layout() { for (const child_view of this.layoutable_views) { child_view.measure_layout(); } this._measure_layout(); } _layout_computed = false; compute_layout() { if (this.parent instanceof LayoutDOMView) { // TODO: this.is_managed this.parent.compute_layout(); } else { this.measure_layout(); this.update_bbox(); this._compute_layout(); this.after_layout(); } this._layout_computed = true; } _compute_layout() { if (this.layout != null) { this.layout.compute(this.bbox.size); for (const child_view of this.layoutable_views) { if (child_view.layout == null) { child_view._compute_layout(); } else { child_view._propagate_layout(); } } } else { for (const child_view of this.layoutable_views) { child_view._compute_layout(); } } } _propagate_layout() { for (const child_view of this.layoutable_views) { if (child_view.layout == null) { child_view._compute_layout(); } } } update_bbox() { for (const child_view of this.layoutable_views) { child_view.update_bbox(); } const changed = super.update_bbox(); if (this.layout != null) { this.layout.visible = this.is_displayed; } return changed; } _after_layout() { } after_layout() { for (const child_view of this.layoutable_views) { child_view.after_layout(); } this._after_layout(); } _after_render() { // XXX no super if (!this.is_managed) { this.invalidate_layout(); } } invalidate_layout() { // TODO: it would be better and more efficient to do a localized // update, but for now this guarantees consistent state of layout. if (this.parent instanceof LayoutDOMView) { this.parent.invalidate_layout(); } else { this.update_layout(); this.compute_layout(); } } invalidate_render() { this.render(); this.r_after_render(); this.invalidate_layout(); } has_finished() { if (!super.has_finished()) { return false; } if (this.is_layout_root && !this._layout_computed) { return false; } for (const child_view of this.child_views) { if (!child_view.has_finished()) { return false; } } return true; } box_sizing() { let { width_policy, height_policy, aspect_ratio } = this.model; const { sizing_mode } = this.model; if (sizing_mode != null) { if (sizing_mode == "inherit") { if (this.parent instanceof LayoutDOMView) { const sizing = this.parent.box_sizing(); width_policy = sizing.width_policy; height_policy = sizing.height_policy; if (aspect_ratio == null) { aspect_ratio = sizing.aspect_ratio; } } } else if (sizing_mode == "fixed") { width_policy = height_policy = "fixed"; } else if (sizing_mode == "stretch_both") { width_policy = height_policy = "max"; } else if (sizing_mode == "stretch_width") { width_policy = "max"; } else if (sizing_mode == "stretch_height") { height_policy = "max"; } else { if (aspect_ratio == null) { aspect_ratio = "auto"; } switch (sizing_mode) { case "scale_width": width_policy = "max"; height_policy = "min"; break; case "scale_height": width_policy = "min"; height_policy = "max"; break; case "scale_both": width_policy = "max"; height_policy = "max"; break; } } } const [halign, valign] = (() => { const { align } = this.model; if (align == "auto") { return [undefined, undefined]; } else if (isArray(align)) { return align; } else { return [align, align]; } })(); const { width, height } = this.model; return { width_policy, height_policy, width, height, aspect_ratio, halign, valign, }; } export(type = "auto", hidpi = true) { const output_backend = (() => { switch (type) { case "auto": // TODO: actually infer the best type case "png": return "canvas"; case "svg": return "svg"; } })(); const composite = new CanvasLayer(output_backend, hidpi); const { x, y, width, height } = this.bbox; composite.resize(width, height); const bg_color = getComputedStyle(this.el).backgroundColor; composite.ctx.fillStyle = bg_color; composite.ctx.fillRect(x, y, width, height); for (const view of this.child_views) { const region = view.export(type, hidpi); const { x, y } = view.bbox.scale(composite.pixel_ratio); composite.ctx.drawImage(region.canvas, x, y); } return composite; } } export class LayoutDOM extends Pane { static __name__ = "LayoutDOM"; constructor(attrs) { super(attrs); } static { this.define((types) => { const { Bool, Float, Auto, Tuple, Or, Null, Nullable } = types; const Number2 = Tuple(Float, Float); const Number4 = Tuple(Float, Float, Float, Float); return { width: [Nullable(Float), null], height: [Nullable(Float), null], min_width: [Nullable(Float), null], min_height: [Nullable(Float), null], max_width: [Nullable(Float), null], max_height: [Nullable(Float), null], margin: [Nullable(Or(Float, Number2, Number4)), null], width_policy: [Or(SizingPolicy, Auto), "auto"], height_policy: [Or(SizingPolicy, Auto), "auto"], aspect_ratio: [Or(Float, Auto, Null), null], flow_mode: [FlowMode, "block"], sizing_mode: [Nullable(SizingMode), null], disabled: [Bool, false], align: [Or(Align, Tuple(Align, Align), Auto), "auto"], resizable: [Or(Bool, Dimensions), false], }; }); } } //# sourceMappingURL=layout_dom.js.map