Source: pixi/labeledgraphics.js

pixi/labeledgraphics.js

/**
 * Defines usefull default text styles.
 */
export class FontInfo {
    static get small() {
        return app.theme.textStyleSmall
    }

    static get normal() {
        return app.theme.textStyle
    }

    static get centered() {
        return Object.assign({}, app.theme.textStyle, { align: 'center' })
    }
}

/**
 * Static methods to support hyphenation of lines.
 *
 * @class Hypenate
 */
export class Hypenate {
    static splitPart(part) {
        let parts = part.split('-')
        if (parts.length == 1) return [part]
        let result = []
        let last = parts.pop()
        for (let p of parts) {
            result.push(p + '-')
        }
        result.push(last)
        return result.filter(p => p.length > 0)
    }

    static splitWord(word) {
        if (typeof language == 'undefined') {
            if (word.indexOf('-') > -1) {
                return word.split('-')
            }
            return [word]
        }
        let parts = language.hyphenate(word)
        let result = []
        for (let part of parts) {
            for (let splitted of this.splitPart(part)) {
                result.push(splitted)
            }
        }
        return result
    }

    static abbreviateLine(label, style, width) {
        const pixiStyle = new PIXI.TextStyle(style)
        let metrics = PIXI.TextMetrics.measureText(label, pixiStyle)
        while (metrics.width > width && label.length > 3) {
            label = label.slice(0, label.length - 1)
            metrics = PIXI.TextMetrics.measureText(label, pixiStyle)
        }
        label = label.slice(0, label.length - 1)
        return label + '…'
    }

    static splitLine(line, pixiStyle, width, space, minus) {
        let x = 0
        let result = ''
        let words = line.split(' ')
        for (let word of words) {
            let wordMetrics = PIXI.TextMetrics.measureText(word, pixiStyle)
            if (x + wordMetrics.width >= width) {
                let parts = this.splitWord(word)
                let newWord = ''
                if (parts.length == 1) {
                    newWord += '\n' + word + ' '
                    x = wordMetrics.width + space.width
                } else {
                    let first = true
                    let lastPart = ''
                    for (let part of parts) {
                        let partMetrics = PIXI.TextMetrics.measureText(
                            part,
                            pixiStyle
                        )
                        if (x + partMetrics.width + space.width > width) {
                            newWord +=
                                (first || lastPart.endsWith('-')
                                    ? '\n'
                                    : '-\n') + part
                            x = partMetrics.width
                        } else {
                            newWord += part
                            x += partMetrics.width
                        }
                        lastPart = part
                        first = false
                    }
                    x += space.width
                }
                result += newWord + ' '
            } else {
                result += word + ' '
                x += wordMetrics.width + space.width
            }
        }
        return result
    }

    /**
     *  Main method and entry point for text hyphenation
     *
     * @static
     * @param {*} text
     * @param {*} style
     * @param {*} width
     * @memberof Hypenate
     * @returns {string}
     */
    static splitLines(text, style, width) {
        const pixiStyle = new PIXI.TextStyle(style)
        const lines = text.split('\n')
        const space = PIXI.TextMetrics.measureText(' ', pixiStyle)
        const minus = PIXI.TextMetrics.measureText('-', pixiStyle)
        let result = []
        for (let line of lines) {
            result.push(this.splitLine(line, pixiStyle, width, space, minus))
        }
        return result.join('\n')
    }
}

class TextLabel extends PIXI.Text {
    /**
     *Creates an instance of TextLabel.
     * @param {string} text - The string that you would like the text to display
     * @param {object|PIXI.TextStyle} [style] - The style parameters
     * @param {canvas}
     * @memberof TextLabel
     */
    constructor(
        text,
        style = null,
        canvas = null,
        { minZoom = 0.1, maxZoom = 10 } = {}
    ) {
        super(text, style, canvas)
        this.normFontSize = this.style.fontSize
        this.minZoom = minZoom
        this.maxZoom = maxZoom
    }

    zoom(factor) {
        let oldValue = parseFloat(this.style.fontSize) / this.normFontSize
        let value = oldValue * factor
        this.setZoom(value)
    }

    setZoom(value) {
        let oldValue = parseFloat(this.style.fontSize) / this.normFontSize
        if (value > this.maxZoom) {
            value = this.maxZoom
        }
        if (value < this.minZoom) {
            value = this.minZoom
        }
        if (value != oldValue) {
            this.style.fontSize = Math.max(value * this.normFontSize, 1)
        }
    }

    setZoomAndScale(scale) {
        this.scale.set(1 / scale)
        this.setZoom(scale)
    }
}

/**
 * A specialization of the PIXI.Graphics class that allows to
 * resuse and place labels across different layout variants
 *
 * @export
 * @class LabeledGraphics
 * @extends {PIXI.Graphics}
 */
export class LabeledGraphics extends PIXI.Graphics {
    /**
     * Creates an instance of LabeledGraphics and defines a local label cache.
     *
     * @memberof LabeledGraphics
     */
    constructor() {
        super()
        this.labels = new Map()
    }

    _createText(label, fontInfo) {
        return new TextLabel(label, fontInfo)
    }

    /**
     * Main additional method. Ensures that a text object is created that is cached
     * under the given key.
     *
     * @param {*} key - The cache key
     * @param {*} label - The label to show
     * @param {*} [attrs={}] - Defines attributes of the text object.
     *                               align: 'right', 'left', or 'center'
     *                               justify: 'top', 'bottom', or 'center'
     *                               maxLines: {integer} truncates the text and adds ellipsis
     *                               maxHeight: {number} truncates text that needs more space and adds ellipsis
     *                               maxWidth: {number} word wraps text using hyphenation if possible
     * @param {*} [fontInfo=FontInfo.normal] - Defines PIXI.TextStyle attributes
     * @returns {PIXI.Text} - instance
     * @memberof LabeledGraphics
     */
    ensureLabel(key, label, attrs = {}, fontInfo = FontInfo.normal) {
        if (attrs.maxWidth && attrs.maxLines == 1) {
            label = Hypenate.abbreviateLine(label, fontInfo, attrs.maxWidth)
        } else {
            if (attrs.maxWidth) {
                label = Hypenate.splitLines(label, fontInfo, attrs.maxWidth)
            }
            if (attrs.maxLines) {
                label = this.truncateLabel(label, fontInfo, attrs.maxLines)
            }
            if (attrs.maxHeight) {
                let styleInfo = new PIXI.TextStyle(fontInfo)
                let metrics = PIXI.TextMetrics.measureText(label, styleInfo)
                let maxLines = Math.max(attrs.maxHeight / metrics.lineHeight, 1)
                label = this.truncateLabel(label, fontInfo, maxLines)
            }
        }

        if (!this.labels.has(key)) {
            let text = this._createText(label, fontInfo)
            this.labels.set(key, text)
            this.addChild(text)
        }
        let text = this.labels.get(key)
        for (let k in attrs) {
            text[k] = attrs[k]
        }
        if (label != text.text) text.text = label
        // We do not follow the flexbox jargon and use align for x and justify for y axis
        // This deviation is needed to ensure backward compatability
        switch (attrs.justify || null) {
            case 'top':
                text.anchor.y = 0
                break
            case 'bottom':
                text.anchor.x = 1
                break
            default:
                text.anchor.y = 0.5
                break
        }
        switch (attrs.align) {
            case 'right':
                text.anchor.x = 1
                break
            case 'center':
                text.anchor.x = 0.5
                break
            default:
                text.anchor.x = 0
                break
        }
        text.visible = true
        return text
    }

    /**
     * Private method that truncates the text and adds an ellipsis if there are more lines
     * than wanted
     *
     * @param {*} text
     * @param {*} style
     * @param {*} [maxLines=Infinity]
     * @returns {string}
     * @memberof LabeledGraphics
     */
    truncateLabel(text, style, maxLines = Infinity) {
        if (maxLines === Infinity) {
            return text
        }
        const { wordWrapWidth } = style
        const pixiStyle = new PIXI.TextStyle(style)
        const { lines } = PIXI.TextMetrics.measureText(text, pixiStyle)
        let newText = text
        if (lines.length > maxLines) {
            const truncatedLines = lines.slice(0, maxLines)
            const lastLine = truncatedLines[truncatedLines.length - 1]
            const words = lastLine.split(' ')
            const wordMetrics = PIXI.TextMetrics.measureText(
                `\u00A0\n...\n${words.join('\n')}`,
                pixiStyle
            )
            const [
                spaceLength,
                dotsLength,
                ...wordLengths
            ] = wordMetrics.lineWidths
            const { text: newLastLine } = wordLengths.reduce(
                (data, wordLength, i) => {
                    if (
                        data.length + wordLength + spaceLength >=
                        wordWrapWidth
                    ) {
                        return { ...data, length: wordWrapWidth }
                    }
                    return {
                        text: `${data.text}${i > 0 ? ' ' : ''}${words[i]}`,
                        length: data.length + wordLength + spaceLength
                    }
                },
                { text: '', length: dotsLength }
            )
            truncatedLines[truncatedLines.length - 1] = `${newLastLine}...`
            newText = truncatedLines.join('\n')
        }
        return newText
    }

    /**
     * Returns the label for the given key.
     *
     * @param {*} key
     * @returns {Object}
     * @memberof LabeledGraphics
     */
    getLabel(key) {
        return this.labels.get(key)
    }

    /**
     * Hides the label with the given key.
     * @param {*} key
     * @memberof LabeledGraphics
     */
    hideLabel(key) {
        let label = this.labels.get(key)
        if (label) {
            label.visible = false
        }
    }

    /**
     * Removes the label with the given key.
     * @param {*} key
     * @memberof LabeledGraphics
     */
    removeLabel(key) {
        let label = this.labels.get(key)
        this.labels.delete(key)
        label.destroy()
    }

    /**
     * Ensures that labels are hidden on clear.
     *
     * @memberof LabeledGraphics
     */
    clear() {
        super.clear()
        for (let key of this.labels.keys()) {
            this.hideLabel(key)
        }
    }

    /**
     * Logs debugging infos
     *
     * @memberof LabeledGraphics
     */
    debugInfos() {
        console.log({ size: this.labels.size, labels: this.labels })
    }
}

const labelCache = new Map()

function getTexture(label, fontInfo = FontInfo.normal) {
    let key = label + fontInfo.fontFamily + fontInfo.fontSize

    if (labelCache.has(key)) {
        return labelCache.get(key)
    }
    let expandedFont = Object.assign({}, fontInfo)
    expandedFont.fontSize *= window.devicePixelRatio
    let text = new PIXI.Text(label, expandedFont)
    text.updateText()
    labelCache.set(key, text.texture)
    return text.texture
}

class SpriteLabel extends PIXI.Sprite {
    constructor(label, fontInfo) {
        let texture = getTexture(label, fontInfo)
        super(texture)
        this.label = label
        this.fontInfo = fontInfo
        this.scale.set(0.8 / window.devicePixelRatio)
    }

    set text(label) {
        this.label = label
        this.texture = getTexture(label, this.fontInfo)
    }

    get text() {
        return this.label
    }
}

export class BitmapLabeledGraphics extends LabeledGraphics {
    _createText(label, fontInfo) {
        let texture = getTexture(label, fontInfo)
        return new SpriteLabel(texture)
    }
}