UNPKG

@bokeh/bokehjs

Version:

Interactive, novel data visualization

675 lines (673 loc) 26.2 kB
import { Annotation, AnnotationView } from "./annotation"; import { LegendItem } from "./legend_item"; import { AlternationPolicy, Orientation, LegendLocation, LegendClickPolicy, Location } from "../../core/enums"; import { resolve_line_dash } from "../../core/visuals/line"; import * as mixins from "../../core/property_mixins"; import { SideLayout, SidePanel } from "../../core/layout/side_panel"; import { BBox } from "../../core/util/bbox"; import { every, some, sum } from "../../core/util/array"; import { dict } from "../../core/util/object"; import { isString } from "../../core/util/types"; import { zip } from "../../core/util/iterator"; import { LegendItemClick } from "../../core/bokeh_events"; import { div, bounding_box, px, empty } from "../../core/dom"; import { TextBox } from "../../core/graphics"; import * as legend_css from "../../styles/legend.css"; import { Padding, BorderRadius } from "../common/kinds"; import { round_rect } from "../common/painting"; import * as resolve from "../common/resolve"; const { ceil } = Math; export class LegendView extends AnnotationView { static __name__ = "LegendView"; get is_dual_renderer() { return true; } _get_size() { const { width, height } = this.bbox; const { margin } = this.model; return { width: width + 2 * margin, height: height + 2 * margin, }; } update_layout() { this.update_geometry(); const { panel } = this; if (panel != null) { this.layout = new SideLayout(panel, () => this.get_size()); } else { this.layout = undefined; } } _resize_observer; initialize() { super.initialize(); this._resize_observer = new ResizeObserver((_entries) => this.request_layout()); this._resize_observer.observe(this.el, { box: "border-box" }); } remove() { this._resize_observer.disconnect(); super.remove(); } connect_signals() { super.connect_signals(); this.connect(this.model.change, () => this.rerender()); const { items } = this.model.properties; this.on_transitive_change(items, () => this._render_items(), { recursive: true }); } _bbox = new BBox(); get bbox() { return this._bbox; } grid_el = div({ class: legend_css.grid }); title_el = div(); entries = []; get padding() { const padding = this.model.border_line_color != null ? this.model.padding : 0; return resolve.padding(padding); } get border_radius() { return resolve.border_radius(this.model.border_radius); } stylesheets() { return [...super.stylesheets(), legend_css.default]; } _paint_glyphs() { const { glyph_width, glyph_height } = this.model; const x0 = 0; const y0 = 0; const x1 = glyph_width; const y1 = glyph_height; for (const { glyph, item, label } of this.entries) { const field = item.get_field_from_label_prop(); glyph.resize(glyph_width, glyph_height); const ctx = glyph.prepare(); for (const renderer of item.renderers) { const view = this.plot_view.views.find_one(renderer); view?.draw_legend(ctx, x0, x1, y0, y1, field, label, item.index); } glyph.finish(); } } get labels() { const collected = []; for (const item of this.model.items) { const labels = item.get_labels_list_from_label_prop(); for (const label of labels) { collected.push({ item, label }); } } return collected; } get _should_rerender_items() { const { entries, labels } = this; if (entries.length != labels.length) { return true; } for (const [entry, { item, label }] of zip(entries, labels)) { if (entry.item != item || entry.label != label) { return true; } } return false; } _toggle_inactive({ el, item }) { el.classList.toggle(legend_css.inactive, !this.is_active(item)); } _render_items() { this.entries = []; const { click_policy } = this; let i = 0; for (const item of this.model.items) { const labels = item.get_labels_list_from_label_prop(); for (const label of labels) { const glyph = this.plot_view.canvas.create_layer(); glyph.el.classList.add(legend_css.glyph); const glyph_el = glyph.canvas; const label_el = div({ class: legend_css.label }, `${label}`); const overlay_el = div({ class: legend_css.overlay }); const item_el = div({ class: legend_css.item }, glyph_el, label_el, overlay_el); item_el.classList.toggle(legend_css.hidden, !item.visible); const entry = { el: item_el, glyph, label_el, item, label, i: i++, row: 0, col: 0 }; this.entries.push(entry); item_el.addEventListener("pointerdown", () => { this.model.trigger_event(new LegendItemClick(this.model, item)); for (const renderer of item.renderers) { click_policy(renderer); } this._toggle_inactive(entry); }); } } const vertical = this.model.orientation == "vertical"; const { nc: ncols, nr: nrows } = (() => { const { ncols, nrows } = this.model; const n = this.entries.length; let nc; let nr; if (ncols != "auto" && nrows != "auto") { nc = ncols; nr = nrows; } else if (ncols != "auto") { nc = ncols; nr = ceil(n / ncols); } else if (nrows != "auto") { nc = ceil(n / nrows); nr = nrows; } else { if (vertical) { nc = 1; nr = n; } else { nc = n; nr = 1; } } return { nc, nr }; })(); let row = 0; let col = 0; for (const entry of this.entries) { entry.el.id = `item_${row}_${col}`; entry.row = row; entry.col = col; if (vertical) { row += 1; if (row >= nrows) { row = 0; col += 1; } } else { col += 1; if (col >= ncols) { col = 0; row += 1; } } } for (const entry of this.entries) { this._toggle_inactive(entry); } for (const { el, i, row, col } of this.entries) { if (this.has_item_background(i, row, col)) { el.classList.add(legend_css.styled); } } empty(this.grid_el); this.grid_el.style.setProperty("--ncols", `${ncols}`); this.grid_el.style.setProperty("--nrows", `${nrows}`); this.grid_el.append(...this.entries.map(({ el }) => el)); } render() { super.render(); const { orientation } = this.model; const vertical = orientation == "vertical"; this.el.classList.toggle(legend_css.interactive, this.is_interactive); this.el.classList.toggle(legend_css.vertical, vertical); const title_el = div({ class: legend_css.title }, this.model.title); this.title_el.remove(); this.title_el = title_el; // can't simply use `rotate`, because rotation doesn't affect layout const { writing_mode, rotate } = (() => { const label_panel = new SidePanel(this.model.title_location); switch (label_panel.face_adjusted_side) { case "above": return { writing_mode: "horizontal-tb", rotate: 0 }; case "below": return { writing_mode: "horizontal-tb", rotate: 0 }; case "left": return { writing_mode: "vertical-rl", rotate: 180 }; case "right": return { writing_mode: "vertical-rl", rotate: 0 }; } })(); const title_styles = this.visuals.title_text.computed_values(); this.style.append(` .${legend_css.title} { font: ${title_styles.font}; color: ${title_styles.color}; -webkit-text-stroke: ${title_styles.outline_width}px ${title_styles.outline_color}; writing-mode: ${writing_mode}; rotate: ${rotate}deg; } `); const label_styles = this.visuals.label_text.computed_values(); this.style.append(` .${legend_css.item} .${legend_css.label} { font: ${label_styles.font}; color: ${label_styles.color}; -webkit-text-stroke: ${label_styles.outline_width}px ${label_styles.outline_color}; } `); const { anchor } = this; this.style.append(` :host { transform: translate(-${anchor.x * 100}%, -${anchor.y * 100}%); } `); this.style.append(` :host { gap: ${px(this.model.title_standoff)}; } .${legend_css.grid} { gap: ${px(this.model.spacing)}; } .${legend_css.item} { gap: ${px(this.model.label_standoff)}; } .${legend_css.item} .${legend_css.glyph} { width: ${px(this.model.glyph_width)}; height: ${px(this.model.glyph_height)}; } .${legend_css.item} .${legend_css.label} { min-width: ${px(this.model.label_width)}; min-height: ${px(this.model.label_height)}; } `); if (this.visuals.item_background_fill.doit) { const { color } = this.visuals.item_background_fill.computed_values(); this.style.append(` .${legend_css.item} { --item-background-color: ${color}; } `); } if (this.visuals.item_background_hatch.doit) { const { scale, pattern } = this.visuals.item_background_hatch.computed_values(); this.style.append(` .${legend_css.item} { --item-background-hatch: url(${pattern}); --item-background-hatch-scale: ${scale}px; } `); } if (this.visuals.inactive_fill.doit) { const { color } = this.visuals.inactive_fill.computed_values(); this.style.append(` .${legend_css.item} { --item-background-inactive-color: ${color}; } `); } if (this.visuals.inactive_hatch.doit) { const { scale, pattern } = this.visuals.inactive_hatch.computed_values(); this.style.append(` .${legend_css.item} { --item-background-inactive-hatch: url(${pattern}); --item-background-inactive-hatch-scale: ${scale}px; } `); } const grid_auto_flow = (() => { switch (this.model.title_location) { case "above": case "below": return "row"; case "left": case "right": return "column"; } })(); this.style.append(` :host { grid-auto-flow: ${grid_auto_flow}; } `); this.shadow_el.append(...(() => { switch (this.model.title_location) { case "above": return [title_el, this.grid_el]; case "below": return [this.grid_el, title_el]; case "left": return [title_el, this.grid_el]; case "right": return [this.grid_el, title_el]; } })()); const { padding, border_radius } = this; this.style.append(` :host { padding-left: ${padding.left}px; padding-right: ${padding.right}px; padding-top: ${padding.top}px; padding-bottom: ${padding.bottom}px; border-top-left-radius: ${border_radius.top_left}px; border-top-right-radius: ${border_radius.top_right}px; border-bottom-right-radius: ${border_radius.bottom_right}px; border-bottom-left-radius: ${border_radius.bottom_left}px; } `); if (this.visuals.background_fill.doit) { const { color } = this.visuals.background_fill.computed_values(); this.style.append(` :host { --background-color: ${color}; background-color: ${color}; } `); } if (this.visuals.background_hatch.doit) { const { scale, pattern } = this.visuals.background_hatch.computed_values(); this.style.append(` :host { --background-hatch: url(${pattern}); --background-hatch-scale: ${scale}px; background-image: var(--background-hatch); background-size: var(--background-hatch-scale); } `); } if (this.visuals.border_line.doit) { const { color, width, dash: raw_dash } = this.visuals.border_line.computed_values(); const invalid_css_border_style = ["dotdash", "dashdot"]; let dash = raw_dash; // Invalid string dash to use CSS/border-style approach if (isString(dash) && invalid_css_border_style.includes(dash)) { // Convert to array representation dash = resolve_line_dash(dash); } // Non-empty dash array case if (!isString(dash) && dash.length > 0) { // Make dash array even if (dash.length % 2 !== 0) { dash = dash.concat(dash); } // Compute extra patterns rules let extra_patterns = ""; for (let index = 0; index < dash.length; index++) { if (index !== 0 && index % 2 === 0) { extra_patterns += `, linear-gradient(to right, ${color} ${dash[index]}px, transparent ${dash[index]}px) ${sum(dash.slice(0, index))}px top/var(--border-line-full-length) ${width}px repeat-x, linear-gradient(to right, ${color} ${dash[index]}px, transparent ${dash[index]}px) ${sum(dash.slice(0, index))}px bottom/var(--border-line-full-length) ${width}px repeat-x, linear-gradient(to bottom, ${color} ${dash[index]}px, transparent ${dash[index]}px) right ${sum(dash.slice(0, index))}px/${width}px var(--border-line-full-length) repeat-y, linear-gradient(to bottom, ${color} ${dash[index]}px, transparent ${dash[index]}px) left ${sum(dash.slice(0, index))}px/${width}px var(--border-line-full-length) repeat-y`; } } this.style.append(` :host { --border-color: ${color}; --border-line-full-length: ${sum(dash)}px; background: linear-gradient(to right, ${color} ${dash[0]}px, transparent ${dash[0]}px) left top/var(--border-line-full-length) ${width}px repeat-x, linear-gradient(to right, ${color} ${dash[0]}px, transparent ${dash[0]}px) left bottom/var(--border-line-full-length) ${width}px repeat-x, linear-gradient(to bottom, ${color} ${dash[0]}px, transparent ${dash[0]}px) right top/${width}px var(--border-line-full-length) repeat-y, linear-gradient(to bottom, ${color} ${dash[0]}px, transparent ${dash[0]}px) left top/${width}px var(--border-line-full-length) repeat-y ${extra_patterns.length > 0 ? `${extra_patterns}` : ""}, ${this.visuals.background_hatch.doit ? "var(--background-hatch) left top/var(--background-hatch-scale) repeat," : ""} var(--background-color, --inverted-color); } `); // Empty dash array (solid border) or border-style supported string case } else { this.style.append(` :host { border-color: ${color}; border-width: ${width}px; border-style: ${isString(dash) ? `${dash}` : "solid"}; } `); } } this._render_items(); } after_render() { super.after_render(); this.update_position(); this.request_paint(); // paint glyphs } get location() { const { location } = this.model; if (isString(location)) { const normal_location = (() => { switch (location) { case "top": return "top_center"; case "bottom": return "bottom_center"; case "left": return "center_left"; case "center": return "center_center"; case "right": return "center_right"; default: return location; } })(); const [v_loc, h_loc] = normal_location.split("_"); return { x: h_loc, y: v_loc }; } else { const [x_loc, y_loc] = location; return { x: x_loc, y: y_loc }; } } get anchor() { const { location } = this; const x_anchor = (() => { switch (location.x) { case "left": return 0.0; case "center": return 0.5; case "right": return 1.0; default: return 0.0; } })(); const y_anchor = (() => { switch (location.y) { case "top": return 0.0; case "center": return 0.5; case "bottom": return 1.0; default: return 1.0; } })(); return { x: x_anchor, y: y_anchor }; } get css_position() { const { location } = this; const { margin } = this.model; const panel = this.layout ?? this.plot_view.frame; const x_pos = (() => { const { x } = location; switch (x) { case "left": return `calc(0% + ${px(margin)})`; case "center": return "50%"; case "right": return `calc(100% - ${px(margin)})`; default: return px(panel.bbox.relative().x_view.compute(x)); } })(); const y_pos = (() => { const { y } = location; switch (y) { case "top": return `calc(0% + ${px(margin)})`; case "center": return "50%"; case "bottom": return `calc(100% - ${px(margin)})`; default: return px(panel.bbox.relative().y_view.compute(y)); } })(); return { x: x_pos, y: y_pos }; } get is_visible() { const { visible, items } = this.model; return visible && items.length != 0 && some(items, (item) => item.visible); } update_position() { if (this.is_visible) { const { x, y } = this.css_position; this.position.replace(` :host { position: ${this.layout != null ? "relative" : "absolute"}; left: ${x}; top: ${y}; } `); } else { this.position.replace(` :host { display: none; } `); } const legend_bbox = bounding_box(this.el); const canvas_bbox = bounding_box(this.plot_view.canvas.el); this._bbox = legend_bbox.relative_to(canvas_bbox); } get is_interactive() { // this doesn't cover server callbacks return this.model.click_policy != "none" || dict(this.model.js_event_callbacks).has("legend_item_click"); } get click_policy() { switch (this.model.click_policy) { case "hide": return (r) => r.visible = !r.visible; case "mute": return (r) => r.muted = !r.muted; case "none": return (_) => { }; } } get is_active() { switch (this.model.click_policy) { case "none": return (_item) => true; case "hide": return (item) => every(item.renderers, (r) => r.visible); case "mute": return (item) => every(item.renderers, (r) => !r.muted); } } has_item_background(_i, row, col) { if (!this.visuals.item_background_fill.doit && !this.visuals.item_background_hatch.doit) { return false; } switch (this.model.item_background_policy) { case "every": return true; case "even": return (row % 2 == 0) == (col % 2 == 0); case "odd": return (row % 2 == 0) != (col % 2 == 0); case "none": return false; } } _paint(ctx) { if (!this.is_visible) { return; } if (this.is_dual_renderer && !this.parent.is_forcing_paint) { if (this._should_rerender_items) { this._render_items(); } this._paint_glyphs(); } else { ctx.save(); const canvas_bbox = bounding_box(this.plot_view.canvas.el); this._draw_legend_box(ctx, canvas_bbox); this._draw_title(ctx, canvas_bbox); this._draw_legend_items(ctx, canvas_bbox); ctx.restore(); } } _draw_legend_box(ctx, canvas_bbox) { ctx.beginPath(); const bbox = bounding_box(this.el).relative_to(canvas_bbox); round_rect(ctx, bbox, this.border_radius); this.visuals.background_fill.apply(ctx); this.visuals.background_hatch.apply(ctx); this.visuals.border_line.apply(ctx); } _draw_title(ctx, canvas_bbox) { const { title } = this.model; if (title == null || title.length == 0 || !this.visuals.title_text.doit) { return; } const text = this.title_el.textContent; const text_box = new TextBox({ text }); const title_bbox = bounding_box(this.title_el).relative_to(canvas_bbox); let { x: sx, y: sy } = title_bbox; switch (this.model.title_location) { case "left": { sy += title_bbox.height; break; } case "right": { sx += title_bbox.width; break; } default: } text_box.position = { sx, sy, x_anchor: "left", y_anchor: "top" }; text_box.visuals = this.visuals.title_text.values(); const panel = new SidePanel(this.model.title_location); text_box.angle = panel.get_label_angle_heuristic("parallel"); text_box.paint(ctx); } _draw_legend_items(ctx, canvas_bbox) { const { is_active } = this; for (const { el: item_el, glyph, label_el, item, i, row, col } of this.entries) { const item_bbox = bounding_box(item_el).relative_to(canvas_bbox); if (this.has_item_background(i, row, col)) { ctx.beginPath(); ctx.rect_bbox(item_bbox); this.visuals.item_background_fill.apply(ctx); this.visuals.item_background_hatch.apply(ctx); } ctx.layer.undo_transform(() => { const glyph_el = glyph.canvas; const glyph_bbox = bounding_box(glyph_el).relative_to(canvas_bbox).scale(ctx.layer.pixel_ratio); ctx.drawImage(glyph_el, glyph_bbox.x, glyph_bbox.y); }); const text = label_el.textContent; const text_box = new TextBox({ text }); const { x: sx, vcenter: sy } = bounding_box(label_el).relative_to(canvas_bbox); text_box.position = { sx, sy, x_anchor: "left", y_anchor: "center" }; text_box.visuals = this.visuals.label_text.values(); text_box.paint(ctx); if (!is_active(item)) { ctx.beginPath(); ctx.rect_bbox(item_bbox); this.visuals.inactive_fill.apply(ctx); this.visuals.inactive_hatch.apply(ctx); } } } } export class Legend extends Annotation { static __name__ = "Legend"; constructor(attrs) { super(attrs); } static { this.prototype.default_view = LegendView; this.mixins([ ["label_", mixins.Text], ["title_", mixins.Text], ["inactive_", mixins.Fill], ["inactive_", mixins.Hatch], ["border_", mixins.Line], ["background_", mixins.Fill], ["background_", mixins.Hatch], ["item_background_", mixins.Fill], ["item_background_", mixins.Hatch], ]); this.define(({ Float, Int, Str, List, Tuple, Or, Ref, Nullable, Positive, Auto }) => ({ orientation: [Orientation, "vertical"], ncols: [Or(Positive(Int), Auto), "auto"], nrows: [Or(Positive(Int), Auto), "auto"], location: [Or(LegendLocation, Tuple(Float, Float)), "top_right"], title: [Nullable(Str), null], title_location: [Location, "above"], title_standoff: [Float, 5], label_standoff: [Float, 5], glyph_width: [Float, 20], glyph_height: [Float, 20], label_width: [Float, 20], label_height: [Or(Float, Auto), "auto"], margin: [Float, 10], padding: [Padding, 10], border_radius: [BorderRadius, 0], spacing: [Float, 3], items: [List(Ref(LegendItem)), []], click_policy: [LegendClickPolicy, "none"], item_background_policy: [AlternationPolicy, "none"], })); this.override({ border_line_color: "#e5e5e5", border_line_alpha: 0.5, border_line_width: 1, background_fill_color: "#ffffff", background_fill_alpha: 0.95, item_background_fill_color: "#f1f1f1", item_background_fill_alpha: 0.8, inactive_fill_color: "white", inactive_fill_alpha: 0.7, label_text_font_size: "13px", label_text_baseline: "middle", title_text_font_size: "13px", title_text_font_style: "italic", }); } } //# sourceMappingURL=legend.js.map