Sync lovely-chart from upstream (#6959)

This commit is contained in:
Alexander Zinchuk 2026-06-01 01:15:30 +02:00
parent 1c9d35c634
commit ec9add5c67
17 changed files with 227 additions and 87 deletions

View File

@ -1,9 +1,14 @@
import { GUTTER, AXES_FONT, X_AXIS_HEIGHT, X_AXIS_SHIFT_START, PLOT_TOP_PADDING } from './constants.js'; import { GUTTER, AXES_FONT_STYLE, X_AXIS_HEIGHT, X_AXIS_SHIFT_START, PLOT_TOP_PADDING } from './constants.js';
import { humanize } from './format.js'; import { humanize } from './format.js';
import { getCssColor } from './skin.js'; import { getCssColor } from './skin.js';
import { applyXEdgeOpacity, applyYEdgeOpacity, xScaleLevelToStep, yScaleLevelToStep } from './formulas.js'; import { applyXEdgeOpacity, applyYEdgeOpacity, xScaleLevelToStep, yScaleLevelToStep } from './formulas.js';
import { toPixels } from './Projection.js'; import { toPixels } from './Projection.js';
function getAxesFont(context) {
const fontFamily = getComputedStyle(context.canvas).fontFamily || 'sans-serif';
return `${AXES_FONT_STYLE} ${fontFamily}`;
}
export function createAxes(context, data, plotSize, colors) { export function createAxes(context, data, plotSize, colors) {
function drawXAxis(state, projection) { function drawXAxis(state, projection) {
context.clearRect(0, plotSize.height - X_AXIS_HEIGHT + 1, plotSize.width, X_AXIS_HEIGHT + 1); context.clearRect(0, plotSize.height - X_AXIS_HEIGHT + 1, plotSize.width, X_AXIS_HEIGHT + 1);
@ -13,7 +18,7 @@ export function createAxes(context, data, plotSize, colors) {
const step = xScaleLevelToStep(scaleLevel); const step = xScaleLevelToStep(scaleLevel);
const opacityFactor = 1 - (state.xAxisScale - scaleLevel); const opacityFactor = 1 - (state.xAxisScale - scaleLevel);
context.font = AXES_FONT; context.font = getAxesFont(context);
context.textAlign = 'center'; context.textAlign = 'center';
context.textBaseline = 'middle'; context.textBaseline = 'middle';
@ -126,7 +131,7 @@ export function createAxes(context, data, plotSize, colors) {
const firstVisibleValue = Math.ceil(yMin / step) * step; const firstVisibleValue = Math.ceil(yMin / step) * step;
const lastVisibleValue = Math.floor(yMax / step) * step; const lastVisibleValue = Math.floor(yMax / step) * step;
context.font = AXES_FONT; context.font = getAxesFont(context);
context.textAlign = isSecondary ? 'right' : 'left'; context.textAlign = isSecondary ? 'right' : 'left';
context.textBaseline = 'bottom'; context.textBaseline = 'bottom';
@ -171,7 +176,7 @@ export function createAxes(context, data, plotSize, colors) {
const percentValues = [0, 0.25, 0.50, 0.75, 1]; const percentValues = [0, 0.25, 0.50, 0.75, 1];
const [, height] = projection.getSize(); const [, height] = projection.getSize();
context.font = AXES_FONT; context.font = getAxesFont(context);
context.textAlign = 'left'; context.textAlign = 'left';
context.textBaseline = 'bottom'; context.textBaseline = 'bottom';
context.lineWidth = 1; context.lineWidth = 1;
@ -198,7 +203,7 @@ export function createAxes(context, data, plotSize, colors) {
const firstVisibleValue = Math.ceil(yMin / step) * step; const firstVisibleValue = Math.ceil(yMin / step) * step;
const lastVisibleValue = Math.floor(yMax / step) * step; const lastVisibleValue = Math.floor(yMax / step) * step;
context.font = AXES_FONT; context.font = getAxesFont(context);
context.textAlign = 'right'; context.textAlign = 'right';
context.textBaseline = 'bottom'; context.textBaseline = 'bottom';

View File

@ -167,9 +167,9 @@ function create(container, originalData) {
} }
function _setupGlobalListeners() { function _setupGlobalListeners() {
document.documentElement.addEventListener('darkmode', () => { new MutationObserver(() => {
_stateManager.update(); _stateManager.update();
}); }).observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
window.addEventListener('resize', () => { window.addEventListener('resize', () => {
if (window.innerWidth !== _windowWidth) { if (window.innerWidth !== _windowWidth) {

View File

@ -23,11 +23,14 @@ export function createMinimap(container, data, colors, rangeCallback) {
let _canvasSize; let _canvasSize;
let _ruler; let _ruler;
let _slider; let _slider;
let _limitMask;
let _capturedOffset; let _capturedOffset;
let _range = {}; let _range = {};
let _state; let _state;
const _limitBegin = data.limitBegin;
const _updateRulerOnRaf = throttleWithRaf(_updateRuler); const _updateRulerOnRaf = throttleWithRaf(_updateRuler);
_setupLayout(); _setupLayout();
@ -69,6 +72,7 @@ export function createMinimap(container, data, colors, rangeCallback) {
_setupCanvas(); _setupCanvas();
_setupRuler(); _setupRuler();
_setupLimitMask();
container.appendChild(_element); container.appendChild(_element);
@ -139,6 +143,23 @@ export function createMinimap(container, data, colors, rangeCallback) {
_element.appendChild(_ruler); _element.appendChild(_ruler);
} }
function _setupLimitMask() {
if (_limitBegin == null) return;
_limitMask = createElement();
_limitMask.className = 'lovely-chart--minimap-limit-mask';
_limitMask.style.width = `${_limitBegin * 100}%`;
_limitMask.innerHTML =
'<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">' +
'<path fill-rule="evenodd" clip-rule="evenodd" d="M16.5265 10.2173V7.54299C16.5265 5.08532 14.4958 3.08585 11.9997 3.08585C9.50365 3.08585 7.47293 5.08532 7.47293 7.54299V10.2173C6.2992 10.2173 5.36524 11.2011 5.42629 12.3733L5.60706 15.844C5.6879 17.3962 5.72833 18.1723 6.00269 18.7852C6.39058 19.6518 7.10506 20.33 7.9906 20.6723C8.61698 20.9144 9.39412 20.9144 10.9484 20.9144H13.051C14.6053 20.9144 15.3825 20.9144 16.0088 20.6723C16.8944 20.33 17.6089 19.6518 17.9967 18.7852C18.2711 18.1723 18.3115 17.3962 18.3924 15.844L18.5731 12.3733C18.6342 11.2011 17.7002 10.2173 16.5265 10.2173ZM11.9997 4.8687C10.5023 4.8687 9.28364 6.06857 9.28364 7.54299V10.2173H14.7158V7.54299C14.7158 6.06857 13.4972 4.8687 11.9997 4.8687Z" fill="currentColor"/>' +
'</svg>';
if (data.onLimitedRangeClick) {
_limitMask.classList.add('lovely-chart--state-interactive');
_limitMask.addEventListener('click', data.onLimitedRangeClick);
}
_element.appendChild(_limitMask);
}
function _isStateChanged(newState) { function _isStateChanged(newState) {
if (!_state) { if (!_state) {
return true; return true;
@ -206,7 +227,8 @@ export function createMinimap(container, data, colors, rangeCallback) {
} }
function _onSliderDrag(moveEvent, captureEvent, { dragOffsetX }) { function _onSliderDrag(moveEvent, captureEvent, { dragOffsetX }) {
const minX1 = 0; const limitX = _limitBegin != null ? _limitBegin * _canvasSize.width : 0;
const minX1 = limitX;
const maxX1 = _canvasSize.width - _slider.offsetWidth; const maxX1 = _canvasSize.width - _slider.offsetWidth;
const newX1 = Math.max(minX1, Math.min(_capturedOffset + dragOffsetX - MINIMAP_EAR_WIDTH, maxX1)); const newX1 = Math.max(minX1, Math.min(_capturedOffset + dragOffsetX - MINIMAP_EAR_WIDTH, maxX1));
@ -218,7 +240,8 @@ export function createMinimap(container, data, colors, rangeCallback) {
} }
function _onLeftEarDrag(moveEvent, captureEvent, { dragOffsetX }) { function _onLeftEarDrag(moveEvent, captureEvent, { dragOffsetX }) {
const minX1 = 0; const limitX = _limitBegin != null ? _limitBegin * _canvasSize.width : 0;
const minX1 = limitX;
const maxX1 = _slider.offsetLeft + _slider.offsetWidth - MINIMAP_EAR_WIDTH * 2; const maxX1 = _slider.offsetLeft + _slider.offsetWidth - MINIMAP_EAR_WIDTH * 2;
const newX1 = Math.min(maxX1, Math.max(minX1, _capturedOffset + dragOffsetX)); const newX1 = Math.min(maxX1, Math.max(minX1, _capturedOffset + dragOffsetX));
@ -244,6 +267,10 @@ export function createMinimap(container, data, colors, rangeCallback) {
nextRange = _adjustDiscreteRange(nextRange); nextRange = _adjustDiscreteRange(nextRange);
} }
if (_limitBegin != null && nextRange.begin < _limitBegin) {
nextRange.begin = _limitBegin;
}
if (nextRange.begin === _range.begin && nextRange.end === _range.end) { if (nextRange.begin === _range.begin && nextRange.end === _range.end) {
return; return;
} }

View File

@ -1,10 +1,11 @@
import { createTransitionManager } from './TransitionManager.js'; import { createTransitionManager } from './TransitionManager.js';
import { throttleWithRaf, getMaxMin, mergeArrays, proxyMerge, sumArrays } from './utils.js'; import { throttleWithRaf, getMaxMin, mergeArrays, proxyMerge } from './utils.js';
import { import {
AXES_MAX_COLUMN_WIDTH, AXES_MAX_COLUMN_WIDTH,
AXES_MAX_ROW_HEIGHT, AXES_MAX_ROW_HEIGHT,
X_AXIS_HEIGHT, X_AXIS_HEIGHT,
ANIMATE_PROPS, ANIMATE_PROPS,
TRANSITION_DEFAULT_DURATION,
Y_AXIS_ZERO_BASED_THRESHOLD, Y_AXIS_ZERO_BASED_THRESHOLD,
} from './constants.js'; } from './constants.js';
import { xStepToScaleLevel, yScaleLevelToStep, yStepToScaleLevel } from './formulas.js'; import { xStepToScaleLevel, yScaleLevelToStep, yStepToScaleLevel } from './formulas.js';
@ -55,7 +56,7 @@ export function createStateManager(data, viewportSize, callback) {
function _buildTransitionConfig() { function _buildTransitionConfig() {
const transitionConfig = []; const transitionConfig = [];
const datasetVisibilities = data.datasets.map(({ key }) => `opacity#${key} 300`); const datasetVisibilities = data.datasets.map(({ key }) => `opacity#${key} ${TRANSITION_DEFAULT_DURATION}`);
mergeArrays([ mergeArrays([
ANIMATE_PROPS, ANIMATE_PROPS,
@ -105,11 +106,11 @@ function calculateState(data, viewportSize, range, filter, focusOn, minimapDelta
calculateYAxisScale(viewportSize.height, yRanges.yMinViewportSecond, yRanges.yMaxViewportSecond); calculateYAxisScale(viewportSize.height, yRanges.yMinViewportSecond, yRanges.yMaxViewportSecond);
const yStep = yScaleLevelToStep(yAxisScale); const yStep = yScaleLevelToStep(yAxisScale);
yRanges.yMinViewport -= yRanges.yMinViewport % yStep; yRanges.yMinViewport = Math.floor(yRanges.yMinViewport / yStep) * yStep;
if (yAxisScaleSecond) { if (yAxisScaleSecond) {
const yStepSecond = yScaleLevelToStep(yAxisScaleSecond); const yStepSecond = yScaleLevelToStep(yAxisScaleSecond);
yRanges.yMinViewportSecond -= yRanges.yMinViewportSecond % yStepSecond; yRanges.yMinViewportSecond = Math.floor(yRanges.yMinViewportSecond / yStepSecond) * yStepSecond;
} }
const datasetsOpacity = {}; const datasetsOpacity = {};
@ -164,7 +165,9 @@ function calculateYRanges(data, filter, labelFromIndex, labelToIndex, prevState)
function calculateYRangesForGroup(data, labelFromIndex, labelToIndex, prevState, datasets) { function calculateYRangesForGroup(data, labelFromIndex, labelToIndex, prevState, datasets) {
const { min: yMinMinimapReal = prevState.yMinMinimap, max: yMaxMinimap = prevState.yMaxMinimap } const { min: yMinMinimapReal = prevState.yMinMinimap, max: yMaxMinimap = prevState.yMaxMinimap }
= getMaxMin(mergeArrays(datasets.map(({ yMax, yMin }) => [yMax, yMin]))); = getMaxMin(mergeArrays(datasets.map(({ yMax, yMin }) => [yMax, yMin])));
const yMinMinimap = yMinMinimapReal / yMaxMinimap > Y_AXIS_ZERO_BASED_THRESHOLD ? yMinMinimapReal : 0; const yMinMinimap = yMinMinimapReal < 0
? yMinMinimapReal
: (yMinMinimapReal / yMaxMinimap > Y_AXIS_ZERO_BASED_THRESHOLD ? yMinMinimapReal : 0);
let yMinViewport; let yMinViewport;
let yMaxViewport; let yMaxViewport;
@ -178,7 +181,9 @@ function calculateYRangesForGroup(data, labelFromIndex, labelToIndex, prevState,
const viewportMaxMin = getMaxMin(mergeArrays(viewportValues)); const viewportMaxMin = getMaxMin(mergeArrays(viewportValues));
const yMinViewportReal = viewportMaxMin.min !== undefined ? viewportMaxMin.min : prevState.yMinViewport; const yMinViewportReal = viewportMaxMin.min !== undefined ? viewportMaxMin.min : prevState.yMinViewport;
yMaxViewport = viewportMaxMin.max !== undefined ? viewportMaxMin.max : prevState.yMaxViewport; yMaxViewport = viewportMaxMin.max !== undefined ? viewportMaxMin.max : prevState.yMaxViewport;
yMinViewport = yMinViewportReal / yMaxViewport > Y_AXIS_ZERO_BASED_THRESHOLD ? yMinViewportReal : 0; yMinViewport = yMinViewportReal < 0
? yMinViewportReal
: (yMinViewportReal / yMaxViewport > Y_AXIS_ZERO_BASED_THRESHOLD ? yMinViewportReal : 0);
} }
return { return {
@ -193,14 +198,26 @@ function calculateYRangesStacked(data, filter, labelFromIndex, labelToIndex, pre
const filteredDatasets = data.datasets.filter((d) => filter[d.key]); const filteredDatasets = data.datasets.filter((d) => filter[d.key]);
const filteredValues = filteredDatasets.map(({ values }) => values); const filteredValues = filteredDatasets.map(({ values }) => values);
const sums = filteredValues.length ? sumArrays(filteredValues) : []; const length = filteredValues[0] ? filteredValues[0].length : 0;
const { max: yMaxMinimap = prevState.yMaxMinimap } = getMaxMin(sums); const posSums = new Array(length).fill(0);
const { max: yMaxViewport = prevState.yMaxViewport } = getMaxMin(sums.slice(labelFromIndex, labelToIndex + 1)); const negSums = new Array(length).fill(0);
for (let i = 0; i < filteredValues.length; i++) {
for (let j = 0; j < length; j++) {
const v = filteredValues[i][j];
if (v == null) continue;
if (v >= 0) posSums[j] += v; else negSums[j] += v;
}
}
const { max: yMaxMinimap = prevState.yMaxMinimap } = getMaxMin(posSums);
const { min: yMinMinimap = prevState.yMinMinimap } = getMaxMin(negSums);
const { max: yMaxViewport = prevState.yMaxViewport } = getMaxMin(posSums.slice(labelFromIndex, labelToIndex + 1));
const { min: yMinViewport = prevState.yMinViewport } = getMaxMin(negSums.slice(labelFromIndex, labelToIndex + 1));
return { return {
yMinViewport: 0, yMinViewport,
yMaxViewport, yMaxViewport,
yMinMinimap: 0, yMinMinimap,
yMaxMinimap, yMaxMinimap,
}; };
} }

View File

@ -219,6 +219,8 @@ export function createTooltip(container, data, plotSize, colors, onZoom, onFocus
function _drawCircles(statistics, labelIndex) { function _drawCircles(statistics, labelIndex) {
statistics.forEach(({ value, key, hasOwnYAxis, originalIndex }) => { statistics.forEach(({ value, key, hasOwnYAxis, originalIndex }) => {
if (value == null) return;
const pointIndex = labelIndex - _state.labelFromIndex; const pointIndex = labelIndex - _state.labelFromIndex;
const point = hasOwnYAxis ? _secondaryPoints[pointIndex] : _points[originalIndex][pointIndex]; const point = hasOwnYAxis ? _secondaryPoints[pointIndex] : _points[originalIndex][pointIndex];
@ -356,13 +358,8 @@ export function createTooltip(container, data, plotSize, colors, onZoom, onFocus
_renderPercentageValue(newDataSet, value, totalValue); _renderPercentageValue(newDataSet, value, totalValue);
const totalText = dataSetContainer.querySelector(`[data-total="true"]`);
if (totalText) {
dataSetContainer.insertBefore(newDataSet, totalText);
} else {
dataSetContainer.appendChild(newDataSet); dataSetContainer.appendChild(newDataSet);
} }
}
function _updateDataSet(currentDataSet, { key, value } = {}, totalValue) { function _updateDataSet(currentDataSet, { key, value } = {}, totalValue) {
currentDataSet.setAttribute('data-present', 'true'); currentDataSet.setAttribute('data-present', 'true');
@ -419,7 +416,7 @@ export function createTooltip(container, data, plotSize, colors, onZoom, onFocus
const totalValue = statistics.reduce((a, x) => a + x.value, 0); const totalValue = statistics.reduce((a, x) => a + x.value, 0);
const pointerVector = getPointerVector(); const pointerVector = getPointerVector();
const filteredStatistics = statistics.filter(({ value }) => value !== 0); const filteredStatistics = statistics.filter(({ value }) => value !== 0 && value != null);
const sortedStatistics = filteredStatistics.sort((a, b) => b.value - a.value); const sortedStatistics = filteredStatistics.sort((a, b) => b.value - a.value);
const limitedStatistics = sortedStatistics.slice(0, MAX_TOOLTIP_ITEMS); const limitedStatistics = sortedStatistics.slice(0, MAX_TOOLTIP_ITEMS);
const finalStatistics = data.isPie ? limitedStatistics.filter(({ value }, index) => _isPieSectorSelected(statistics, value, totalValue, index, pointerVector)) : limitedStatistics; const finalStatistics = data.isPie ? limitedStatistics.filter(({ value }, index) => _isPieSectorSelected(statistics, value, totalValue, index, pointerVector)) : limitedStatistics;

View File

@ -1,10 +1,8 @@
import { SPEED_TEST_FAST_FPS, SPEED_TEST_INTERVAL, TRANSITION_DEFAULT_DURATION } from './constants.js'; import { SPEED_TEST_FAST_FPS, SPEED_TEST_INTERVAL, TRANSITION_DEFAULT_DURATION } from './constants.js';
function transition(t) { function transition(t) {
// faster // iOS-style ease-out (no overshoot)
// return -t * (t - 2); return 1 - Math.pow(1 - t, 3);
// easeOut
return 1 - Math.pow(1 - t, 1.675);
} }
export function createTransitionManager(onTick) { export function createTransitionManager(onTick) {

View File

@ -1,7 +1,7 @@
export const DPR = window.devicePixelRatio || 1; export const DPR = window.devicePixelRatio || 1;
export const DEFAULT_RANGE = { begin: 0.8, end: 1 }; export const DEFAULT_RANGE = { begin: 0.8, end: 1 };
export const TRANSITION_DEFAULT_DURATION = 300; export const TRANSITION_DEFAULT_DURATION = 400;
export const LONG_PRESS_TIMEOUT = 500; export const LONG_PRESS_TIMEOUT = 500;
export const GUTTER = 10; export const GUTTER = 10;
@ -17,7 +17,7 @@ export const PIE_MINIMUM_VISIBLE_PERCENT = 0.02;
export const BALLOON_OFFSET = 20; export const BALLOON_OFFSET = 20;
export const MAX_TOOLTIP_ITEMS = 12; export const MAX_TOOLTIP_ITEMS = 12;
export const AXES_FONT = '300 10px Helvetica, Arial, sans-serif'; export const AXES_FONT_STYLE = '300 10px';
export const AXES_MAX_COLUMN_WIDTH = 45; export const AXES_MAX_COLUMN_WIDTH = 45;
export const AXES_MAX_ROW_HEIGHT = 50; export const AXES_MAX_ROW_HEIGHT = 50;
export const X_AXIS_HEIGHT = 30; export const X_AXIS_HEIGHT = 30;

View File

@ -1,6 +1,11 @@
import { getMaxMin } from './utils.js'; import { getMaxMin } from './utils.js';
import { statsFormatDay, statsFormatDayHour, statsFormatText, statsFormatMin } from './format.js'; import { statsFormatDay, statsFormatDayHour, statsFormatText, statsFormatMin } from './format.js';
const DEFAULT_COLORS = [
'#3497ED', '#2373DB', '#9ED448', '#5FB641',
'#F5BD25', '#F79E39', '#E65850', '#5D5CDC',
];
const LABEL_TYPE_TO_FORMATTER = { const LABEL_TYPE_TO_FORMATTER = {
'day': "statsFormat('day')", 'day': "statsFormat('day')",
'hour': "statsFormat('hour')", 'hour': "statsFormat('hour')",
@ -10,7 +15,7 @@ const LABEL_TYPE_TO_FORMATTER = {
}; };
export function analyzeData(data) { export function analyzeData(data) {
const { title, labelFormatter: labelFormatterRaw, labelType, tooltipFormatter, isStacked, isPercentage, secondaryYAxis, hasSecondYAxis, onZoom, minimapRange, hideCaption, zoomOutLabel, valuePrefix, valueSuffix } = data; const { title, labelFormatter: labelFormatterRaw, labelType, tooltipFormatter, isStacked, isPercentage, secondaryYAxis, hasSecondYAxis, onZoom, minimapRange, hideCaption, zoomOutLabel, valuePrefix, valueSuffix, limitDate, onLimitedRangeClick } = data;
const labelFormatter = labelFormatterRaw || (labelType && LABEL_TYPE_TO_FORMATTER[labelType]); const labelFormatter = labelFormatterRaw || (labelType && LABEL_TYPE_TO_FORMATTER[labelType]);
const { datasets, labels } = prepareDatasets(data); const { datasets, labels } = prepareDatasets(data);
@ -46,6 +51,15 @@ export function analyzeData(data) {
break; break;
} }
let limitBegin = null;
if (limitDate != null) {
const totalXWidth = labels.length - 1;
const idx = labels.findIndex((l) => l >= limitDate);
if (idx > 0) {
limitBegin = idx / totalXWidth;
}
}
const analyzed = { const analyzed = {
title, title,
labelFormatter, labelFormatter,
@ -70,6 +84,8 @@ export function analyzeData(data) {
minimapRange, minimapRange,
hideCaption, hideCaption,
zoomOutLabel, zoomOutLabel,
limitBegin,
onLimitedRangeClick,
}; };
analyzed.shouldZoomToPie = !analyzed.onZoom && analyzed.isPercentage; analyzed.shouldZoomToPie = !analyzed.onZoom && analyzed.isPercentage;
@ -81,6 +97,8 @@ export function analyzeData(data) {
function prepareDatasets(data) { function prepareDatasets(data) {
const { type, labels, datasets, hasSecondYAxis } = data; const { type, labels, datasets, hasSecondYAxis } = data;
let nextDefaultColor = 0;
return { return {
labels: cloneArray(labels), labels: cloneArray(labels),
datasets: datasets.map(({ name, color, values }, i) => { datasets: datasets.map(({ name, color, values }, i) => {
@ -90,7 +108,7 @@ function prepareDatasets(data) {
type, type,
key: `y${i}`, key: `y${i}`,
name, name,
color, color: color || DEFAULT_COLORS[nextDefaultColor++ % DEFAULT_COLORS.length],
values: cloneArray(values), values: cloneArray(values),
hasOwnYAxis: hasSecondYAxis && i === datasets.length - 1, hasOwnYAxis: hasSecondYAxis && i === datasets.length - 1,
yMin, yMin,

View File

@ -84,20 +84,30 @@ function drawDataset(type, ...args) {
function drawDatasetLine(context, points, projection, options) { function drawDatasetLine(context, points, projection, options) {
context.beginPath(); context.beginPath();
let pixels = []; const segments = [];
let current = [];
for (let j = 0, l = points.length; j < l; j++) { for (let j = 0, l = points.length; j < l; j++) {
const { labelIndex, stackValue } = points[j]; const point = points[j];
pixels.push(toPixels(projection, labelIndex, stackValue)); if (point.gap) {
if (current.length) {
segments.push(current);
current = [];
} }
continue;
}
current.push(toPixels(projection, point.labelIndex, point.stackValue));
}
if (current.length) segments.push(current);
segments.forEach((segment) => {
let pixels = segment;
if (options.simplification) { if (options.simplification) {
const simplifierFn = simplify(pixels); pixels = simplify(pixels)(options.simplification).points;
pixels = simplifierFn(options.simplification).points;
} }
pixels.forEach(([x, y], k) => {
pixels.forEach(([x, y]) => { if (k === 0) context.moveTo(x, y);
context.lineTo(x, y); else context.lineTo(x, y);
});
}); });
context.save(); context.save();
@ -119,6 +129,7 @@ function drawDatasetBars(context, points, projection, options) {
context.fillStyle = options.color; context.fillStyle = options.color;
for (let j = 0, l = points.length; j < l; j++) { for (let j = 0, l = points.length; j < l; j++) {
if (points[j].gap) continue;
const { labelIndex, stackValue, stackOffset = 0 } = points[j]; const { labelIndex, stackValue, stackOffset = 0 } = points[j];
const [, yFrom] = toPixels(projection, labelIndex, Math.max(stackOffset, yMin)); const [, yFrom] = toPixels(projection, labelIndex, Math.max(stackOffset, yMin));
@ -139,18 +150,29 @@ function drawDatasetBars(context, points, projection, options) {
function drawDatasetSteps(context, points, projection, options) { function drawDatasetSteps(context, points, projection, options) {
context.beginPath(); context.beginPath();
let pixels = []; const segments = [];
let current = [];
for (let j = 0, l = points.length; j < l; j++) { for (let j = 0, l = points.length; j < l; j++) {
const { labelIndex, stackValue } = points[j]; const point = points[j];
pixels.push( if (point.gap) {
toPixels(projection, labelIndex - PLOT_BARS_WIDTH_SHIFT, stackValue), if (current.length) {
toPixels(projection, labelIndex + PLOT_BARS_WIDTH_SHIFT, stackValue), segments.push(current);
current = [];
}
continue;
}
current.push(
toPixels(projection, point.labelIndex - PLOT_BARS_WIDTH_SHIFT, point.stackValue),
toPixels(projection, point.labelIndex + PLOT_BARS_WIDTH_SHIFT, point.stackValue),
); );
} }
if (current.length) segments.push(current);
pixels.forEach(([x, y]) => { segments.forEach((segment) => {
context.lineTo(x, y); segment.forEach(([x, y], k) => {
if (k === 0) context.moveTo(x, y);
else context.lineTo(x, y);
});
}); });
context.save(); context.save();
@ -240,7 +262,8 @@ function drawDatasetPie(context, points, projection, options) {
context.fill(); context.fill();
if (percent >= PIE_MINIMUM_VISIBLE_PERCENT) { if (percent >= PIE_MINIMUM_VISIBLE_PERCENT) {
context.font = `700 ${getPieTextSize(percent, radius)}px Helvetica, Arial, sans-serif`; const fontFamily = getComputedStyle(context.canvas).fontFamily || 'sans-serif';
context.font = `700 ${getPieTextSize(percent, radius)}px ${fontFamily}`;
context.textAlign = 'center'; context.textAlign = 'center';
context.textBaseline = 'middle'; context.textBaseline = 'middle';
context.fillStyle = 'white'; context.fillStyle = 'white';

View File

@ -41,10 +41,13 @@ export function statsFormatText(labels) {
} }
export function humanize(value, decimals = 1) { export function humanize(value, decimals = 1) {
if (value >= 1e6) { const abs = Math.abs(value);
return keepThreeDigits(value / 1e6, decimals) + 'M'; const sign = value < 0 ? '-' : '';
} else if (value >= 1e3) {
return keepThreeDigits(value / 1e3, decimals) + 'K'; if (abs >= 1e6) {
return sign + keepThreeDigits(abs / 1e6, decimals) + 'M';
} else if (abs >= 1e3) {
return sign + keepThreeDigits(abs / 1e3, decimals) + 'K';
} }
return value; return value;

View File

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.5265 10.2173V7.54299C16.5265 5.08532 14.4958 3.08585 11.9997 3.08585C9.50365 3.08585 7.47293 5.08532 7.47293 7.54299V10.2173C6.2992 10.2173 5.36524 11.2011 5.42629 12.3733L5.60706 15.844C5.6879 17.3962 5.72833 18.1723 6.00269 18.7852C6.39058 19.6518 7.10506 20.33 7.9906 20.6723C8.61698 20.9144 9.39412 20.9144 10.9484 20.9144H13.051C14.6053 20.9144 15.3825 20.9144 16.0088 20.6723C16.8944 20.33 17.6089 19.6518 17.9967 18.7852C18.2711 18.1723 18.3115 17.3962 18.3924 15.844L18.5731 12.3733C18.6342 11.2011 17.7002 10.2173 16.5265 10.2173ZM11.9997 4.8687C10.5023 4.8687 9.28364 6.06857 9.28364 7.54299V10.2173H14.7158V7.54299C14.7158 6.06857 13.4972 4.8687 11.9997 4.8687Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 845 B

View File

@ -11,9 +11,10 @@ export function preparePoints(data, datasets, range, visibilities, bounds, pieTo
const points = values.map((datasetValues, i) => ( const points = values.map((datasetValues, i) => (
datasetValues.map((value, j) => { datasetValues.map((value, j) => {
let visibleValue = value; const isGap = value == null;
let visibleValue = isGap ? 0 : value;
if (data.isStacked) { if (data.isStacked && !isGap) {
visibleValue *= visibilities[i]; visibleValue *= visibilities[i];
} }
@ -23,6 +24,7 @@ export function preparePoints(data, datasets, range, visibilities, bounds, pieTo
visibleValue, visibleValue,
stackOffset: 0, stackOffset: 0,
stackValue: visibleValue, stackValue: visibleValue,
gap: isGap,
}; };
}) })
)); ));
@ -57,17 +59,31 @@ function preparePercentage(points, bounds) {
} }
function prepareStacked(points) { function prepareStacked(points) {
const accum = []; const posAccum = [];
const negAccum = [];
points.forEach((datasetPoints) => { points.forEach((datasetPoints) => {
datasetPoints.forEach((point, j) => { datasetPoints.forEach((point, j) => {
if (accum[j] === undefined) { if (posAccum[j] === undefined) {
accum[j] = 0; posAccum[j] = 0;
negAccum[j] = 0;
} }
point.stackOffset = accum[j]; if (point.gap) {
accum[j] += point.visibleValue; point.stackOffset = posAccum[j];
point.stackValue = accum[j]; point.stackValue = posAccum[j];
return;
}
if (point.visibleValue >= 0) {
point.stackOffset = posAccum[j];
posAccum[j] += point.visibleValue;
point.stackValue = posAccum[j];
} else {
point.stackOffset = negAccum[j];
negAccum[j] += point.visibleValue;
point.stackValue = negAccum[j];
}
}); });
}); });
} }

View File

@ -39,9 +39,9 @@ styleElement.appendChild(document.createTextNode(''));
document.head.appendChild(styleElement); document.head.appendChild(styleElement);
const styleSheet = styleElement.sheet; const styleSheet = styleElement.sheet;
document.documentElement.addEventListener('darkmode', () => { new MutationObserver(() => {
skin = detectSkin(); skin = detectSkin();
}); }).observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
export function createColors(datasetColors) { export function createColors(datasetColors) {
const colors = {}; const colors = {};

View File

@ -15,7 +15,6 @@
text-decoration: none; text-decoration: none;
background-color: transparent; background-color: transparent;
transition: opacity 150ms ease; transition: opacity 150ms ease;
&:hover { &:hover {
@ -78,6 +77,7 @@
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 -256 1792 1792' version='1.1'%0A%3E%3Cg transform='matrix(1,0,0,-1,7.5932203,1217.0847)' id='g3003'%3E%3Cpath d='m 1671,970 q 0,-40 -28,-68 L 919,178 783,42 Q 755,14 715,14 675,14 647,42 L 511,178 149,540 q -28,28 -28,68 0,40 28,68 l 136,136 q 28,28 68,28 40,0 68,-28 l 294,-295 656,657 q 28,28 68,28 40,0 68,-28 l 136,-136 q 28,-28 28,-68 z' style='fill:white'/%3E%3C/g%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 -256 1792 1792' version='1.1'%0A%3E%3Cg transform='matrix(1,0,0,-1,7.5932203,1217.0847)' id='g3003'%3E%3Cpath d='m 1671,970 q 0,-40 -28,-68 L 919,178 783,42 Q 755,14 715,14 675,14 647,42 L 511,178 149,540 q -28,28 -28,68 0,40 28,68 l 136,136 q 28,28 68,28 40,0 68,-28 l 294,-295 656,657 q 28,28 68,28 40,0 68,-28 l 136,-136 q 28,-28 28,-68 z' style='fill:white'/%3E%3C/g%3E%3C/svg%3E");
background-size: 100%; background-size: 100%;
} }
} }
.lovely-chart--button-label { .lovely-chart--button-label {

View File

@ -7,20 +7,11 @@
--zoom-out-text: #108BE3; --zoom-out-text: #108BE3;
--tooltip-background: #ffffff; --tooltip-background: #ffffff;
--tooltip-arrow: #D2D5D7; --tooltip-arrow: #D2D5D7;
--minimap-limit-color: #616770;
-webkit-user-select: none; -webkit-user-select: none;
user-select: none; user-select: none;
position: relative;
overflow: hidden;
font: 300 13px '-apple-system', 'HelveticaNeue', Helvetica, Arial, sans-serif;
color: #222222;
text-align: left;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
html.theme-dark & { html.theme-dark & {
--background-color: #242F3E; --background-color: #242F3E;
--text-color: #ffffff; --text-color: #ffffff;
@ -30,8 +21,19 @@
--zoom-out-text: #48AAF0; --zoom-out-text: #48AAF0;
--tooltip-background: #1c2533; --tooltip-background: #1c2533;
--tooltip-arrow: #D2D5D7; --tooltip-arrow: #D2D5D7;
--minimap-limit-color: #BFC0C2;
} }
position: relative;
overflow: hidden;
font: 300 13px '-apple-system', 'HelveticaNeue', Helvetica, Arial, sans-serif;
color: #222222;
text-align: left;
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
// &.lovely-chart--state-invisible > * { // &.lovely-chart--state-invisible > * {
// display: none; // display: none;
// } // }

View File

@ -61,6 +61,39 @@
} }
} }
.lovely-chart--minimap-limit-mask {
display: flex;
align-items: center;
justify-content: center;
position: absolute;
top: 0;
left: 0;
height: 100%;
border-radius: 6px 0 0 6px;
border-right: 2px dashed var(--minimap-limit-color);
color: var(--minimap-limit-color);
pointer-events: none;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
&.lovely-chart--state-interactive {
cursor: var(--custom-cursor, pointer);
pointer-events: auto;
transition: opacity 200ms ease;
&:hover {
opacity: 0.85;
}
}
}
.lovely-chart--minimap-slider { .lovely-chart--minimap-slider {
display: inline-block; display: inline-block;

View File

@ -1,17 +1,15 @@
// https://jsperf.com/finding-maximum-element-in-an-array // https://jsperf.com/finding-maximum-element-in-an-array
export function getMaxMin(array) { export function getMaxMin(array) {
const length = array.length; const length = array.length;
let max = array[0]; let max;
let min = array[0]; let min;
for (let i = 0; i < length; i++) { for (let i = 0; i < length; i++) {
const value = array[i]; const value = array[i];
if (value > max) { if (value == null) continue;
max = value; if (max === undefined || value > max) max = value;
} else if (value < min) { if (min === undefined || value < min) min = value;
min = value;
}
} }
return { max, min }; return { max, min };