Files
2023-01-13 15:30:20 +08:00

733 lines
24 KiB
JavaScript

/* *
*
* Experimental Timeline Series.
* Note: This API is in alpha stage and will be changed before final release.
*
* (c) 2010-2018 Highsoft AS
*
* Author: Daniel Studencki
*
* License: www.highcharts.com/license
*
* */
'use strict';
import H from '../parts/Globals.js';
var addEvent = H.addEvent,
extend = H.extend,
defined = H.defined,
LegendSymbolMixin = H.LegendSymbolMixin,
TrackerMixin = H.TrackerMixin,
merge = H.merge,
pick = H.pick,
Point = H.Point,
Series = H.Series,
undocumentedSeriesType = H.seriesType,
wrap = H.wrap;
/* *
* The timeline series type.
*
* @private
* @class
* @name Highcharts.seriesTypes.timeline
*
* @augments Highcharts.Series
*/
undocumentedSeriesType('timeline', 'line'
/* *
* The timeline series presents given events along a drawn line.
*
* @sample highcharts/series-timeline/alternate-labels Timeline series
*
* @extends plotOptions.line
* @since 7.0.0
* @product highcharts
* @excluding animationLimit, boostThreshold, connectEnds, connectNulls,
* cropThreshold, dashStyle, findNearestPointBy,
* getExtremesFromAll, lineWidth, negativeColor, pointInterval,
* pointIntervalUnit, pointPlacement, pointStart, softThreshold,
* stacking, step, threshold, turboThreshold, zoneAxis, zones
* @optionparent plotOptions.timeline
*/
, {
colorByPoint: true,
stickyTracking: false,
ignoreHiddenPoint: true,
legendType: 'point',
lineWidth: 0,
tooltip: {
headerFormat: '<span style="color:{point.color}">● </span>' +
'<span style="font-weight: bold;">{point.point.date}</span><br/>',
pointFormat: '{point.description}'
},
states: {
hover: {
lineWidthPlus: 5,
halo: {
size: 0
}
}
},
dataLabels: {
enabled: true,
allowOverlap: true,
/* *
* The width of the line connecting the data label to the point.
*
*
* In styled mode, the connector stroke width is given in the
* `.highcharts-data-label-connector` class.
*
* @type {Number}
* @default 1
* @sample {highcharts} highcharts/series-timeline/connector-styles
* Custom connector width and color
*/
connectorWidth: 1,
/* *
* The color of the line connecting the data label to the point.
*
* In styled mode, the connector stroke is given in the
* `.highcharts-data-label-connector` class.
*
* @type {String}
* @sample {highcharts} highcharts/series-timeline/connector-styles
* Custom connector width and color
*/
connectorColor: '#000000',
backgroundColor: '#ffffff',
/* *
* @type {Highcharts.FormatterCallbackFunction<object>}
* @default function () {
* var format;
*
* if (!this.series.chart.styledMode) {
* format = '<span style="color:' + this.point.color +
* '">● </span><span style="font-weight: bold;" > ' +
* (this.point.date || '') + '</span><br/>' +
* (this.point.label || '');
* } else {
* format = '<span>● </span>' +
* '<span>' + (this.point.date || '') +
* '</span><br/>' + (this.point.label || '');
* }
* return format;
* }
* @apioption plotOptions.timeline.dataLabels.formatter
*/
formatter: function () {
var format;
if (!this.series.chart.styledMode) {
format = '<span style="color:' + this.point.color +
'">● </span><span style="font-weight: bold;" > ' +
(this.point.date || '') + '</span><br/>' +
(this.point.label || '');
} else {
format = '<span>● </span>' +
'<span>' + (this.point.date || '') +
'</span><br/>' + (this.point.label || '');
}
return format;
},
borderWidth: 1,
borderColor: '#666666',
/* *
* A pixel value defining the distance between the data label
* and the point. Negative numbers puts the label on top
* of the point.
*
* @type {Number}
* @default 100
*/
distance: 100,
/* *
* Whether to position data labels alternately. For example, if
* [distance](#plotOptions.timeline.dataLabels.distance) is set
* equal to `100`, then the first data label 's distance will be
* set equal to `100`, the second one equal to `-100`, and so on.
*
* @type {Boolean}
* @default true
* @sample {highcharts} highcharts/series-timeline/alternate-disabled
* Alternate disabled
*/
alternate: true,
verticalAlign: 'middle',
color: '#333333'
},
marker: {
enabledThreshold: 0,
symbol: 'square',
height: 15
}
}
/* *
* @lends Highcharts.Series#
*/
, {
requireSorting: false,
trackerGroups: ['markerGroup', 'dataLabelsGroup'],
// Use a simple symbol from LegendSymbolMixin
drawLegendSymbol: LegendSymbolMixin.drawRectangle,
// Use a group of trackers from TrackerMixin
drawTracker: TrackerMixin.drawTrackerPoint,
init: function () {
var series = this;
Series.prototype.init.apply(series, arguments);
// Distribute data labels before rendering them. Distribution is based
// on the 'dataLabels.distance' and 'dataLabels.alternate' property.
addEvent(series, 'drawDataLabels', function () {
// Delete the oldTextWidth parameter, in order to force adjusting
// data label wrapper box width. It's needed only when useHTML
// is enabled. This prevents the data label text getting out
// of the box range.
if (series.options.dataLabels.useHTML) {
series.points.forEach(function (p) {
if (p.visible && p.dataLabel) {
delete p.dataLabel.text.oldTextWidth;
}
});
}
// Distribute data labels basing on defined algorithm.
series.distributeDL();
});
addEvent(series, 'afterDrawDataLabels', function () {
var seriesOptions = series.options,
options = seriesOptions.dataLabels,
hasRendered = series.hasRendered || 0,
defer = pick(options.defer, !!seriesOptions.animation),
connectorsGroup = series.connectorsGroup,
dataLabel;
// Create (or redraw) the group for all connectors.
connectorsGroup = series.plotGroup(
'connectorsGroup',
'data-labels-connectors',
defer && !hasRendered ? 'hidden' : 'visible',
options.zIndex || 5
);
// Draw or align connector for each point.
series.points.forEach(function (point) {
dataLabel = point.dataLabel;
if (dataLabel) {
// Within this wrap method is necessary to save the current
// animation params, because the data label target position
// (after animation) is needed to align connectors.
wrap(dataLabel, 'animate', function (proceed, params) {
if (this.targetPosition) {
this.targetPosition = params;
}
return proceed.apply(
this,
Array.prototype.slice.call(arguments, 1)
);
});
// Initiate the targetPosition field within data label
// object. It's necessary because there is need to know
// expected position of specific data label, when aligning
// connectors. This field is overrided inside of
// SVGElement.animate() wrapped method.
if (!dataLabel.targetPosition) {
dataLabel.targetPosition = {};
}
return !point.connector ?
point.drawConnector() :
point.alignConnector();
}
});
// Animate connectors group. It's animated in the same way like
// dataLabels, and also depends on dataLabels.defer parameter.
if (defer) {
connectorsGroup.attr({
opacity: +hasRendered
});
if (!hasRendered) {
addEvent(series, 'afterAnimate', function () {
if (series.visible) {
connectorsGroup.show(true);
}
connectorsGroup[
seriesOptions.animation ? 'animate' : 'attr'
]({
opacity: 1
}, {
duration: 200
});
});
}
}
});
},
alignDataLabel: function (point, dataLabel) {
var series = this,
isInverted = series.chart.inverted,
visiblePoints = series.visibilityMap.filter(function (point) {
return point;
}),
visiblePointsCount = series.visiblePointsCount,
pointIndex = visiblePoints.indexOf(point),
isFirstOrLast = !pointIndex ||
pointIndex === visiblePointsCount - 1,
dataLabelsOptions = series.options.dataLabels,
userDLOptions = point.userDLOptions || {},
// Define multiplier which is used to calculate data label width.
// If data labels are alternate, they have two times more space to
// adapt (excepting first and last ones, which has only one
// and half), than in case of placing all data labels side by side.
multiplier = dataLabelsOptions.alternate ?
(isFirstOrLast ? 1.5 : 2) :
1,
distance,
availableSpace = Math.floor(series.xAxis.len / visiblePointsCount),
pad = dataLabel.padding,
targetDLWidth,
styles;
// Adjust data label width to the currently available space.
if (point.visible) {
distance = Math.abs(userDLOptions.x || point.options.dataLabels.x);
if (isInverted) {
targetDLWidth = (distance - pad) * 2 - (point.itemHeight / 2);
styles = {
width: targetDLWidth,
// Apply ellipsis when data label height is exceeded.
textOverflow: dataLabel.width / targetDLWidth *
dataLabel.height / 2 > availableSpace * multiplier ?
'ellipsis' : 'none'
};
} else {
styles = {
width: userDLOptions.width ||
dataLabelsOptions.width ||
availableSpace * multiplier - (pad * 2)
};
}
dataLabel.css(styles);
if (!series.chart.styledMode) {
dataLabel.shadow({});
}
}
Series.prototype.alignDataLabel.apply(series, arguments);
},
processData: function () {
var series = this,
xMap = [],
base,
visiblePoints = 0,
i;
series.visibilityMap = series.getVisibilityMap();
// Calculate currently visible points.
series.visibilityMap.forEach(function (point) {
if (point) {
visiblePoints++;
}
});
series.visiblePointsCount = visiblePoints;
base = series.xAxis.options.max / visiblePoints;
// Generate xData map.
for (i = 1; i <= visiblePoints; i++) {
xMap.push(
(base * i) - (base / 2)
);
}
// Set all hidden points y values as negatives, in order to move them
// away from plot area. It is necessary to avoid hiding data labels,
// when dataLabels.allowOverlap is set to false.
series.visibilityMap.forEach(function (vis, i) {
if (!vis) {
xMap.splice(i, 0, series.yData[i] === null ? null : -99);
}
});
series.xData = xMap;
series.yData = xMap.map(function (data) {
return defined(data) ? 1 : null;
});
Series.prototype.processData.call(this, arguments);
},
generatePoints: function () {
var series = this;
Series.prototype.generatePoints.apply(series);
series.points.forEach(function (point, i) {
point.applyOptions({
x: series.xData[i]
});
});
},
getVisibilityMap: function () {
var series = this,
map = (series.data.length ?
series.data : series.userOptions.data
).map(function (point) {
return (
point &&
point.visible !== false &&
!point.isNull
) ? point : false;
});
return map;
},
distributeDL: function () {
var series = this,
dataLabelsOptions = series.options.dataLabels,
options,
pointDLOptions,
newOptions = {},
visibilityIndex = 1,
distance = dataLabelsOptions.distance;
series.points.forEach(function (point) {
if (point.visible && !point.isNull) {
options = point.options;
pointDLOptions = point.options.dataLabels;
if (!series.hasRendered) {
point.userDLOptions = merge({}, pointDLOptions);
}
newOptions[series.chart.inverted ? 'x' : 'y'] =
dataLabelsOptions.alternate && visibilityIndex % 2 ?
-distance : distance;
options.dataLabels = merge(newOptions, point.userDLOptions);
visibilityIndex++;
}
});
},
markerAttribs: function (point, state) {
var series = this,
seriesMarkerOptions = series.options.marker,
seriesStateOptions,
pointMarkerOptions = point.marker || {},
symbol = pointMarkerOptions.symbol || seriesMarkerOptions.symbol,
pointStateOptions,
width = pick(
pointMarkerOptions.width,
seriesMarkerOptions.width,
series.xAxis.len / series.visiblePointsCount
),
height = pick(
pointMarkerOptions.height,
seriesMarkerOptions.height
),
radius = 0,
attribs;
// Handle hover and select states
if (state) {
seriesStateOptions = seriesMarkerOptions.states[state] || {};
pointStateOptions = pointMarkerOptions.states &&
pointMarkerOptions.states[state] || {};
radius = pick(
pointStateOptions.radius,
seriesStateOptions.radius,
radius + (
seriesStateOptions.radiusPlus ||
0
)
);
}
point.hasImage = symbol && symbol.indexOf('url') === 0;
attribs = {
x: Math.floor(point.plotX) - (width / 2) - (radius / 2),
y: point.plotY - (height / 2) - (radius / 2),
width: width + radius,
height: height + radius
};
return attribs;
},
bindAxes: function () {
var series = this,
timelineXAxis = {
gridLineWidth: 0,
lineWidth: 0,
min: 0,
dataMin: 0,
minPadding: 0,
max: 100,
dataMax: 100,
maxPadding: 0,
title: null,
tickPositions: []
},
timelineYAxis = {
gridLineWidth: 0,
min: 0.5,
dataMin: 0.5,
minPadding: 0,
max: 1.5,
dataMax: 1.5,
maxPadding: 0,
title: null,
labels: {
enabled: false
}
};
Series.prototype.bindAxes.call(series);
extend(series.xAxis.options, timelineXAxis);
extend(series.yAxis.options, timelineYAxis);
}
}
/* *
* @lends Highcharts.Point#
*/
, {
init: function () {
var point = Point.prototype.init.apply(this, arguments);
point.name = pick(point.name, point.date, 'Event');
point.y = 1;
return point;
},
// The setVisible method is taken from Pie series prototype, in order to
// prevent importing whole Pie series.
setVisible: function (vis, redraw) {
var point = this,
series = point.series,
chart = series.chart,
ignoreHiddenPoint = series.options.ignoreHiddenPoint;
redraw = pick(redraw, ignoreHiddenPoint);
if (vis !== point.visible) {
// If called without an argument, toggle visibility
point.visible = point.options.visible = vis =
vis === undefined ? !point.visible : vis;
// update userOptions.data
series.options.data[series.data.indexOf(point)] = point.options;
// Show and hide associated elements. This is performed regardless
// of redraw or not, because chart.redraw only handles full series.
['graphic', 'dataLabel', 'connector'].forEach(
function (key) {
if (point[key]) {
point[key][vis ? 'show' : 'hide'](true);
}
}
);
if (point.legendItem) {
chart.legend.colorizeItem(point, vis);
}
// #4170, hide halo after hiding point
if (!vis && point.state === 'hover') {
point.setState('');
}
// Handle ignore hidden slices
if (ignoreHiddenPoint) {
series.isDirty = true;
}
if (redraw) {
chart.redraw();
}
}
},
setState: function () {
var proceed = Series.prototype.pointClass.prototype.setState;
// Prevent triggering the setState method on null points.
if (!this.isNull) {
proceed.apply(this, arguments);
}
},
getConnectorPath: function () {
var point = this,
chart = point.series.chart,
xAxisLen = point.series.xAxis.len,
inverted = chart.inverted,
direction = inverted ? 'x2' : 'y2',
dl = point.dataLabel,
targetDLPos = dl.targetPosition,
coords = {
x1: point.plotX,
y1: point.plotY,
x2: point.plotX,
y2: targetDLPos.y || dl.y
},
negativeDistance = coords[direction] < point.series.yAxis.len / 2,
path;
// Recalculate coords when the chart is inverted.
if (inverted) {
coords = {
x1: point.plotY,
y1: xAxisLen - point.plotX,
x2: targetDLPos.x || dl.x,
y2: xAxisLen - point.plotX
};
}
// Subtract data label width or height from expected coordinate so that
// the connector would start from the appropriate edge.
if (negativeDistance) {
coords[direction] += dl[inverted ? 'width' : 'height'];
}
path = chart.renderer.crispLine([
'M',
coords.x1,
coords.y1,
'L',
coords.x2,
coords.y2
], dl.options.connectorWidth || 1);
return path;
},
drawConnector: function () {
var point = this,
series = point.series,
dlOptions = point.dataLabel.options = merge(
{}, series.options.dataLabels,
point.options.dataLabels
);
point.connector = series.chart.renderer.path(point.getConnectorPath())
.add(series.connectorsGroup);
if (!series.chart.styledMode) {
point.connector.attr({
stroke: dlOptions.connectorColor,
'stroke-width': dlOptions.connectorWidth,
opacity: point.dataLabel.opacity
});
}
},
alignConnector: function () {
var point = this,
connector = point.connector,
bBox = connector.getBBox(),
isVisible = bBox.y > 0;
connector[isVisible ? 'animate' : 'attr']({
d: point.getConnectorPath()
});
}
});
// Hide/show connector related with a specific data label, after overlapping
// detected.
addEvent(H.Chart, 'afterHideOverlappingLabels', function () {
var series = this.series,
dataLabel,
connector;
series.forEach(function (series) {
if (series.points) {
series.points.forEach(function (point) {
dataLabel = point.dataLabel;
connector = point.connector;
if (
dataLabel &&
dataLabel.targetPosition &&
connector
) {
connector.attr({
opacity: dataLabel.targetPosition.opacity ||
dataLabel.newOpacity
});
}
});
}
});
});
/* *
* The `timeline` series. If the [type](#series.timeline.type) option is
* not specified, it is inherited from [chart.type](#chart.type).
*
* @extends series,plotOptions.timeline
* @excluding animationLimit, boostThreshold, connectEnds, connectNulls,
* cropThreshold, dashStyle, dataParser, dataURL, findNearestPointBy,
* getExtremesFromAll, lineWidth, negativeColor,
* pointInterval, pointIntervalUnit, pointPlacement, pointStart,
* softThreshold, stacking, stack, step, threshold, turboThreshold,
* zoneAxis, zones
* @product highcharts
* @apioption series.timeline
*/
/* *
* An array of data points for the series. For the `timeline` series type,
* points can be given with three general parameters, `date`, `label`,
* and `description`:
*
* Example:
*
* ```js
* series: [{
* type: 'timeline',
* data: [{
* date: 'Jan 2018',
* label: 'Some event label',
* description: 'Description to show in tooltip'
* }]
* }]
* ```
*
* @sample {highcharts} highcharts/series-timeline/alternate-labels
* Alternate labels
*
* @type {Array<number|*>}
* @extends series.line.data
* @excluding marker, x, y
* @product highcharts
* @apioption series.timeline.data
*/
/* *
* The date of event.
*
* @type {string}
* @product highcharts
* @apioption series.timeline.data.date
*/
/* *
* The label of event.
*
* @type {string}
* @product highcharts
* @apioption series.timeline.data.label
*/
/* *
* The description of event. This description will be shown in tooltip.
*
* @type {string}
* @product highcharts
* @apioption series.timeline.data.description
*/