Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for outline shapes to Text-like glyphs #13620

Merged
merged 10 commits into from
May 14, 2024
Merged
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.