Skip to content

Commit

Permalink
Add support for outline shapes to Text-like glyphs (#13620)
Browse files Browse the repository at this point in the history
* Build Text (etc.) glyph's labels once

* Add support for outline shapes to Text glyph

* Add visual integration tests

* Add styling/mathtext/latex_outline_shapes

* Add release notes

* Add a note about hit testing

* Update unit tests

* Rename {plain,trapezium} -> {none,trapezoid}

* Update outline_shape's docstring

* Include outline_shape="none" in tests and examples
  • Loading branch information
mattpap committed May 14, 2024
1 parent 10179da commit 00c0ade
Show file tree
Hide file tree
Showing 18 changed files with 369 additions and 27 deletions.
3 changes: 3 additions & 0 deletions bokehjs/src/lib/core/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,9 @@ export type MutedPolicy = typeof MutedPolicy["__type__"]
export const Orientation = Enum("vertical", "horizontal")
export type Orientation = typeof Orientation["__type__"]

export const OutlineShapeName = Enum("none", "box", "rectangle", "square", "circle", "ellipse", "trapezoid", "parallelogram", "diamond", "triangle")
export type OutlineShapeName = typeof OutlineShapeName["__type__"]

export const OutputBackend = Enum("canvas", "svg", "webgl")
export type OutputBackend = typeof OutputBackend["__type__"]

Expand Down
11 changes: 9 additions & 2 deletions bokehjs/src/lib/core/util/bbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -342,13 +342,20 @@ export class BBox implements Rect, Equatable {
return this.width/this.height
}

get hcenter(): number {
get x_center(): number {
return (this.left + this.right)/2
}
get vcenter(): number {
get y_center(): number {
return (this.top + this.bottom)/2
}

get hcenter(): number {
return this.x_center
}
get vcenter(): number {
return this.y_center
}

get area(): number {
return this.width*this.height
}
Expand Down
3 changes: 2 additions & 1 deletion bokehjs/src/lib/core/util/math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import type {AngleUnits, Direction} from "../enums"
import {isObject} from "./types"
import {assert} from "./assert"

const {PI, abs, sign} = Math
const {PI, abs, sign, sqrt} = Math
export {PI, abs, sqrt}

export function angle_norm(angle: number): number {
if (angle == 0) {
Expand Down
8 changes: 4 additions & 4 deletions bokehjs/src/lib/models/glyphs/defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import type {MarkerType} from "core/enums"
import type {LineVector, FillVector, HatchVector} from "core/visuals"
import type {Context2d} from "core/util/canvas"

export type VectorVisuals = {line: LineVector, fill: FillVector, hatch: HatchVector}

const SQ3 = Math.sqrt(3)
const SQ5 = Math.sqrt(5)
const c36 = (SQ5+1)/4
Expand Down Expand Up @@ -91,8 +93,6 @@ function _one_tri(ctx: Context2d, r: number): void {
ctx.closePath()
}

type VectorVisuals = {line: LineVector, fill: FillVector, hatch: HatchVector}

function asterisk(ctx: Context2d, i: number, r: number, visuals: VectorVisuals): void {
_one_cross(ctx, r)
_one_x(ctx, r)
Expand Down Expand Up @@ -342,7 +342,7 @@ function y(ctx: Context2d, i: number, r: number, visuals: VectorVisuals): void {

export type RenderOne = (ctx: Context2d, i: number, r: number, visuals: VectorVisuals) => void

export const marker_funcs: {[key in MarkerType]: RenderOne} = {
export const marker_funcs = {
asterisk,
circle,
circle_cross,
Expand Down Expand Up @@ -371,4 +371,4 @@ export const marker_funcs: {[key in MarkerType]: RenderOne} = {
dash,
x,
y,
}
} satisfies {[key in MarkerType]: RenderOne}
7 changes: 3 additions & 4 deletions bokehjs/src/lib/models/glyphs/math_text_glyph.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {Text, TextView} from "./text"
import type {BaseText} from "../text/base_text"
import {MathTextView} from "../text/math_text"
import type {GraphicsBox} from "core/graphics"
import type * as p from "core/properties"
import type {ViewStorage, IterViews} from "core/build_views"
import {build_views, remove_views} from "core/build_views"
Expand Down Expand Up @@ -41,16 +42,14 @@ export abstract class MathTextGlyphView extends TextView {

protected abstract _build_label(text: string): BaseText

protected override async _build_labels(): Promise<void> {
const {text} = this.base ?? this

protected override async _build_labels(text: p.Uniform<string | null>): Promise<(GraphicsBox | null)[]> {
const labels = Array.from(text, (text_i) => {
return text_i == null ? null : this._build_label(text_i)
})

await build_views(this._label_views, labels.filter(non_null), {parent: this.renderer})

this.labels = labels.map((label_i) => {
return labels.map((label_i) => {
return label_i == null ? null : this._label_views.get(label_i)!.graphics()
})
}
Expand Down
4 changes: 2 additions & 2 deletions bokehjs/src/lib/models/glyphs/tex_glyph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import type {Dict} from "core/types"
import {Enum, Or, Auto} from "core/kinds"
import {parse_delimited_string} from "../text/utils"

const DisplayMode = Or(Enum("inline", "block"), Auto)
type DisplayMode = typeof DisplayMode["__type__"]
export const DisplayMode = Or(Enum("inline", "block"), Auto)
export type DisplayMode = typeof DisplayMode["__type__"]

export interface TeXGlyphView extends TeXGlyph.Data {}

Expand Down
130 changes: 118 additions & 12 deletions bokehjs/src/lib/models/glyphs/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,18 +17,21 @@ import type {TextAnchor} from "../common/kinds"
import {BorderRadius, Padding} from "../common/kinds"
import * as resolve from "../common/resolve"
import {round_rect} from "../common/painting"
import type {VectorVisuals} from "./defs"
import {sqrt, PI} from "core/util/math"
import type {OutlineShapeName} from "core/enums"

class TextAnchorSpec extends p.DataSpec<TextAnchor> {}
class OutlineShapeSpec extends p.DataSpec<OutlineShapeName> {}

export interface TextView extends Text.Data {}

export class TextView extends XYGlyphView {
declare model: Text
declare visuals: Text.Visuals

protected async _build_labels(): Promise<void> {
const {text} = this.base ?? this
this.labels = Array.from(text, (value) => {
protected async _build_labels(text: p.Uniform<string | null>): Promise<(GraphicsBox | null)[]> {
return Array.from(text, (value) => {
if (value == null) {
return null
} else {
Expand All @@ -39,7 +42,11 @@ export class TextView extends XYGlyphView {
}

override async _set_lazy_data(): Promise<void> {
await this._build_labels()
if (this.inherited_text) {
this._inherit_attr<Text.Data>("labels")
} else {
this._define_attr<Text.Data>("labels", await this._build_labels(this.text))
}
}

override after_visuals(): void {
Expand Down Expand Up @@ -92,7 +99,7 @@ export class TextView extends XYGlyphView {
}

protected _paint(ctx: Context2d, indices: number[], data?: Partial<Text.Data>): void {
const {sx, sy, x_offset, y_offset, angle} = {...this, ...data}
const {sx, sy, x_offset, y_offset, angle, outline_shape} = {...this, ...data}
const {text, background_fill, background_hatch, border_line} = this.visuals
const {anchor_: anchor, border_radius, padding} = this
const {labels, swidth, sheight} = this
Expand All @@ -102,6 +109,7 @@ export class TextView extends XYGlyphView {
const sy_i = sy[i] + y_offset.get(i)
const angle_i = angle.get(i)
const label_i = labels[i]
const shape_i = outline_shape.get(i)

if (!isFinite(sx_i + sy_i + angle_i) || label_i == null) {
continue
Expand All @@ -118,18 +126,20 @@ export class TextView extends XYGlyphView {
ctx.rotate(angle_i)
ctx.translate(-dx_i, -dy_i)

if (background_fill.v_doit(i) || background_hatch.v_doit(i) || border_line.v_doit(i)) {
ctx.beginPath()
if (shape_i != "none" && (background_fill.v_doit(i) || background_hatch.v_doit(i) || border_line.v_doit(i))) {
const bbox = new BBox({x: 0, y: 0, width: swidth_i, height: sheight_i})
round_rect(ctx, bbox, border_radius)
background_fill.apply(ctx, i)
background_hatch.apply(ctx, i)
border_line.apply(ctx, i)
const visuals = {
fill: background_fill,
hatch: background_hatch,
line: border_line,
}
this._paint_shape(ctx, i, shape_i, bbox, visuals, border_radius)
}

if (text.v_doit(i)) {
const {left, top} = padding
ctx.translate(left, top)
label_i.visuals = text.values(i)
label_i.paint(ctx)
ctx.translate(-left, -top)
}
Expand All @@ -140,6 +150,100 @@ export class TextView extends XYGlyphView {
}
}

protected _paint_shape(ctx: Context2d, i: number, shape: OutlineShapeName, bbox: BBox, visuals: VectorVisuals, border_radius: Corners<number>): void {
ctx.beginPath()
switch (shape) {
case "none": {
break
}
case "box":
case "rectangle": {
round_rect(ctx, bbox, border_radius)
break
}
case "square": {
const square = (() => {
const {x, y, width, height} = bbox
if (width > height) {
const dy = (width - height)/2
return new BBox({x, y: y - dy, width, height: width})
} else {
const dx = (height - width)/2
return new BBox({x: x - dx, y, width: height, height})
}
})()
round_rect(ctx, square, border_radius)
break
}
case "circle": {
const cx = bbox.x_center
const cy = bbox.y_center
const radius = sqrt(bbox.width**2 + bbox.height**2)/2
ctx.arc(cx, cy, radius, 0, 2*PI, false)
break
}
case "ellipse": {
const cx = bbox.x_center
const cy = bbox.y_center
const rx = bbox.width/2
const ry = bbox.height/2
const n = 1.5
const x_0 = rx
const y_0 = ry
const a = sqrt(x_0**2 + x_0**(2/n)*y_0**(2 - 2/n))
const b = sqrt(y_0**2 + y_0**(2/n)*x_0**(2 - 2/n))
ctx.ellipse(cx, cy, a, b, 0, 0, 2*PI)
break
}
case "trapezoid": {
const {left, right, top, bottom, width} = bbox
const ext = 0.2*width
ctx.moveTo(left, top)
ctx.lineTo(right, top)
ctx.lineTo(right + ext, bottom)
ctx.lineTo(left - ext, bottom)
ctx.closePath()
break
}
case "parallelogram": {
const {left, right, top, bottom, width} = bbox
const ext = 0.2*width
ctx.moveTo(left, top)
ctx.lineTo(right + ext, top)
ctx.lineTo(right, bottom)
ctx.lineTo(left - ext, bottom)
ctx.closePath()
break
}
case "diamond": {
const {x_center, y_center, width, height} = bbox
ctx.moveTo(x_center, y_center - height)
ctx.lineTo(width + width/2, y_center)
ctx.lineTo(x_center, y_center + height)
ctx.lineTo(-width/2, y_center)
ctx.closePath()
break
}
case "triangle": {
const w = bbox.width
const h = bbox.height
const l = sqrt(3)/2*w
const H = h + l
ctx.translate(w/2, -l)
ctx.moveTo(0, 0)
ctx.lineTo(H/2, H)
ctx.lineTo(-H/2, H)
ctx.closePath()
ctx.translate(-w/2, l)
break
}
}

visuals.fill.apply(ctx, i)
visuals.hatch.apply(ctx, i)
visuals.line.apply(ctx, i)
}

protected override _hit_point(geometry: PointGeometry): Selection {
const hit_xy = {x: geometry.sx, y: geometry.sy}

Expand Down Expand Up @@ -244,6 +348,7 @@ export namespace Text {
anchor: TextAnchorSpec
padding: p.Property<Padding>
border_radius: p.Property<BorderRadius>
outline_shape: OutlineShapeSpec
} & Mixins

export type Mixins =
Expand All @@ -260,7 +365,7 @@ export namespace Text {
}

export type Data = p.GlyphDataOf<Props> & {
labels: (GraphicsBox | null)[]
readonly labels: (GraphicsBox | null)[]

swidth: Float32Array
sheight: Float32Array
Expand Down Expand Up @@ -299,6 +404,7 @@ export class Text extends XYGlyph {
anchor: [ TextAnchorSpec, {value: "auto"} ],
padding: [ Padding, 0 ],
border_radius: [ BorderRadius, 0 ],
outline_shape: [ OutlineShapeSpec, "box" ],
}))

this.override<Text.Props>({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Figure bbox=[0, 0, 1000, 400]
Canvas bbox=[0, 0, 1000, 400]
CartesianFrame bbox=[5, 5, 990, 390]
GlyphRenderer bbox=[60, 356, 880, 0]
Text bbox=[60, 356, 880, 0]
GlyphRenderer bbox=[60, 239, 880, 0]
TeXGlyph bbox=[60, 239, 880, 0]
GlyphRenderer bbox=[60, 83, 880, 0]
TeXGlyph bbox=[60, 83, 880, 0]
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 00c0ade

Please sign in to comment.