Skip to content

Commit

Permalink
Add support for outline shapes to Text glyph
Browse files Browse the repository at this point in the history
  • Loading branch information
mattpap committed Dec 24, 2023
1 parent 8392019 commit 83075b3
Show file tree
Hide file tree
Showing 10 changed files with 156 additions and 18 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 @@ -169,6 +169,9 @@ export const ScrollbarPolicy = Enum("auto", "visible", "hidden")
export type SelectionMode = typeof SelectionMode["__type__"]
export const SelectionMode = Enum("replace", "append", "intersect", "subtract", "xor")

export const ShapeName = Enum("plain", "box", "rectangle", "square", "circle", "ellipse", "trapezium", "parallelogram", "triangle")
export type ShapeName = typeof ShapeName["__type__"]

export type Side = "above" | "below" | "left" | "right"
export const Side = Enum("above", "below", "left", "right")

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 @@ -313,13 +313,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}
113 changes: 104 additions & 9 deletions bokehjs/src/lib/models/glyphs/text.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,12 @@ 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 {ShapeName} from "core/enums"

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

export interface TextView extends Text.Data {}

Expand Down Expand Up @@ -95,7 +99,7 @@ export class TextView extends XYGlyphView {
}

protected _render(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 @@ -105,9 +109,11 @@ 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)
if (!isFinite(sx_i + sy_i + angle_i) || label_i == null) {
continue
}

const swidth_i = swidth[i]
const sheight_i = sheight[i]
Expand All @@ -120,13 +126,14 @@ 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 != "plain" && (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._render_shape(ctx, i, shape_i, bbox, visuals, border_radius)
}

if (text.v_doit(i)) {
Expand All @@ -143,6 +150,91 @@ export class TextView extends XYGlyphView {
}
}

protected _render_shape(ctx: Context2d, i: number, shape: ShapeName, bbox: BBox, visuals: VectorVisuals, border_radius: Corners<number>): void {
ctx.beginPath()
switch (shape) {
case "plain": {
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 "trapezium": {
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 "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 All @@ -159,8 +251,9 @@ export class TextView extends XYGlyphView {
const angle_i = angle.get(i)
const label_i = labels[i]

if (!isFinite(sx_i + sy_i + angle_i) || label_i == null)
if (!isFinite(sx_i + sy_i + angle_i) || label_i == null) {
continue
}

const swidth_i = swidth[i]
const sheight_i = sheight[i]
Expand Down Expand Up @@ -246,6 +339,7 @@ export namespace Text {
anchor: TextAnchorSpec
padding: p.Property<Padding>
border_radius: p.Property<BorderRadius>
outline_shape: OutlineShapeSpec
} & Mixins

export type Mixins =
Expand Down Expand Up @@ -301,6 +395,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
4 changes: 4 additions & 0 deletions bokehjs/test/unit/core/enums.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,10 @@ describe("enums module", () => {
expect([...enums.SelectionMode]).to.be.equal(["replace", "append", "intersect", "subtract", "xor"])
})

it("should have ShapeName", () => {
expect([...enums.ShapeName]).to.be.equal(["plain", "box", "rectangle", "square", "circle", "ellipse", "trapezium", "parallelogram", "triangle"])
})

it("should have Side", () => {
expect([...enums.Side]).to.be.equal(["above", "below", "left", "right"])
})
Expand Down
4 changes: 4 additions & 0 deletions src/bokeh/core/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ class MyModel(Model):
'RoundingFunction',
'ScrollbarPolicy',
'SelectionMode',
'ShapeName',
'SizingMode',
'SizingPolicy',
'SortDirection',
Expand Down Expand Up @@ -461,6 +462,9 @@ def enumeration(*values: Any, case_sensitive: bool = True, quote: bool = False)
#: Selection modes
SelectionMode = enumeration("replace", "append", "intersect", "subtract", "xor")

#: Names of pre-defined shapes (used in ``Text.outline_shape``)
ShapeName = enumeration("plain", "box", "rectangle", "square", "circle", "ellipse", "trapezium", "parallelogram", "triangle")

#: Sizing mode policies
SizingModeType = Literal["stretch_width", "stretch_height", "stretch_both", "scale_width", "scale_height", "scale_both", "fixed", "inherit"]
SizingMode = enumeration(SizingModeType)
Expand Down
19 changes: 17 additions & 2 deletions src/bokeh/models/glyphs.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,6 @@
''' Display a variety of visual shapes whose attributes can be associated
with data columns from ``ColumnDataSources``.
The full list of glyphs is below:
.. toctree::
Expand Down Expand Up @@ -41,6 +39,7 @@
Direction,
ImageOrigin,
Palette,
ShapeName,
StepMode,
enumeration,
)
Expand Down Expand Up @@ -1559,6 +1558,22 @@ def __init__(self, *args, **kwargs) -> None:
This property is experimental and may change at any point.
""")

outline_shape = DataSpec(Enum(ShapeName), default="box", help="""
Allows to override the shape of the outline the text box.
The default outline is of a text box is its bounding box (or rectangle).
This can be changed to a selection of pre-defined shapes, like circle,
ellipse, diamond, parallelogram, etc. Those shapes are circumscribed onto
the bounding box, so that the contents of a box fit inside those shapes.
This property is in effect only when either border line, background fill
and/or background hatch properties are set. To avoid drawing a shape, one
can use ``"plain"`` named shape.
.. note::
This property is experimental and may change at any point.
""")

text_props = Include(TextProps, help="""
The {prop} values for the text.
""")
Expand Down
4 changes: 4 additions & 0 deletions tests/baselines/defaults.json5
Original file line number Diff line number Diff line change
Expand Up @@ -4084,6 +4084,10 @@
},
padding: 0,
border_radius: 0,
outline_shape: {
type: "value",
value: "box",
},
text_color: {
type: "value",
value: "#444444",
Expand Down
5 changes: 5 additions & 0 deletions tests/unit/bokeh/core/test_enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@
'RoundingFunction',
'ScrollbarPolicy',
'SelectionMode',
'ShapeName',
'SizingMode',
'SizingPolicy',
'SortDirection',
Expand Down Expand Up @@ -294,6 +295,9 @@ def test_RoundingFunction(self) -> None:
def test_SelectionMode(self) -> None:
assert tuple(bce.SelectionMode) == ("replace", "append", "intersect", "subtract", "xor")

def test_ShapeName(self) -> None:
assert tuple(bce.ShapeName) == ("plain", "box", "rectangle", "square", "circle", "ellipse", "trapezium", "parallelogram", "triangle")

def test_SizingMode(self) -> None:
assert tuple(bce.SizingMode) == ("stretch_width", "stretch_height", "stretch_both", "scale_width", "scale_height", "scale_both", "fixed", "inherit")

Expand Down Expand Up @@ -388,6 +392,7 @@ def test_enums_contents() -> None:
'RoundingFunction',
'ScrollbarPolicy',
'SelectionMode',
'ShapeName',
'SizingMode',
'SizingPolicy',
'SortDirection',
Expand Down

0 comments on commit 83075b3

Please sign in to comment.