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 +}