highcharts
Version:
JavaScript charting framework
283 lines (282 loc) • 11.2 kB
JavaScript
/* *
*
* Experimental Highcharts module which enables visualization of a word cloud.
*
* (c) 2016-2025 Highsoft AS
* Authors: Jon Arild Nygard
*
* License: www.highcharts.com/license
*
* !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!!
* */
;
import DPU from '../DrawPointUtilities.js';
import H from '../../Core/Globals.js';
const { noop } = H;
import SeriesRegistry from '../../Core/Series/SeriesRegistry.js';
const { column: ColumnSeries } = SeriesRegistry.seriesTypes;
import U from '../../Core/Utilities.js';
const { extend, isArray, isNumber, isObject, merge } = U;
import WordcloudPoint from './WordcloudPoint.js';
import WordcloudSeriesDefaults from './WordcloudSeriesDefaults.js';
import WU from './WordcloudUtils.js';
const { archimedeanSpiral, extendPlayingField, getBoundingBoxFromPolygon, getPlayingField, getPolygon, getRandomPosition, getRotation, getScale, getSpiral, intersectionTesting, isPolygonsColliding, rectangularSpiral, rotate2DToOrigin, rotate2DToPoint, squareSpiral, updateFieldBoundaries } = WU;
/* *
*
* Class
*
* */
/**
* @private
* @class
* @name Highcharts.seriesTypes.wordcloud
*
* @augments Highcharts.Series
*/
class WordcloudSeries extends ColumnSeries {
/**
*
* Functions
*
*/
pointAttribs(point, state) {
const attribs = H.seriesTypes.column.prototype
.pointAttribs.call(this, point, state);
delete attribs.stroke;
delete attribs['stroke-width'];
return attribs;
}
/**
* Calculates the fontSize of a word based on its weight.
*
* @private
* @function Highcharts.Series#deriveFontSize
*
* @param {number} [relativeWeight=0]
* The weight of the word, on a scale 0-1.
*
* @param {number} [maxFontSize=1]
* The maximum font size of a word.
*
* @param {number} [minFontSize=1]
* The minimum font size of a word.
*
* @return {number}
* Returns the resulting fontSize of a word. If minFontSize is larger then
* maxFontSize the result will equal minFontSize.
*/
deriveFontSize(relativeWeight, maxFontSize, minFontSize) {
const weight = isNumber(relativeWeight) ? relativeWeight : 0, max = isNumber(maxFontSize) ? maxFontSize : 1, min = isNumber(minFontSize) ? minFontSize : 1;
return Math.floor(Math.max(min, weight * max));
}
drawPoints() {
const series = this, hasRendered = series.hasRendered, xAxis = series.xAxis, yAxis = series.yAxis, chart = series.chart, group = series.group, options = series.options, animation = options.animation, allowExtendPlayingField = options.allowExtendPlayingField, renderer = chart.renderer, placed = [], placementStrategy = series.placementStrategy[options.placementStrategy], rotation = options.rotation, weights = series.points.map(function (p) {
return p.weight;
}), maxWeight = Math.max.apply(null, weights),
// `concat()` prevents from sorting the original array.
points = series.points.concat().sort((a, b) => (b.weight - a.weight // Sort descending
));
let testElement = renderer.text().add(group), field;
// Reset the scale before finding the dimensions (#11993).
// SVGGRaphicsElement.getBBox() (used in SVGElement.getBBox(boolean))
// returns slightly different values for the same element depending on
// whether it is rendered in a group which has already defined scale
// (e.g. 6) or in the group without a scale (scale = 1).
series.group.attr({
scaleX: 1,
scaleY: 1
});
// Get the dimensions for each word.
// Used in calculating the playing field.
for (const point of points) {
const relativeWeight = 1 / maxWeight * point.weight, fontSize = series.deriveFontSize(relativeWeight, options.maxFontSize, options.minFontSize), css = extend({
fontSize: fontSize + 'px'
}, options.style);
testElement.css(css).attr({
x: 0,
y: 0,
text: point.name
});
const bBox = testElement.getBBox(true);
point.dimensions = {
height: bBox.height,
width: bBox.width
};
}
// Calculate the playing field.
field = getPlayingField(xAxis.len, yAxis.len, points);
const spiral = getSpiral(series.spirals[options.spiral], {
field: field
});
// Draw all the points.
for (const point of points) {
const relativeWeight = 1 / maxWeight * point.weight, fontSize = series.deriveFontSize(relativeWeight, options.maxFontSize, options.minFontSize), css = extend({
fontSize: fontSize + 'px'
}, options.style), placement = placementStrategy(point, {
data: points,
field: field,
placed: placed,
rotation: rotation
}), attr = extend(series.pointAttribs(point, (point.selected && 'select')), {
align: 'center',
'alignment-baseline': 'middle',
'dominant-baseline': 'middle', // #15973: Firefox
x: placement.x,
y: placement.y,
text: point.name,
rotation: isNumber(placement.rotation) ?
placement.rotation :
void 0
}), polygon = getPolygon(placement.x, placement.y, point.dimensions.width, point.dimensions.height, placement.rotation), rectangle = getBoundingBoxFromPolygon(polygon);
let delta = intersectionTesting(point, {
rectangle: rectangle,
polygon: polygon,
field: field,
placed: placed,
spiral: spiral,
rotation: placement.rotation
}), animate;
// If there is no space for the word, extend the playing field.
if (!delta && allowExtendPlayingField) {
// Extend the playing field to fit the word.
field = extendPlayingField(field, rectangle);
// Run intersection testing one more time to place the word.
delta = intersectionTesting(point, {
rectangle: rectangle,
polygon: polygon,
field: field,
placed: placed,
spiral: spiral,
rotation: placement.rotation
});
}
// Check if point was placed, if so delete it, otherwise place it
// on the correct positions.
if (isObject(delta)) {
attr.x = (attr.x || 0) + delta.x;
attr.y = (attr.y || 0) + delta.y;
rectangle.left += delta.x;
rectangle.right += delta.x;
rectangle.top += delta.y;
rectangle.bottom += delta.y;
field = updateFieldBoundaries(field, rectangle);
placed.push(point);
point.isNull = false;
point.isInside = true; // #15447
}
else {
point.isNull = true;
}
if (animation) {
// Animate to new positions
animate = {
x: attr.x,
y: attr.y
};
// Animate from center of chart
if (!hasRendered) {
attr.x = 0;
attr.y = 0;
// Or animate from previous position
}
else {
delete attr.x;
delete attr.y;
}
}
DPU.draw(point, {
animatableAttribs: animate,
attribs: attr,
css: css,
group: group,
renderer: renderer,
shapeArgs: void 0,
shapeType: 'text'
});
}
// Destroy the element after use.
testElement = testElement.destroy();
// Scale the series group to fit within the plotArea.
const scale = getScale(xAxis.len, yAxis.len, field);
series.group.attr({
scaleX: scale,
scaleY: scale
});
}
hasData() {
const series = this;
return (isObject(series) &&
series.visible === true &&
isArray(series.points) &&
series.points.length > 0);
}
getPlotBox() {
const series = this, chart = series.chart, inverted = chart.inverted,
// Swap axes for inverted (#2339)
xAxis = series[(inverted ? 'yAxis' : 'xAxis')], yAxis = series[(inverted ? 'xAxis' : 'yAxis')], width = xAxis ? xAxis.len : chart.plotWidth, height = yAxis ? yAxis.len : chart.plotHeight, x = xAxis ? xAxis.left : chart.plotLeft, y = yAxis ? yAxis.top : chart.plotTop;
return {
translateX: x + (width / 2),
translateY: y + (height / 2),
scaleX: 1, // #1623
scaleY: 1
};
}
}
/* *
*
* Static properties
*
* */
WordcloudSeries.defaultOptions = merge(ColumnSeries.defaultOptions, WordcloudSeriesDefaults);
extend(WordcloudSeries.prototype, {
animate: noop,
animateDrilldown: noop,
animateDrillupFrom: noop,
isCartesian: false,
pointClass: WordcloudPoint,
setClip: noop,
// Strategies used for deciding rotation and initial position of a word. To
// implement a custom strategy, have a look at the function random for
// example.
placementStrategy: {
random: function (point, options) {
const field = options.field, r = options.rotation;
return {
x: getRandomPosition(field.width) - (field.width / 2),
y: getRandomPosition(field.height) - (field.height / 2),
rotation: getRotation(r.orientations, point.index, r.from, r.to)
};
},
center: function (point, options) {
const r = options.rotation;
return {
x: 0,
y: 0,
rotation: getRotation(r.orientations, point.index, r.from, r.to)
};
}
},
pointArrayMap: ['weight'],
// Spirals used for placing a word after the initial position experienced a
// collision with either another word or the borders. To implement a custom
// spiral, look at the function archimedeanSpiral for example.
spirals: {
'archimedean': archimedeanSpiral,
'rectangular': rectangularSpiral,
'square': squareSpiral
},
utils: {
extendPlayingField: extendPlayingField,
getRotation: getRotation,
isPolygonsColliding: isPolygonsColliding,
rotate2DToOrigin: rotate2DToOrigin,
rotate2DToPoint: rotate2DToPoint
}
});
SeriesRegistry.registerSeriesType('wordcloud', WordcloudSeries);
/* *
*
* Default Export
*
* */
export default WordcloudSeries;