diff --git a/e2e/screenshots/all.test.ts-snapshots/baselines/timeslip-alpha/timeslip-prototype-chrome-linux.png b/e2e/screenshots/all.test.ts-snapshots/baselines/timeslip-alpha/timeslip-prototype-chrome-linux.png new file mode 100644 index 0000000000..df6d0d222e Binary files /dev/null and b/e2e/screenshots/all.test.ts-snapshots/baselines/timeslip-alpha/timeslip-prototype-chrome-linux.png differ diff --git a/package.json b/package.json index bf57b385ae..9192426a04 100644 --- a/package.json +++ b/package.json @@ -153,7 +153,6 @@ "mini-css-extract-plugin": "1.6.2", "moment": "^2.29.1", "moment-timezone": "^0.5.32", - "sass": "^1.49.9", "numeral": "^2.0.6", "postcss": "^8.3.0", "postcss-cli": "^8.3.1", @@ -167,6 +166,7 @@ "react-dom": "^16.13.0", "react-is": "^16.13.0", "redux-devtools-extension": "^2.13.8", + "sass": "^1.49.9", "sass-graph": "^3.0.5", "seedrandom": "^3.0.5", "semantic-release": "^19.0.3", diff --git a/packages/charts/api/charts.api.md b/packages/charts/api/charts.api.md index 75168139b2..8ae568e347 100644 --- a/packages/charts/api/charts.api.md +++ b/packages/charts/api/charts.api.md @@ -524,6 +524,7 @@ export const ChartType: Readonly<{ Goal: "goal"; Partition: "partition"; Flame: "flame"; + Timeslip: "timeslip"; XYAxis: "xy_axis"; Heatmap: "heatmap"; Wordcloud: "wordcloud"; @@ -1082,6 +1083,18 @@ export interface GeometryValue { y: any; } +// @public +export type GetData = (dataDemand: { + lo: TimeBin; + hi: TimeBin; + binUnit: string; + binUnitCount: number; + unitBarMaxWidthPixels: number; +}) => Array<{ + epochMs: number; + value: number; +}>; + // @public (undocumented) export function getNodeName(node: ArrayNode): string; @@ -2718,6 +2731,21 @@ export interface TimeScale { type: typeof ScaleType.Time; } +// Warning: (ae-forgotten-export) The symbol "buildProps" needs to be exported by the entry point index.d.ts +// +// @public +export const Timeslip: (props: SFProps) => null; + +// @public +export interface TimeslipSpec extends Spec { + // (undocumented) + chartType: typeof ChartType.Timeslip; + // (undocumented) + getData: GetData; + // (undocumented) + specType: typeof SpecType.Series; +} + // @public export function toEntries, S>(array: T[], accessor: keyof T, staticValue: S): Record; @@ -3086,6 +3114,10 @@ export interface YDomainBase { // @public (undocumented) export type YDomainRange = YDomainBase & DomainRange & LogScaleOptions; +// Warnings were encountered during analysis: +// +// src/chart_types/timeslip/timeslip_api.ts:21:3 - (ae-forgotten-export) The symbol "TimeBin" needs to be exported by the entry point index.d.ts + // (No @packageDocumentation comment for this package) ``` diff --git a/packages/charts/src/chart_types/index.ts b/packages/charts/src/chart_types/index.ts index c42acdc11f..2726dc7ce7 100644 --- a/packages/charts/src/chart_types/index.ts +++ b/packages/charts/src/chart_types/index.ts @@ -17,6 +17,7 @@ export const ChartType = Object.freeze({ Goal: 'goal' as const, Partition: 'partition' as const, Flame: 'flame' as const, + Timeslip: 'timeslip' as const, XYAxis: 'xy_axis' as const, Heatmap: 'heatmap' as const, Wordcloud: 'wordcloud' as const, diff --git a/packages/charts/src/chart_types/timeslip/internal_chart_state.ts b/packages/charts/src/chart_types/timeslip/internal_chart_state.ts new file mode 100644 index 0000000000..92d70cbd8b --- /dev/null +++ b/packages/charts/src/chart_types/timeslip/internal_chart_state.ts @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ChartType } from '..'; +import { DEFAULT_CSS_CURSOR } from '../../common/constants'; +import { LegendItemExtraValues } from '../../common/legend'; +import { SeriesKey } from '../../common/series_id'; +import { InternalChartState } from '../../state/chart_state'; +import { InitStatus } from '../../state/selectors/get_internal_is_intialized'; +import { TimeslipWithTooltip } from './timeslip_chart'; + +/** @internal */ +export class TimeslipState implements InternalChartState { + chartType = ChartType.Timeslip; + getChartTypeDescription = () => 'Timeslip chart'; + chartRenderer = TimeslipWithTooltip; + + // default empty properties, unused in Timeslip + eventCallbacks = () => {}; + isInitialized = () => InitStatus.Initialized; + isBrushAvailable = () => false; + isBrushing = () => false; + isChartEmpty = () => false; + getLegendItemsLabels = () => []; + getLegendItems = () => []; + getLegendExtraValues = () => new Map(); + getPointerCursor = () => DEFAULT_CSS_CURSOR; + getTooltipAnchor = () => ({ x: 0, y: 0, width: 0, height: 0 }); + isTooltipVisible = () => ({ visible: false, isExternal: false }); + getTooltipInfo = () => ({ header: null, values: [] }); + getProjectionContainerArea = () => ({ width: 0, height: 0, top: 0, left: 0 }); + getMainProjectionArea = () => ({ width: 0, height: 0, top: 0, left: 0 }); + getBrushArea = () => null; + getDebugState = () => ({}); +} diff --git a/packages/charts/src/chart_types/timeslip/timeslip/axis_model.ts b/packages/charts/src/chart_types/timeslip/timeslip/axis_model.ts new file mode 100644 index 0000000000..8bc417703e --- /dev/null +++ b/packages/charts/src/chart_types/timeslip/timeslip/axis_model.ts @@ -0,0 +1,51 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// @ts-noCheck + +const getNiceTicksForApproxCount = (domainMin, domainMax, approxDesiredTickCount) => { + const diff = domainMax - domainMin; + const rawPitch = diff / approxDesiredTickCount; + const exponent = Math.floor(Math.log10(rawPitch)); + const orderOfMagnitude = 10 ** exponent; // this represents the order of magnitude eg. 10000, so that... + const mantissa = rawPitch / orderOfMagnitude; // it's always the case that 1 <= mantissa <= 9.99999999999 + const niceMantissa = mantissa > 5 ? 10 : mantissa > 2 ? 5 : mantissa > 1 ? 2 : 1; // snap to 10, 5, 2 or 1 + const tickInterval = niceMantissa * orderOfMagnitude; + if (!isFinite(tickInterval)) { + return []; + } + const result = []; + for (let i = Math.floor(domainMin / tickInterval); i <= Math.ceil(domainMax / tickInterval); i++) { + result.push(i * tickInterval); + } + return result; +}; + +const getNiceTicks = (domainMin, domainMax, maximumTickCount) => { + let bestCandidate = []; + for (let i = 0; i <= maximumTickCount; i++) { + const candidate = getNiceTicksForApproxCount(domainMin, domainMax, maximumTickCount - i); + if (candidate.length <= maximumTickCount && candidate.length > 0) return candidate; + if (bestCandidate.length === 0 || maximumTickCount - candidate.length < maximumTickCount - bestCandidate.length) { + bestCandidate = candidate; + } + } + return bestCandidate.length > maximumTickCount + ? [...(maximumTickCount > 1 ? [bestCandidate[0]] : []), bestCandidate[bestCandidate.length - 1]] + : []; +}; + +/** @internal */ +export const axisModel = (domainLandmarks, desiredTickCount) => { + const domainMin = Math.min(...domainLandmarks); + const domainMax = Math.max(...domainLandmarks); + const niceTicks = getNiceTicks(domainMin, domainMax, desiredTickCount); + const niceDomainMin = niceTicks.length >= 2 ? niceTicks[0] : domainMin; + const niceDomainMax = niceTicks.length >= 2 ? niceTicks[niceTicks.length - 1] : domainMax; + return { niceDomainMin, niceDomainMax, niceTicks }; +}; diff --git a/packages/charts/src/chart_types/timeslip/timeslip/data.ts b/packages/charts/src/chart_types/timeslip/timeslip/data.ts new file mode 100644 index 0000000000..733885602b --- /dev/null +++ b/packages/charts/src/chart_types/timeslip/timeslip/data.ts @@ -0,0 +1,34 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// @ts-noCheck + +/** @internal */ +export const dataSource = Symbol('dataSource'); + +/** @internal */ +export const getEnrichedData = (rows) => { + const stats = rows.reduce( + (p, { epochMs, value }) => { + const { minEpochMs, maxEpochMs, minValue, maxValue } = p; + p.minEpochMs = Math.min(minEpochMs, epochMs); + p.maxEpochMs = Math.max(maxEpochMs, epochMs); + p.minValue = Math.min(minValue, value); + p.maxValue = Math.max(maxValue, value); + return p; + }, + { + minEpochMs: Infinity, + maxEpochMs: -Infinity, + minValue: Infinity, + maxValue: -Infinity, + }, + ); + // console.log({ from: new Date(stats.minEpochMs), to: new Date(stats.maxEpochMs), count: rows.length }) + return { rows, stats }; +}; diff --git a/packages/charts/src/chart_types/timeslip/timeslip/domain_tween.ts b/packages/charts/src/chart_types/timeslip/timeslip/domain_tween.ts new file mode 100644 index 0000000000..5757ceecd5 --- /dev/null +++ b/packages/charts/src/chart_types/timeslip/timeslip/domain_tween.ts @@ -0,0 +1,33 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// @ts-noCheck + +import { mix } from './utils/math'; + +const REFERENCE_AF_LENGTH = 16.67; // ms +const REFERENCE_Y_RECURRENCE_ALPHA = 0.1; +const TWEEN_DONE_EPSILON = 0.001; + +/** @internal */ +export const domainTween = (interactionState, deltaT, targetMin, targetMax) => { + const { niceDomainMin: currentMin, niceDomainMax: currentMax } = interactionState; + + // pure logic + const speedExp = Math.pow((currentMax - currentMin) / (targetMax - targetMin), 0.2); // speeds up big decreases + const advance = 1 - (1 - REFERENCE_Y_RECURRENCE_ALPHA) ** ((speedExp * deltaT) / REFERENCE_AF_LENGTH); + const min = Number.isFinite(currentMin) ? mix(currentMin, targetMin, advance) : targetMin; + const max = Number.isFinite(currentMax) ? mix(currentMax, targetMax, advance) : targetMax; + const tweenIncomplete = Math.abs(1 - (max - min) / (targetMax - targetMin)) > TWEEN_DONE_EPSILON; + + // remember + interactionState.niceDomainMin = min; + interactionState.niceDomainMax = max; + + return tweenIncomplete; +}; diff --git a/packages/charts/src/chart_types/timeslip/timeslip/render/annotations/chart_title.ts b/packages/charts/src/chart_types/timeslip/timeslip/render/annotations/chart_title.ts new file mode 100644 index 0000000000..e23b1a4590 --- /dev/null +++ b/packages/charts/src/chart_types/timeslip/timeslip/render/annotations/chart_title.ts @@ -0,0 +1,22 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// @ts-noCheck + +/** @internal */ +export function renderChartTitle(ctx, config, chartWidth, cartesianTop, aggregationFunctionName) { + ctx.save(); + const titleFontSize = 32; // todo move to config + ctx.textBaseline = 'middle'; + ctx.textAlign = 'center'; + ctx.font = `normal normal 200 ${titleFontSize}px Inter, Helvetica, Arial, sans-serif`; // todo move to config + ctx.fillStyle = config.subduedFontColor; + ctx.fillText(config.queryConfig.metricFieldName, chartWidth / 2, cartesianTop / 2 - titleFontSize * 0.5); + ctx.fillText(aggregationFunctionName, chartWidth / 2, cartesianTop / 2 + titleFontSize * 0.5); + ctx.restore(); +} diff --git a/packages/charts/src/chart_types/timeslip/timeslip/render/annotations/time_extent.ts b/packages/charts/src/chart_types/timeslip/timeslip/render/annotations/time_extent.ts new file mode 100644 index 0000000000..9de1fdf7ce --- /dev/null +++ b/packages/charts/src/chart_types/timeslip/timeslip/render/annotations/time_extent.ts @@ -0,0 +1,35 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// @ts-noCheck + +/** @internal */ +export function renderTimeExtentAnnotation( + ctx, + config, + localeOptions, + timeDomainFrom, + timeDomainTo, + cartesianWidth, + chartTopFontSize, +) { + ctx.save(); + ctx.textBaseline = 'bottom'; + ctx.textAlign = 'right'; + ctx.font = config.monospacedFontShorthand; + ctx.fillStyle = config.subduedFontColor; + // todo switch to new Intl.DateTimeFormat for more performance https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat + ctx.fillText( + `${new Date(timeDomainFrom * 1000).toLocaleString(config.locale, localeOptions)} — ${new Date( + timeDomainTo * 1000, + ).toLocaleString(config.locale, localeOptions)}`, + cartesianWidth, + -chartTopFontSize * 0.5, + ); + ctx.restore(); +} diff --git a/packages/charts/src/chart_types/timeslip/timeslip/render/annotations/time_unit.ts b/packages/charts/src/chart_types/timeslip/timeslip/render/annotations/time_unit.ts new file mode 100644 index 0000000000..84015139ed --- /dev/null +++ b/packages/charts/src/chart_types/timeslip/timeslip/render/annotations/time_unit.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// @ts-noCheck + +import { uiStrings } from '../../translations'; + +/** @internal */ +export function renderTimeUnitAnnotation(ctx, config, binUnitCount, binUnit, chartTopFontSize, unitBarMaxWidthPixels) { + ctx.save(); + ctx.textBaseline = 'bottom'; + ctx.textAlign = 'left'; + ctx.font = config.monospacedFontShorthand; + ctx.fillStyle = config.a11y.contrast === 'low' ? config.subduedFontColor : config.defaultFontColor; + ctx.fillText( + `1 ${uiStrings[config.locale].bar} = ${binUnitCount} ${ + uiStrings[config.locale][binUnit + (binUnitCount !== 1 ? 's' : '')] + }`, + 0, + -chartTopFontSize * 0.5, + ); + const unitBarY = -chartTopFontSize * 2.2; + ctx.fillRect(0, unitBarY, unitBarMaxWidthPixels, 1); + ctx.fillRect(0, unitBarY - 3, 1, 7); + ctx.fillRect(unitBarMaxWidthPixels - 1, unitBarY - 3, 1, 7); + ctx.restore(); +} diff --git a/packages/charts/src/chart_types/timeslip/timeslip/render/cartesian.ts b/packages/charts/src/chart_types/timeslip/timeslip/render/cartesian.ts new file mode 100644 index 0000000000..3fe6f2a885 --- /dev/null +++ b/packages/charts/src/chart_types/timeslip/timeslip/render/cartesian.ts @@ -0,0 +1,86 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// @ts-noCheck + +import { renderRaster } from './raster'; + +/** @internal */ +export const renderCartesian = ( + ctx, + config, + dataState, + guiConfig, + defaultMinorTickLabelFormat, + emWidth, + fadeOutPixelWidth, + defaultLabelFormat, + yTickNumberFormatter, + rasterSelector, + cartesianWidth, + cartesianHeight, + { domainFrom, domainTo }, + yUnitScale, + yUnitScaleClamped, + niceTicks, +) => { + ctx.textBaseline = 'top'; + ctx.fillStyle = config.defaultFontColor; + ctx.font = config.cssFontShorthand; + ctx.textAlign = 'left'; + + const timeExtent = domainTo - domainFrom; + + const getPixelX = (timePointSec) => { + const continuousOffset = timePointSec - domainFrom; + const ratio = continuousOffset / timeExtent; + return cartesianWidth * ratio; + }; + + const notTooDense = + (domainFrom, domainTo) => + ({ minimumPixelsPerSecond }) => { + const domainInSeconds = domainTo - domainFrom; + const pixelsPerSecond = cartesianWidth / domainInSeconds; + return pixelsPerSecond > minimumPixelsPerSecond; + }; + + const layers = rasterSelector(notTooDense(domainFrom, domainTo)); + + const loHi = layers.reduce( + renderRaster({ + ctx, + config, + guiConfig, + dataState, + fadeOutPixelWidth, + emWidth, + defaultMinorTickLabelFormat, + defaultLabelFormat, + yTickNumberFormatter, + domainFrom, + domainTo, + getPixelX, + cartesianWidth, + cartesianHeight, + niceTicks, + yUnitScale, + yUnitScaleClamped, + layers, + }), + { lo: null, hi: null, unitBarMaxWidthPixelsSum: 0, unitBarMaxWidthPixelsCount: 0 }, + ); + + return { + lo: loHi.lo, + hi: loHi.hi, + binUnit: layers[0].unit, + binUnitCount: layers[0].unitMultiplier, + unitBarMaxWidthPixels: loHi.unitBarMaxWidthPixelsSum / loHi.unitBarMaxWidthPixelsCount, + }; +}; diff --git a/packages/charts/src/chart_types/timeslip/timeslip/render/column.ts b/packages/charts/src/chart_types/timeslip/timeslip/render/column.ts new file mode 100644 index 0000000000..c4ae860edd --- /dev/null +++ b/packages/charts/src/chart_types/timeslip/timeslip/render/column.ts @@ -0,0 +1,210 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// @ts-noCheck + +import { renderBarGlyph } from './glyphs/bar'; +import { renderBoxplotGlyph } from './glyphs/boxplot'; + +/** @internal */ +export const renderColumn = ( + { + ctx, + config, + guiConfig, + dataState, + emWidth, + fadeOutPixelWidth, + getPixelX, + labelFormat, + minorLabelFormat, + unitBarMaxWidthPixelsSum, + unitBarMaxWidthPixelsCount, + labeled, + textNestLevel, + textNestLevelRowLimited, + cartesianWidth, + cartesianHeight, + i, + valid, + luma, + lineThickness, + lineNestLevelRowLimited, + halfLineThickness, + domainFrom, + layers, + rows, + yUnitScale, + yUnitScaleClamped, + }, + { fontColor, timePointSec, nextTimePointSec }, + pixelX = getPixelX(timePointSec), + maxWidth = Infinity, +) => { + if (labeled && textNestLevel <= guiConfig.maxLabelRowCount) { + const text = + textNestLevelRowLimited === guiConfig.maxLabelRowCount + ? labelFormat(timePointSec * 1000) + : minorLabelFormat(timePointSec * 1000); + if (text.length > 0) { + const textX = pixelX + config.horizontalPixelOffset; + const y = config.verticalPixelOffset + (textNestLevelRowLimited - 1) * config.rowPixelPitch; + const leftShortening = + maxWidth === Infinity ? 0 : Math.max(0, ctx.measureText(text).width + config.horizontalPixelOffset - maxWidth); + const rightShortening = + textX + Math.min(maxWidth, text.length * emWidth) < cartesianWidth + ? 0 + : Math.max(0, textX + ctx.measureText(text).width - cartesianWidth); + const maxWidthRight = Math.max(0, cartesianWidth - textX); + const clipLeft = config.clipLeft && leftShortening > 0; + const clipRight = config.clipRight && rightShortening > 0; + if (clipLeft) { + ctx.save(); + ctx.beginPath(); + ctx.rect(config.horizontalPixelOffset, y - 0.35 * config.rowPixelPitch, maxWidth, config.rowPixelPitch); + ctx.clip(); + } + if (clipRight) { + ctx.save(); + ctx.beginPath(); + ctx.rect(textX, y - 0.35 * config.rowPixelPitch, maxWidthRight, config.rowPixelPitch); + ctx.clip(); + } + ctx.fillStyle = + fontColor ?? (guiConfig.a11y.contrast === 'low' ? config.subduedFontColor : config.defaultFontColor); + ctx.fillText(text, textX - leftShortening, y); + if (clipRight) { + const { r, g, b } = config.backgroundColor; + const fadeOutRight = ctx.createLinearGradient(textX, 0, textX + maxWidthRight, 0); + fadeOutRight.addColorStop(0, `rgba(${r},${g},${b},0)`); + fadeOutRight.addColorStop( + maxWidthRight === 0 ? 0.5 : Math.max(0, 1 - fadeOutPixelWidth / maxWidthRight), + `rgba(${r},${g},${b},0)`, + ); + fadeOutRight.addColorStop(1, `rgba(${r},${g},${b},1)`); + ctx.fillStyle = fadeOutRight; + ctx.fill(); + ctx.restore(); + } + if (clipLeft) { + const { r, g, b } = config.backgroundColor; + const fadeOutLeft = ctx.createLinearGradient(0, 0, maxWidth, 0); + fadeOutLeft.addColorStop(0, `rgba(${r},${g},${b},1)`); + fadeOutLeft.addColorStop( + maxWidth === 0 ? 0.5 : Math.min(1, fadeOutPixelWidth / maxWidth), + `rgba(${r},${g},${b},0)`, + ); + fadeOutLeft.addColorStop(1, `rgba(${r},${g},${b},0)`); + ctx.fillStyle = fadeOutLeft; + ctx.fill(); + ctx.restore(); + } + } + } + + // draw bars + const barPad = guiConfig.implicit ? halfLineThickness : 0; + const fullBarPixelX = getPixelX(timePointSec); + const barMaxWidthPixels = getPixelX(nextTimePointSec) - fullBarPixelX - 2 * barPad; + if (i === 0) { + unitBarMaxWidthPixelsSum += barMaxWidthPixels; + unitBarMaxWidthPixelsCount++; + } + renderBar: if ( + i === 0 && + valid && + dataState.binUnit === layers[0].unit && + dataState.binUnitCount === layers[0].unitMultiplier + ) { + const foundRow = rows.find((r) => timePointSec * 1000 <= r.epochMs && r.epochMs < nextTimePointSec * 1000); + if (!foundRow) { + break renderBar; // comment it out if the goal is to see zero values where data is missing + } + ctx.save(); + + // left side special case + const leftShortfall = Math.abs(pixelX - fullBarPixelX); + const leftOpacityMultiplier = leftShortfall ? 1 - Math.max(0, Math.min(1, leftShortfall / barMaxWidthPixels)) : 1; + + // right side special case + const barX = pixelX + barPad; + const rightShortfall = Math.max(0, barX + barMaxWidthPixels - cartesianWidth); + + const maxBarHeight = cartesianHeight; + const barWidthPixels = barMaxWidthPixels - rightShortfall; + + const rightOpacityMultiplier = rightShortfall + ? 1 - Math.max(0, Math.min(1, rightShortfall / barMaxWidthPixels)) + : 1; + const { r, g, b } = config.barChroma; + const maxOpacity = config.barFillAlpha; + const opacityMultiplier = leftOpacityMultiplier * rightOpacityMultiplier; + const opacity = maxOpacity * opacityMultiplier; + const opacityDependentLineThickness = opacityMultiplier === 1 ? 1 : Math.sqrt(opacityMultiplier); + if (guiConfig.queryConfig.boxplot && foundRow.boxplot) { + renderBoxplotGlyph( + ctx, + barMaxWidthPixels, + barX, + leftShortfall, + foundRow, + maxBarHeight, + yUnitScaleClamped, + opacityMultiplier, + r, + g, + b, + maxOpacity, + ); + } else { + renderBarGlyph( + ctx, + barWidthPixels, + leftShortfall, + maxBarHeight, + yUnitScale, + foundRow, + yUnitScaleClamped, + r, + g, + b, + opacity, + barX, + opacityDependentLineThickness, + ); + } + ctx.restore(); + } + + // render vertical grid lines + // the measured text width, plus the `config.horizontalPixelOffset` on the left side must fit inside `maxWidth` + if (domainFrom < timePointSec) { + ctx.fillStyle = `rgb(${luma},${luma},${luma})`; + ctx.fillRect( + pixelX - halfLineThickness, + -cartesianHeight, + lineThickness, + cartesianHeight + lineNestLevelRowLimited * config.rowPixelPitch, + ); + if (guiConfig.implicit && lineNestLevelRowLimited > 0) { + const verticalSeparation = 1; // todo config + ctx.fillStyle = 'lightgrey'; // todo config + ctx.fillRect( + pixelX - halfLineThickness, + verticalSeparation, + lineThickness, + lineNestLevelRowLimited * config.rowPixelPitch - verticalSeparation, + ); + } + } + + return { + unitBarMaxWidthPixelsSum, + unitBarMaxWidthPixelsCount, + }; +}; diff --git a/packages/charts/src/chart_types/timeslip/timeslip/render/glyphs/bar.ts b/packages/charts/src/chart_types/timeslip/timeslip/render/glyphs/bar.ts new file mode 100644 index 0000000000..6641478308 --- /dev/null +++ b/packages/charts/src/chart_types/timeslip/timeslip/render/glyphs/bar.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// @ts-noCheck + +/** @internal */ +export function renderBarGlyph( + ctx, + barWidthPixels, + leftShortfall, + maxBarHeight, + yUnitScale, + foundRow, + yUnitScaleClamped, + r, + g, + b, + opacity, + barX, + opacityDependentLineThickness, +) { + const renderedBarWidth = Math.max(0, barWidthPixels - leftShortfall); + const barEnd = -maxBarHeight * yUnitScale(foundRow.value); + const clampedBarEnd = -maxBarHeight * yUnitScaleClamped(foundRow.value); + const clampedBarStart = -maxBarHeight * yUnitScaleClamped(0); + const barHeight = Math.abs(clampedBarStart - clampedBarEnd); + const barY = Math.min(clampedBarStart, clampedBarEnd); + ctx.fillStyle = `rgba(${r},${g},${b},${opacity})`; + ctx.fillRect(barX, barY, renderedBarWidth, barHeight); + if (clampedBarEnd === barEnd) { + ctx.fillStyle = `rgba(${r},${g},${b},1)`; + ctx.fillRect(barX, clampedBarEnd, renderedBarWidth, opacityDependentLineThickness); // avoid Math.sqrt + } +} diff --git a/packages/charts/src/chart_types/timeslip/timeslip/render/glyphs/boxplot.ts b/packages/charts/src/chart_types/timeslip/timeslip/render/glyphs/boxplot.ts new file mode 100644 index 0000000000..591188ae73 --- /dev/null +++ b/packages/charts/src/chart_types/timeslip/timeslip/render/glyphs/boxplot.ts @@ -0,0 +1,57 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// @ts-noCheck + +/** @internal */ +export function renderBoxplotGlyph( + ctx, + barMaxWidthPixels, + barX, + leftShortfall, + foundRow, + maxBarHeight, + yUnitScaleClamped, + opacityMultiplier, + r, + g, + b, + maxOpacity, +) { + const goldenRatio = 1.618; // todo move it into constants + const boxplotWidth = barMaxWidthPixels / goldenRatio; // - clamp(rightShortfall etc etc) + const whiskerWidth = boxplotWidth / 2; + const boxplotLeftX = barX + (barMaxWidthPixels - boxplotWidth) / 2 - leftShortfall; + const boxplotCenterX = boxplotLeftX + boxplotWidth / 2; + const { /*min, */ lower, q1, q2, q3, upper /*max */ } = foundRow.boxplot; + const lowerY = maxBarHeight * yUnitScaleClamped(lower); + const q1Y = maxBarHeight * yUnitScaleClamped(q1); + const q2Y = maxBarHeight * yUnitScaleClamped(q2); + const q3Y = maxBarHeight * yUnitScaleClamped(q3); + const upperY = maxBarHeight * yUnitScaleClamped(upper); + // boxplot rectangle body with border + if (lowerY !== upperY && q1Y !== q2Y && q2Y !== q3Y) { + const unitVisibility = opacityMultiplier ** 5; + ctx.beginPath(); + ctx.rect(boxplotLeftX, -q3Y, boxplotWidth, q3Y - q1Y); + ctx.fillStyle = `rgba(${r},${g},${b},${maxOpacity * unitVisibility})`; + ctx.fill(); + ctx.strokeStyle = `rgba(${r},${g},${b},1)`; + ctx.lineWidth = unitVisibility; + //ctx.stroke() + // boxplot whiskers + ctx.fillStyle = `rgba(${r},${g},${b},1)`; + ctx.fillRect(boxplotCenterX - whiskerWidth / 2, -upperY, whiskerWidth, unitVisibility); // upper horizontal + ctx.fillRect(boxplotCenterX - boxplotWidth / 2, -q3Y, boxplotWidth, unitVisibility); // q2 horizontal + ctx.fillRect(boxplotCenterX - boxplotWidth / 2, -q2Y, boxplotWidth, unitVisibility); // q2 horizontal + ctx.fillRect(boxplotCenterX - boxplotWidth / 2, -q1Y, boxplotWidth, unitVisibility); // q2 horizontal + ctx.fillRect(boxplotCenterX - whiskerWidth / 2, -lowerY, whiskerWidth, unitVisibility); // lower horizontal + ctx.fillRect(boxplotCenterX, -upperY, unitVisibility, upperY - q3Y); // top vertical + ctx.fillRect(boxplotCenterX, -q1Y, unitVisibility, q1Y - lowerY); // bottom vertical + } +} diff --git a/packages/charts/src/chart_types/timeslip/timeslip/render/glyphs/debug_box.ts b/packages/charts/src/chart_types/timeslip/timeslip/render/glyphs/debug_box.ts new file mode 100644 index 0000000000..716edff484 --- /dev/null +++ b/packages/charts/src/chart_types/timeslip/timeslip/render/glyphs/debug_box.ts @@ -0,0 +1,21 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// @ts-noCheck + +/** @internal */ +export function renderDebugBox(ctx, cartesianWidth, cartesianHeight) { + ctx.save(); + ctx.beginPath(); + ctx.rect(0, 0, cartesianWidth, cartesianHeight); + ctx.strokeStyle = 'magenta'; + ctx.setLineDash([5, 5]); + ctx.lineWidth = 1; + ctx.stroke(); + ctx.restore(); +} diff --git a/packages/charts/src/chart_types/timeslip/timeslip/render/raster.ts b/packages/charts/src/chart_types/timeslip/timeslip/render/raster.ts new file mode 100644 index 0000000000..8b448b0570 --- /dev/null +++ b/packages/charts/src/chart_types/timeslip/timeslip/render/raster.ts @@ -0,0 +1,172 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// @ts-noCheck + +import { clamp } from '../utils/math'; +import { renderColumn } from './column'; + +/** @internal */ +export const renderRaster = + ({ + ctx, + config, + guiConfig, + dataState, + fadeOutPixelWidth, + emWidth, + defaultMinorTickLabelFormat, + defaultLabelFormat, + yTickNumberFormatter, + domainFrom, + domainTo, + getPixelX, + cartesianWidth, + cartesianHeight, + niceTicks, + yUnitScale, + yUnitScaleClamped, + layers, + }) => + (loHi, { labeled, binStarts, minorTickLabelFormat, detailedLabelFormat }, i, a) => { + const { + valid, + dataResponse: { rows }, + } = dataState; + + const minorLabelFormat = minorTickLabelFormat ?? defaultMinorTickLabelFormat; + const labelFormat = detailedLabelFormat ?? minorLabelFormat ?? defaultLabelFormat; + const textNestLevel = a.slice(0, i + 1).filter((layer) => layer.labeled).length; + const lineNestLevel = a[i] === a[0] ? 0 : textNestLevel; + const textNestLevelRowLimited = Math.min(guiConfig.maxLabelRowCount, textNestLevel); // max. N rows + const lineNestLevelRowLimited = Math.min(guiConfig.maxLabelRowCount, lineNestLevel); + const lineThickness = config.lineThicknessSteps[i]; + const luma = + config.lumaSteps[i] * + (guiConfig.darkMode + ? guiConfig.a11y.contrast === 'low' + ? 0.5 + : 1 + : guiConfig.a11y.contrast === 'low' + ? 1.5 + : 1); + const halfLineThickness = lineThickness / 2; + + // render all bins that start in the visible domain + let firstInsideBinStart; + let precedingBinStart; + + const columnProps = { + ctx, + config, + guiConfig, + dataState, + fadeOutPixelWidth, + emWidth, + getPixelX, + labelFormat, + minorLabelFormat, + unitBarMaxWidthPixelsSum: loHi.unitBarMaxWidthPixelsSum, + unitBarMaxWidthPixelsCount: loHi.unitBarMaxWidthPixelsCount, + labeled, + textNestLevel, + textNestLevelRowLimited, + cartesianWidth, + cartesianHeight, + i, + valid, + luma, + lineThickness, + halfLineThickness, + lineNestLevelRowLimited, + domainFrom, + layers, + rows, + yUnitScale, + yUnitScaleClamped, + }; + + for (const binStart of binStarts(domainFrom, domainTo)) { + const { timePointSec } = binStart; + if (domainFrom > timePointSec) { + precedingBinStart = binStart; + continue; + } + if (timePointSec > domainTo) { + break; + } + + if (i === 0) { + loHi.lo = loHi.lo || binStart; + loHi.hi = binStart; + } + + if (!firstInsideBinStart) { + firstInsideBinStart = binStart; + } + const { unitBarMaxWidthPixelsSum, unitBarMaxWidthPixelsCount } = renderColumn(columnProps, binStart); + loHi.unitBarMaxWidthPixelsSum = unitBarMaxWidthPixelsSum; + loHi.unitBarMaxWidthPixelsCount = unitBarMaxWidthPixelsCount; + } + + // render specially the tick that just precedes the domain, therefore may insert into it (eg. intentionally, via needing to see tick texts) + if (precedingBinStart) { + if (i === 0) { + // condition necessary, otherwise it'll be the binStart of some temporally coarser bin + loHi.lo = precedingBinStart; // partial bin on the left + } + const { unitBarMaxWidthPixelsSum, unitBarMaxWidthPixelsCount } = renderColumn( + columnProps, + precedingBinStart, + 0, + firstInsideBinStart + ? Math.max(0, getPixelX(firstInsideBinStart.timePointSec) - config.horizontalPixelOffset) + : Infinity, + ); + loHi.unitBarMaxWidthPixelsSum = unitBarMaxWidthPixelsSum; + loHi.unitBarMaxWidthPixelsCount = unitBarMaxWidthPixelsCount; + } + + // render horizontal grids + const horizontalGrids = true; + if (horizontalGrids) { + ctx.save(); + const { r, g, b } = config.backgroundColor; + const lineStyle = guiConfig.implicit + ? `rgb(${r},${g},${b})` + : `rgba(128,128,128,${guiConfig.a11y.contrast === 'low' ? 0.5 : 1})`; + ctx.textBaseline = 'middle'; + ctx.font = config.cssFontShorthand; + const overhang = 8; // todo put it in config + const gap = 8; // todo put it in config + for (const gridDomainValueY of niceTicks) { + const yUnit = yUnitScale(gridDomainValueY); + if (yUnit !== clamp(yUnit, -0.01, 1.01)) { + // todo set it back to 0 and 1 if recurrence relation of transitioning can reach 1 in finite time + continue; + } + const y = -cartesianHeight * yUnit; + const text = yTickNumberFormatter.format(gridDomainValueY); + ctx.fillStyle = gridDomainValueY === 0 ? config.defaultFontColor : lineStyle; + ctx.fillRect( + -overhang, + y, + cartesianWidth + 2 * overhang, + gridDomainValueY === 0 ? 0.5 : guiConfig.implicit ? 0.2 : 0.1, + ); + ctx.fillStyle = config.subduedFontColor; + ctx.textAlign = 'left'; + ctx.fillText(text, cartesianWidth + overhang + gap, y); + ctx.textAlign = 'right'; + ctx.fillText(text, -overhang - gap, y); + } + ctx.restore(); + } + + return loHi; + }; diff --git a/packages/charts/src/chart_types/timeslip/timeslip/timeslip_render.ts b/packages/charts/src/chart_types/timeslip/timeslip/timeslip_render.ts new file mode 100644 index 0000000000..11bb9203ab --- /dev/null +++ b/packages/charts/src/chart_types/timeslip/timeslip/timeslip_render.ts @@ -0,0 +1,716 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// @ts-noCheck + +import { cachedZonedDateTimeFrom, timeProp } from '../../xy_chart/axes/timeslip/chrono/cached_chrono'; +import { rasters } from '../../xy_chart/axes/timeslip/rasters'; +import { axisModel } from './axis_model'; +import { dataSource, getEnrichedData } from './data'; +import { domainTween } from './domain_tween'; +import { renderChartTitle } from './render/annotations/chart_title'; +import { renderTimeExtentAnnotation } from './render/annotations/time_extent'; +import { renderTimeUnitAnnotation } from './render/annotations/time_unit'; +import { renderCartesian } from './render/cartesian'; +import { renderDebugBox } from './render/glyphs/debug_box'; +import { elementSizes, zoomSafePointerX, zoomSafePointerY } from './utils/dom'; +import { observe, toCallbackFn } from './utils/generator'; +import { clamp, mix, unitClamp } from './utils/math'; +import { axisScale, getDesiredTickCount } from './utils/projection'; + +const panOngoing = (interactionState) => Number.isFinite(interactionState.dragStartX); + +/** + * noinspection JSUnusedGlobalSymbols + * @internal + */ +export const timeslipRender = (canvas /*: HTMLCanvasElement*/, ctx /*: CanvasRenderingContext2D*/, getData) => { + const processAction = toCallbackFn(handleEvents()); + + const initialDarkMode = false; + const drawCartesianBox = false; + + const singleValuedMetricsAggregationFunctionNames = { + sum: 'value', + min: 'minimum', + max: 'maximum', + avg: 'average', + cardinality: 'cardinality', + median_absolute_deviation: 'med abs dev', + rate: 'rate', + value_count: 'value count', + }; + + const aggregationFunctionNames = { + ...singleValuedMetricsAggregationFunctionNames, + }; + + const metricFieldNames = ['machine.ram', 'bytes', 'memory']; + + const minZoom = 0; + const maxZoom = 33; + + // these are hand tweaked constants that fulfill various design constraints, let's discuss before changing them + const lineThicknessSteps = [/*0,*/ 0.5, 0.75, 1, 1, 1, 1.25, 1.25, 1.5, 1.5, 1.75, 1.75, 2, 2, 2, 2, 2]; + const lumaSteps = [/*255,*/ 192, 72, 32, 16, 8, 4, 2, 1, 0, 0, 0, 0, 0, 0, 0, 0]; + + const smallFontSize = 12; + const timeZone = 'Europe/Zurich'; + + const themeLight = { + defaultFontColor: 'black', + subduedFontColor: '#393939', + offHourFontColor: 'black', + weekendFontColor: 'darkred', + backgroundColor: { r: 255, g: 255, b: 255 }, + lumaSteps, + }; + + const themeDark = { + defaultFontColor: 'white', + subduedFontColor: 'darkgrey', + offHourFontColor: 'white', + weekendFontColor: 'indianred', + backgroundColor: { r: 0, g: 0, b: 0 }, + lumaSteps: lumaSteps.map((l) => 255 - l), + }; + + const config = { + darkMode: initialDarkMode, + sparse: false, + implicit: false, + maxLabelRowCount: 3, // can be 1, 2, 3 + queryConfig: { + metricFieldName: metricFieldNames[0], + aggregation: 'value_count', + boxplot: false, + window: 0, + alpha: 0.4, + beta: 0.2, + gamma: 0.2, + period: 1, + multiplicative: false, + binOffset: 0, + }, + a11y: { + shortcuts: true, + contrast: 'medium', + animation: true, + sonification: false, + }, + locale: 'en-US', + numUnit: 'short', + ...(initialDarkMode && themeDark), + ...(!initialDarkMode && themeLight), + barChroma: { r: 96, g: 146, b: 192 }, + barFillAlpha: 0.3, + lineThicknessSteps, + domainFrom: cachedZonedDateTimeFrom({ timeZone, year: 2012, month: 1, day: 1 })[timeProp.epochSeconds], + domainTo: cachedZonedDateTimeFrom({ timeZone, year: 2022, month: 1, day: 1 })[timeProp.epochSeconds], + minBinWidth: 'day', + maxBinWidth: 'year', + pixelRangeFrom: 100, + pixelRangeTo: 500, + tickLabelMaxProtrusionLeft: 0, // constraining not used yet + tickLabelMaxProtrusionRight: 0, // constraining not used yet + protrudeAxisLeft: true, // constraining not used yet + protrudeAxisRight: true, // constraining not used yet + smallFontSize, + cssFontShorthand: `normal normal 100 ${smallFontSize}px Inter, Helvetica, Arial, sans-serif`, + monospacedFontShorthand: `normal normal 100 ${smallFontSize}px "Roboto Mono", Consolas, Menlo, Courier, monospace`, + rowPixelPitch: 16, + horizontalPixelOffset: 4, + verticalPixelOffset: 6, + minimumTickPixelDistance: 24, + workHourMin: 6, + workHourMax: 21, + clipLeft: true, + clipRight: true, + }; + + const dpi = window.devicePixelRatio; + + const horizontalCartesianAreaPad = [0.04, 0.04]; + const verticalCartesianAreaPad = [0.12, 0.12]; + + const interactionState = { + // current zoom and pan level + zoom: 5.248, + pan: 0.961, + + // remembering touch points for zoom/pam + multitouch: [], + + // zoom/pan + dragStartX: NaN, + zoomStart: NaN, + panStart: NaN, + + // kinetic pan + lastDragX: NaN, + dragVelocity: NaN, + flyVelocity: NaN, + + // Y domain + niceDomainMin: NaN, + niceDomainMax: NaN, + + // other + screenDimensions: elementSizes(canvas, horizontalCartesianAreaPad, verticalCartesianAreaPad), + searchText: '', + }; + + const localeOptions = { + hour12: false, + year: 'numeric', + month: 'short', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }; + + const dataState = { + valid: false, + pending: false, + lo: { year: Infinity, month: 12, day: 31, hour: 23, minute: 59, second: 59 }, + hi: { year: -Infinity, month: 1, day: 1, hour: 0, minute: 0, second: 0 }, + binUnit: '', + binUnitCount: NaN, + queryConfig: {}, + dataResponse: { stats: {}, rows: [] }, + }; + + const rasterSelector = rasters(config, timeZone); + + // todo this may need an update with locale change + const defaultLabelFormat = new Intl.DateTimeFormat(config.locale, { + weekday: 'short', + hour: 'numeric', + minute: 'numeric', + timeZone, + }).format; + + // todo this may need an update with locale change + const defaultMinorTickLabelFormat = new Intl.DateTimeFormat(config.locale, { + weekday: 'short', + hour: 'numeric', + minute: 'numeric', + timeZone, + }).format; + + const fadeOutPixelWidth = 12; // todo add to config + + const invalid = (dataDemand) => { + return ( + !dataState.valid || + dataState.binUnit !== dataDemand.binUnit || + dataState.binUnitCount !== dataDemand.binUnitCount || + dataDemand.lo.timePointSec < dataState.lo.timePointSec || + dataDemand.hi.timePointSec > dataState.hi.timePointSec || + dataState.queryConfig !== JSON.stringify(config.queryConfig) || + dataState.searchText !== interactionState.searchText + ); + }; + + const updateDataState = (dataDemand, config, dataResponse, interactionState) => { + dataState.pending = false; + dataState.valid = true; + dataState.lo = dataDemand.lo; + dataState.hi = dataDemand.hi; + dataState.binUnit = dataDemand.binUnit; + dataState.binUnitCount = dataDemand.binUnitCount; + dataState.queryConfig = JSON.stringify(config.queryConfig); + dataState.dataResponse = dataResponse; + dataState.searchText = interactionState.searchText; + }; + + const yTickNumberFormatter = new Intl.NumberFormat( + config.locale, + config.numUnit === 'none' + ? {} + : { + notation: 'compact', + compactDisplay: config.numUnit, + }, + ); + + // constants for Y + const ZERO_Y_BASE = true; + + const emWidth = ctx.measureText('mmmmmmmmmm').width / 10; // approx width to avoid too many measurements + + let canvasWidth = NaN; + let canvasHeight = NaN; + + const fromSec = config.domainFrom; + const toSec = config.domainTo; + const fullTimeExtent = toSec - fromSec; + + const zoomMultiplier = () => 2 ** interactionState.zoom; + + let rAF = -1; + let prevT = 0; + + const timedRender = (t) => { + const deltaT = t - prevT; + prevT = t; + chartWithTime(ctx, config, interactionState, deltaT); + }; + + function scheduleChartRender() { + window.cancelAnimationFrame(rAF); + rAF = window.requestAnimationFrame(timedRender); + } + + function doCartesian( + ctx, + cartesianHeight, + config, + interactionState, + deltaT, + cartesianWidth, + timeDomainFrom, + timeDomainTo, + ) { + ctx.save(); + ctx.translate(0, cartesianHeight); + + const domainLandmarks = [ + dataState.dataResponse.stats.minValue, + dataState.dataResponse.stats.maxValue, + ...(ZERO_Y_BASE ? [0] : []), + ]; + const desiredTickCount = getDesiredTickCount(cartesianHeight, config.smallFontSize, config.sparse); + const { niceDomainMin, niceDomainMax, niceTicks } = axisModel(domainLandmarks, desiredTickCount); + const yTweenOngoing = domainTween(interactionState, deltaT, niceDomainMin, niceDomainMax); // updates interactionState + // const panTweenOngoing = interactionState; + const yUnitScale = axisScale(interactionState.niceDomainMin, interactionState.niceDomainMax); + const yUnitScaleClamped = (d) => unitClamp(yUnitScale(d)); + + const dataDemand = renderCartesian( + ctx, + config, + dataState, + config, + defaultMinorTickLabelFormat, + emWidth, + fadeOutPixelWidth, + defaultLabelFormat, + yTickNumberFormatter, + rasterSelector, + cartesianWidth, + cartesianHeight, + { + domainFrom: timeDomainFrom, + domainTo: timeDomainTo, + }, + yUnitScale, + yUnitScaleClamped, + niceTicks, + ); + + ctx.restore(); + + return { yTweenOngoing, dataDemand }; + } + + function getTimeDomain() { + const { pan } = interactionState; + const zoomedTimeExtent = fullTimeExtent / zoomMultiplier(); + const leeway = fullTimeExtent - zoomedTimeExtent; + const timeDomainFrom = fromSec + pan * leeway; + const timeDomainTo = toSec - (1 - pan) * leeway; + return { timeDomainFrom, timeDomainTo }; + } + + function ensureCanvasElementSize(newCanvasWidth, newCanvasHeight) { + if (newCanvasWidth !== canvasWidth) { + canvas.setAttribute('width', String(newCanvasWidth)); + canvasWidth = newCanvasWidth; + } + if (newCanvasHeight !== canvasHeight) { + canvas.setAttribute('height', String(newCanvasHeight)); + canvasHeight = newCanvasHeight; + } + } + + function renderChartWithTime( + ctx, + backgroundFillStyle, + newCanvasWidth, + newCanvasHeight, + config, + chartWidth, + cartesianTop, + aggregationFunctionName, + cartesianLeft, + cartesianHeight, + interactionState, + deltaT, + cartesianWidth, + timeDomainFrom, + timeDomainTo, + drawCartesianBox, + chartTopFontSize, + ) { + ctx.save(); + ctx.scale(dpi, dpi); + ctx.fillStyle = backgroundFillStyle; + // clearRect is not enough, as browser image copy ignores canvas background color + ctx.fillRect(0, 0, newCanvasWidth, newCanvasHeight); + + // chart title + renderChartTitle(ctx, config, chartWidth, cartesianTop, aggregationFunctionName); + + ctx.translate(cartesianLeft, cartesianTop); + + // cartesian + const { yTweenOngoing, dataDemand } = doCartesian( + ctx, + cartesianHeight, + config, + interactionState, + deltaT, + cartesianWidth, + timeDomainFrom, + timeDomainTo, + ); + + // cartesian area box + if (drawCartesianBox) { + renderDebugBox(ctx, cartesianWidth, cartesianHeight); + } + + // chart time unit info + renderTimeUnitAnnotation( + ctx, + config, + dataDemand.binUnitCount, + dataDemand.binUnit, + chartTopFontSize, + dataDemand.unitBarMaxWidthPixels, + ); + + // chart time from/to extent info + renderTimeExtentAnnotation( + ctx, + config, + localeOptions, + timeDomainFrom, + timeDomainTo, + cartesianWidth, + chartTopFontSize, + ); + + ctx.restore(); + return { yTweenOngoing, dataDemand }; + } + + const dataArrived = ({ dataDemand, dataResponse }) => { + updateDataState(dataDemand, config, dataResponse, interactionState); + scheduleChartRender(); + }; + + function chartWithTime(ctx, config, interactionState, deltaT) { + const { + outerWidth: chartWidth, + outerHeight: chartHeight, + innerLeft: cartesianLeft, + innerWidth: cartesianWidth, + innerTop: cartesianTop, + innerHeight: cartesianHeight, + } = interactionState.screenDimensions; + + const { timeDomainFrom, timeDomainTo } = getTimeDomain(); + + const qc = config.queryConfig; + const aggregationFunctionName = aggregationFunctionNames[qc.aggregation]; + const chartTopFontSize = config.smallFontSize + 2; // todo move to config + const background = config.backgroundColor; + const backgroundFillStyle = `rgba(${background.r},${background.g},${background.b},1)`; + + // resize if needed + const newCanvasWidth = dpi * chartWidth; + const newCanvasHeight = dpi * chartHeight; + ensureCanvasElementSize(newCanvasWidth, newCanvasHeight); + + // render chart + const { yTweenOngoing, dataDemand } = renderChartWithTime( + ctx, + backgroundFillStyle, + newCanvasWidth, + newCanvasHeight, + config, + chartWidth, + cartesianTop, + aggregationFunctionName, + cartesianLeft, + cartesianHeight, + interactionState, + deltaT, + cartesianWidth, + timeDomainFrom, + timeDomainTo, + drawCartesianBox, + chartTopFontSize, + ); + + if ( + !dataState.pending && + invalid(dataDemand) && + dataDemand.lo && + dataDemand.hi && + dataDemand.binUnit && + dataDemand.binUnitCount + ) { + dataState.pending = true; + processAction({ + dataDemand, + target: dataSource, + type: 'dataArrived', + dataResponse: getEnrichedData(getData(dataDemand)), + }); + } else if (yTweenOngoing) { + scheduleChartRender(); + } + } + + const setDomElements = () => { + const chartSizeInfo = elementSizes(canvas, horizontalCartesianAreaPad, verticalCartesianAreaPad); + interactionState.screenDimensions = chartSizeInfo; + const { r, g, b } = config.backgroundColor; + const backgroundColorCSS = `rgb(${r},${g},${b})`; + document.body.style.backgroundColor = backgroundColorCSS; + }; + + const fullRender = () => { + setDomElements(); + scheduleChartRender(); + }; + + fullRender(); + + /** + * event listener utils + */ + + const getPanDeltaPerDragPixel = () => 1 / ((zoomMultiplier() - 1) * interactionState.screenDimensions.innerWidth); + + const panFromDeltaPixel = (panStart, delta) => { + const panDeltaPerDragPixel = getPanDeltaPerDragPixel(); + interactionState.pan = Math.max(0, Math.min(1, panStart - panDeltaPerDragPixel * delta)) || 0; + }; + + const inCartesianBand = (e) => { + const y = zoomSafePointerY(e); + const { innerTop: cartesianTop, innerBottom: cartesianBottom } = interactionState.screenDimensions; + return cartesianTop <= y && y <= cartesianBottom; + }; + + const inCartesianArea = (e) => { + const x = zoomSafePointerX(e); + const y = zoomSafePointerY(e); + const { innerTop, innerBottom, innerLeft, innerRight } = interactionState.screenDimensions; + return innerLeft <= x && x <= innerRight && innerTop <= y && y <= innerBottom; + }; + + /** + * event handlers + */ + + const zoom = (pointerUnitLocation, newZoom, panDelta = 0) => { + const oldInvisibleFraction = 1 - 1 / zoomMultiplier(); + interactionState.zoom = clamp(newZoom, minZoom, maxZoom); + const newInvisibleFraction = 1 - 1 / zoomMultiplier(); + interactionState.pan = + unitClamp( + mix(pointerUnitLocation + panDelta, interactionState.pan, oldInvisibleFraction / newInvisibleFraction), + ) || 0; + }; + + const zoomAroundX = (centerX, newZoom, panDelta = 0) => { + const { innerWidth: cartesianWidth, innerLeft: cartesianLeft } = interactionState.screenDimensions; + const unitZoomCenter = Math.max(0, Math.min(cartesianWidth, centerX - cartesianLeft)) / cartesianWidth; + zoom(unitZoomCenter, newZoom, panDelta); + }; + + const pan = (normalizedDeltaPan) => { + const deltaPan = normalizedDeltaPan / 2 ** interactionState.zoom; + interactionState.pan = unitClamp(interactionState.pan + deltaPan) || 0; + }; + + // these two change together: the kinetic friction deceleration from a click drag, and from a wheel drag should match + // currently, the narrower the chart, the higher the deceleration, which is perhaps better than width invariant slowing + const dragVelocityAttenuation = 0.92; + const wheelPanVelocityDivisor = 1000; + + const wheelZoomVelocityDivisor = 250; + const keyZoomVelocityDivisor = 2; // 1 means, on each up/down keypress, double/halve the visible time domain + const keyPanVelocityDivisor = 10; // 1 means, on each left/right keypress, move the whole of current visible time domain + + const wheel = (e) => { + if (!inCartesianBand(e)) return; + + if (e.metaKey) { + pan(-e.deltaY / wheelPanVelocityDivisor); + } else { + const centerX = zoomSafePointerX(e); + const newZoom = interactionState.zoom - e.deltaY / wheelZoomVelocityDivisor; + zoomAroundX(centerX, newZoom); + } + + scheduleChartRender(); + }; + + const dragStartAtX = (startingX) => { + interactionState.dragStartX = startingX; + interactionState.lastDragX = startingX; + interactionState.dragVelocity = NaN; + interactionState.flyVelocity = NaN; + interactionState.panStart = interactionState.pan; + }; + + const dragStart = (e) => dragStartAtX(zoomSafePointerX(e)); + + const kineticDragHandler = (t) => { + const velocity = interactionState.flyVelocity; + if (Math.abs(velocity) > 0.01) { + panFromDeltaPixel(interactionState.pan, velocity); + interactionState.flyVelocity *= dragVelocityAttenuation; + timedRender(t); + window.requestAnimationFrame(kineticDragHandler); + } else { + interactionState.flyVelocity = NaN; + } + }; + + const dragEnd = () => { + interactionState.flyVelocity = interactionState.dragVelocity; + interactionState.dragVelocity = NaN; + interactionState.dragStartX = NaN; + interactionState.panStart = NaN; + window.requestAnimationFrame(kineticDragHandler); + }; + + const panFromX = (currentX) => { + const deltaX = currentX - interactionState.lastDragX; + const { dragVelocity } = interactionState; + interactionState.dragVelocity = + deltaX * dragVelocity > 0 && Math.abs(deltaX) < Math.abs(dragVelocity) + ? dragVelocity // mix(dragVelocity, deltaX, 0.04) + : deltaX; + interactionState.lastDragX = currentX; + const delta = currentX - interactionState.dragStartX; + panFromDeltaPixel(interactionState.panStart, delta); + return delta; + }; + + const touchMidpoint = (multitouch) => (multitouch[0].x + multitouch[1].x) / 2; + + const touchmove = (e) => { + const multitouch = [...(e.touches ?? [])] + .map((t) => ({ + id: t.identifier, + x: zoomSafePointerX(t), + })) + .sort(({ x: a }, { x: b }) => a - b); + + if (interactionState.multitouch.length === 0 && multitouch.length === 2) { + interactionState.multitouch = multitouch; + interactionState.zoomStart = interactionState.zoom; + const centerX = touchMidpoint(multitouch); + dragStartAtX(centerX); + } else if ( + multitouch.length !== 2 || + [...multitouch, ...interactionState.multitouch].filter((t, i, a) => a.findIndex((tt) => tt.id === t.id) === i) + .length !== 2 + ) { + interactionState.multitouch = []; + interactionState.zoomStart = NaN; + /* + interactionState.dragStartX = NaN + interactionState.lastDragX = NaN + // interactionState.dragVelocity = NaN + // interactionState.flyVelocity = NaN + interactionState.panStart = NaN + */ + } + if (interactionState.multitouch.length === 2) { + const centerX = touchMidpoint(multitouch); + const zoomMultiplier = + (multitouch[1].x - multitouch[0].x) / (interactionState.multitouch[1].x - interactionState.multitouch[0].x); + const panDelta = 0; // panFromX(centerX) + zoomAroundX(centerX, interactionState.zoomStart + Math.log2(zoomMultiplier), panDelta); + scheduleChartRender(); + } else if (inCartesianArea(e) || Number.isFinite(interactionState.panStart)) { + if (!panOngoing(interactionState)) { + dragStart(e); + } else { + const currentX = zoomSafePointerX(e); + panFromX(currentX); + scheduleChartRender(); + } + } + }; + + const touchstart = (e) => inCartesianArea(e) && dragStart(e); + const touchend = dragEnd; + const mousedown = touchstart; + const mousemove = (e) => e.buttons === 1 && touchmove(e); + const mouseup = touchend; + const touchcancel = touchend; + + const chartKeydown = (e) => { + const panDirection = { ArrowLeft: -1, ArrowRight: 1 }[e.code]; + const zoomDirection = { ArrowUp: -1, ArrowDown: 1 }[e.code]; + if (panDirection || zoomDirection) { + if (panDirection) pan(panDirection / keyPanVelocityDivisor); + if (zoomDirection) zoom(0.5, interactionState.zoom + zoomDirection / keyZoomVelocityDivisor); + e.preventDefault(); // preventDefault needed because otherwise a right arrow key takes the user to the next element + scheduleChartRender(); + } + }; + + const resize = () => fullRender(); + + /** + * attaching event handlers + */ + + const eventHandlersForWindow = { resize }; + const eventHandlersForCanvas = { + wheel, + mousemove, + mousedown, + mouseup, + touchmove, + touchstart, + touchend, + touchcancel, + keydown: chartKeydown, + }; + const eventHandlersForData = { dataArrived }; + + const eventHandlers = new Map([ + [window, eventHandlersForWindow], + [canvas, eventHandlersForCanvas], + [dataSource, eventHandlersForData], + ]); + + function* handleEvents() { + for (;;) { + const e = yield; + const handler = eventHandlers.get(e.target)[e.type]; + if (handler) handler(e); + } + } + + observe(window, processAction, eventHandlersForWindow); + observe(canvas, processAction, eventHandlersForCanvas); +}; diff --git a/packages/charts/src/chart_types/timeslip/timeslip/translations.ts b/packages/charts/src/chart_types/timeslip/timeslip/translations.ts new file mode 100644 index 0000000000..b9c25e962b --- /dev/null +++ b/packages/charts/src/chart_types/timeslip/timeslip/translations.ts @@ -0,0 +1,222 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// @ts-noCheck + +/** @internal */ +export const uiStrings = { + 'ar-TN': { + bar: 'حاجز', + year: 'سنة', + month: 'شهر', + week: 'أسبوع', + day: 'يوم', + hour: 'ساعة', + minute: 'دقيقة', + second: 'ثانية', + millisecond: 'مللي ثانية', + years: 'سنوات', + months: 'اشهر', + weeks: 'أسابيع', + days: 'أيام', + hours: 'ساعات', + minutes: 'دقائق', + seconds: 'ثواني', + milliseconds: 'مللي ثانية', + }, + 'de-CH': { + bar: 'Balken', + year: 'Jahr', + month: 'Monat', + week: 'Woche', + day: 'Tag', + hour: 'Stunde', + minute: 'Minute', + second: 'Sekunde', + millisecond: 'Millisekunde', + years: 'Jahre', + months: 'Monate', + weeks: 'Wochen', + days: 'Tage', + hours: 'Stunden', + minutes: 'Minuten', + seconds: 'Sekunden', + milliseconds: 'Millisekunden', + }, + 'fr-FR': { + bar: 'barre', + year: 'année', + month: 'mois', + week: 'semaine', + day: 'jour', + hour: 'heure', + minute: 'minute', + second: 'seconde', + millisecond: 'milliseconde', + years: 'ans', + months: 'mois', + weeks: 'semaines', + days: 'jours', + hours: 'heures', + minutes: 'minutes', + seconds: 'secondes', + milliseconds: 'millisecondes', + }, + 'en-US': { + bar: 'bar', + year: 'year', + month: 'month', + week: 'week', + day: 'day', + hour: 'hour', + minute: 'minute', + second: 'second', + millisecond: 'millisecond', + years: 'years', + months: 'months', + weeks: 'weeks', + days: 'days', + hours: 'hours', + minutes: 'minutes', + seconds: 'seconds', + milliseconds: 'milliseconds', + }, + 'el-GR': { + bar: 'γραμμή', + year: 'χρόνος', + month: 'μήνα', + week: 'εβδομάδα', + day: 'μέρα', + hour: 'ώρα', + minute: 'λεπτό', + second: 'δευτερόλεπτο', + millisecond: 'χιλιοστό του δευτερολέπτου', + years: 'μήνες', + months: 'months', + weeks: 'εβδομάδες', + days: 'ημέρες', + hours: 'ώρες', + minutes: 'λεπτά', + seconds: 'δευτερόλεπτα', + milliseconds: 'χιλιοστά του δευτερολέπτου', + }, + 'hu-HU': { + bar: 'oszlop', + year: 'év', + month: 'hónap', + week: 'hét', + day: 'nap', + hour: 'óra', + minute: 'perc', + second: 'másodperc', + millisecond: 'ezredmásodperc', + years: 'év', + months: 'hónap', + weeks: 'hét', + days: 'nap', + hours: 'óra', + minutes: 'perc', + seconds: 'másodperc', + milliseconds: 'ezredmásodperc', + }, + 'he-IL': { + bar: 'עַמוּדָה', + year: 'שנה', + month: 'חודש', + week: 'שבוע', + day: 'יום', + hour: 'שעה', + minute: 'דקות', + second: 'השני', + millisecond: 'אלפית השנייה', + years: 'years', + months: 'חודשים', + weeks: 'שבועות', + days: 'ימים', + hours: 'שעות', + minutes: 'דקות', + seconds: 'שניות', + milliseconds: 'אלפיות השנייה', + }, + 'hi-IN': { + bar: 'बार', + year: 'वर्ष', + month: 'महीना', + week: 'सप्ताह', + day: 'दिन', + hour: 'घंटा', + minute: 'मिनट', + second: 'सेकंड', + millisecond: 'मिलीसेकंड', + years: 'साल', + months: 'महीने', + weeks: 'सप्ताह', + days: 'दिन', + hours: 'घंटे', + minutes: 'मिनट', + seconds: 'सेकंड', + milliseconds: 'मिलीसेकेंड', + }, + 'it-IT': { + bar: 'barra', + year: 'anno', + month: 'mese', + week: 'settimana', + day: 'giorno', + hour: 'ora', + minute: 'minuto', + second: 'secondo', + millisecond: 'millisecondo', + years: 'anni', + months: 'mesi', + weeks: 'settimane', + days: 'giorni', + hours: 'ore', + minutes: 'minuti', + seconds: 'secondi', + milliseconds: 'millisecondi', + }, + 'ja-JA': { + bar: '棒', + year: '年', + month: 'ヶ月', + week: '週間', + day: '日', + hour: '時間', + minute: '分', + second: '秒', + millisecond: 'ミリ秒', + years: '年間', + months: 'ヵ月', + weeks: '週間', + days: '日間', + hours: '時間', + minutes: '分間', + seconds: '秒間', + milliseconds: 'ミリ秒', + }, + 'ru-RU': { + bar: 'полоса', + year: 'год', + month: 'месяц', + week: 'неделя', + day: 'день', + hour: 'час', + minute: 'минута', + second: 'секунда', + millisecond: 'миллисекунда', + years: 'лет', + months: 'месяцев', + weeks: 'недель', + days: 'дней', + hours: 'часов', + minutes: 'минут', + seconds: 'секунд', + milliseconds: 'миллисекунд', + }, +}; diff --git a/packages/charts/src/chart_types/timeslip/timeslip/utils/dom.ts b/packages/charts/src/chart_types/timeslip/timeslip/utils/dom.ts new file mode 100644 index 0000000000..e800c2fc0a --- /dev/null +++ b/packages/charts/src/chart_types/timeslip/timeslip/utils/dom.ts @@ -0,0 +1,39 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// @ts-noCheck + +/** @internal */ +export const zoomSafePointerX = (e) => e.layerX ?? e.clientX; // robust against Chrome, Safari, Firefox menu zooms and/or pinch zoom (FF needs zero margin) + +/** @internal */ +export const zoomSafePointerY = (e) => e.layerY ?? e.clientX; // robust against Chrome, Safari, Firefox menu zooms and/or pinch zoom (FF needs zero margin) + +/** @internal */ +export const elementSizes = (canvas, horizontalPad, verticalPad) => { + const { width: outerWidth, height: outerHeight } = canvas.getBoundingClientRect(); + + const innerLeft = outerWidth * horizontalPad[0]; + const innerWidth = outerWidth * (1 - horizontalPad.reduce((p, n) => p + n)); + const innerRight = innerLeft + innerWidth; + + const innerTop = outerHeight * verticalPad[0]; + const innerHeight = outerHeight * (1 - verticalPad.reduce((p, n) => p + n)); + const innerBottom = innerTop + innerHeight; + + return { + outerWidth: outerWidth, + outerHeight, + innerLeft, + innerRight, + innerWidth, + innerTop, + innerBottom, + innerHeight, + }; +}; diff --git a/packages/charts/src/chart_types/timeslip/timeslip/utils/generator.ts b/packages/charts/src/chart_types/timeslip/timeslip/utils/generator.ts new file mode 100644 index 0000000000..175ae8de78 --- /dev/null +++ b/packages/charts/src/chart_types/timeslip/timeslip/utils/generator.ts @@ -0,0 +1,24 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// @ts-noCheck + +/** @internal */ +export const toCallbackFn = (generatorObject) => { + generatorObject.next(); // this starts the generator object, eg. resulting in initial render without any events + return (event) => generatorObject.next(event); +}; + +/** @internal */ +export const observe = (eventTarget, commonHandler, handlers) => { + for (const eventName in handlers) eventTarget.addEventListener(eventName, commonHandler, { passive: false }); + // the returned function allows the removal of the event listeners if needed + return () => { + for (const eventName in handlers) eventTarget.removeEventListener(eventName, commonHandler); + }; +}; diff --git a/packages/charts/src/chart_types/timeslip/timeslip/utils/math.ts b/packages/charts/src/chart_types/timeslip/timeslip/utils/math.ts new file mode 100644 index 0000000000..6dbb9edc33 --- /dev/null +++ b/packages/charts/src/chart_types/timeslip/timeslip/utils/math.ts @@ -0,0 +1,16 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// @ts-noCheck + +/** @internal */ +export const mix = (start, end, a) => start * (1 - a) + end * a; // like the glsl function +/** @internal */ +export const clamp = (n, lo, hi) => (n < lo ? lo : n > hi ? hi : n); +/** @internal */ +export const unitClamp = (n) => (n < 0 ? 0 : n > 1 ? 1 : n); diff --git a/packages/charts/src/chart_types/timeslip/timeslip/utils/projection.ts b/packages/charts/src/chart_types/timeslip/timeslip/utils/projection.ts new file mode 100644 index 0000000000..821e943830 --- /dev/null +++ b/packages/charts/src/chart_types/timeslip/timeslip/utils/projection.ts @@ -0,0 +1,23 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +// @ts-noCheck + +/** @internal */ +export const getDesiredTickCount = (cartesianHeight, fontSize, sparse) => { + const desiredMaxTickCount = Math.floor(cartesianHeight / (3 * fontSize)); + return sparse ? 1 + Math.ceil(Math.pow(desiredMaxTickCount, 0.25)) : 1 + Math.ceil(Math.sqrt(desiredMaxTickCount)); +}; + +/** @internal */ +export const axisScale = (niceDomainMin, niceDomainMax) => { + const niceDomainExtent = niceDomainMax - niceDomainMin; + const yScaleMultiplier = 1 / (niceDomainExtent || 1); + const offset = -niceDomainMin * yScaleMultiplier; + return (d) => offset + d * yScaleMultiplier; +}; diff --git a/packages/charts/src/chart_types/timeslip/timeslip_api.ts b/packages/charts/src/chart_types/timeslip/timeslip_api.ts new file mode 100644 index 0000000000..ef2d880c9e --- /dev/null +++ b/packages/charts/src/chart_types/timeslip/timeslip_api.ts @@ -0,0 +1,62 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import { ChartType } from '..'; +import { Spec } from '../../specs'; +import { SpecType } from '../../specs/constants'; // kept as long-winded import on separate line otherwise import circularity emerges +import { buildSFProps, SFProps, useSpecFactory } from '../../state/spec_factory'; +import { stripUndefined } from '../../utils/common'; +import { TimeBin } from '../xy_chart/axes/timeslip/rasters'; + +/** + * data getter function + * @public + */ +export type GetData = (dataDemand: { + lo: TimeBin; // iirc TimeBin is enough, and the other TimeRaster etc. props aren't needed + hi: TimeBin; // iirc TimeBin is enough, and the other TimeRaster etc. props aren't needed + binUnit: string; // TimeRaster['unit']; // as of the initial commit, it's just a string + binUnitCount: number; + unitBarMaxWidthPixels: number; +}) => Array<{ epochMs: number; value: number }>; + +/** + * Specifies the timeslip chart + * @public + */ +export interface TimeslipSpec extends Spec { + specType: typeof SpecType.Series; + chartType: typeof ChartType.Timeslip; + getData: GetData; +} + +const buildProps = buildSFProps()( + { + chartType: ChartType.Timeslip, + specType: SpecType.Series, + }, + {}, +); + +/** + * Adds timeslip spec to chart specs + * @public + */ +export const Timeslip = ( + props: SFProps< + TimeslipSpec, + keyof typeof buildProps['overrides'], + keyof typeof buildProps['defaults'], + keyof typeof buildProps['optionals'], + keyof typeof buildProps['requires'] + >, +) => { + const { defaults, overrides } = buildProps; + useSpecFactory({ ...defaults, ...stripUndefined(props), ...overrides }); + return null; +}; diff --git a/packages/charts/src/chart_types/timeslip/timeslip_chart.tsx b/packages/charts/src/chart_types/timeslip/timeslip_chart.tsx new file mode 100644 index 0000000000..610349cc5a --- /dev/null +++ b/packages/charts/src/chart_types/timeslip/timeslip_chart.tsx @@ -0,0 +1,158 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React, { CSSProperties, RefObject } from 'react'; +import { connect } from 'react-redux'; +import { bindActionCreators, Dispatch } from 'redux'; + +import { ChartType } from '..'; +import { DEFAULT_CSS_CURSOR } from '../../common/constants'; +import { SettingsSpec, SpecType, TooltipType } from '../../specs'; +import { onChartRendered } from '../../state/actions/chart'; +import { BackwardRef, GlobalChartState } from '../../state/chart_state'; +import { getA11ySettingsSelector } from '../../state/selectors/get_accessibility_config'; +import { getSettingsSpecSelector } from '../../state/selectors/get_settings_spec'; +import { getTooltipSpecSelector } from '../../state/selectors/get_tooltip_spec'; +import { getSpecsFromStore } from '../../state/utils'; +import { Size } from '../../utils/dimensions'; +import { roundUpSize } from '../flame_chart/render/common'; +// @ts-ignore until it becomes TS +import { timeslipRender } from './timeslip/timeslip_render'; +import { TimeslipSpec, GetData } from './timeslip_api'; + +interface StateProps { + getData: GetData; + chartDimensions: Size; + a11ySettings: ReturnType; + tooltipRequired: boolean; + onElementOver: NonNullable; + onElementClick: NonNullable; + onElementOut: NonNullable; + onRenderChange: NonNullable; +} + +interface DispatchProps { + onChartRendered: typeof onChartRendered; +} + +interface OwnProps { + containerRef: BackwardRef; + forwardStageRef: RefObject; +} + +type TimeslipProps = StateProps & DispatchProps & OwnProps; + +class TimeslipComponent extends React.Component { + static displayName = 'Timeslip'; + + // DOM API Canvas2d and WebGL resources + private ctx: CanvasRenderingContext2D | null = null; + + componentDidMount = () => { + /* + * the DOM element has just been appended, and getContext('2d') is always non-null, + * so we could use a couple of ! non-null assertions but no big plus + */ + this.tryCanvasContext(); + this.drawCanvas(); + this.props.onChartRendered(); + this.props.containerRef().current?.addEventListener('wheel', (e) => e.preventDefault(), { passive: false }); + + timeslipRender(this.props.forwardStageRef.current, this.ctx, this.props.getData); + }; + + componentWillUnmount() { + this.props.containerRef().current?.removeEventListener('wheel', (e) => e.preventDefault()); + } + + componentDidUpdate = () => { + if (!this.ctx) this.tryCanvasContext(); + }; + + render = () => { + const { + forwardStageRef, + chartDimensions: { width: requestedWidth, height: requestedHeight }, + a11ySettings, + } = this.props; + const width = roundUpSize(requestedWidth); + const height = roundUpSize(requestedHeight); + const style: CSSProperties = { + width, + height, + top: 0, + left: 0, + padding: 0, + margin: 0, + border: 0, + position: 'absolute', + cursor: DEFAULT_CSS_CURSOR, + }; + const dpr = window.devicePixelRatio; /* * this.pinchZoomScale */ + const canvasWidth = width * dpr; + const canvasHeight = height * dpr; + return ( + <> +
+ +
+ + ); + }; + + private drawCanvas = () => { + if (!this.ctx) return; + this.props.onRenderChange(true); // emit API callback + }; + + private tryCanvasContext = () => { + const canvas = this.props.forwardStageRef.current; + this.ctx = canvas && canvas.getContext('2d'); + }; +} + +const mapStateToProps = (state: GlobalChartState): StateProps => { + const timeslipSpec = getSpecsFromStore(state.specs, ChartType.Timeslip, SpecType.Series)[0]; + const settingsSpec = getSettingsSpecSelector(state); + return { + getData: timeslipSpec?.getData ?? (() => []), + chartDimensions: state.parentDimensions, + a11ySettings: getA11ySettingsSelector(state), + tooltipRequired: getTooltipSpecSelector(state).type !== TooltipType.None, + + // mandatory charts API protocol; todo extract these mappings once there are other charts like Timeslip + onElementOver: settingsSpec.onElementOver ?? (() => {}), + onElementClick: settingsSpec.onElementClick ?? (() => {}), + onElementOut: settingsSpec.onElementOut ?? (() => {}), + onRenderChange: settingsSpec.onRenderChange ?? (() => {}), // todo eventually also update data props on a local .echChartStatus element: data-ech-render-complete={rendered} data-ech-render-count={renderedCount} data-ech-debug-state={debugStateString} + }; +}; + +const mapDispatchToProps = (dispatch: Dispatch): DispatchProps => + bindActionCreators( + { + onChartRendered, + }, + dispatch, + ); + +const TimeslipChartLayers = connect(mapStateToProps, mapDispatchToProps)(TimeslipComponent); + +/** @internal */ +export const TimeslipWithTooltip = (containerRef: BackwardRef, forwardStageRef: RefObject) => ( + +); diff --git a/packages/charts/src/chart_types/xy_chart/axes/timeslip/rasters.ts b/packages/charts/src/chart_types/xy_chart/axes/timeslip/rasters.ts index bd4406407f..250f5c1151 100644 --- a/packages/charts/src/chart_types/xy_chart/axes/timeslip/rasters.ts +++ b/packages/charts/src/chart_types/xy_chart/axes/timeslip/rasters.ts @@ -24,7 +24,7 @@ const approxWidthsInSeconds: Record = { millisecond: 0.001, }; -/** @internal */ +/** @public */ export interface TimeBin { timePointSec: number; nextTimePointSec: number; diff --git a/packages/charts/src/index.ts b/packages/charts/src/index.ts index 3c7590c22f..bb94735a4f 100644 --- a/packages/charts/src/index.ts +++ b/packages/charts/src/index.ts @@ -137,4 +137,5 @@ export { GroupKeysOrKeyFn, GroupByKeyFn } from './chart_types/xy_chart/utils/gro export { computeRatioByGroups } from './utils/data/data_processing'; export { TimeFunction } from './utils/time_functions'; export * from './chart_types/flame_chart/flame_api'; +export * from './chart_types/timeslip/timeslip_api'; export { LegacyAnimationConfig } from './common/animation'; diff --git a/packages/charts/src/state/chart_state.ts b/packages/charts/src/state/chart_state.ts index 29d8b93bfb..d03549293d 100644 --- a/packages/charts/src/state/chart_state.ts +++ b/packages/charts/src/state/chart_state.ts @@ -14,6 +14,7 @@ import { GoalState } from '../chart_types/goal_chart/state/chart_state'; import { HeatmapState } from '../chart_types/heatmap/state/chart_state'; import { MetricState } from '../chart_types/metric/state/chart_state'; import { PartitionState } from '../chart_types/partition_chart/state/chart_state'; +import { TimeslipState } from '../chart_types/timeslip/internal_chart_state'; import { WordcloudState } from '../chart_types/wordcloud/state/chart_state'; import { XYAxisChartState } from '../chart_types/xy_chart/state/chart_state'; import { CategoryKey } from '../common/category'; @@ -430,6 +431,7 @@ const constructors: Record InternalChartState | null> = { [ChartType.Goal]: () => new GoalState(), [ChartType.Partition]: () => new PartitionState(), [ChartType.Flame]: () => new FlameState(), + [ChartType.Timeslip]: () => new TimeslipState(), [ChartType.XYAxis]: () => new XYAxisChartState(), [ChartType.Heatmap]: () => new HeatmapState(), [ChartType.Wordcloud]: () => new WordcloudState(), diff --git a/storybook/stories/timeslip/01_timeslip.story.tsx b/storybook/stories/timeslip/01_timeslip.story.tsx new file mode 100644 index 0000000000..e65ee0fb9d --- /dev/null +++ b/storybook/stories/timeslip/01_timeslip.story.tsx @@ -0,0 +1,55 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +import React from 'react'; + +import { Chart, Timeslip, Settings, PartialTheme, GetData } from '@elastic/charts'; + +import { useBaseTheme } from '../../use_base_theme'; + +const getData = (dataDemand: Parameters[0]) => { + const from = dataDemand.lo.timePointSec; + const to = dataDemand.hi.nextTimePointSec; + const binWidth = dataDemand.lo.nextTimePointSec - from; + const result = []; + let time = from; + while (time < to) { + result.push({ + epochMs: (time + 0.5 * binWidth) * 1000, + value: + 8 * Math.sin(time / 100000000) + + 4 * Math.sin(time / 1000000) + + 2 * Math.sin(time / 10000) + + Math.sin(time / 100) + + 0.5 * Math.sin(time) + + 0.25 * Math.sin(time * 100) + + 0.125 * Math.sin(time * 10000), + }); + time += binWidth; + } + return result; +}; + +export const Example = () => { + const theme: PartialTheme = { + chartMargins: { top: 0, left: 0, bottom: 0, right: 0 }, + chartPaddings: { left: 0, right: 0, top: 0, bottom: 0 }, + }; + + // fixing width and height at multiples of 256 for now + return ( + + + + + ); +}; + +Example.parameters = { + background: { default: 'white' }, +}; diff --git a/storybook/stories/timeslip/timeslip.stories.tsx b/storybook/stories/timeslip/timeslip.stories.tsx new file mode 100644 index 0000000000..465183bc84 --- /dev/null +++ b/storybook/stories/timeslip/timeslip.stories.tsx @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0 and the Server Side Public License, v 1; you may not use this file except + * in compliance with, at your election, the Elastic License 2.0 or the Server + * Side Public License, v 1. + */ + +export default { + title: 'Timeslip (@alpha)', +}; + +export { Example as timeslipPrototype } from './01_timeslip.story';