UNPKG

@amcharts/amcharts5

Version:
1,206 lines 60.1 kB
import { WordCloudDefaultTheme } from "./WordCloudDefaultTheme";
import { Series } from "../../core/render/Series";
import { Template } from "../../core/util/Template";
import { Label } from "../../core/render/Label";
import { Container } from "../../core/render/Container";
import { Graphics } from "../../core/render/Graphics";
import { ListTemplate } from "../../core/util/List";
import * as $utils from "../../core/util/Utils";
import * as $array from "../../core/util/Array";
import * as $math from "../../core/util/Math";
import * as $type from "../../core/util/Type";
/**
 * Creates a [[WordCloud]] series.
 *
 * @see {@link https://www.amcharts.com/docs/v5/charts/word-cloud/} for more info
 * @important
 */
export class WordCloud extends Series {
    constructor() {
        super(...arguments);
        /**
         * A [[Graphics]] element that draws the `svgPath` shape the words are
         * arranged into, aligned exactly with the word-constraint region.
         *
         * By default it renders as a faint silhouette (the theme's alternative
         * background color at 10% opacity) behind the words, and only when
         * `svgPath` is set. Style it via
         * `series.shape.setAll({ fill: color, fillOpacity: 0.1, ... })`.
         *
         * Its geometry (`svgPath`, `scale`, `x`, `y`) and visibility are managed
         * by the series; `fill`/`stroke` and other styling are yours to set.
         *
         * It is added as the first child, so it always renders behind the words.
         *
         * @since 5.20.1
         */
        this.shape = this.children.push(Graphics.new(this._root, {
            themeTags: ["wordcloud", "shape"]
        }));
        this._pointSets = [];
        this._sets = 3;
        // bit-packed occupancy board sized to the plot area (32 pixels per int) and
        // a private scratch canvas used to rasterize word glyph masks
        this._board = new Int32Array(0);
        this._boardW = 0;
        this._boardH = 0;
        this._boardStride = 0;
        // Container holding the word labels. The `maskByShape` clip is applied to
        // THIS container (not the whole series), so the labels actually get clipped —
        // masking the series itself has no effect on the labels.
        this._labelsContainer = this.children.push(Container.new(this._root, {}));
        // whether the `maskByShape` clip is currently applied to `_labelsContainer`.
        // The mask itself is built fresh on each apply and owned by the container
        // (which disposes it on removal), so we only track the applied state here.
        this._maskApplied = false;
        // fitted-shape geometry for the clip mask (kept in sync in `_prepareShapeMask`)
        this._shapeScale = 1;
        this._shapeTx = 0;
        this._shapeTy = 0;
        this._svgPathWarned = false;
        /**
         * A [[ListTemplate]] of all labels in series.
         *
         * `labels.template` can also be used to configure labels.
         */
        this.labels = this.addDisposer(this._makeLabels());
    }
    _afterNew() {
        this._defaultThemes.push(WordCloudDefaultTheme.new(this._root));
        this.fields.push("category", "fill");
        this._setDefault("valueField", "value");
        this._setDefault("categoryField", "category");
        this._setDefault("fillField", "fill");
        super._afterNew();
    }
    /**
     * @ignore
     */
    makeLabel(dataItem) {
        const label = this._labelsContainer.children.push(this.labels.make());
        label._setDataItem(dataItem);
        const fill = dataItem.get("fill");
        if (fill != null) {
            label.set("fill", fill);
        }
        label.set("x", -999999); // do not change!
        dataItem.set("label", label);
        this.labels.push(label);
        return label;
    }
    _makeLabels() {
        return new ListTemplate(Template.new({}), () => Label._new(this._root, {
            themeTags: $utils.mergeTags(this.labels.template.get("themeTags", []), ["wordcloud", "series"])
        }, [this.labels.template]));
    }
    processDataItem(dataItem) {
        super.processDataItem(dataItem);
        if (dataItem.get("fill") == null) {
            let colors = this.get("colors");
            if (colors) {
                dataItem.setRaw("fill", colors.next());
            }
        }
        this.makeLabel(dataItem);
    }
    _prepareChildren() {
        super._prepareChildren();
        // `maxCount`/`minValue`/`minWordLength`/`excludeWords` are word-extraction
        // filters applied in `_getWords`, which only ran on a `text` change — so
        // re-extract when any of them change too (only relevant when using `text`).
        if (this.isDirty("text") || this.isDirty("maxCount") || this.isDirty("minValue") || this.isDirty("minWordLength") || this.isDirty("excludeWords")) {
            const text = this.get("text");
            if (text != null) {
                this.data.setAll(this._getWords(text));
            }
            // Clear these so the `data.setAll` above doesn't re-enter this block and
            // recurse (mirrors the original `text` handling).
            this._dirty["text"] = false;
            this._dirty["maxCount"] = false;
            this._dirty["minValue"] = false;
            this._dirty["minWordLength"] = false;
            this._dirty["excludeWords"] = false;
        }
    }
    _updateChildren() {
        super._updateChildren();
        // Layout works in an innerWidth/innerHeight box, so the words (and the
        // shape) have to be moved into the padded area, or they sit in the top
        // left corner with all the padding pooled at the bottom right.
        this._labelsContainer.setAll({
            x: this.get("paddingLeft", 0),
            y: this.get("paddingTop", 0)
        });
        let step = this.get("step", 1) * 2;
        // `shapeTolerance` only affects placement when a shape is active, so it
        // shouldn't trigger a relayout with no `svgPath` set. `maskByShape` never
        // relayouts (it only toggles the clip — handled after this block).
        const shapeSet = this.get("svgPath") != null;
        if (this._valuesDirty || this._sizeDirty || this.isPrivateDirty("adjustedFontSize") || this.isDirty("minFontSize") || this.isDirty("maxFontSize") || this.isDirty("step") || this.isDirty("angles") || this.isDirty("randomness") || this.isDirty("randomizeAngles") || this.isDirty("allowNesting") || this.isDirty("svgPath") || (shapeSet && this.isDirty("shapeTolerance"))) {
            // The layout re-fits from full size every pass (`_layoutAll` searches
            // its own `shrink` from 1), so keep `adjustedFontSize` at 1. Otherwise
            // a stale (ratcheted-down) value pre-scales the fonts and the cloud
            // can't grow back when the container is enlarged.
            this.setPrivateRaw("adjustedFontSize", 1);
            const adjustedFontSize = this.getPrivate("adjustedFontSize", 1);
            const w = this.innerWidth();
            const h = this.innerHeight();
            const smaller = Math.min(w, h);
            const bigger = Math.max(w, h);
            if (smaller < 800) {
                step = step / 2;
            }
            this._pointSets = [];
            for (let i = 0; i < this._sets; i++) {
                // bigger step at the beginning
                const setStep = step * (this._sets - i);
                const points = $math.spiralPoints(w / 2, h / 2, w, h, 0, setStep * h / bigger, setStep * w / bigger, 0, 0);
                // generated more points and remove those out of bounds
                // (single compacting pass; per-point splicing was O(n^2))
                let n = 0;
                for (let j = 0; j < points.length; j++) {
                    const point = points[j];
                    if (point.x >= 0 && point.x <= w && point.y >= 0 && point.y <= h) {
                        points[n++] = point;
                    }
                }
                points.length = n;
                this._pointSets.push(points);
            }
            let sum = 0;
            let absSum = 0;
            let valueHigh = 0;
            let valueLow = Infinity;
            let count = 0;
            $array.each(this._dataItems, (dataItem) => {
                const valueWorking = dataItem.get("valueWorking", 0);
                sum += valueWorking;
                absSum += Math.abs(valueWorking);
            });
            this._dataItems.sort((a, b) => {
                let aValue = a.get("value", 0);
                let bValue = b.get("value", 0);
                if (aValue > bValue) {
                    return -1;
                }
                if (aValue < bValue) {
                    return 1;
                }
                return 0;
            });
            $array.each(this._dataItems, (dataItem) => {
                const value = dataItem.get("valueWorking", 0);
                if (value >= absSum) {
                    sum = dataItem.get("value", 0);
                }
                if (value > valueHigh) {
                    valueHigh = value;
                }
                if (value < valueLow) {
                    valueLow = value;
                }
                count++;
            });
            this.setPrivateRaw("valueLow", valueLow);
            this.setPrivateRaw("valueHigh", valueHigh);
            this.setPrivateRaw("valueSum", sum);
            this.setPrivateRaw("valueAverage", sum / count);
            this.setPrivateRaw("valueAbsoluteSum", absSum);
            const smallerSize = Math.min(w, h);
            const minFontSize = $utils.relativeToValue(this.get("minFontSize", 10), smallerSize) * adjustedFontSize;
            const maxFontSize = $utils.relativeToValue(this.get("maxFontSize", 100), smallerSize) * adjustedFontSize;
            const angles = this.get("angles", [0]);
            const randomizeAngles = this.get("randomizeAngles", true);
            $array.each(this._dataItems, (dataItem, index) => {
                const value = dataItem.get("valueWorking", 0);
                let fontSize = minFontSize + (maxFontSize - minFontSize) * (value - valueLow) / (valueHigh - valueLow);
                if ($type.isNaN(fontSize)) {
                    fontSize = maxFontSize;
                }
                const set = this._sets - 1 - Math.floor((fontSize - minFontSize) / (maxFontSize - minFontSize) * (this._sets - 1));
                dataItem.setRaw("set", set);
                dataItem.setRaw("fontSize", fontSize);
                // Random angle by default; cycle through `angles` in order when
                // `randomizeAngles` is off (reproducible with `randomness: 0`).
                let angle = randomizeAngles
                    ? angles[Math.floor(Math.random() * (angles.length))]
                    : angles[index % angles.length];
                dataItem.setRaw("angle", angle);
            });
            // the whole layout happens right here, in one synchronous pass
            this._layoutAll();
        }
        // `maskByShape` only applies/removes the shape clip — no relayout needed
        // (which would re-shuffle the words). A no-op when there is no active
        // shape, so it never forces a redraw with `svgPath` unset.
        if (this.isDirty("maskByShape")) {
            this._updateShapeVisibility();
        }
    }
    /**
     * Measures, rasterizes and places all words in one synchronous pass.
     * Occupancy is a bit-packed in-memory board tested with bitwise AND — no
     * canvas readback and no per-frame word processing.
     */
    _layoutAll() {
        // reset `progress` (raw, no event) so the `set(…, 1)` at the end fires on
        // every layout, not just the first — listeners can detect each relayout
        this.setRaw("progress", 0);
        // build (or reuse) the `svgPath` shape mask
        this._prepareShapeMask();
        const autoFit = this.get("autoFit", false);
        // autoFit shrinks by 0.9 until everything fits; 32 steps take the font
        // size below 4% of the original, which always fits in practice
        const maxAttempts = autoFit ? 32 : 1;
        let shrink = 1;
        let placements;
        for (let attempt = 0; attempt < maxAttempts; attempt++) {
            placements = this._layoutAttempt(shrink);
            if (placements) {
                break;
            }
            shrink *= 0.9;
        }
        if (placements) {
            // expose the applied autoFit scale on the documented private
            // (raw: the gate resets it to 1 before the next pass, so this only
            // reports the result and never feeds back into the font sizing)
            this.setPrivateRaw("adjustedFontSize", shrink);
            const animationDuration = this.get("animationDuration", 0);
            const animationEasing = this.get("animationEasing");
            // The layout is computed in one pass, but the reveal can still be
            // sequenced word by word for a staggered appearance. Only placed
            // words get a reveal slot, in placement (biggest-first) order;
            // skipped words neither reveal nor leave a gap in the cascade.
            const sequenced = this.get("sequencedInterpolation", false);
            const sequencedDelay = this.get("sequencedDelay", 15);
            let revealIndex = 0;
            $array.each(placements, (placement) => {
                const dataItem = placement.dataItem;
                const label = dataItem.get("label");
                const point = placement.point;
                if (point) {
                    dataItem.setRaw("angle", placement.angle);
                    if (label.get("x") != -999999) {
                        label.animate({ key: "x", to: point.x, duration: animationDuration, easing: animationEasing });
                        label.animate({ key: "y", to: point.y, duration: animationDuration, easing: animationEasing });
                        label.animate({ key: "rotation", to: placement.angle, duration: animationDuration, easing: animationEasing });
                        label.animate({ key: "fontSize", to: placement.fontSize, duration: animationDuration, easing: animationEasing });
                    }
                    else {
                        label.setAll({ x: point.x, y: point.y, rotation: placement.angle, fontSize: placement.fontSize });
                        if (sequenced) {
                            label.appear(undefined, revealIndex * sequencedDelay);
                        }
                        else {
                            label.appear();
                        }
                    }
                    revealIndex++;
                }
                else {
                    label.set("x", -999999);
                }
            });
        }
        this._setC("progress", 1);
    }
    /**
     * Runs one full placement pass with all font sizes scaled by `shrink`.
     * Returns the computed placements, or `undefined` if a word did not fit
     * and `autoFit` wants a retry at a smaller scale.
     */
    _layoutAttempt(shrink) {
        const w = this.innerWidth();
        const h = this.innerHeight();
        const resolution = this._root._renderer.resolution;
        const placements = [];
        if (w <= 0 || h <= 0) {
            return placements;
        }
        // occupancy board, sized to the plot area
        const boardW = Math.ceil(w * resolution);
        const boardH = Math.ceil(h * resolution);
        const boardStride = Math.ceil(boardW / 32);
        const boardLength = boardH * boardStride;
        if (this._board.length == boardLength) {
            this._board.fill(0);
        }
        else {
            this._board = new Int32Array(boardLength);
        }
        this._boardW = boardW;
        this._boardH = boardH;
        this._boardStride = boardStride;
        // The board holds only placed WORDS (used for word-vs-word collision).
        // The shape's exterior is kept separate (`_shapeMask`) and tested against
        // an eroded word box, so words may spill over the shape edge by a bit of
        // their own size but never overlap each other.
        const shapeMask = (this._shapeMask && this._shapeMask.length == boardLength) ? this._shapeMask : undefined;
        // A word may overhang the shape edge by this fraction of its own size,
        // plus any flat `shapeTolerance`. Proportional, so small edge words poke
        // out a little and big words a bit more — filling edges without a crude
        // uniform tolerance.
        const overhangFraction = 0.4;
        const shapeTolerance = this.get("shapeTolerance", 0);
        const autoFit = this.get("autoFit", false);
        const randomness = this.get("randomness", 0);
        const angles = this.get("angles", [0]);
        // when nesting is off, each word occupies its whole bounding box (the
        // box is stamped, not the glyphs), so bounding boxes never overlap
        const stampBox = !this.get("allowNesting", true);
        // candidate points get consumed as words are placed, so work on copies
        // (a shrink retry needs the originals intact)
        const pointSets = [];
        $array.each(this._pointSets, (points) => {
            pointSets.push(points.slice());
        });
        // With a shape, all words draw from a single distance-ranked list
        // (thickest-first) shared across sizes, so the shape fills core-first
        // out to the edges. Without a shape, the per-size spiral sets are used.
        const shapeCandidates = this._shapeCandidates ? this._shapeCandidates.slice() : undefined;
        for (let i = 0; i < this.dataItems.length; i++) {
            const dataItem = this.dataItems[i];
            const text = dataItem.get("category", "");
            const label = dataItem.get("label");
            const fontSize = dataItem.get("fontSize", 0) * shrink;
            let angle = dataItem.get("angle", 0);
            // `_wordFontStyle` forces a heavy weight so the measured/rasterized
            // mask is fatter than the drawn glyph, keeping natural spacing
            // between words.
            const font = this._wordFontStyle(label, fontSize);
            // vertical advance per line, so multi-line categories (with `\n`)
            // are measured and stamped as the stacked block they render as
            const lineStep = this._lineStep(label, fontSize);
            const size = this._measureWord(text, font, fontSize, lineStep);
            const lw = size.width;
            const lh = size.height;
            if (!(lw > 0 && lh > 0 && fontSize > 0)) {
                placements.push({ dataItem: dataItem, angle: angle, fontSize: fontSize });
                continue;
            }
            // The label's padding inflates the collision box, the bounds checks
            // and the covered-point pruning (in the word's local axes,
            // pre-rotation), so every word keeps a padding-sized moat — while the
            // stamped mask stays glyph-precise so small words still nest into
            // big-letter concavities.
            const padLeft = label.get("paddingLeft", 0);
            const padRight = label.get("paddingRight", 0);
            const padTop = label.get("paddingTop", 0);
            const padBottom = label.get("paddingBottom", 0);
            const plw = lw + padLeft + padRight;
            const plh = lh + padTop + padBottom;
            // turn an overly wide word so it fits better (using its padded width)
            if (w > h && plw >= w / 2) {
                $array.each(angles, (a) => {
                    if (a == 0 && angle != 0) {
                        angle = 0;
                    }
                });
            }
            if (h > w && plw >= w / 2) {
                $array.each(angles, (a) => {
                    if (Math.abs(a) == 90 && angle == 0) {
                        angle = a;
                    }
                });
            }
            // Nesting on (default): stamp only the glyphs, so small words nest
            // into big-letter concavities. Nesting off: stamp the full box below,
            // so bounding boxes never overlap (useful with opaque backgrounds,
            // whose rectangles would otherwise slide into a neighbor's gaps).
            const sprite = stampBox ? undefined : this._rasterizeWord(text, font, lw, lh, angle);
            // Collision mask: a SOLID padded rectangle at the word's actual
            // angle. For 0/90 this degenerates to the axis-aligned box (same
            // result as before), but for diagonal words it hugs the real
            // footprint, so neighbors can pack into the otherwise-empty
            // corners of the axis-aligned bounding box.
            const collisionMask = this._rasterizeBox(plw, plh, angle);
            // For a shape: a smaller box, eroded by the allowed overhang, that
            // must stay INSIDE the shape (tested against `shapeMask`). The word's
            // full box may spill past the shape edge by `maxOverhang`, but the
            // eroded core cannot — so words hug the outline proportionally to
            // their size instead of leaving a fixed margin.
            let shapeCore;
            if (shapeMask) {
                const maxOverhang = shapeTolerance + overhangFraction * Math.min(lw, lh);
                const ew = Math.max(1, plw - 2 * maxOverhang);
                const eh = Math.max(1, plh - 2 * maxOverhang);
                shapeCore = this._rasterizeBox(ew, eh, angle);
            }
            // padded footprint extents (rotated AABB) in logical plot pixels,
            // used for the plot-edge checks and the coarse point pruning
            const rad = angle * Math.PI / 180;
            const cosA = Math.abs(Math.cos(rad));
            const sinA = Math.abs(Math.sin(rad));
            const fw = plw * cosA + plh * sinA;
            const fh = plw * sinA + plh * cosA;
            const points = shapeCandidates || pointSets[dataItem.get("set", 0)];
            let pIndex = Math.round(Math.random() * points.length * randomness);
            let placed = false;
            while (true) {
                const p = points[pIndex];
                if (!p) {
                    break;
                }
                if (p.x - fw / 2 < 0 || p.x + fw / 2 > w || p.y - fh / 2 < 0 || p.y + fh / 2 > h) {
                    pIndex++;
                    continue;
                }
                // collision-test the padding-inflated solid oriented box; the
                // stamp is that same box for a backed word, or just its glyphs
                const bx = Math.round(p.x * resolution - collisionMask.bw / 2);
                const by = Math.round(p.y * resolution - collisionMask.bh / 2);
                if (this._collidesAt(collisionMask, bx, by)) {
                    pIndex += 2;
                    continue;
                }
                // the eroded core must stay inside the shape (allows overhang)
                if (shapeCore && shapeMask) {
                    const sbx = Math.round(p.x * resolution - shapeCore.bw / 2);
                    const sby = Math.round(p.y * resolution - shapeCore.bh / 2);
                    if (this._collidesAt(shapeCore, sbx, sby, shapeMask)) {
                        pIndex += 2;
                        continue;
                    }
                }
                if (stampBox) {
                    // mark the whole bounding box occupied (no nesting)
                    this._stampSprite(collisionMask, bx, by);
                }
                else {
                    const px = Math.round(p.x * resolution - sprite.bw / 2);
                    const py = Math.round(p.y * resolution - sprite.bh / 2);
                    this._stampSprite(sprite, px, py);
                }
                // remove candidate points covered by the placed word, testing the
                // word's ORIENTED box (not its axis-aligned bounding box). For a
                // diagonal (e.g. 45°) word the AABB is a much larger square, so
                // pruning by it would delete candidates in the empty corners that
                // later words could still use — leaving gaps around slanted words.
                const cosR = Math.cos(rad);
                const sinR = Math.sin(rad);
                const hw = plw / 2;
                const hh = plh / 2;
                let n = 0;
                for (let j = 0; j < points.length; j++) {
                    const point = points[j];
                    const dx = point.x - p.x;
                    const dy = point.y - p.y;
                    // rotate the offset into the word's local axes
                    const rx = dx * cosR + dy * sinR;
                    const ry = -dx * sinR + dy * cosR;
                    if (Math.abs(rx) > hw || Math.abs(ry) > hh) {
                        points[n++] = point;
                    }
                }
                points.length = n;
                placements.push({ dataItem: dataItem, point: { x: p.x, y: p.y }, angle: angle, fontSize: fontSize });
                placed = true;
                break;
            }
            if (!placed) {
                if (autoFit) {
                    // signal the caller to retry with smaller font sizes
                    return undefined;
                }
                placements.push({ dataItem: dataItem, angle: angle, fontSize: fontSize });
            }
        }
        return placements;
    }
    /**
     * Builds (or reuses) the `svgPath` shape mask for the current plot size.
     * The mask marks the fitted shape's EXTERIOR, tested against an eroded word
     * box so words stay (mostly) inside the shape. Placement is driven by the
     * distance-ranked `_shapeCandidates` (thickest interior first).
     */
    _prepareShapeMask() {
        const svgPath = this.get("svgPath");
        const w = this.innerWidth();
        const h = this.innerHeight();
        if (!svgPath || w <= 0 || h <= 0) {
            this._shapeMask = undefined;
            this._shapeMaskKey = undefined;
            this._shapeCandidates = undefined;
            // hide the silhouette and drop any clip; user styling stays intact
            this._updateShapeVisibility();
            return;
        }
        const resolution = this._root._renderer.resolution;
        const boardW = Math.ceil(w * resolution);
        const boardH = Math.ceil(h * resolution);
        const boardStride = Math.ceil(boardW / 32);
        // the mask is the TRUE shape; `shapeTolerance` no longer dilates it (it
        // feeds the per-word overhang in `_layoutAttempt` instead), so it is not
        // part of the cache key
        const key = boardW + "x" + boardH + "|" + svgPath;
        // the mask only depends on the plot size and the path — reuse it
        // across relayouts and autoFit shrink passes
        if (this._shapeMaskKey != key) {
            this._shapeMaskKey = key;
            this._shapeCandidates = undefined;
            this._shapeMask = undefined;
            try {
                const path = new Path2D(svgPath);
                const bbox = this._svgPathBBox(path);
                if (!bbox || !(bbox.width > 0) || !(bbox.height > 0)) {
                    this._warnSvgPath("svgPath produced no drawable shape, ignoring it");
                    this._updateShapeVisibility();
                    return;
                }
                // proportional fit (no stretching), centered in the plot
                const scale = Math.min(w / bbox.width, h / bbox.height);
                const tx = (w - bbox.width * scale) / 2 - bbox.left * scale;
                const ty = (h - bbox.height * scale) / 2 - bbox.top * scale;
                const context = this._scratchContext || this._makeScratch();
                const canvas = this._scratchCanvas;
                if (canvas.width < boardW) {
                    canvas.width = boardW;
                }
                if (canvas.height < boardH) {
                    canvas.height = boardH;
                }
                context.setTransform(1, 0, 0, 1, 0, 0);
                context.clearRect(0, 0, boardW, boardH);
                context.setTransform(scale * resolution, 0, 0, scale * resolution, tx * resolution, ty * resolution);
                context.fillStyle = "#000000";
                context.fill(path);
                context.setTransform(1, 0, 0, 1, 0, 0);
                // pack the EXTERIOR (empty pixels) into the mask; `inkCount`
                // guards against a path that produced no interior at all
                const data = context.getImageData(0, 0, boardW, boardH).data;
                const mask = new Int32Array(boardH * boardStride);
                let inkCount = 0;
                let n = 3;
                for (let r = 0; r < boardH; r++) {
                    const rowBase = r * boardStride;
                    for (let c = 0; c < boardW; c++) {
                        if (data[n] == 0) {
                            mask[rowBase + (c >> 5)] |= 1 << (31 - (c & 31));
                        }
                        else {
                            inkCount++;
                        }
                        n += 4;
                    }
                }
                if (inkCount == 0) {
                    this._warnSvgPath("svgPath produced no drawable shape, ignoring it");
                    this._updateShapeVisibility();
                    return;
                }
                this._shapeMask = mask;
                // distance-transform candidate points (thickest-first) so words
                // fill the whole shape, edges included, instead of just an ellipse
                this._shapeCandidates = this._buildShapeCandidates(boardW, boardH, data, resolution);
                // Align the public `shape` Graphics with the fitted region:
                // Graphics renders native path point p at (x + scale * p),
                // which is exactly the mask's logical = scale * native + (tx, ty)
                // mapping, so the silhouette overlays the word-constraint area.
                // Only geometry is managed here — user styling is untouched.
                // shifted by the padding to match the labels container, which is
                // offset the same way (the clip mask lives inside that container,
                // so it follows on its own)
                this.shape._setCAll({ svgPath: svgPath, scale: scale, x: tx + this.get("paddingLeft", 0), y: ty + this.get("paddingTop", 0) });
                // remember the fitted geometry; the clip mask (used when
                // `maskByShape`) is built lazily from it in `_applyMaskGraphics`
                this._shapeScale = scale;
                this._shapeTx = tx;
                this._shapeTy = ty;
                // if a clip is already applied, rebuild it against the new
                // geometry/path (e.g. after a resize or a path change); skip when
                // `maskByShape` is being turned off this pass (removed just below)
                if (this._maskApplied && this.get("maskByShape", false)) {
                    this._applyMaskGraphics();
                }
            }
            catch (e) {
                this._warnSvgPath("svgPath could not be parsed, ignoring it");
                this._updateShapeVisibility();
                return;
            }
        }
        // with a shape active, `_shapeCandidates` (distance-ranked) drives
        // placement in `_layoutAttempt` instead of the spiral; here we only
        // manage the silhouette's visibility and the optional clip mask (runs on
        // every exit, so removing/invalidating `svgPath` un-clips the labels)
        this._updateShapeVisibility();
    }
    /**
     * Shows or hides the `shape` silhouette and applies (or removes) the
     * `maskByShape` clip on the labels container, based on whether a shape is
     * currently active. Must run on EVERY `_prepareShapeMask` exit — otherwise a
     * stale clip from a previous shape would keep the labels cut off after the
     * shape is removed or its path becomes invalid.
     */
    _updateShapeVisibility() {
        // covers a cached invalid path as well
        const shapeActive = !!(this._shapeMask && this._shapeCandidates);
        this.shape._setC("forceHidden", !shapeActive);
        // clip the words to the shape when `maskByShape` is on (cuts off the
        // letter parts that overhang the outline). Applied to the labels
        // container so the labels themselves are clipped.
        const wantMask = shapeActive && this.get("maskByShape", false);
        if (wantMask && !this._maskApplied) {
            this._applyMaskGraphics();
        }
        else if (!wantMask && this._maskApplied) {
            // removing a mask disposes it (Container); the next apply builds a
            // fresh one
            this._labelsContainer.set("mask", undefined);
            this._maskApplied = false;
        }
    }
    /**
     * Builds a FRESH clip-mask [[Graphics]] from the fitted-shape geometry and
     * applies it to the labels container. Rebuilt on every apply because removing
     * a mask disposes it (see [[Container]]), so a single reused instance would
     * be dead after the first toggle-off.
     */
    _applyMaskGraphics() {
        const svgPath = this.get("svgPath");
        if (svgPath == null) {
            return;
        }
        const mask = Graphics.new(this._root, {});
        mask._setCAll({ scale: this._shapeScale, x: this._shapeTx, y: this._shapeTy });
        // not a child, so not in the normal update cycle — use a `draw` callback
        // (replayed at clip time) rather than the `svgPath` setting
        mask.set("draw", (display) => {
            display.svgPath(svgPath);
        });
        this._labelsContainer.set("mask", mask);
        this._maskApplied = true;
    }
    /**
     * Builds candidate placement points for a shape, ordered by distance to the
     * shape's edge (thickest interior first). A two-pass chamfer distance
     * transform measures how much room each interior pixel has; placing words
     * biggest-first into the roomiest free spots fills the core first and lets
     * progressively smaller words flow out to the edges — filling the whole
     * silhouette instead of just an inscribed ellipse.
     */
    _buildShapeCandidates(boardW, boardH, data, resolution) {
        const size = boardW * boardH;
        const dist = new Float32Array(size);
        const INF = 1e9;
        // exterior (alpha 0) starts at distance 0, interior at infinity
        let a = 3;
        for (let i = 0; i < size; i++) {
            dist[i] = data[a] == 0 ? 0 : INF;
            a += 4;
        }
        const SQ2 = Math.SQRT2;
        // forward pass (top-left -> bottom-right)
        for (let r = 0; r < boardH; r++) {
            const rowBase = r * boardW;
            for (let c = 0; c < boardW; c++) {
                const idx = rowBase + c;
                if (dist[idx] == 0) {
                    continue;
                }
                let d = dist[idx];
                if (c > 0) {
                    d = Math.min(d, dist[idx - 1] + 1);
                }
                if (r > 0) {
                    d = Math.min(d, dist[idx - boardW] + 1);
                    if (c > 0) {
                        d = Math.min(d, dist[idx - boardW - 1] + SQ2);
                    }
                    if (c < boardW - 1) {
                        d = Math.min(d, dist[idx - boardW + 1] + SQ2);
                    }
                }
                dist[idx] = d;
            }
        }
        // backward pass (bottom-right -> top-left)
        for (let r = boardH - 1; r >= 0; r--) {
            const rowBase = r * boardW;
            for (let c = boardW - 1; c >= 0; c--) {
                const idx = rowBase + c;
                if (dist[idx] == 0) {
                    continue;
                }
                let d = dist[idx];
                if (c < boardW - 1) {
                    d = Math.min(d, dist[idx + 1] + 1);
                }
                if (r < boardH - 1) {
                    d = Math.min(d, dist[idx + boardW] + 1);
                    if (c < boardW - 1) {
                        d = Math.min(d, dist[idx + boardW + 1] + SQ2);
                    }
                    if (c > 0) {
                        d = Math.min(d, dist[idx + boardW - 1] + SQ2);
                    }
                }
                dist[idx] = d;
            }
        }
        // sub-sample the interior on a grid and rank by clearance (thickest first)
        const grid = Math.max(2, Math.round(4 * resolution));
        const ranked = [];
        for (let r = 0; r < boardH; r += grid) {
            const rowBase = r * boardW;
            for (let c = 0; c < boardW; c += grid) {
                const d = dist[rowBase + c];
                if (d > 0 && d < INF) {
                    ranked.push({ x: c / resolution, y: r / resolution, d: d });
                }
            }
        }
        ranked.sort((p1, p2) => p2.d - p1.d);
        const points = [];
        if (ranked.length > 0) {
            for (let i = 0; i < ranked.length; i++) {
                points.push({ x: ranked[i].x, y: ranked[i].y });
            }
        }
        else {
            // degenerate: the fitted shape inks the whole board (no exterior), so
            // every pixel stayed at INF and nothing ranked — fall back to a plain
            // grid so the shape still fills instead of rendering blank
            for (let r = 0; r < boardH; r += grid) {
                for (let c = 0; c < boardW; c += grid) {
                    points.push({ x: c / resolution, y: r / resolution });
                }
            }
        }
        return points;
    }
    /**
     * Finds the bounding box of an SVG path (in path units) by rasterizing it
     * onto the scratch canvas at decreasing probe scales until it fits, then
     * scanning the ink bounds. Canvas-based, so it works without attaching
     * any SVG element to the document.
     */
    _svgPathBBox(path) {
        const probeSize = 1024;
        const context = this._scratchContext || this._makeScratch();
        const canvas = this._scratchCanvas;
        if (canvas.width < probeSize) {
            canvas.width = probeSize;
        }
        if (canvas.height < probeSize) {
            canvas.height = probeSize;
        }
        const scales = [1, 0.25, 0.0625, 0.015625, 0.00390625];
        for (let k = 0; k < scales.length; k++) {
            const scale = scales[k];
            context.setTransform(1, 0, 0, 1, 0, 0);
            context.clearRect(0, 0, probeSize, probeSize);
            context.setTransform(scale, 0, 0, scale, probeSize / 2, probeSize / 2);
            context.fillStyle = "#000000";
            context.fill(path);
            context.setTransform(1, 0, 0, 1, 0, 0);
            const data = context.getImageData(0, 0, probeSize, probeSize).data;
            let minX = probeSize;
            let minY = probeSize;
            let maxX = -1;
            let maxY = -1;
            let n = 3;
            for (let r = 0; r < probeSize; r++) {
                for (let c = 0; c < probeSize; c++) {
                    if (data[n] != 0) {
                        if (c < minX) {
                            minX = c;
                        }
                        if (c > maxX) {
                            maxX = c;
                        }
                        if (r < minY) {
                            minY = r;
                        }
                        if (r > maxY) {
                            maxY = r;
                        }
                    }
                    n += 4;
                }
            }
            if (maxX < 0) {
                // no ink at this scale: a path whose coords sit outside the probe
                // window (large or translated) shows nothing at scale 1 but ISN'T
                // degenerate — retry smaller before giving up
                if (k < scales.length - 1) {
                    continue;
                }
                return undefined;
            }
            // ink touching the probe border means the path was likely clipped,
            // so retry at a smaller scale (unless this was the last one)
            if ((minX == 0 || minY == 0 || maxX == probeSize - 1 || maxY == probeSize - 1) && k < scales.length - 1) {
                continue;
            }
            return {
                left: (minX - probeSize / 2) / scale,
                top: (minY - probeSize / 2) / scale,
                width: (maxX - minX + 1) / scale,
                height: (maxY - minY + 1) / scale
            };
        }
        return undefined;
    }
    /**
     * Logs a one-time console warning about an unusable `svgPath` (invalid or
     * degenerate). Warns once per series to avoid flooding the console.
     */
    _warnSvgPath(message) {
        if (!this._svgPathWarned) {
            this._svgPathWarned = true;
            console.warn("WordCloud: " + message);
        }
    }
    /**
     * Builds the CSS font shorthand used to measure and rasterize a word's
     * collision mask, mirroring the renderer's `CanvasText._getFontStyle` (same
     * part order, same fallbacks) so the scratch-canvas raster lines up with the
     * drawn glyph. The weight is forced to `900` so the mask is fatter than the
     * displayed label — that extra thickness is what keeps natural spacing
     * between words (the label itself is drawn with its own weight).
     */
    _wordFontStyle(label, fontSize) {
        const parts = [];
        const fontVariant = label.get("fontVariant");
        if (fontVariant) {
            parts.push(fontVariant);
        }
        // forced heavy weight (see doc): fattens the mask for word spacing
        parts.push("900");
        const fontStyle = label.get("fontStyle");
        if (fontStyle) {
            parts.push(fontStyle);
        }
        parts.push(fontSize + "px");
        let fontFamily = label.get("fontFamily");
        if (fontFamily == "inherit") {
            fontFamily = getComputedStyle(this._root.dom).getPropertyValue("font-family");
        }
        if (fontFamily) {
            parts.push(fontFamily);
        }
        else {
            parts.push("Arial");
        }
        return parts.join(" ");
    }
    _makeScratch() {
        const canvas = document.createElement("canvas");
        const context = canvas.getContext("2d", { willReadFrequently: true });
        this._scratchCanvas = canvas;
        this._scratchContext = context;
        return context;
    }
    /**
     * Vertical advance between lines for a label, honoring its `lineHeight`
     * setting (the theme default is 100%, i.e. one font size per line). Used to
     * stack multi-line categories (`\n`) the way the label renders them.
     */
    _lineStep(label, fontSize) {
        const lineHeight = label.get("lineHeight");
        if (lineHeight != null) {
            // like the renderer, a plain number is a multiplier and a `Percent`
            // is relative (theme default 100%). Approximated against the font
            // size rather than the measured glyph height, so the stacked mask
            // runs a touch tall — safe (extra gap, never overlap).
            const step = $type.isNumber(lineHeight)
                ? lineHeight * fontSize
                : $utils.relativeToValue(lineHeight, fontSize);
            if (step > 0) {
                return step;
            }
        }
        return fontSize;
    }
    /**
     * Measures a word synchronously on the scratch canvas. Categories may span
     * several lines (`\n`); the width is the widest line and the height stacks
     * the lines by `lineStep`, matching the label's rendered footprint.
     */
    _measureWord(text, font, fontSize, lineStep) {
        const context = this._scratchContext || this._makeScratch();
        context.setTransform(1, 0, 0, 1, 0, 0);
        context.font = font;
        const lines = text.split("\n");
        // width is the widest line's advance width
        let width = 0;
        for (let i = 0; i < lines.length; i++) {
            const w = context.measureText(lines[i]).width;
            if (w > width) {
                width = w;
            }
        }
        let height;
        if (lines.length > 1) {
            // stacked block: one `lineStep` per line
            height = lines.length * lineStep;
        }
        else {
            // single line: tight glyph height (falls back to the font size)
            const metrics = context.measureText(text);
            height = 0;
            if (metrics.actualBoundingBoxAscent != null && metrics.actualBoundingBoxDescent != null) {
                height = metrics.actualBoundingBoxAscent + metrics.actualBoundingBoxDescent;
            }
            if (!(height > 0)) {
                height = fontSize;
            }
        }
        return { width: width, height: height };
    }
    /**
     * Rasterizes a word (at its final font and rotation) on the scratch canvas
     * and packs the alpha channel into a bit mask.
     */
    _rasterizeWord(text, font, lw, lh, angle) {
        const resolution = this._root._renderer.resolution;
        const rad = angle * Math.PI / 180;
        const cos = Math.abs(Math.cos(rad));
        const sin = Math.abs(Math.sin(rad));
        // bounding box of the rotated word, in device pixels
        const bw = Math.max(1, Math.ceil((lw * cos + lh * sin) * resolution));
        const bh = Math.max(1, Math.ceil((lw * sin + lh * cos) * resolution));
        const context = this._scratchContext || this._makeScratch();
        const canvas = this._scratchCanvas;
        // grow-only, to avoid constant reallocation
        if (canvas.width < bw) {
            canvas.width = bw;
        }
        if (canvas.height < bh) {
            canvas.height = bh;
        }
        context.setTransform(1, 0, 0, 1, 0, 0);
        context.clearRect(0, 0, bw, bh);
        // same scale model as the renderer: draw in logical pixels, transform
        // carries the resolution (translation is in device pixels)
        context.setTransform(resolution, 0, 0, resolution, bw / 2, bh / 2);
        if (rad != 0) {
            context.rotate(rad);
        }
        context.font = font;
        context.textAlign = "center";
        context.textBaseline = "middle";
        context.fillStyle = "#000000";
        // draw each line centered in its own equal horizontal band, so a
        // multi-line category rasterizes to the same stacked footprint it
        // renders as (single line: one band centered on the origin, unchanged)
        const lines = text.split("\n");
        const slice = lh / lines.length;
        for (let i = 0; i < lines.length; i++) {
            context.fillText(lines[i], 0, (i + 0.5 - lines.length / 2) * slice);
        }
        return this._packScratch(bw, bh);
    }
    /**
     * Rasterizes a SOLID rectangle of the given (padded) dimensions at the
     * word's angle, and packs it into a bit mask. Used as the collision
     * footprint: at 0/90 degrees it equals the axis-aligned box, while for
     * diagonal words it hugs the real footprint so neighbors can pack into
     * the empty corners of the axis-aligned bounding box.
     */
    _rasterizeBox(lw, lh, angle) {
        const resolution = this._root._renderer.resolution;
        const rad = angle * Math.PI / 180;
        const cos = Math.abs(Math.cos(rad));
        const sin = Math.abs(Math.sin(rad));
        // bounding box of the rotated rectangle, in device pixels
        const bw = Math.max(1, Math.ceil((lw * cos + lh * sin) * resolution));
        const bh = Math.max(1, Math.ceil((lw * sin + lh * cos) * resolution));
        const context = this._scratchContext || this._makeScratch();
        const canvas = this._scratchCanvas;
        if (canvas.width < bw) {
            canvas.width = bw;
        }
        if (canvas.height < bh) {
            canvas.height = bh;
        }
        context.setTransform(1, 0, 0, 1, 0, 0);
        context.clearRect(0, 0, bw, bh);
        context.setTransform(resolution, 0, 0, resolution, bw / 2, bh / 2);
        if (rad != 0) {
            context.rotate(rad);
        }
        context.fillStyle = "#000000";
        context.fillRect(-lw / 2, -lh / 2, lw, lh);
        return this._packScratch(bw, bh);
    }
    /**
     * Packs the alpha channel of the scratch canvas' top-left `bw x bh`
     * region into a bit mask (32 pixels per int, MSB first).
     */
    _packScratch(bw, bh) {
        const context = this._scratchContext;
        const stride = Math.ceil(bw / 32);
        const bits = new Int32Array(bh * stride);
        const data = context.getImageData(0, 0, bw, bh).data;
        let n = 3;
        for (let r = 0; r < bh; r++) {
            const rowBase = r * stride;
            for (let c = 0; c < bw; c++) {
                if (data[n] != 0) {
                    bits[rowBase + (c >> 5)] |= 1 << (31 - (c & 31));
                }
                n += 4;
            }
        }
        return { bits: bits, stride: stride, bw: bw, bh: bh };
    }
    /**
     * Tests a collision mask against the occupancy board at device
     * coordinates `(px, py)` (top-left corner). Bitwise AND, exact to the
     * pixel. The glyph-shaped nesting comes from the board containing only
     * glyph ink; the mask itself is the word's solid oriented (padded) box.
     */
    _collidesAt(sprite, px, py, board = this._board) {
        // A box poking past the board (possible by a rounding pixel) is
        // treated as a collision; the word simply tries the next point.
        if (px < 0 || py < 0 || px + sprite.bw > this._boardW || py + sprite.bh > this._boardH) {
            return true;
        }
        const boardStride = this._boardStride;
        const bits = sprite.bits;
        const spriteStride = sprite.stride;
        const shift = px & 31;
        const x0 = px >> 5;
        const spanInts = (sprite.bw + shift + 31) >> 5;
        for (let r = 0; r < sprite.bh; r++) {
            let last = 0;
            const boardOff = (py + r) * boardStride + x0;
            const spriteOff = r * spriteStride;
            for (let i = 0; i < spanInts; i++) {
                const s = i < spriteStride ? bits[spriteOff + i] : 0;
                const chunk = shift == 0 ? s : ((last << (32 - shift)) | (s >>> shift));
                if (chunk != 0 && (chunk & board[boardOff + i]) != 0) {
                    return true;
                }
                last = s;
            }
        }
        return false;
    }
    /**
     * Stamps a sprite into the occupancy board (bitwise OR) at device
     * coordinates `(px, py)` (top-left corner).
     */
    _stampSprite(sprite, px, py) {
        const board = this._board;
        const boardStride = this._boardStride;
        const bits = sprite.bits;
        const spriteStride = sprite.stride;
        const shift = px & 31;
        const x0 = px >> 5;
        const spanInts = (sprite.bw + shift + 31) >> 5;
        for (let r = 0; r < sprite.bh; r++) {
            let last = 0;
            const boardOff = (py + r) * boardStride + x0;
            const spriteOff = r * spriteStride;
            for (let i = 0; i < spanInts; i++) {
                const s = i < spriteStride ? bits[spriteOff + i] : 0;
                const chunk = shift == 0 ? s : ((last << (32 - shift)) | (s >>> shift));
                if (chunk != 0) {
                    board[boardOff + i] |= chunk;
                }
                last = s;
            }
        }
    }
    /**
* @ignore
*/
    disposeDataItem(dataItem) {
        super.disposeDataItem(dataItem);
        const label = dataItem.get("label");
        if (label) {
            this.labels.removeValue(label);
            label.dispose();
        }
    }
    /**
* Extracts words and number of their appearances from a text.
*
* @ignore
* @param  input  Source text
*/
    _getWords(input) {
        let words = [];
        if (input) {
            const chars = "\u0041-\u005A\u0061-\u007A\u00AA\u00B5\u00BA\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02C1\u02C6-\u02D1\u02E0-\u02E4\u02EC\u02EE\u0370-\u0374\u0376-\u0377\u037A-\u037D\u0386\u0388-\u038A\u038C\u038E-\u03A1\u03A3-\u03F5\u03F7-\u0481\u048A-\u0523\u0531-\u0556\u0559\u0561-\u0587\u05D0-\u05EA\u05F0-\u05F2\u0621-\u064A\u066E-\u066F\u0671-\u06D3\u06D5\u06E5-\u06E6\u06EE-\u06EF\u06FA-\u06FC\u06FF\u0710\u0712-\u072F\u074D-\u07A5\u07B1\u07CA-\u07EA\u07F4-\u07F5\u07FA\u0904-\u0939\u093D\u0950\u0958-\u0961\u0971-\u0972\u097B-\u097F\u0985-\u098C\u098F-\u0990\u0993-\u09A8\u09AA-\u09B0\u09B2\u09B6-\u09B9\u09BD\u09CE\u09DC-\u09DD\u09DF-\u09E1\u09F0-\u09F1\u0A05-\u0A0A\u0A0F-\u0A10\u0A13-\u0A28\u0A2A-\u0A30\u0A32-\u0A33\u0A35-\u0A36\u0A38-\u0A39\u0A59-\u0A5C\u0A5E\u0A72-\u0A74\u0A85-\u0A8D\u0A8F-\u0A91\u0A93-\u0AA8\u0AAA-\u0AB0\u0AB2-\u0AB3\u0AB5-\u0AB9\u0ABD\u0AD0\u0AE0-\u0AE1\u0B05-\u0B0C\u0B0F-\u0B10\u0B13-\u0B28\u0B2A-\u0B30\u0B32-\u0B33\u0B35-\u0B39\u0B3D\u0B5C-\u0B5D\u0B5F-\u0B61\u0B71\u0B83\u0B85-\u0B8A\u0B8E-\u0B90\u0B92-\u0B95\u0B99-\u0B9A\u0B9C\u0B9E-\u0B9F\u0BA3-\u0BA4\u0BA8-\u0BAA\u0BAE-\u0BB9\u0BD0\u0C05-\u0C0C\u0C0E-\u0C10\u0C12-\u0C28\u0C2A-\u0C33\u0C35-\u0C39\u0C3D\u0C58-\u0C59\u0C60-\u0C61\u0C85-\u0C8C\u0C8E-\u0C90\u0C92-\u0CA8\u0CAA-\u0CB3\u0CB5-\u0CB9\u0CBD\u0CDE\u0CE0-\u0CE1\u0D05-\u0D0C\u0D0E-\u0D10\u0D12-\u0D28\u0D2A-\u0D39\u0D3D\u0D60-\u0D61\u0D7A-\u0D7F\u0D85-\u0D96\u0D9A-\u0DB1\u0DB3-\u0DBB\u0DBD\u0DC0-\u0DC6\u0E01-\u0E30\u0E32-\u0E33\u0E40-\u0E46\u0E81-\u0E82\u0E84\u0E87-\u0E88\u0E8A\u0E8D\u0E94-\u0E97\u0E99-\u0E9F\u0EA1-\u0EA3\u0EA5\u0EA7\u0EAA-\u0EAB\u0EAD-\u0EB0\u0EB2-\u0EB3\u0EBD\u0EC0-\u0EC4\u0EC6\u0EDC-\u0EDD\u0F00\u0F40-\u0F47\u0F49-\u0F6C\u0F88-\u0F8B\u1000-\u102A\u103F\u1050-\u1055\u105A-\u105D\u1061\u1065-\u1066\u106E-\u1070\u1075-\u1081\u108E\u10A0-\u10C5\u10D0-\u10FA\u10FC\u1100-\u1159\u115F-\u11A2\u11A8-\u11F9\u1200-\u1248\u124A-\u124D\u1250-\u1256\u1258\u125A-\u125D\u1260-\u1288\u128A-\u128D\u1290-\u12B0\u12B2-\u12B5\u12B8-\u12BE\u12C0\u12C2-\u12C5\u12C8-\u12D6\u12D8-\u1310\u1312-\u1315\u1318-\u135A\u1380-\u138F\u13A0-\u13F4\u1401-\u166C\u166F-\u1676\u1681-\u169A\u16A0-\u16EA\u16EE-\u16F0\u1700-\u170C\u170E-\u1711\u1720-\u1731\u1740-\u1751\u1760-\u176C\u176E-\u1770\u1780-\u17B3\u17D7\u17DC\u1820-\u1877\u1880-\u18A8\u18AA\u1900-\u191C\u1950-\u196D\u1970-\u1974\u1980-\u19A9\u19C1-\u19C7\u1A00-\u1A16\u1B05-\u1B33\u1B45-\u1B4B\u1B83-\u1BA0\u1BAE-\u1BAF\u1C00-\u1C23\u1C4D-\u1C4F\u1C5A-\u1C7D\u1D00-\u1DBF\u1E00-\u1F15\u1F18-\u1F1D\u1F20-\u1F45\u1F48-\u1F4D\u1F50-\u1F57\u1F59\u1F5B\u1F5D\u1F5F-\u1F7D\u1F80-\u1FB4\u1FB6-\u1FBC\u1FBE\u1FC2-\u1FC4\u1FC6-\u1FCC\u1FD0-\u1FD3\u1FD6-\u1FDB\u1FE0-\u1FEC\u1FF2-\u1FF4\u1FF6-\u1FFC\u2071\u207F\u2090-\u2094\u2102\u2107\u210A-\u2113\u2115\u2119-\u211D\u2124\u2126\u2128\u212A-\u212D\u212F-\u2139\u213C-\u213F\u2145-\u2149\u214E\u2160-\u2188\u2C00-\u2C2E\u2C30-\u2C5E\u2C60-\u2C6F\u2C71-\u2C7D\u2C80-\u2CE4\u2D00-\u2D25\u2D30-\u2D65\u2D6F\u2D80-\u2D96\u2DA0-\u2DA6\u2DA8-\u2DAE\u2DB0-\u2DB6\u2DB8-\u2DBE\u2DC0-\u2DC6\u2DC8-\u2DCE\u2DD0-\u2DD6\u2DD8-\u2DDE\u2E2F\u3005-\u3007\u3021-\u3029\u3031-\u3035\u3038-\u303C\u3041-\u3096\u309D-\u309F\u30A1-\u30FA\u30FC-\u30FF\u3105-\u312D\u3131-\u318E\u31A0-\u31B7\u31F0-\u31FF\u3400\u4DB5\u4E00\u9FC3\uA000-\uA48C\uA500-\uA60C\uA610-\uA61F\uA62A-\uA62B\uA640-\uA65F\uA662-\uA66E\uA67F-\uA697\uA717-\uA71F\uA722-\uA788\uA78B-\uA78C\uA7FB-\uA801\uA803-\uA805\uA807-\uA80A\uA80C-\uA822\uA840-\uA873\uA882-\uA8B3\uA90A-\uA925\uA930-\uA946\uAA00-\uAA28\uAA40-\uAA42\uAA44-\uAA4B\uAC00-\uD7A3\uF900-\uFA2D\uFA30-\uFA6A\uFA70-\uFAD9\uFB00-\uFB06\uFB13-\uFB17\uFB1D\uFB1F-\uFB28\uFB2A-\uFB36\uFB38-\uFB3C\uFB3E\uFB40-\uFB41\uFB43-\uFB44\uFB46-\uFBB1\uFBD3-\uFD3D\uFD50-\uFD8F\uFD92-\uFDC7\uFDF0-\uFDFB\uFE70-\uFE74\uFE76-\uFEFC\uFF21-\uFF3A\uFF41-\uFF5A\uFF66-\uFFBE\uFFC2-\uFFC7\uFFCA-\uFFCF\uFFD2-\uFFD7\uFFDA-\uFFDC0-9@+";
            const reg = new RegExp("([" + chars + "]+[\-" + chars + "]*[" + chars + "]+)|([" + chars + "]+)", "ig");
            let res = input.match(reg);
            if (!res) {
                return [];
            }
            // Map keyed by lowercase word for O(1) lookup
            const wordMap = new Map();
            for (let i = 0; i < res.length; i++) {
                const word = res[i];
                const lower = word.toLowerCase();
                const item = wordMap.get(lower);
                if (item) {
                    item.value++;
                    if (!this.isCapitalized(word)) {
                        item.category = word;
                    }
                }
                else {
                    const entry = { category: word, value: 1 };
                    wordMap.set(lower, entry);
                    words.push(entry);
                }
            }
            let excludeWords = this.get("excludeWords");
            const minValue = this.get("minValue", 1);
            const minWordLength = this.get("minWordLength", 1);
            if (minValue > 1 || minWordLength > 1 || (excludeWords && excludeWords.length > 0)) {
                for (let i = words.length - 1; i >= 0; i--) {
                    let w = words[i];
                    let word = w.category;
                    if (w.value < minValue) {
                        words.splice(i, 1);
                    }
                    if (word.length < minWordLength) {
                        words.splice(i, 1);
                    }
                    if (excludeWords && excludeWords.indexOf(word) !== -1) {
                        words.splice(i, 1);
                    }
                }
            }
            words.sort(function (a, b) {
                if (a.value == b.value) {
                    return 0;
                }
                else if (a.value > b.value) {
                    return -1;
                }
                else {
                    return 1;
                }
            });
            const maxCount = this.get("maxCount", Infinity);
            if (words.length > maxCount) {
                words = words.slice(0, maxCount);
            }
        }
        return words;
    }
    /**
* Checks if word is capitalized (starts with an uppercase) or not.
*
* @ignore
* @param   word  Word
* @return        Capitalized?
*/
    isCapitalized(word) {
        let lword = word.toLowerCase();
        return word[0] != lword[0]
            && word.substr(1) == lword.substr(1)
            && word != lword;
    }
}
WordCloud.className = "WordCloud";
WordCloud.classNames = Series.classNames.concat([WordCloud.className]);
//# sourceMappingURL=WordCloud.js.map