@bokeh/bokehjs
Version:
Interactive, novel data visualization
552 lines • 20.1 kB
JavaScript
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