2022-04-09 01:18:22 +02:00

468 lines
14 KiB
JavaScript

import { setupCanvas, clearCanvas } from './canvas';
import { BALLOON_OFFSET, X_AXIS_HEIGHT } from './constants';
import { getPieRadius } from './formulas';
import { formatInteger, getLabelDate, getLabelTime, statsFormatDayHourFull } from './format';
import { getCssColor } from './skin';
import { throttle, throttleWithRaf } from './utils';
import { addEventListener, createElement } from './minifiers';
import { toPixels } from './Projection';
export function createTooltip(container, data, plotSize, colors, onZoom, onFocus) {
let _state;
let _points;
let _projection;
let _secondaryPoints;
let _secondaryProjection;
let _element;
let _canvas;
let _context;
let _balloon;
let _offsetX;
let _offsetY;
let _clickedOnLabel = null;
let _isZoomed = false;
let _isZooming = false;
const _selectLabelOnRaf = throttleWithRaf(_selectLabel);
const _throttledUpdateContent = throttle(_updateContent, 100, true, true);
_setupLayout();
function update(state, points, projection, secondaryPoints, secondaryProjection) {
_state = state;
_points = points;
_projection = projection;
_secondaryPoints = secondaryPoints;
_secondaryProjection = secondaryProjection;
_selectLabel(true);
}
function toggleLoading(isLoading) {
_balloon.classList.toggle('lovely-chart--state-loading', isLoading);
if (!isLoading) {
_clear();
}
}
function toggleIsZoomed(isZoomed) {
if (isZoomed !== _isZoomed) {
_isZooming = true;
}
_isZoomed = isZoomed;
_balloon.classList.toggle('lovely-chart--state-inactive', isZoomed);
}
function _setupLayout() {
_element = createElement();
_element.className = `lovely-chart--tooltip`;
_setupCanvas();
_setupBalloon();
if ('ontouchstart' in window) {
addEventListener(_element, 'touchmove', _onMouseMove);
addEventListener(_element, 'touchstart', _onMouseMove);
addEventListener(document, 'touchstart', _onDocumentMove);
} else {
addEventListener(_element, 'mousemove', _onMouseMove);
addEventListener(_element, 'click', _onClick);
addEventListener(document, 'mousemove', _onDocumentMove);
}
container.appendChild(_element);
}
function _setupCanvas() {
const { canvas, context } = setupCanvas(_element, plotSize);
_canvas = canvas;
_context = context;
}
function _setupBalloon() {
_balloon = createElement();
_balloon.className = `lovely-chart--tooltip-balloon${!data.isZoomable ? ' lovely-chart--state-inactive' : ''}`;
_balloon.innerHTML = '<div class="lovely-chart--tooltip-title"></div><div class="lovely-chart--tooltip-legend"></div><div class="lovely-chart--spinner"></div>';
if ('ontouchstart' in window && data.isZoomable) {
addEventListener(_balloon, 'click', _onBalloonClick);
}
_element.appendChild(_balloon);
}
function _onMouseMove(e) {
if (e.target === _balloon || _balloon.contains(e.target) || _clickedOnLabel) {
return;
}
_isZooming = false;
const pageOffset = _getPageOffset(_element);
_offsetX = (e.touches ? e.touches[0].clientX : e.clientX) - pageOffset.left;
_offsetY = (e.touches ? e.touches[0].clientY : e.clientY) - pageOffset.top;
_selectLabelOnRaf();
}
function _onDocumentMove(e) {
if (_offsetX !== null && e.target !== _element && !_element.contains(e.target)) {
_clear();
}
}
function _onClick(e) {
if (_isZooming) {
return;
}
if (data.isZoomable) {
const oldLabelIndex = _clickedOnLabel;
_clickedOnLabel = null;
_onMouseMove(e, true);
const newLabelIndex = _getLabelIndex();
if (newLabelIndex !== oldLabelIndex) {
_clickedOnLabel = newLabelIndex;
}
onZoom(newLabelIndex);
}
}
function _onBalloonClick() {
if (_balloon.classList.contains('lovely-chart--state-inactive')) {
return;
}
const labelIndex = _projection.findClosestLabelIndex(_offsetX);
onZoom(labelIndex);
}
function _clear(isExternal) {
_offsetX = null;
_clickedOnLabel = null;
clearCanvas(_canvas, _context);
_hideBalloon();
if (!isExternal && onFocus) {
onFocus(null);
}
}
function _getLabelIndex() {
const labelIndex = _projection.findClosestLabelIndex(_offsetX);
return labelIndex < _state.labelFromIndex || labelIndex > _state.labelToIndex ? null : labelIndex;
}
function _selectLabel(isExternal) {
if (!_offsetX || !_state || _isZooming) {
return;
}
const labelIndex = _getLabelIndex();
if (labelIndex === null) {
_clear(isExternal);
return;
}
const pointerVector = getPointerVector();
const shouldShowBalloon = data.isPie ? pointerVector.distance <= getPieRadius(_projection) : true;
if (!isExternal && onFocus) {
if (data.isPie) {
onFocus(pointerVector);
} else {
onFocus(labelIndex);
}
}
function getValue(values, labelIndex) {
if (data.isPie) {
return values.slice(_state.labelFromIndex, _state.labelToIndex + 1).reduce((a, x) => a + x, 0);
}
return values[labelIndex];
}
const [xPx] = toPixels(_projection, labelIndex, 0);
const statistics = data.datasets
.map(({ key, name, values, hasOwnYAxis }, i) => ({
key,
name,
value: getValue(values, labelIndex),
hasOwnYAxis,
originalIndex: i,
}))
.filter(({ key }) => _state.filter[key]);
if (statistics.length && shouldShowBalloon) {
_updateBalloon(statistics, labelIndex);
} else {
_hideBalloon();
}
clearCanvas(_canvas, _context);
if (data.isLines || data.isAreas) {
if (data.isLines) {
_drawCircles(statistics, labelIndex);
}
_drawTail(xPx, plotSize.height - X_AXIS_HEIGHT, getCssColor(colors, 'grid-lines'));
}
}
function _drawCircles(statistics, labelIndex) {
statistics.forEach(({ value, key, hasOwnYAxis, originalIndex }) => {
const pointIndex = labelIndex - _state.labelFromIndex;
const point = hasOwnYAxis ? _secondaryPoints[pointIndex] : _points[originalIndex][pointIndex];
if (!point) {
return;
}
const [x, y] = hasOwnYAxis
? toPixels(_secondaryProjection, labelIndex, point.stackValue)
: toPixels(_projection, labelIndex, point.stackValue);
// TODO animate
_drawCircle(
[x, y],
getCssColor(colors, `dataset#${key}`),
getCssColor(colors, 'background'),
);
});
}
function _drawCircle([xPx, yPx], strokeColor, fillColor) {
_context.strokeStyle = strokeColor;
_context.fillStyle = fillColor;
_context.lineWidth = 2;
_context.beginPath();
_context.arc(xPx, yPx, 4, 0, 2 * Math.PI);
_context.fill();
_context.stroke();
}
function _drawTail(xPx, height, color) {
_context.strokeStyle = color;
_context.lineWidth = 1;
_context.beginPath();
_context.moveTo(xPx, 0);
_context.lineTo(xPx, height);
_context.stroke();
}
function _getBalloonLeftOffset(labelIndex) {
const meanLabel = (_state.labelFromIndex + _state.labelToIndex) / 2;
const { angle } = getPointerVector();
const shouldPlaceRight = data.isPie ? angle > Math.PI / 2 : labelIndex < meanLabel;
const leftOffset = shouldPlaceRight ? _offsetX + BALLOON_OFFSET : _offsetX - (_balloon.offsetWidth + BALLOON_OFFSET);
return Math.min(Math.max(0, leftOffset), container.offsetWidth - _balloon.offsetWidth);
}
function _getBalloonTopOffset() {
return data.isPie ? `${_offsetY}px` : 0;
}
function _updateBalloon(statistics, labelIndex) {
_balloon.style.transform = `translate3D(${_getBalloonLeftOffset(labelIndex)}px, ${_getBalloonTopOffset()}, 0)`;
_balloon.classList.add('lovely-chart--state-shown');
if (data.isPie) {
_updateContent(null, statistics);
} else {
_throttledUpdateContent(_getTitle(data, labelIndex), statistics);
}
}
function _getTitle(data, labelIndex) {
switch (data.tooltipFormatter) {
case 'statsFormatDayHourFull':
return statsFormatDayHourFull(data.xLabels[labelIndex].value);
case 'statsTooltipFormat(\'day\')':
return getLabelDate(data.xLabels[labelIndex]);
case 'statsTooltipFormat(\'5min\')':
return getLabelTime(data.xLabels[labelIndex]);
default:
return data.xLabels[labelIndex].text;
}
}
function _isPieSectorSelected(statistics, value, totalValue, index, pointerVector) {
const offset = index > 0 ? statistics.slice(0, index).reduce((a, x) => a + x.value, 0) : 0;
const beginAngle = offset / totalValue * Math.PI * 2 - Math.PI / 2;
const endAngle = (offset + value) / totalValue * Math.PI * 2 - Math.PI / 2;
return pointerVector &&
beginAngle <= pointerVector.angle &&
pointerVector.angle < endAngle &&
pointerVector.distance <= getPieRadius(_projection);
}
function _updateTitle(title) {
const titleContainer = _balloon.children[0];
if (data.isPie) {
if (titleContainer) {
titleContainer.style.display = 'none';
}
} else {
if (titleContainer.style.display === 'none') {
titleContainer.style.display = '';
}
const currentTitle = titleContainer.querySelector(':not(.lovely-chart--state-hidden)');
if (!titleContainer.innerHTML || !currentTitle) {
titleContainer.innerHTML = `<span>${title}</span>`;
} else {
currentTitle.innerHTML = title;
}
}
}
function _insertNewDataSet(dataSetContainer, { name, key, value }, totalValue) {
const className = `lovely-chart--tooltip-dataset-value lovely-chart--position-right lovely-chart--color-${data.colors[key].slice(1)}`;
const newDataSet = createElement();
newDataSet.className = 'lovely-chart--tooltip-dataset';
newDataSet.setAttribute('data-present', 'true');
newDataSet.setAttribute('data-name', name);
newDataSet.innerHTML = `<span class="lovely-chart--dataset-title">${name}</span><span class="${className}">${formatInteger(value)}</span>`;
_renderPercentageValue(newDataSet, value, totalValue);
const totalText = dataSetContainer.querySelector(`[data-total="true"]`);
if (totalText) {
dataSetContainer.insertBefore(newDataSet, totalText);
} else {
dataSetContainer.appendChild(newDataSet);
}
}
function _updateDataSet(currentDataSet, { key, value } = {}, totalValue) {
currentDataSet.setAttribute('data-present', 'true');
const valueElement = currentDataSet.querySelector(`.lovely-chart--tooltip-dataset-value.lovely-chart--color-${data.colors[key].slice(1)}:not(.lovely-chart--state-hidden)`);
valueElement.innerHTML = formatInteger(value);
_renderPercentageValue(currentDataSet, value, totalValue);
}
function _renderPercentageValue(dataSet, value, totalValue) {
if (!data.isPercentage) {
return;
}
if (data.isPie) {
Array.from(dataSet.querySelectorAll(`.lovely-chart--percentage-title`)).forEach(e => e.remove());
return;
}
const percentageValue = totalValue ? Math.round(value / totalValue * 100) : 0;
const percentageElement = dataSet.querySelector(`.lovely-chart--percentage-title:not(.lovely-chart--state-hidden)`);
if (!percentageElement) {
const newPercentageTitle = createElement('span');
newPercentageTitle.className = 'lovely-chart--percentage-title lovely-chart--position-left';
newPercentageTitle.innerHTML = `${percentageValue}%`;
dataSet.prepend(newPercentageTitle);
} else {
percentageElement.innerHTML = `${percentageValue}%`;
}
}
function _updateDataSets(statistics) {
const dataSetContainer = _balloon.children[1];
if (data.isPie) {
dataSetContainer.classList.add('lovely-chart--tooltip-legend-pie');
}
Array.from(dataSetContainer.children).forEach((dataSet) => {
if (!data.isPie && dataSetContainer.classList.contains('lovely-chart--tooltip-legend-pie')) {
dataSet.remove();
} else {
dataSet.setAttribute('data-present', 'false');
}
});
const totalValue = statistics.reduce((a, x) => a + x.value, 0);
const pointerVector = getPointerVector();
const finalStatistics = data.isPie ? statistics.filter(({ value }, index) => _isPieSectorSelected(statistics, value, totalValue, index, pointerVector)) : statistics;
finalStatistics.forEach((statItem) => {
const currentDataSet = dataSetContainer.querySelector(`[data-name="${statItem.name}"]`);
if (!currentDataSet) {
_insertNewDataSet(dataSetContainer, statItem, totalValue);
} else {
_updateDataSet(currentDataSet, statItem, totalValue);
}
});
if ((data.isBars || data.isSteps) && data.isStacked) {
_renderTotal(dataSetContainer, formatInteger(totalValue));
}
Array.from(dataSetContainer.querySelectorAll('[data-present="false"]'))
.forEach((dataSet) => {
dataSet.remove();
});
}
function _updateContent(title, statistics) {
_updateTitle(title);
_updateDataSets(statistics);
}
function _renderTotal(dataSetContainer, totalValue) {
const totalText = dataSetContainer.querySelector(`[data-total="true"]`);
const className = `lovely-chart--tooltip-dataset-value lovely-chart--position-right`;
if (!totalText) {
const newTotalText = createElement();
newTotalText.className = 'lovely-chart--tooltip-dataset';
newTotalText.setAttribute('data-present', 'true');
newTotalText.setAttribute('data-total', 'true');
newTotalText.innerHTML = `<span>All</span><span class="${className}">${totalValue}</span>`;
dataSetContainer.appendChild(newTotalText);
} else {
totalText.setAttribute('data-present', 'true');
const valueElement = totalText.querySelector(`.lovely-chart--tooltip-dataset-value:not(.lovely-chart--state-hidden)`);
valueElement.innerHTML = totalValue;
}
}
function _hideBalloon() {
_balloon.classList.remove('lovely-chart--state-shown');
}
function getPointerVector() {
const { width, height } = _element.getBoundingClientRect();
const center = [width / 2, height / 2];
const angle = Math.atan2(_offsetY - center[1], _offsetX - center[0]);
const distance = Math.sqrt((_offsetX - center[0]) ** 2 + (_offsetY - center[1]) ** 2);
return {
angle: angle >= -Math.PI / 2 ? angle : 2 * Math.PI + angle,
distance,
};
}
function _getPageOffset(el) {
return el.getBoundingClientRect();
}
return { update, toggleLoading, toggleIsZoomed };
}