@bokeh/bokehjs
Version:
Interactive, novel data visualization
457 lines • 15.7 kB
JavaScript
import { to_object } from "../../core/util/object";
import { isNumber } from "../../core/util/types";
import { load_image } from "../../core/util/image";
import { color2css, color2hexrgb, color2rgba } from "../../core/util/color";
import { text_width } from "../../core/graphics";
import { font_metrics, parse_css_font_size, parse_css_length } from "../../core/util/text";
import { insert_text_on_position } from "../../core/util/string";
import { AffineTransform } from "../../core/util/affine";
import { BBox } from "../../core/util/bbox";
import { BaseText, BaseTextView } from "./base_text";
import { default_provider } from "./providers";
/**
* Helper class for rendering MathText into Canvas
*/
export class MathTextView extends BaseTextView {
static __name__ = "MathTextView";
graphics() {
return this;
}
valign;
angle;
_position = { sx: 0, sy: 0 };
// Align does nothing, needed to maintain compatibility with TextBox,
// to align you need to use TeX Macros.
// http://docs.mathjax.org/en/latest/input/tex/macros/index.html?highlight=align
align = "left";
// Same for infer_text_height
infer_text_height() {
return "ascent_descent";
}
_x_anchor = "left";
_y_anchor = "center";
_base_font_size = 13; // the same as :host's font-size (13px)
set base_font_size(v) {
if (v != null) {
this._base_font_size = v;
}
}
get base_font_size() {
return this._base_font_size;
}
font_size_scale = 1.0;
font;
color;
svg_image = null;
svg_element;
_rect() {
const { width, height } = this._size();
const { x, y } = this._computed_position();
const bbox = new BBox({ x, y, width, height });
return bbox.rect;
}
set position(p) {
this._position = p;
}
get position() {
return this._position;
}
get text() {
return this.model.text;
}
get provider() {
return default_provider;
}
async lazy_initialize() {
await super.lazy_initialize();
if (this.provider.status == "not_started") {
await this.provider.fetch();
}
}
connect_signals() {
super.connect_signals();
this.on_change(this.model.properties.text, () => this.load_image());
}
set visuals(v) {
const color = v.color;
const alpha = v.alpha;
const style = v.font_style;
let size = v.font_size;
const face = v.font;
const { font_size_scale, _base_font_size } = this;
const res = parse_css_font_size(size);
if (res != null) {
let { value, unit } = res;
value *= font_size_scale;
if (unit == "em" && _base_font_size != 0) {
value *= _base_font_size;
unit = "px";
}
size = `${value}${unit}`;
}
const font = `${style} ${size} ${face}`;
this.font = font;
this.color = color2css(color, alpha);
const align = v.align;
//this._visual_align = align
this._x_anchor = align;
const baseline = v.baseline;
this._y_anchor = (() => {
switch (baseline) {
case "top": return "top";
case "middle": return "center";
case "bottom": return "bottom";
default: return "baseline";
}
})();
}
/**
* Calculates position of element after considering
* anchor and dimensions
*/
_computed_position() {
const { width, height } = this._size();
const { sx, sy, x_anchor = this._x_anchor, y_anchor = this._y_anchor } = this.position;
const metrics = font_metrics(this.font);
const x = sx - (() => {
if (isNumber(x_anchor)) {
return x_anchor * width;
}
else {
switch (x_anchor) {
case "left": return 0;
case "center": return 0.5 * width;
case "right": return width;
}
}
})();
const y = sy - (() => {
if (isNumber(y_anchor)) {
return y_anchor * height;
}
else {
switch (y_anchor) {
case "top":
if (metrics.height > height) {
return (height - (-this.valign - metrics.descent) - metrics.height);
}
else {
return 0;
}
case "center": return 0.5 * height;
case "bottom":
if (metrics.height > height) {
return (height + metrics.descent + this.valign);
}
else {
return height;
}
case "baseline": return 0.5 * height;
}
}
})();
return { x, y };
}
/**
* Uses the width, height and given angle to calculate the size
*/
size() {
const { width, height } = this._size();
const { angle } = this;
if (angle == null || angle == 0) {
return { width, height };
}
else {
const c = Math.cos(Math.abs(angle));
const s = Math.sin(Math.abs(angle));
return {
width: Math.abs(width * c + height * s),
height: Math.abs(width * s + height * c),
};
}
}
get_image_dimensions() {
const fmetrics = font_metrics(this.font);
// XXX: perhaps use getComputedStyle()?
const svg_styles = this.svg_element.getAttribute("style")?.split(";");
if (svg_styles != null) {
const rules_map = new Map();
svg_styles.forEach(property => {
const [rule, value] = property.split(":");
if (rule.trim() != "") {
rules_map.set(rule.trim(), value.trim());
}
});
const v_align = parse_css_length(rules_map.get("vertical-align"));
if (v_align?.unit == "ex") {
this.valign = v_align.value * fmetrics.x_height;
}
else if (v_align?.unit == "px") {
this.valign = v_align.value;
}
}
const ex = (() => {
const width = this.svg_element.getAttribute("width");
const height = this.svg_element.getAttribute("height");
return {
width: width != null && width.endsWith("ex") ? parseFloat(width) : 1,
height: height != null && height.endsWith("ex") ? parseFloat(height) : 1,
};
})();
return {
width: fmetrics.x_height * ex.width,
height: fmetrics.x_height * ex.height,
};
}
get truncated_text() {
return this.model.text.length > 6
? `${this.model.text.substring(0, 6)}...`
: this.model.text;
}
width;
height;
_size() {
if (this.svg_image == null) {
if (this.provider.status == "failed" || this.provider.status == "not_started") {
return {
width: text_width(this.truncated_text, this.font),
height: font_metrics(this.font).height,
};
}
else {
return { width: this._base_font_size, height: this._base_font_size };
}
}
const fmetrics = font_metrics(this.font);
let { width, height } = this.get_image_dimensions();
height = Math.max(height, fmetrics.height);
const w_scale = this.width?.unit == "%" ? this.width.value : 1;
const h_scale = this.height?.unit == "%" ? this.height.value : 1;
return { width: width * w_scale, height: height * h_scale };
}
bbox() {
const { p0, p1, p2, p3 } = this.rect();
const left = Math.min(p0.x, p1.x, p2.x, p3.x);
const top = Math.min(p0.y, p1.y, p2.y, p3.y);
const right = Math.max(p0.x, p1.x, p2.x, p3.x);
const bottom = Math.max(p0.y, p1.y, p2.y, p3.y);
return new BBox({ left, right, top, bottom });
}
rect() {
const rect = this._rect();
const { angle } = this;
if (angle == null || angle == 0) {
return rect;
}
else {
const { sx, sy } = this.position;
const tr = new AffineTransform();
tr.translate(sx, sy);
tr.rotate(angle);
tr.translate(-sx, -sy);
return tr.apply_rect(rect);
}
}
paint_rect(ctx) {
const { p0, p1, p2, p3 } = this.rect();
ctx.save();
ctx.strokeStyle = "red";
ctx.lineWidth = 1;
ctx.beginPath();
const { round } = Math;
ctx.moveTo(round(p0.x), round(p0.y));
ctx.lineTo(round(p1.x), round(p1.y));
ctx.lineTo(round(p2.x), round(p2.y));
ctx.lineTo(round(p3.x), round(p3.y));
ctx.closePath();
ctx.stroke();
ctx.restore();
}
paint_bbox(ctx) {
const { x, y, width, height } = this.bbox();
ctx.save();
ctx.strokeStyle = "blue";
ctx.lineWidth = 1;
ctx.beginPath();
const { round } = Math;
ctx.moveTo(round(x), round(y));
ctx.lineTo(round(x), round(y + height));
ctx.lineTo(round(x + width), round(y + height));
ctx.lineTo(round(x + width), round(y));
ctx.closePath();
ctx.stroke();
ctx.restore();
}
async request_image() {
if (this.provider.MathJax == null) {
return;
}
const mathjax_element = this._process_text();
if (mathjax_element == null) {
this._has_finished = true;
return;
}
const svg_element = mathjax_element.children[0];
this.svg_element = svg_element;
svg_element.setAttribute("font", this.font);
svg_element.setAttribute("stroke", this.color);
const svg = svg_element.outerHTML;
const src = `data:image/svg+xml;utf-8,${encodeURIComponent(svg)}`;
this.svg_image = await load_image(src);
}
async load_image() {
await this.request_image();
this.parent.request_layout();
}
/**
* Takes a Canvas' Context2d and if the image has already
* been loaded draws the image in it otherwise draws the model's text.
*/
paint(ctx) {
if (this.svg_image == null) {
if (this.provider.status == "not_started" || this.provider.status == "loading") {
this.provider.ready.connect(() => this.load_image());
}
if (this.provider.status == "loaded") {
void this.load_image();
}
}
ctx.save();
const { sx, sy } = this.position;
const { angle } = this;
if (angle != null && angle != 0) {
ctx.translate(sx, sy);
ctx.rotate(angle);
ctx.translate(-sx, -sy);
}
const { x, y } = this._computed_position();
if (this.svg_image != null) {
const { width, height } = this.get_image_dimensions();
ctx.drawImage(this.svg_image, x, y, width, height);
}
else if (this.provider.status == "failed" || this.provider.status == "not_started") {
ctx.fillStyle = this.color;
ctx.font = this.font;
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
ctx.fillText(this.truncated_text, x, y + font_metrics(this.font).ascent);
}
ctx.restore();
if (!this._has_finished && (this.provider.status == "failed" || this.svg_image != null)) {
this._has_finished = true;
this.parent.notify_finished_after_paint();
}
}
}
export class MathText extends BaseText {
static __name__ = "MathText";
constructor(attrs) {
super(attrs);
}
}
export class AsciiView extends MathTextView {
static __name__ = "AsciiView";
// TODO: Color ascii
get styled_text() {
return this.text;
}
_process_text() {
return undefined; // TODO: this.provider.MathJax?.ascii2svg(text)
}
_size() {
return {
width: text_width(this.text, this.font),
height: font_metrics(this.font).height,
};
}
paint(ctx) {
ctx.save();
const { sx, sy } = this.position;
const { angle } = this;
if (angle != null && angle != 0) {
ctx.translate(sx, sy);
ctx.rotate(angle);
ctx.translate(-sx, -sy);
}
const { x, y } = this._computed_position();
ctx.fillStyle = this.color;
ctx.font = this.font;
ctx.textAlign = "left";
ctx.textBaseline = "alphabetic";
ctx.fillText(this.text, x, y + font_metrics(this.font).ascent);
ctx.restore();
this._has_finished = true;
this.parent.notify_finished_after_paint();
}
}
export class Ascii extends MathText {
static __name__ = "Ascii";
constructor(attrs) {
super(attrs);
}
static {
this.prototype.default_view = AsciiView;
}
}
export class MathMLView extends MathTextView {
static __name__ = "MathMLView";
get styled_text() {
let styled = this.text.trim();
let matches = styled.match(/<math(.*?[^?])?>/s);
if (matches == null) {
return this.text.trim();
}
styled = insert_text_on_position(styled, styled.indexOf(matches[0]) + matches[0].length, `<mstyle displaystyle="true" mathcolor="${color2hexrgb(this.color)}" ${this.font.includes("bold") ? 'mathvariant="bold"' : ""}>`);
matches = styled.match(/<\/[^>]*?math.*?>/s);
if (matches == null) {
return this.text.trim();
}
return insert_text_on_position(styled, styled.indexOf(matches[0]), "</mstyle>");
}
_process_text() {
const fmetrics = font_metrics(this.font);
return this.provider.MathJax?.mathml2svg(this.styled_text, {
em: this.base_font_size,
ex: fmetrics.x_height,
});
}
}
export class MathML extends MathText {
static __name__ = "MathML";
constructor(attrs) {
super(attrs);
}
static {
this.prototype.default_view = MathMLView;
}
}
export class TeXView extends MathTextView {
static __name__ = "TeXView";
get styled_text() {
const [r, g, b] = color2rgba(this.color);
return `\\color[RGB]{${r}, ${g}, ${b}} ${this.font.includes("bold") ? `\\boldsymbol{${this.text}}` : this.text}`;
}
_process_text() {
// TODO: allow plot/document level configuration of macros
const fmetrics = font_metrics(this.font);
return this.provider.MathJax?.tex2svg(this.styled_text, {
display: !this.model.inline,
em: this.base_font_size,
ex: fmetrics.x_height,
}, to_object(this.model.macros));
}
}
export class TeX extends MathText {
static __name__ = "TeX";
constructor(attrs) {
super(attrs);
}
static {
this.prototype.default_view = TeXView;
this.define(({ Bool, Float, Str, Dict, Tuple, Or }) => ({
macros: [Dict(Or(Str, Tuple(Str, Float))), {}],
inline: [Bool, false],
}));
}
}
//# sourceMappingURL=math_text.js.map