d3-cloud
Version:
Generate word clouds in JavaScript.
709 lines (638 loc) • 23.3 kB
JavaScript
// Word cloud layout by Jason Davies, https://www.jasondavies.com/wordcloud/
// Algorithm due to Jonathan Feinberg, https://s3.amazonaws.com/static.mrfeinberg.com/bv_ch03.pdf
import { createSpriteContext, rasterizeSprite, spriteFields, spriteMask } from "./sprite.js";
// Coordinates and layout sizes are limited so that block indices and strategy
// offsets remain exact integers.
const MAX_COORDINATE = 1 << 24;
const MAX_BLOCK_SIZE = 4096;
const MIN_SEARCH_BUDGET = 100000;
// Built-in spirals stretch to the layout's aspect ratio up to this limit.
// Beyond it they would barely move along the shorter axis, and searches that
// cannot leave a blocked column or row would run for minutes.
const MAX_SPIRAL_ASPECT_RATIO = 64;
const STRATEGIES = {
archimedean: archimedeanStrategy,
rectangular: rectangularStrategy,
none: noneStrategy
};
export default class CloudLayout {
constructor() {
this._size = [256, 256];
this._overflow = true;
this._strategy = archimedeanStrategy;
this._random = Math.random;
this._blockSize = 512;
this._canvasFactory = defaultCanvasFactory;
this._spriteContext = null;
this._bounds = null;
this._blocks = createOccupancyBlocks(this._blockSize);
// The number of place() calls in progress, which strategies or random()
// could re-enter.
this._searches = 0;
// Maps each placed word returned by place() to the mask and position
// needed to erase it. Results that are dropped are garbage collected.
this._placements = new WeakMap();
}
canvas(_) {
if (!arguments.length) {
return this._canvasFactory;
}
if (typeof _ !== "function") {
throw new TypeError("canvas() expects a canvas factory function");
}
this._canvasFactory = _;
this._spriteContext = null;
return this;
}
clear() {
this._assertNotSearching("clear()");
this._placements = new WeakMap();
this._bounds = null;
this._blocks = createOccupancyBlocks(this._blockSize);
return this;
}
bounds() {
return cloneBounds(this._bounds);
}
size(_) {
if (!arguments.length) {
return this._size.slice();
}
this._size = normalizeSize(_);
return this;
}
overflow(_) {
if (!arguments.length) {
return this._overflow;
}
this._overflow = !!_;
return this;
}
strategy(_) {
if (!arguments.length) {
return this._strategy;
}
this._strategy = resolveStrategy(_);
return this;
}
random(_) {
if (!arguments.length) {
return this._random;
}
if (typeof _ !== "function") {
throw new TypeError("random() expects a function");
}
this._random = _;
return this;
}
blockSize(_) {
if (!arguments.length) {
return this._blockSize;
}
const nextBlockSize = normalizeBlockSize(_);
if (nextBlockSize === this._blockSize) {
return this;
}
this._assertNotSearching("blockSize()");
if (this._bounds) {
throw new Error("Cannot change blockSize after placement; call clear() first");
}
this._blockSize = nextBlockSize;
this._blocks = createOccupancyBlocks(this._blockSize);
return this;
}
getSprite(source, options) {
const fields = spriteFields(source, optionsObject(options, "getSprite()"));
if (!fields) {
return null;
}
return rasterizeSprite(fields, this._getContext());
}
erase(word) {
const placement = word && typeof word === "object" ? this._placements.get(word) : undefined;
if (!placement) {
throw new Error("erase() expects a word returned by place() on this layout since it was last cleared");
}
this._blocks.remove(placement.mask, placement.x, placement.y);
this._placements.delete(word);
if (this._blocks.isEmpty()) {
this._bounds = null;
}
}
place(sprite, options) {
const mask = spriteMask(sprite);
if (!mask) {
throw new TypeError("place() expects a CloudSprite from getSprite() on a layout from the same copy of d3-cloud");
}
const { x: optionX, y: optionY, strategy: optionStrategy } = optionsObject(options, "place()");
const strategy = optionStrategy == null ? this._strategy : resolveStrategy(optionStrategy);
const x = optionX == null ? seedCoordinate(this._size[0], this._random) : placeCoordinate(optionX);
const y = optionY == null ? seedCoordinate(this._size[1], this._random) : placeCoordinate(optionY);
let position;
this._searches++;
try {
position = tryPlaceSprite(this._blocks, mask, x, y, this._bounds, strategy, this._size, this._overflow, this._random);
} finally {
this._searches--;
}
if (!position) {
return null;
}
this._bounds = extendBounds(this._bounds, mask, position.x, position.y);
const word = createPlacedWord(sprite, position.x, position.y);
this._placements.set(word, { mask, x: position.x, y: position.y });
return word;
}
// A search holds the occupancy blocks it started with. Replacing them
// mid-search would leave the word out of the new blocks, where later words
// could overlap it.
_assertNotSearching(method) {
if (this._searches) {
throw new Error(`${method} cannot be called while place() is searching`);
}
}
_getContext() {
// A failed draw invalidates the state of every layout sharing the canvas,
// so each asks its factory again, and a reused canvas is reset.
if (!this._spriteContext || this._spriteContext.invalid) {
this._spriteContext = createSpriteContext(this._canvasFactory());
}
return this._spriteContext;
}
}
// Returns the first position at which the sprite fits, after inserting it into
// the occupancy blocks there, or null.
function tryPlaceSprite(blocks, mask, seedX, seedY, bounds, strategy, size, overflow, random) {
const range = anchorRange(mask, bounds, size, overflow);
if (!range) {
return null;
}
if (withinRange(range, seedX, seedY) && !blocks.collides(mask, seedX, seedY)) {
blocks.insert(mask, seedX, seedY);
return { x: seedX, y: seedY };
}
// Search from the nearest anchor at which the sprite could be placed, so
// seeds outside a fixed box or far from the cloud need no long search.
const startX = Math.min(Math.max(seedX, range.x0), range.x1);
const startY = Math.min(Math.max(seedY, range.y0), range.y1);
const next = strategy(
{ x: startX, y: startY },
{
size: size.slice(),
aspectRatio: sizeAspectRatio(size),
bounds: cloneBounds(bounds),
overflow,
random
}
);
if (typeof next !== "function") {
throw new TypeError("strategy factories must return a candidate generator");
}
// Candidates outside the range cannot be placed. Stop once one is beyond
// the range, and the box or cloud the sprite is placed against, on both
// axes; after trying at least as many candidates inside the range as it has
// anchors; or after a long run outside it, so generators that repeat points
// also stop.
const envelope = mask.envelope;
const limitX = Math.max(startX - range.x0, range.x1 - startX) + envelope.x1 - envelope.x0 + 1;
const limitY = Math.max(startY - range.y0, range.y1 - startY) + envelope.y1 - envelope.y0 + 1;
const insideLimit = Math.max(MIN_SEARCH_BUDGET, (range.x1 - range.x0 + 1) * (range.y1 - range.y0 + 1));
const outsideLimit = Math.max(MIN_SEARCH_BUDGET, 4 * limitX * limitY);
let candidate;
let inside = 0;
let outside = 0;
while ((candidate = normalizeStrategyCandidate(next()))) {
if (!withinRange(range, candidate.x, candidate.y)) {
if (Math.abs(candidate.x - startX) >= limitX && Math.abs(candidate.y - startY) >= limitY) break;
if (++outside >= outsideLimit) break;
continue;
}
outside = 0;
if (++inside > insideLimit) break;
if (!blocks.collides(mask, candidate.x, candidate.y)) {
blocks.insert(mask, candidate.x, candidate.y);
return candidate;
}
}
return null;
}
// Returns the integer anchors at which the sprite could be placed: inside the
// box when overflow is disabled, and with its envelope overlapping the cloud
// bounds once there are any, or null if no anchors fit. Both ends of the
// returned range are inclusive, unlike the exclusive right/bottom mask edges.
// With overflow enabled and no cloud bounds, any seed within the coordinate
// limit fits.
function anchorRange(mask, bounds, size, overflow) {
let x0 = -MAX_COORDINATE;
let y0 = -MAX_COORDINATE;
let x1 = MAX_COORDINATE;
let y1 = MAX_COORDINATE;
const envelope = mask.envelope;
if (!overflow) {
x0 = Math.max(x0, Math.ceil(-size[0] / 2 - mask.x0));
y0 = Math.max(y0, Math.ceil(-size[1] / 2 - mask.y0));
x1 = Math.min(x1, Math.floor(size[0] / 2 - mask.x1));
y1 = Math.min(y1, Math.floor(size[1] / 2 - mask.y1));
}
if (bounds) {
x0 = Math.max(x0, Math.floor(bounds[0].x - envelope.x1) + 1);
y0 = Math.max(y0, Math.floor(bounds[0].y - envelope.y1) + 1);
x1 = Math.min(x1, Math.ceil(bounds[1].x - envelope.x0) - 1);
y1 = Math.min(y1, Math.ceil(bounds[1].y - envelope.y0) - 1);
}
return x0 <= x1 && y0 <= y1 ? { x0, y0, x1, y1 } : null;
}
function withinRange(range, x, y) {
return x >= range.x0 && x <= range.x1 && y >= range.y0 && y <= range.y1;
}
function createOccupancyBlocks(blockSize) {
const blocks = new Map();
const blockWordsPerRow = blockSize >>> 5;
// Applies visit to each block region the sprite overlaps at x, y, stopping
// early if it returns true. Missing blocks are created when inserting, and
// emptied blocks are pruned when removing.
function visitBlocks(mask, x, y, visit, create, prune) {
const data = mask.data;
const wordsPerRow = mask.words;
const left = x + mask.x0;
const top = y + mask.y0;
const right = x + mask.x1;
const bottom = y + mask.y1;
const firstX = Math.floor(left / blockSize);
const lastX = Math.floor((right - 1) / blockSize);
const firstY = Math.floor(top / blockSize);
const lastY = Math.floor((bottom - 1) / blockSize);
for (let blockY = firstY; blockY <= lastY; blockY++) {
let blockRow = blocks.get(blockY);
if (!blockRow) {
if (!create) continue;
blockRow = new Map();
blocks.set(blockY, blockRow);
}
const blockTop = blockY * blockSize;
const overlapTop = Math.max(top, blockTop);
const overlapBottom = Math.min(bottom, blockTop + blockSize);
for (let blockX = firstX; blockX <= lastX; blockX++) {
const blockLeft = blockX * blockSize;
const overlapLeft = Math.max(left, blockLeft);
const overlapRight = Math.min(right, blockLeft + blockSize);
if (overlapLeft >= overlapRight || overlapTop >= overlapBottom) continue;
let block = blockRow.get(blockX);
if (!block) {
if (!create) continue;
block = new Uint32Array(blockWordsPerRow * blockSize);
blockRow.set(blockX, block);
}
if (visit(
data,
wordsPerRow,
left,
top,
block,
blockWordsPerRow,
blockLeft,
blockTop,
overlapLeft,
overlapTop,
overlapRight,
overlapBottom
)) {
return true;
}
if (prune && isZeroBlock(block)) {
blockRow.delete(blockX);
}
}
if (prune && blockRow.size === 0) blocks.delete(blockY);
}
return false;
}
return {
isEmpty() {
return blocks.size === 0;
},
collides(mask, x, y) {
return visitBlocks(mask, x, y, packedRegionCollides, false, false);
},
insert(mask, x, y) {
visitBlocks(mask, x, y, stampPackedRegion, true, false);
},
remove(mask, x, y) {
visitBlocks(mask, x, y, clearPackedRegion, false, true);
}
};
}
function packedRegionCollides(aData, aWordsPerRow, aLeft, aTop, bData, bWordsPerRow, bLeft, bTop, overlapLeft, overlapTop, overlapRight, overlapBottom) {
const rows = overlapBottom - overlapTop;
const words = (overlapRight - overlapLeft + 31) >>> 5;
const aStartBit = overlapLeft - aLeft;
const bStartBit = overlapLeft - bLeft;
const aStartRow = overlapTop - aTop;
const bStartRow = overlapTop - bTop;
const trailing = (overlapRight - overlapLeft) & 31;
const lastMask = trailing ? (~0 << (32 - trailing)) >>> 0 : 0xffffffff;
for (let row = 0; row < rows; row++) {
const aRowOffset = (aStartRow + row) * aWordsPerRow;
const bRowOffset = (bStartRow + row) * bWordsPerRow;
for (let word = 0; word < words; word++) {
const mask = word === words - 1 ? lastMask : 0xffffffff;
const aWord = readPackedWord(aData, aRowOffset, aWordsPerRow, aStartBit + (word << 5));
const bWord = readPackedWord(bData, bRowOffset, bWordsPerRow, bStartBit + (word << 5));
if ((aWord & bWord & mask) !== 0) {
return true;
}
}
}
return false;
}
// Keep insertion and erasure specialized: a shared set/clear writer measured
// about 6% slower in an insertion/erasure microbenchmark.
function stampPackedRegion(sourceData, sourceWordsPerRow, sourceLeft, sourceTop, targetData, targetWordsPerRow, targetLeft, targetTop, overlapLeft, overlapTop, overlapRight, overlapBottom) {
const rows = overlapBottom - overlapTop;
const words = (overlapRight - overlapLeft + 31) >>> 5;
const sourceStartBit = overlapLeft - sourceLeft;
const targetStartBit = overlapLeft - targetLeft;
const sourceStartRow = overlapTop - sourceTop;
const targetStartRow = overlapTop - targetTop;
const trailing = (overlapRight - overlapLeft) & 31;
const lastMask = trailing ? (~0 << (32 - trailing)) >>> 0 : 0xffffffff;
for (let row = 0; row < rows; row++) {
const sourceRowOffset = (sourceStartRow + row) * sourceWordsPerRow;
const targetRowOffset = (targetStartRow + row) * targetWordsPerRow;
for (let word = 0; word < words; word++) {
const mask = word === words - 1 ? lastMask : 0xffffffff;
const sourceWord = readPackedWord(sourceData, sourceRowOffset, sourceWordsPerRow, sourceStartBit + (word << 5)) & mask;
orPackedWord(targetData, targetRowOffset, targetWordsPerRow, targetStartBit + (word << 5), sourceWord);
}
}
}
function clearPackedRegion(sourceData, sourceWordsPerRow, sourceLeft, sourceTop, targetData, targetWordsPerRow, targetLeft, targetTop, overlapLeft, overlapTop, overlapRight, overlapBottom) {
const rows = overlapBottom - overlapTop;
const words = (overlapRight - overlapLeft + 31) >>> 5;
const sourceStartBit = overlapLeft - sourceLeft;
const targetStartBit = overlapLeft - targetLeft;
const sourceStartRow = overlapTop - sourceTop;
const targetStartRow = overlapTop - targetTop;
const trailing = (overlapRight - overlapLeft) & 31;
const lastMask = trailing ? (~0 << (32 - trailing)) >>> 0 : 0xffffffff;
for (let row = 0; row < rows; row++) {
const sourceRowOffset = (sourceStartRow + row) * sourceWordsPerRow;
const targetRowOffset = (targetStartRow + row) * targetWordsPerRow;
for (let word = 0; word < words; word++) {
const mask = word === words - 1 ? lastMask : 0xffffffff;
const sourceWord = readPackedWord(sourceData, sourceRowOffset, sourceWordsPerRow, sourceStartBit + (word << 5)) & mask;
clearPackedWord(targetData, targetRowOffset, targetWordsPerRow, targetStartBit + (word << 5), sourceWord);
}
}
}
function readPackedWord(sprite, rowOffset, wordsPerRow, bitIndex) {
const wordIndex = bitIndex >>> 5;
const bitOffset = bitIndex & 31;
const current = wordIndex < wordsPerRow ? sprite[rowOffset + wordIndex] : 0;
if (!bitOffset) {
return current;
}
const next = wordIndex + 1 < wordsPerRow ? sprite[rowOffset + wordIndex + 1] : 0;
return ((current << bitOffset) | (next >>> (32 - bitOffset))) >>> 0;
}
function orPackedWord(target, rowOffset, wordsPerRow, bitIndex, value) {
const wordIndex = bitIndex >>> 5;
const bitOffset = bitIndex & 31;
if (!bitOffset) {
target[rowOffset + wordIndex] |= value;
return;
}
target[rowOffset + wordIndex] |= value >>> bitOffset;
if (wordIndex + 1 < wordsPerRow) {
target[rowOffset + wordIndex + 1] |= (value << (32 - bitOffset)) >>> 0;
}
}
function clearPackedWord(target, rowOffset, wordsPerRow, bitIndex, value) {
const wordIndex = bitIndex >>> 5;
const bitOffset = bitIndex & 31;
if (!bitOffset) {
target[rowOffset + wordIndex] &= (~value) >>> 0;
return;
}
target[rowOffset + wordIndex] &= (~(value >>> bitOffset)) >>> 0;
if (wordIndex + 1 < wordsPerRow) {
target[rowOffset + wordIndex + 1] &= (~((value << (32 - bitOffset)) >>> 0)) >>> 0;
}
}
function isZeroBlock(block) {
for (let index = 0; index < block.length; index++) {
if (block[index] !== 0) {
return false;
}
}
return true;
}
function createPlacedWord(sprite, x, y) {
// The placed word copies the sprite's public fields, except its mask.
const { sprite: _mask, spriteWidth: _spriteWidth, ...fields } = sprite;
return { ...fields, x, y };
}
// Returns the bounds extended to include the mask at x, y, updating existing
// bounds in place.
function extendBounds(bounds, mask, x, y) {
const left = x + mask.x0;
const top = y + mask.y0;
const right = x + mask.x1;
const bottom = y + mask.y1;
if (!bounds) {
return [{ x: left, y: top }, { x: right, y: bottom }];
}
if (left < bounds[0].x) bounds[0].x = left;
if (top < bounds[0].y) bounds[0].y = top;
if (right > bounds[1].x) bounds[1].x = right;
if (bottom > bounds[1].y) bounds[1].y = bottom;
return bounds;
}
function cloneBounds(bounds) {
if (!bounds) {
return bounds;
}
return [
{ x: bounds[0].x, y: bounds[0].y },
{ x: bounds[1].x, y: bounds[1].y }
];
}
// Steps a tenth of a radian at a time along a spiral whose radius grows by a
// tenth of a pixel per step, stretched horizontally by the aspect ratio.
export function archimedeanStrategy(initial, context) {
const e = spiralAspectRatio(context.aspectRatio);
const dt = context.random() < .5 ? 1 : -1;
// The search origin may have been clamped since the initial attempt.
let t = -dt;
return function() {
const angle = (t += dt) * .1;
// With the aspect ratio capped, offsets only exceed 32 bits after hundreds
// of millions of candidates.
return {
x: initial.x + ~~(e * angle * Math.cos(angle)),
y: initial.y + ~~(angle * Math.sin(angle))
};
};
}
// Walks a 4px grid in growing rectangles. The rectangle grows whenever the next
// grid column or row reaches its boundary in aspect-scaled distance, and only
// the new edges are visited, in angle order, so every grid point is visited
// once. Each edge is a run of points, so each candidate takes constant time and
// memory at any aspect ratio.
export function rectangularStrategy(initial, context) {
// e is the capped aspect ratio; a and b are the rectangle's horizontal and
// vertical half-extents in grid steps (each step is 4px).
const e = spiralAspectRatio(context.aspectRatio);
// Mirroring the rectangles vertically reverses the spiral's direction.
const sign = context.random() < .5 ? 1 : -1;
let a = 0;
let b = 0;
// The new edges, as runs of [x, y, dx, dy, count].
let runs = [];
let run = 0;
let x = 0;
let y = 0;
let dx = 0;
let dy = 0;
let count = 1;
function grow() {
const nextX = e >= 1 ? (a + 1) / e : a + 1;
const nextY = e >= 1 ? b + 1 : (b + 1) * e;
const growX = nextX <= nextY;
const growY = nextY <= nextX;
if (growX) a++;
if (growY) b++;
if (growX && growY) {
runs = [
-a, -1, 0, -1, b,
-a + 1, -b, 1, 0, 2 * a,
a, -b + 1, 0, 1, 2 * b,
a - 1, b, -1, 0, 2 * a,
-a, b - 1, 0, -1, b
];
} else if (growX) {
runs = [
-a, -1, 0, -1, b,
a, -b, 0, 1, 2 * b + 1,
-a, b, 0, -1, b + 1
];
} else {
runs = [
-a, -b, 1, 0, 2 * a + 1,
a, b, -1, 0, 2 * a + 1
];
}
run = 0;
}
return function() {
while (!count) {
if (run === runs.length) grow();
x = runs[run++];
y = runs[run++];
dx = runs[run++];
dy = runs[run++];
count = runs[run++];
}
const candidate = {
x: initial.x + 4 * x,
y: initial.y + 4 * sign * y
};
x += dx;
y += dy;
count--;
return candidate;
};
}
export function noneStrategy() {
return function() {
return null;
};
}
function defaultCanvasFactory() {
return document.createElement("canvas");
}
function normalizeAspectRatio(value) {
value = +value;
return value > 0 && Number.isFinite(value) ? value : 1;
}
function spiralAspectRatio(value) {
return Math.min(Math.max(normalizeAspectRatio(value), 1 / MAX_SPIRAL_ASPECT_RATIO), MAX_SPIRAL_ASPECT_RATIO);
}
function normalizeBlockSize(value) {
value = +value;
if (!(value >= 1 && value <= MAX_BLOCK_SIZE)) {
throw new RangeError(`blockSize() expects a size from 1 to ${MAX_BLOCK_SIZE}`);
}
return Math.ceil(value / 32) * 32;
}
function placeCoordinate(value) {
value = +value;
if (!Number.isFinite(value)) {
throw new TypeError("place() expects finite x and y coordinates");
}
if (Math.abs(value) > MAX_COORDINATE) {
throw new RangeError(`place() expects x and y coordinates within ±${MAX_COORDINATE}`);
}
return Math.floor(value);
}
function normalizeSize(value) {
if (!Array.isArray(value)) {
value = [value, value];
}
return [
normalizeSizeDimension(value[0]),
normalizeSizeDimension(value[1])
];
}
function normalizeSizeDimension(value) {
value = +value;
if (!(value >= 0 && value <= MAX_COORDINATE)) {
throw new RangeError(`size() expects dimensions from 0 to ${MAX_COORDINATE}`);
}
return value;
}
function sizeAspectRatio(size) {
return normalizeAspectRatio(size[0] / size[1]);
}
function seedCoordinate(size, random) {
if (!(size > 0)) {
return 0;
}
const value = random();
if (!(typeof value === "number" && value >= 0 && value < 1)) {
throw new RangeError("random() must return a number in [0, 1)");
}
return Math.floor((value - 0.5) * (size / 2));
}
// Accepts null or undefined as no options, and any non-array object.
function optionsObject(options, method) {
if (options == null) {
return {};
}
if (typeof options !== "object" || Array.isArray(options)) {
throw new TypeError(`${method} expects an options object`);
}
return options;
}
function resolveStrategy(value) {
const strategy = typeof value === "string" && Object.prototype.hasOwnProperty.call(STRATEGIES, value)
? STRATEGIES[value] : value;
if (typeof strategy !== "function") {
throw new TypeError("strategy() expects a built-in strategy name or strategy factory");
}
return strategy;
}
function normalizeStrategyCandidate(value) {
if (!value) {
return null;
}
if (typeof value === "object") {
const x = +value.x;
const y = +value.y;
if (!Number.isFinite(x) || !Number.isFinite(y)) {
throw new TypeError("strategy candidates must have finite x and y");
}
return { x: Math.floor(x), y: Math.floor(y) };
}
throw new TypeError("strategy candidates must be {x, y} or null");
}