diff --git a/examples/native/index.html b/examples/native/index.html
index 35010e11..fae189c6 100644
--- a/examples/native/index.html
+++ b/examples/native/index.html
@@ -11,7 +11,10 @@
Native Examples
diff --git a/examples/native/src/labels/index.ts b/examples/native/src/labels/index.ts
new file mode 100644
index 00000000..feca6d38
--- /dev/null
+++ b/examples/native/src/labels/index.ts
@@ -0,0 +1,120 @@
+import * as Renderer from '@trellis/renderers/webgl'
+import * as Graph from '@trellis/index'
+import * as Collide from '@trellis/layout/collide'
+
+const GREEN = '#91AD49'
+const GREEN_LIGHT = '#C6D336'
+const DARK_GREEN = '#607330'
+
+const TEXT_ICON: Graph.TextIcon = {
+ type: 'textIcon',
+ family: 'sans-serif',
+ color: '#fff',
+ weight: '400',
+ text: '!',
+ size: 14
+}
+
+const NODE_STYLE: Graph.NodeStyle = {
+ color: GREEN,
+ icon: TEXT_ICON,
+ stroke: [{ width: 2, color: GREEN_LIGHT }],
+ label: {
+ position: 'right',
+ fontName: 'NodeLabel',
+ fontFamily: 'Arial, sans-serif',
+ background: { color: GREEN_LIGHT },
+ margin: 4
+ }
+}
+
+const NODE_HOVER_STYLE: Graph.NodeStyle = {
+ color: DARK_GREEN,
+ icon: TEXT_ICON,
+ stroke: [{ width: 2, color: GREEN_LIGHT }],
+ label: {
+ position: 'right',
+ fontName: 'NodeLabelHover',
+ fontFamily: 'Arial, sans-serif',
+ background: { color: DARK_GREEN },
+ color: '#FFF',
+ margin: 4
+ }
+}
+
+const data = [
+ 'Myriel',
+ 'Napoleon',
+ 'Mlle.Baptistine',
+ 'Mme.Magloire',
+ 'CountessdeLo',
+ 'Geborand',
+ 'Champtercier',
+ 'Cravatte',
+ 'Count',
+ 'OldMan',
+ 'Labarre',
+ 'Valjean'
+]
+
+const collide = Collide.Layout()
+
+const edges: Graph.Edge[] = []
+let nodes = data.map((label, index) => ({
+ radius: 10,
+ label: index % 2 === 0 ? label + ' 北京' : label,
+ id: `${index}-${label}`,
+ style: NODE_STYLE
+}))
+
+const layout = collide({ nodes, edges, options: { nodePadding: 50 } })
+nodes = layout.nodes
+
+const size = { width: 1250, height: 650 }
+const bounds = Graph.getSelectionBounds(nodes, 100)
+const viewport = Graph.boundsToViewport(bounds, size)
+const container = document.querySelector('#graph') as HTMLDivElement
+
+const options: Renderer.Options = {
+ ...viewport,
+ ...size,
+ minZoom: 0.025,
+ onViewportDrag: (event: Renderer.ViewportDragEvent | Renderer.ViewportDragDecelerateEvent) => {
+ // console.log('viewport drag', `x: ${event.dx}, y: ${event.dy}`)
+ options.x! += event.dx
+ options.y! += event.dy
+ renderer.update({ nodes, edges, options })
+ },
+ onViewportWheel: ({ dx, dy, dz }) => {
+ options.x! += dx
+ options.y! += dy
+ options.zoom! += dz
+ renderer.update({ nodes, edges, options })
+ },
+ onNodePointerEnter: (event: Renderer.NodePointerEvent) => {
+ // console.log('node pointer enter', `x: ${event.x}, y: ${event.y}`)
+ nodes = nodes.map((node) => (node.id === event.target.id ? { ...node, label: node.label + ' 北京', style: NODE_HOVER_STYLE } : node))
+ renderer.update({ nodes, edges, options })
+ },
+ onNodeDrag: (event: Renderer.NodeDragEvent) => {
+ // console.log('node drag', `x: ${event.x}, y: ${event.y}`)
+ nodes = nodes.map((node) =>
+ node.id === event.target.id ? { ...node, x: (node.x ?? 0) + event.dx, y: (node.y ?? 0) + event.dy } : node
+ )
+ renderer.update({ nodes, edges, options })
+ },
+ onNodePointerLeave: (event: Renderer.NodePointerEvent) => {
+ // console.log('node pointer leave', `x: ${event.x}, y: ${event.y}`)
+ nodes = nodes.map((node) =>
+ node.id === event.target.id ? { ...node, label: node.label?.slice(0, node.label.length - 3), style: NODE_STYLE } : node
+ )
+ renderer.update({ nodes, edges, options })
+ }
+}
+
+const renderer = new Renderer.Renderer({ container, width: options.width, height: options.height, debug: true }).update({
+ nodes,
+ edges,
+ options
+})
+;(window as any).renderer = renderer
diff --git a/examples/native/src/labels/labels.html b/examples/native/src/labels/labels.html
new file mode 100644
index 00000000..822cc05c
--- /dev/null
+++ b/examples/native/src/labels/labels.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+ Trellis Example: Label Style
+
+
+
+
+
+
diff --git a/examples/native/src/simple/index.ts b/examples/native/src/perf/index.ts
similarity index 95%
rename from examples/native/src/simple/index.ts
rename to examples/native/src/perf/index.ts
index b98abe1a..4d85199c 100644
--- a/examples/native/src/simple/index.ts
+++ b/examples/native/src/perf/index.ts
@@ -21,21 +21,28 @@ const sampleCoordinatePlane = function* (count: number, step: number, sample: nu
const PURPLE = '#7A5DC5'
const LIGHT_PURPLE = '#CAD'
+const ARIAL_PINK = 'ArialPink'
const NODE_STYLE: Graph.NodeStyle = {
color: PURPLE,
stroke: [{ width: 2, color: LIGHT_PURPLE }],
icon: { type: 'textIcon', text: 'T', family: 'sans-serif', size: 14, color: '#fff', weight: '400' },
label: {
- position: 'bottom',
- color: LIGHT_PURPLE
+ position: 'top',
+ fontName: ARIAL_PINK,
+ fontFamily: ['Arial', 'sans-serif'],
+ margin: 2,
+ background: {
+ color: '#f66',
+ opacity: 0.5
+ }
}
}
const NODE_HOVER_STYLE: Graph.NodeStyle = {
color: '#f66',
stroke: [{ width: 2, color: '#fcc' }],
- label: { position: 'bottom' },
+ label: { position: 'bottom', color: '#fcc' },
icon: { type: 'textIcon', text: 'L', family: 'sans-serif', size: 14, color: '#fff', weight: '400' }
}
diff --git a/examples/native/src/simple/simple.html b/examples/native/src/perf/perf.html
similarity index 90%
rename from examples/native/src/simple/simple.html
rename to examples/native/src/perf/perf.html
index 736ba429..ab99af39 100644
--- a/examples/native/src/simple/simple.html
+++ b/examples/native/src/perf/perf.html
@@ -5,7 +5,7 @@
- Simple Trellis Example
+ Trellis Example: Perf
diff --git a/src/bindings/react/button.ts b/src/bindings/react/button.ts
index 51bae3f0..84f5bb02 100644
--- a/src/bindings/react/button.ts
+++ b/src/bindings/react/button.ts
@@ -6,6 +6,7 @@ export type Props = {
group?: 'top' | 'middle' | 'bottom'
title?: string
onClick?: () => void
+ children?: React.ReactNode
}
const STYLE = {
diff --git a/src/index.ts b/src/index.ts
index 1adb9aa5..8d07f884 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,4 +1,6 @@
import { TWO_PI } from './renderers/webgl/utils'
+import type { LabelStyle } from './renderers/webgl/objects/label'
+import type { Stroke } from './types'
export type Node = {
id: string
@@ -43,22 +45,6 @@ export type ImageIcon = {
offsetY?: number
}
-export type Stroke = { color: string; width: number }
-
-export type LabelBackground = { color: string; opacity?: number }
-
-export type LabelPosition = 'bottom' | 'left' | 'top' | 'right'
-
-export type LabelStyle = Partial<{
- color: string
- fontFamily: string
- fontSize: number
- maxWidth: number
- stroke: Stroke
- position: LabelPosition
- background: LabelBackground
-}>
-
export type NodeStyle = {
color?: string
icon?: TextIcon | ImageIcon
@@ -392,3 +378,7 @@ export const angle = (x0: number, y0: number, x1: number, y1: number) => {
const angle = Math.atan2(y0 - y1, x0 - x1)
return angle < 0 ? angle + TWO_PI : angle
}
+
+// exports
+export type { Stroke } from './types'
+export type { LabelStyle, LabelBackgroundStyle, LabelPosition, FontWeight, TextAlign } from './renderers/webgl/objects/label'
diff --git a/src/renderers/webgl/node.ts b/src/renderers/webgl/node.ts
index 7ea9e17e..cbffdc25 100644
--- a/src/renderers/webgl/node.ts
+++ b/src/renderers/webgl/node.ts
@@ -2,7 +2,6 @@ import { FederatedPointerEvent } from 'pixi.js'
import { MIN_LABEL_ZOOM, MIN_INTERACTION_ZOOM, MIN_NODE_STROKE_ZOOM, Renderer, MIN_NODE_ICON_ZOOM } from '.'
import * as Graph from '../..'
import { Label } from './objects/label'
-import { positionNodeLabel } from './utils'
import { NodeFill } from './objects/nodeFill'
import { NodeStrokes } from './objects/nodeStrokes'
import { Icon } from './objects/icon'
@@ -44,15 +43,15 @@ export class NodeRenderer {
update(node: Graph.Node) {
if (this.label === undefined) {
- if (node.label) {
- this.label = new Label(this.renderer.labelsContainer, node.label)
- }
- } else {
- if (node.label === undefined) {
- this.renderer.labelObjectManager.delete(this.label)
- this.labelMounted = false
- this.label = undefined
+ if (node.label !== undefined) {
+ this.label = new Label(this.renderer.labelsContainer, node.label, node.style?.label)
}
+ } else if (node.label === undefined || node.label.trim() === '') {
+ this.renderer.labelObjectManager.delete(this.label)
+ this.labelMounted = false
+ this.label = undefined
+ } else if (!this.label.equals(node.label, node.style?.label)) {
+ this.label.update(node.label, node.style?.label)
}
if (this.icon === undefined) {
@@ -502,9 +501,8 @@ export class NodeRenderer {
this.fill.update(this.x, this.y, radius, node.style)
this.strokes.update(this.x, this.y, radius, node.style)
- if (this.label && node.label) {
- const labelPosition = positionNodeLabel(this.x, this.y, node.label, this.strokes.radius, node.style?.label?.position)
- this.label.update(node.label, labelPosition[0], labelPosition[1], node.style?.label)
+ if (this.label !== undefined) {
+ this.label.moveTo(this.x, this.y, this.strokes.radius)
}
if (this.icon && node.style?.icon) {
this.icon.update(this.x, this.y, node.style.icon)
diff --git a/src/renderers/webgl/objects/label.ts b/src/renderers/webgl/objects/label.ts
deleted file mode 100644
index dcc1e882..00000000
--- a/src/renderers/webgl/objects/label.ts
+++ /dev/null
@@ -1,158 +0,0 @@
-import { BitmapText, Container, Text, TextStyle, TextStyleAlign } from 'pixi.js'
-import { isASCII } from '../utils'
-import { LabelPosition, LabelStyle } from '../../..'
-
-const DEFAULT_FONT_SIZE = 10
-const DEFAULT_ORIENTATION = 'bottom'
-const TEXT_OUTLINE_STYLE: Partial = { lineJoin: 'round', stroke: '#fff', strokeThickness: 2 }
-
-/**
- * TODO
- * - add support for background color, font color, font family
- * - moving/scaling labels is slow. render ASCII text characters as sprites to partical container?
- */
-export class Label {
- mounted = false
-
- private container: Container
- private text: BitmapText | Text
- private label: string
- private x?: number
- private y?: number
- private fontSize?: number
- private position?: LabelPosition
-
- constructor(container: Container, label: string) {
- this.container = container
- this.label = label
- this.text = isASCII(label) ? new BitmapText(label, { fontName: 'Label' }) : new Text(label, TEXT_OUTLINE_STYLE)
- }
-
- update(label: string, x: number, y: number, style?: LabelStyle) {
- if (label !== this.label) {
- this.setText(label)
- this.label = label
- }
-
- if (x !== this.x) {
- this.text.x = x
- this.x = x
- }
-
- if (y !== this.y) {
- this.text.y = y
- this.y = y
- }
-
- const fontSize = style?.fontSize ?? DEFAULT_FONT_SIZE
-
- if (fontSize !== this.fontSize) {
- this.setFontSize(fontSize)
- this.fontSize = fontSize
- }
-
- const position = style?.position ?? DEFAULT_ORIENTATION
-
- if (position !== this.position) {
- switch (style?.position ?? DEFAULT_ORIENTATION) {
- case 'bottom':
- this.setAlign('center')
- this.text.anchor.set(0.5, 0)
- break
- case 'left':
- this.setAlign('left')
- this.text.anchor.set(1, 0.5)
- break
- case 'top':
- this.setAlign('center')
- this.text.anchor.set(0.5, 1)
- break
- case 'right':
- this.setAlign('right')
- this.text.anchor.set(0, 0.5)
- break
- }
-
- this.position = position
- }
-
- return this
- }
-
- mount() {
- if (!this.mounted) {
- this.container.addChild(this.text)
- this.mounted = true
- }
-
- return this
- }
-
- unmount() {
- if (this.mounted) {
- this.container.removeChild(this.text)
- this.mounted = false
- }
-
- return this
- }
-
- delete() {
- this.unmount()
- this.text.destroy()
-
- return undefined
- }
-
- private setText(label: string) {
- if (this.text instanceof BitmapText) {
- if (isASCII(label)) {
- this.text.text = label
- } else {
- const isMounted = this.mounted
- this.delete()
- this.text = new Text(label, TEXT_OUTLINE_STYLE)
- this.fontSize = undefined
- this.position = undefined
- this.x = undefined
- this.y = undefined
- if (isMounted) {
- this.mount()
- }
- }
- } else {
- if (isASCII(label)) {
- const isMounted = this.mounted
- this.delete()
- this.text = new BitmapText(label, { fontName: 'Label' })
- this.fontSize = undefined
- this.position = undefined
- this.x = undefined
- this.y = undefined
- if (isMounted) {
- this.mount()
- }
- } else {
- this.text.text = label
- }
- }
-
- this.label = label
- }
-
- private setFontSize(fontSize: number) {
- if (this.text instanceof BitmapText) {
- this.text.fontSize = fontSize
- } else {
- this.text.style.fontSize = fontSize
- }
- }
-
- private setAlign(align: TextStyleAlign) {
- if (this.text instanceof BitmapText) {
- this.text.align = align
- } else {
- this.text.style.align = align
- }
- }
-}
diff --git a/src/renderers/webgl/objects/label/background.ts b/src/renderers/webgl/objects/label/background.ts
new file mode 100644
index 00000000..6bae1b0d
--- /dev/null
+++ b/src/renderers/webgl/objects/label/background.ts
@@ -0,0 +1,140 @@
+import utils, { STYLE_DEFAULTS } from './utils'
+import type { LabelBackgroundStyle } from './utils'
+import { BitmapText, ColorSource, Container, Point, Rectangle, Sprite, Text, Texture } from 'pixi.js'
+import { equals } from '../../../..'
+
+export class LabelBackground {
+ mounted = false
+
+ private x?: number
+ private y?: number
+ private dirty = false
+ private sprite: Sprite
+ private label: Text | BitmapText
+ private container: Container
+ private rect: Rectangle
+ private _style: LabelBackgroundStyle
+
+ constructor(container: Container, label: Text | BitmapText, style: LabelBackgroundStyle) {
+ this.label = label
+ this.container = container
+ this._style = style
+ this.rect = this.label.getLocalBounds()
+
+ const { width, height } = this.size
+
+ this.sprite = Sprite.from(Texture.WHITE)
+ this.sprite.height = height
+ this.sprite.width = width
+ this.sprite.anchor.set(this.label.anchor.x, this.label.anchor.y)
+ this.sprite.alpha = this.style.opacity
+ this.sprite.tint = this.style.color
+ }
+
+ update(label: Text | BitmapText, style: LabelBackgroundStyle) {
+ this.dirty = !equals(style.padding, this._style.padding)
+ this.bounds = label.getLocalBounds()
+ this.anchor = label.anchor.clone()
+
+ if (this.label !== label) {
+ this.label = label
+ }
+
+ if (this._style !== style) {
+ this._style = style
+ this.color = style.color
+ this.opacity = style.opacity ?? STYLE_DEFAULTS.OPACITY
+ }
+
+ if (this.dirty) {
+ this.dirty = false
+ this.resize()
+ }
+
+ return this
+ }
+
+ moveTo(x: number, y: number) {
+ if (this.x !== x) {
+ this.x = x
+ this.sprite.x = x
+ }
+
+ if (this.y !== y) {
+ this.y = y
+ this.sprite.y = y
+ }
+ }
+
+ mount() {
+ if (!this.mounted) {
+ this.container.addChild(this.sprite)
+ this.mounted = true
+ }
+
+ return this
+ }
+
+ unmount() {
+ if (this.mounted) {
+ this.container.removeChild(this.sprite)
+ this.mounted = false
+ }
+
+ return this
+ }
+
+ delete() {
+ this.unmount()
+ this.sprite.destroy()
+
+ return undefined
+ }
+
+ private resize() {
+ const { height, width } = this.size
+ if (height !== this.sprite.height) {
+ this.sprite.height = height
+ }
+ if (width !== this.sprite.width) {
+ this.sprite.width = width
+ }
+ return this
+ }
+
+ private get style() {
+ return utils.mergeBackgroundDefaults(this._style)
+ }
+
+ private get size() {
+ const [top, right, bottom, left] = utils.getBackgroundPadding(this._style.padding)
+ const height = this.rect.height + top + bottom
+ const width = this.rect.width + right + left
+ return { width, height }
+ }
+
+ private set anchor(anchor: Point) {
+ if (!this.sprite.anchor.equals(anchor)) {
+ this.sprite.anchor.copyFrom(anchor)
+ }
+ }
+
+ private set color(color: ColorSource) {
+ if (this.sprite.tint !== color) {
+ this.sprite.tint = color
+ }
+ }
+
+ private set opacity(opacity: number) {
+ if (this.sprite.alpha !== opacity) {
+ this.sprite.alpha = opacity
+ }
+ }
+
+ private set bounds(bounds: Rectangle) {
+ if (this.rect.width !== bounds.width || this.rect.height !== bounds.height) {
+ this.rect = bounds
+ this.dirty = true
+ }
+ }
+}
diff --git a/src/renderers/webgl/objects/label/index.ts b/src/renderers/webgl/objects/label/index.ts
new file mode 100644
index 00000000..7b229fe6
--- /dev/null
+++ b/src/renderers/webgl/objects/label/index.ts
@@ -0,0 +1,289 @@
+import utils, { STYLE_DEFAULTS } from './utils'
+import type { LabelPosition, LabelStyle, LabelBackgroundStyle, TextAlign, FontWeight } from './utils'
+import type { Stroke } from '../../../../types'
+import { BitmapText, Container, Text } from 'pixi.js'
+import { LabelBackground } from './background'
+
+/**
+ * TODO
+ * - add support for background color, custom font loading
+ * - add support for loading custom fonts as asset bundles
+ * - moving/scaling labels is slow. render ASCII text characters as sprites to partical container?
+ */
+export class Label {
+ mounted = false
+
+ private x?: number
+ private y?: number
+ private dirty = false
+ private transformed = false
+ private label: string
+ private container: Container
+ private text: BitmapText | Text
+ private labelBackground: LabelBackground | null = null
+ private _style: LabelStyle | undefined
+
+ constructor(container: Container, label: string, style: LabelStyle | undefined) {
+ this.container = container
+ this.label = label
+ this._style = style
+
+ if (utils.isASCII(this.label)) {
+ utils.loadFont(this.style)
+ this.text = new BitmapText(this.label, utils.getBitmapStyle(this.style))
+ this.text.resolution = 2
+ } else {
+ this.text = new Text(this.label, utils.getTextStyle(this.style))
+ }
+
+ this.text.anchor.set(...utils.getAnchorPoint(this.style.position))
+ if (this.style.background !== undefined) {
+ this.labelBackground = new LabelBackground(container, this.text, this.style.background)
+ }
+ }
+
+ update(label: string, style: LabelStyle | undefined) {
+ const labelHasChanged = this.label !== label
+ const styleHasChanged = this._style !== style
+
+ this._style = style
+
+ if (labelHasChanged) {
+ this.label = label
+ this.text.text = label
+
+ const isBitmapText = this.isBitmapText()
+ const isASCII = utils.isASCII(label)
+ // if the text type has changed, regenerate a new text object
+ if ((isBitmapText && !isASCII) || (!isBitmapText && isASCII)) {
+ this.transformText()
+ }
+ }
+
+ if (styleHasChanged) {
+ this.stroke = style?.stroke
+ this.wordWrap = style?.wordWrap
+ this.color = style?.color ?? STYLE_DEFAULTS.COLOR
+ this.fontWeight = style?.fontWeight ?? STYLE_DEFAULTS.FONT_WEIGHT
+ this.letterSpacing = style?.letterSpacing ?? STYLE_DEFAULTS.LETTER_SPACING
+ this.position = style?.position ?? STYLE_DEFAULTS.POSITION
+ this.fontSize = style?.fontSize ?? STYLE_DEFAULTS.FONT_SIZE
+ this.fontFamily = style?.fontFamily ?? STYLE_DEFAULTS.FONT_FAMILY
+ this.fontName = style?.fontName ?? STYLE_DEFAULTS.FONT_NAME
+ }
+
+ if (this.dirty) {
+ this.dirty = false
+ this.updateText()
+ }
+
+ if (labelHasChanged || styleHasChanged) {
+ this.transformed = false
+ this.background = style?.background
+ }
+
+ return this
+ }
+
+ moveTo(x: number, y: number, offset = 0) {
+ const { label, bg } = utils.getLabelCoordinates(x, y, offset, this.isBitmapText(), this.style)
+
+ this.labelBackground?.moveTo(bg.x, bg.y)
+
+ if (label.x !== this.x) {
+ this.text.x = label.x
+ this.x = label.x
+ }
+ if (label.y !== this.y) {
+ this.text.y = label.y
+ this.y = label.y
+ }
+ }
+
+ mount() {
+ if (!this.mounted) {
+ this.labelBackground?.mount()
+ this.container.addChild(this.text)
+ this.mounted = true
+ }
+
+ return this
+ }
+
+ unmount() {
+ if (this.mounted) {
+ this.labelBackground?.unmount()
+ this.container.removeChild(this.text)
+ this.mounted = false
+ }
+
+ return this
+ }
+
+ delete() {
+ this.unmount()
+ this.text.destroy()
+ if (!this.transformed) {
+ this.labelBackground?.delete()
+ }
+
+ return undefined
+ }
+
+ equals(label: string, style: LabelStyle | undefined) {
+ return this.label === label && this._style === style
+ }
+
+ private isBitmapText(text: Text | BitmapText = this.text): text is BitmapText {
+ return text instanceof BitmapText
+ }
+
+ private updateText() {
+ if (this.isBitmapText(this.text)) {
+ this.text.updateText()
+ } else {
+ this.text.updateText(true)
+ }
+ }
+
+ private transformText() {
+ this.transformed = true
+ const isMounted = this.mounted
+
+ this.delete()
+
+ if (utils.isASCII(this.label)) {
+ utils.loadFont(this.style)
+ this.text = new BitmapText(this.label, utils.getBitmapStyle(this.style))
+ this.text.resolution = 2
+ } else {
+ this.text = new Text(this.label, utils.getTextStyle(this.style))
+ }
+
+ this.anchor = utils.getAnchorPoint(this.style.position)
+ this.text.x = this.x ?? 0
+ this.text.y = this.y ?? 0
+
+ if (isMounted) {
+ this.mount()
+ }
+ }
+
+ private get style() {
+ return utils.mergeDefaults(this._style)
+ }
+
+ private set position(position: LabelPosition) {
+ this.align = utils.getTextAlign(position)
+ this.anchor = utils.getAnchorPoint(position)
+ }
+
+ private set align(align: TextAlign) {
+ if (this.isBitmapText(this.text)) {
+ if (this.text.align !== align) {
+ this.dirty = true
+ this.text.align = align
+ }
+ } else {
+ if (this.text.style.align !== align) {
+ this.dirty = true
+ this.text.style.align = align
+ }
+ }
+ }
+
+ private set anchor([x, y]: [x: number, y: number]) {
+ if (!this.text.anchor.equals({ x, y })) {
+ this.text.anchor.set(x, y)
+ }
+ }
+
+ private set fontSize(fontSize: number) {
+ if (this.isBitmapText(this.text)) {
+ if (this.text.fontSize !== fontSize) {
+ this.dirty = true
+ this.text.fontSize = fontSize
+ }
+ } else {
+ if (this.text.style.fontSize !== fontSize) {
+ this.dirty = true
+ this.text.style.fontSize = fontSize
+ }
+ }
+ }
+
+ private set wordWrap(wordWrap: number | undefined) {
+ const wordWrapWidth = wordWrap ?? 0
+ if (!this.isBitmapText(this.text) && this.text.style.wordWrapWidth !== wordWrapWidth) {
+ this.dirty = true
+ this.text.style.wordWrap = wordWrap !== undefined
+ this.text.style.wordWrapWidth = wordWrapWidth
+ }
+ }
+
+ private set color(color: string) {
+ if (!this.isBitmapText(this.text) && this.text.style.fill !== color) {
+ this.dirty = true
+ this.text.style.fill = color
+ }
+ }
+
+ private set stroke(value: Stroke | undefined) {
+ if (!this.isBitmapText(this.text)) {
+ const stroke = value?.color ?? STYLE_DEFAULTS.STROKE
+ const strokeThickness = value?.width ?? STYLE_DEFAULTS.STROKE_THICKNESS
+ if (this.text.style.stroke !== stroke || this.text.style.strokeThickness !== strokeThickness) {
+ this.dirty = true
+ this.text.style.stroke = stroke
+ this.text.style.strokeThickness = strokeThickness
+ }
+ }
+ }
+
+ private set fontFamily(fontFamily: string) {
+ if (!this.isBitmapText(this.text) && fontFamily !== this.text.style.fontFamily) {
+ this.dirty = true
+ this.text.style.fontFamily = fontFamily
+ }
+ }
+
+ private set fontName(fontName: string) {
+ if (this.isBitmapText(this.text) && this.text.fontName !== fontName) {
+ this.dirty = true
+ this.text.fontName = fontName
+ }
+ }
+
+ private set fontWeight(fontWeight: FontWeight) {
+ if (!this.isBitmapText(this.text) && this.text.style.fontWeight !== fontWeight) {
+ this.dirty = true
+ this.text.style.fontWeight = fontWeight
+ }
+ }
+
+ private set background(background: LabelBackgroundStyle | undefined) {
+ if (this.labelBackground === null && background !== undefined) {
+ this.labelBackground = new LabelBackground(this.container, this.text, background)
+ } else if (this.labelBackground && background !== undefined) {
+ this.labelBackground.update(this.text, background)
+ } else if (this.labelBackground && background === undefined) {
+ this.labelBackground.delete()
+ this.labelBackground = null
+ }
+ }
+
+ private set letterSpacing(letterSpacing: number) {
+ if (!this.isBitmapText(this.text)) {
+ if (this.text.style.letterSpacing !== letterSpacing) {
+ this.dirty = true
+ this.text.style.letterSpacing = letterSpacing
+ }
+ } else if (this.text.letterSpacing !== letterSpacing) {
+ this.dirty = true
+ this.text.letterSpacing = letterSpacing
+ }
+ }
+}
+
+export { LabelBackground } from './background'
+export type { LabelStyle, LabelBackgroundStyle, LabelPosition, FontWeight, TextAlign } from './utils'
diff --git a/src/renderers/webgl/objects/label/utils.ts b/src/renderers/webgl/objects/label/utils.ts
new file mode 100644
index 00000000..ee8ce8a4
--- /dev/null
+++ b/src/renderers/webgl/objects/label/utils.ts
@@ -0,0 +1,240 @@
+import { MIN_ZOOM } from '../..'
+import type { Stroke } from '../../../../types'
+import { Text, TextStyle, ITextStyle, IBitmapTextStyle, BitmapFont, LINE_JOIN } from 'pixi.js'
+
+export type TextAlign = 'left' | 'center' | 'right' | 'justify'
+export type FontWeight = 'normal' | 'bold' | 'bolder' | 'lighter' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'
+export type LabelPosition = 'bottom' | 'left' | 'top' | 'right'
+
+export type LabelBackgroundStyle = {
+ color: string
+ opacity?: number
+ padding?: number | number[]
+}
+
+export type LabelStyle = Partial<{
+ fontName: string
+ fontSize: number
+ margin: number
+ wordWrap: number
+ letterSpacing: number
+ fontFamily: string
+ fontWeight: FontWeight
+ stroke: Stroke
+ color: string
+ position: LabelPosition
+ background: LabelBackgroundStyle
+}>
+
+type _StyleDefaults = 'fontSize' | 'position' | 'fontFamily' | 'fontName' | 'margin'
+export type StyleWithDefaults = Omit & {
+ fontSize: number
+ position: LabelPosition
+ fontFamily: string | string[]
+ fontName: string
+ margin: number
+}
+
+export const RESOLUTION = 2
+export const STYLE_DEFAULTS = {
+ FONT_SIZE: 10,
+ STROKE_THICKNESS: 0,
+ LETTER_SPACING: 0.5,
+ WORD_WRAP: false,
+ MARGIN: 2,
+ OPACITY: 1,
+ PADDING: [4, 8] as [number, number],
+ STROKE: '#FFF',
+ FONT_NAME: 'Label',
+ COLOR: '#000000',
+ ALIGN: 'center' as const,
+ POSITION: 'bottom' as const,
+ FONT_WEIGHT: 'normal' as const,
+ FONT_FAMILY: 'Arial, sans-serif'
+}
+
+// install text defaults
+Text.defaultResolution = RESOLUTION
+Text.defaultAutoResolution = false
+TextStyle.defaultStyle = {
+ ...TextStyle.defaultStyle,
+ align: STYLE_DEFAULTS.ALIGN,
+ fill: STYLE_DEFAULTS.COLOR,
+ stroke: STYLE_DEFAULTS.STROKE,
+ lineJoin: LINE_JOIN.ROUND,
+ wordWrap: STYLE_DEFAULTS.WORD_WRAP,
+ fontSize: STYLE_DEFAULTS.FONT_SIZE,
+ fontFamily: STYLE_DEFAULTS.FONT_FAMILY,
+ strokeThickness: STYLE_DEFAULTS.STROKE_THICKNESS,
+ letterSpacing: STYLE_DEFAULTS.LETTER_SPACING
+}
+
+// utils
+const mergeDefaults = ({
+ position = STYLE_DEFAULTS.POSITION,
+ fontSize = STYLE_DEFAULTS.FONT_SIZE,
+ fontFamily = STYLE_DEFAULTS.FONT_FAMILY,
+ fontName = STYLE_DEFAULTS.FONT_NAME,
+ margin = STYLE_DEFAULTS.MARGIN,
+ ...style
+}: LabelStyle = {}): StyleWithDefaults => ({
+ position,
+ fontSize,
+ fontFamily,
+ fontName,
+ margin,
+ ...style
+})
+
+const mergeBackgroundDefaults = ({
+ color,
+ opacity = STYLE_DEFAULTS.OPACITY,
+ padding = STYLE_DEFAULTS.PADDING
+}: LabelBackgroundStyle): Required => ({
+ color,
+ opacity,
+ padding
+})
+
+const isASCII = (str: string) => {
+ for (const char of str) {
+ if (char.codePointAt(0)! > 126) {
+ return false
+ }
+ }
+
+ return true
+}
+
+const getTextAlign = (position: LabelPosition): TextAlign => {
+ return position === 'left' || position === 'right' ? position : 'center'
+}
+
+const getAnchorPoint = (position: LabelPosition): [x: number, y: number] => {
+ switch (position) {
+ case 'bottom':
+ return [0.5, 0]
+ case 'left':
+ return [1, 0.5]
+ case 'top':
+ return [0.5, 1]
+ case 'right':
+ return [0, 0.5]
+ }
+}
+
+const getTextStyle = ({ color, fontFamily, fontSize, fontWeight, wordWrap, stroke, position, letterSpacing }: StyleWithDefaults) => {
+ const style: Partial = {}
+ if (color !== undefined) {
+ style.fill = color
+ }
+ if (fontFamily !== undefined) {
+ style.fontFamily = fontFamily
+ }
+ if (fontSize !== undefined) {
+ style.fontSize = fontSize
+ }
+ if (fontWeight !== undefined) {
+ style.fontWeight = fontWeight
+ }
+ if (wordWrap !== undefined) {
+ style.wordWrap = true
+ style.wordWrapWidth = wordWrap
+ }
+ if (stroke !== undefined) {
+ style.stroke = stroke.color
+ style.strokeThickness = stroke.width
+ }
+ if (position !== STYLE_DEFAULTS.POSITION) {
+ style.align = getTextAlign(position)
+ }
+ if (letterSpacing !== undefined) {
+ style.letterSpacing = letterSpacing
+ }
+ return new TextStyle(style)
+}
+
+const getBitmapStyle = (style: StyleWithDefaults): Partial => ({
+ fontName: style.fontName,
+ fontSize: style.fontSize,
+ align: getTextAlign(style.position),
+ letterSpacing: style.letterSpacing ?? STYLE_DEFAULTS.LETTER_SPACING
+})
+
+const loadFont = (style: StyleWithDefaults) => {
+ if (BitmapFont.available[style.fontName] === undefined) {
+ BitmapFont.from(style.fontName, getTextStyle({ ...style, fontSize: style.fontSize * RESOLUTION * MIN_ZOOM }), {
+ chars: BitmapFont.ASCII
+ })
+ }
+}
+
+const getBackgroundPadding = (
+ padding: number | number[] = STYLE_DEFAULTS.PADDING
+): [top: number, right: number, bottom: number, left: number] => {
+ const [top, right = top, bottom = top, left = right]: number[] = typeof padding === 'number' ? [padding] : padding
+ return [top, right, bottom, left]
+}
+
+const getLabelCoordinates = (
+ x: number,
+ y: number,
+ offset: number,
+ isBitmapText: boolean,
+ { position, background, margin }: StyleWithDefaults
+) => {
+ const shift = margin + offset
+ const label = { x, y }
+ const bg = { x, y }
+
+ let top = 0
+ let right = 0
+ let bottom = 0
+ let left = 0
+ if (background !== undefined) {
+ const [t, r, b, l] = getBackgroundPadding(background.padding)
+ top += t
+ right += r
+ bottom += b
+ left += l
+ }
+
+ if (isBitmapText && (position === 'left' || position === 'right')) {
+ label.y -= 1
+ bg.y -= 1
+ }
+
+ switch (position) {
+ case 'bottom':
+ label.y += shift + top
+ bg.y += shift
+ break
+ case 'left':
+ label.x -= shift + right
+ bg.x -= shift
+ break
+ case 'top':
+ label.y -= shift + bottom
+ bg.y -= shift
+ break
+ case 'right':
+ label.x += shift + left
+ bg.x += shift
+ break
+ }
+
+ return { label, bg }
+}
+
+export default {
+ isASCII,
+ mergeDefaults,
+ mergeBackgroundDefaults,
+ getLabelCoordinates,
+ getTextAlign,
+ getAnchorPoint,
+ getTextStyle,
+ getBitmapStyle,
+ loadFont,
+ getBackgroundPadding
+}
diff --git a/src/renderers/webgl/utils.ts b/src/renderers/webgl/utils.ts
index 67d8e1df..4e609763 100644
--- a/src/renderers/webgl/utils.ts
+++ b/src/renderers/webgl/utils.ts
@@ -25,46 +25,3 @@ export const movePoint = (x: number, y: number, angle: number, distance: number)
export const midPoint = (x0: number, y0: number, x1: number, y1: number) => [(x0 + x1) / 2, (y0 + y1) / 2] as const
export const length = (x0: number, y0: number, x1: number, y1: number) => Math.hypot(x1 - x0, y1 - y0)
-
-export const positionNodeLabel = (
- x: number,
- y: number,
- label: string,
- radius: number,
- position: Graph.LabelPosition = 'bottom'
-): [x: number, y: number] => {
- if (isASCII(label)) {
- // BitmapText shifts text down 2px
- switch (position) {
- case 'bottom':
- return [x, y + radius]
- case 'left':
- return [x - radius - 2, y - 2]
- case 'top':
- return [x, y - radius - 4]
- case 'right':
- return [x + radius + 2, y - 2]
- }
- } else {
- switch (position) {
- case 'bottom':
- return [x, y + radius]
- case 'left':
- return [x - radius - 2, y + 1]
- case 'top':
- return [x, y - radius + 2]
- case 'right':
- return [x + radius + 2, y + 1]
- }
- }
-}
-
-export const isASCII = (str: string) => {
- for (const char of str) {
- if (char.codePointAt(0)! > 126) {
- return false
- }
- }
-
- return true
-}
diff --git a/src/types.ts b/src/types.ts
index 6ea3977d..cbf92303 100644
--- a/src/types.ts
+++ b/src/types.ts
@@ -1,3 +1,8 @@
export type Extend = {
[K in Exclude]: T[K]
} & R
+
+export type Stroke = {
+ color: string
+ width: number
+}