/* eslint-disable react/no-access-state-in-setstate */
/* eslint-disable react/no-did-update-set-state */
/* eslint-disable react/destructuring-assignment */
import React, { ReactNode } from 'react';
import Color from 'color';
import type { Group } from '@pyroscope/models/src';
import type { Timeline } from '@webapp/models/timeline';
import { Annotation } from '@webapp/models/annotation';
import Legend from '@webapp/pages/tagExplorer/components/Legend';
import type { TooltipCallbackProps } from '@webapp/components/TimelineChart/Tooltip.plugin';
import TooltipWrapper from '@webapp/components/TimelineChart/TooltipWrapper';
import type { ITooltipWrapperProps } from '@webapp/components/TimelineChart/TooltipWrapper';
import TimelineChart from '@webapp/components/TimelineChart/TimelineChart';
import { ContextMenuProps } from '@webapp/components/TimelineChart/ContextMenu.plugin';
import {
markingsFromSelection,
ANNOTATION_COLOR,
} from '@webapp/components/TimelineChart/markings';
import { centerTimelineData } from '@webapp/components/TimelineChart/centerTimelineData';
import styles from './TimelineChartWrapper.module.css';
export interface TimelineGroupData {
data: Group;
tagName: string;
color?: Color;
}
export interface TimelineData {
data?: Timeline;
color?: string;
}
interface Selection {
from: string;
to: string;
color: Color;
overlayColor: Color;
}
type SingleDataProps = {
/** used to display at max 2 time series */
mode: 'singles';
/** timelineA refers to the first (and maybe unique) timeline */
timelineA: TimelineData;
/** timelineB refers to the second timeline, useful for comparison view */
timelineB?: TimelineData;
};
// Used in Tag Explorer
type MultipleDataProps = {
/** used when displaying multiple time series. original use case is for tag explorer */
mode: 'multiple';
/** timelineGroups refers to group of timelines, useful for explore view */
timelineGroups: TimelineGroupData[];
/** if there is active group, the other groups should "dim" themselves */
activeGroup: string;
/** show or hide legend */
showTagsLegend: boolean;
/** to set active tagValue using */
handleGroupByTagValueChange: (groupByTagValue: string) => void;
};
type TimelineDataProps = SingleDataProps | MultipleDataProps;
type TimelineChartWrapperProps = TimelineDataProps & {
/** the id attribute of the element float will use to apply to, it should be globally unique */
id: string;
['data-testid']?: string;
onSelect: (from: string, until: string) => void;
format: 'lines' | 'bars';
height?: string;
/** refers to the highlighted selection */
selection?: {
left?: Selection;
right?: Selection;
};
timezone: 'browser' | 'utc';
title?: ReactNode;
/** whether to show a selection with grabbable handle
* ATTENTION: it only works with a single selection */
selectionWithHandler?: boolean;
/** selection type 'single' => gray selection, 'double' => color selection */
selectionType: 'single' | 'double';
onHoverDisplayTooltip?: React.FC;
/** list of annotations timestamp, to be rendered as markings */
annotations?: Annotation[];
/** What element to render when clicking */
ContextMenu?: (props: ContextMenuProps) => React.ReactNode;
/** The list of timeline IDs (flotjs component) to sync the crosshair with */
syncCrosshairsWith?: string[];
};
class TimelineChartWrapper extends React.Component<
TimelineChartWrapperProps,
// TODO add type
ShamefulAny
> {
// eslint-disable-next-line react/static-property-placement
static defaultProps = {
format: 'bars',
mode: 'singles',
timezone: 'browser',
height: '100px',
};
constructor(props: TimelineChartWrapperProps) {
super(props);
let flotOptions = {
margin: {
top: 0,
left: 0,
bottom: 0,
right: 0,
},
selection: {
selectionWithHandler: props.selectionWithHandler || false,
mode: 'x',
// custom selection works for 'single' selection type,
// 'double' selection works in old fashion way
// we use different props to customize selection appearance
selectionType: props.selectionType,
overlayColor:
props.selectionType === 'double'
? undefined
: props?.selection?.['right']?.overlayColor ||
props?.selection?.['left']?.overlayColor,
boundaryColor:
props.selectionType === 'double'
? undefined
: props?.selection?.['right']?.color ||
props?.selection?.['left']?.color,
},
crosshair: {
mode: 'x',
color: '#C3170D',
lineWidth: '1',
},
grid: {
borderWidth: 1, // outside border of the timelines
hoverable: true,
// For the contextMenu plugin to work. From the docs:
// > If you set “clickable” to true, the plot will listen for click events
// on the plot area and fire a “plotclick” event on the placeholder with
// a position and a nearby data item object as parameters.
clickable: true,
},
annotations: [],
syncCrosshairsWith: [],
yaxis: {
show: false,
min: 0,
},
points: {
show: false,
symbol: () => {}, // function that draw points on the chart
},
lines: {
show: false,
},
bars: {
show: true,
},
xaxis: {
mode: 'time',
timezone: props.timezone,
reserveSpace: false,
// according to https://github.com/flot/flot/blob/master/API.md#customizing-the-axes
minTickSize: [3, 'second'],
},
};
flotOptions = (() => {
switch (props.format) {
case 'lines': {
return {
...flotOptions,
lines: {
show: true,
lineWidth: 0.8,
},
bars: {
show: false,
},
};
}
case 'bars': {
return {
...flotOptions,
bars: {
show: true,
},
lines: {
show: false,
},
};
}
default: {
throw new Error(`Invalid format: '${props.format}'`);
}
}
})();
this.state = { flotOptions };
this.state.flotOptions.grid.markings = this.plotMarkings();
this.state.flotOptions.annotations = this.composeAnnotationsList();
}
// TODO: this only seems to sync props back into the state, which seems unnecessary
componentDidUpdate(prevProps: TimelineChartWrapperProps) {
if (
prevProps.selection !== this.props.selection ||
prevProps.annotations !== this.props.annotations ||
prevProps.syncCrosshairsWith !== this.props.syncCrosshairsWith
) {
const newFlotOptions = this.state.flotOptions;
newFlotOptions.grid.markings = this.plotMarkings();
newFlotOptions.annotations = this.composeAnnotationsList();
newFlotOptions.syncCrosshairsWith = this.props.syncCrosshairsWith;
this.setState({ flotOptions: newFlotOptions });
}
}
composeAnnotationsList = () => {
return Array.isArray(this.props.annotations)
? this.props.annotations?.map((a) => ({
timestamp: a.timestamp,
content: a.content,
type: 'message',
color: ANNOTATION_COLOR,
}))
: [];
};
plotMarkings = () => {
const selectionMarkings = markingsFromSelection(
this.props.selectionType,
this.props.selection?.left,
this.props.selection?.right
);
return [...selectionMarkings];
};
setOnHoverDisplayTooltip = (
data: ITooltipWrapperProps & TooltipCallbackProps
) => {
const tooltipContent = [];
const TooltipBody: React.FC | undefined =
this.props?.onHoverDisplayTooltip;
if (TooltipBody) {
tooltipContent.push(
);
}
if (tooltipContent.length) {
return (
{tooltipContent.map((tooltipBody) => tooltipBody)}
);
}
return null;
};
renderMultiple = (props: MultipleDataProps) => {
const { flotOptions } = this.state;
const { timelineGroups, activeGroup, showTagsLegend } = props;
const { timezone } = this.props;
// TODO: unify with renderSingle
const onHoverDisplayTooltip = (
data: ITooltipWrapperProps & TooltipCallbackProps
) => this.setOnHoverDisplayTooltip(data);
const customFlotOptions = {
...flotOptions,
onHoverDisplayTooltip,
ContextMenu: this.props.ContextMenu,
xaxis: { ...flotOptions.xaxis, autoscaleMargin: null, timezone },
wrapperId: this.props.id,
};
const centeredTimelineGroups = timelineGroups.map(
({ data, color, tagName }) => {
return {
data: centerTimelineData({ data }),
tagName,
color:
activeGroup && activeGroup !== tagName ? color?.fade(0.75) : color,
};
}
);
return (
<>
{this.timelineChart(centeredTimelineGroups, customFlotOptions)}
{showTagsLegend && (
)}
>
);
};
renderSingle = (props: SingleDataProps) => {
const { flotOptions } = this.state;
const { timelineA } = props;
let { timelineB } = props;
const { timezone, title } = this.props;
// TODO deep copy
timelineB = timelineB ? JSON.parse(JSON.stringify(timelineB)) : undefined;
// TODO: unify with renderMultiple
const onHoverDisplayTooltip = (
data: ITooltipWrapperProps & TooltipCallbackProps
) => this.setOnHoverDisplayTooltip(data);
const customFlotOptions = {
...flotOptions,
onHoverDisplayTooltip,
ContextMenu: this.props.ContextMenu,
wrapperId: this.props.id,
xaxis: {
...flotOptions.xaxis,
// In case there are few chunks left, then we'd like to add some margins to
// both sides making it look more centers
autoscaleMargin:
timelineA?.data && timelineA.data.samples.length > 3 ? null : 0.005,
timezone,
},
};
// Since this may be overwritten, we always need to set it up correctly
if (timelineA && timelineB) {
customFlotOptions.bars.show = false;
} else {
customFlotOptions.bars.show = true;
}
// If they are the same, skew the second one slightly so that they are both visible
if (areTimelinesTheSame(timelineA, timelineB)) {
// the factor is completely arbitrary, we use a positive number to skew above
timelineB = skewTimeline(timelineB, 4);
}
if (isSingleDatapoint(timelineA, timelineB)) {
// check if both have a single value
// if so, let's use bars
// since we can't put a point when there's no data when using points
if (timelineB && timelineB.data && timelineB.data.samples.length <= 1) {
customFlotOptions.bars.show = true;
// Also slightly skew to show them side by side
timelineB.data.startTime += 0.01;
}
}
const data = [
timelineA &&
timelineA.data && {
...timelineA,
data: centerTimelineData(timelineA),
},
timelineB &&
timelineB.data && { ...timelineB, data: centerTimelineData(timelineB) },
].filter((a) => !!a);
return (
<>
{title}
{this.timelineChart(data, customFlotOptions)}
>
);
};
timelineChart = (
data: ({ data: number[][]; color?: string | Color } | undefined)[],
customFlotOptions: ShamefulAny
) => {
return (
);
};
render = () => {
if (this.props.mode === 'multiple') {
return this.renderMultiple(this.props);
}
return this.renderSingle(this.props);
};
}
function isSingleDatapoint(timelineA: TimelineData, timelineB?: TimelineData) {
const aIsSingle = timelineA.data && timelineA.data.samples.length <= 1;
if (!aIsSingle) {
return false;
}
if (timelineB && timelineB.data) {
return timelineB.data.samples.length <= 1;
}
return true;
}
function skewTimeline(
timeline: TimelineData | undefined,
factor: number
): TimelineData | undefined {
if (!timeline) {
return undefined;
}
// TODO: deep copy
const copy = JSON.parse(JSON.stringify(timeline)) as typeof timeline;
if (copy && copy.data) {
let min = copy.data.samples[0];
let max = copy.data.samples[0];
for (let i = 0; i < copy.data.samples.length; i += 1) {
const b = copy.data.samples[i];
if (b < min) {
min = b;
}
if (b > max) {
max = b;
}
}
const height = 100; // px
const skew = (max - min) / height;
if (copy.data) {
copy.data.samples = copy.data.samples.map((a) => {
// We don't want to skew negative values, since users are expecting an absent value
if (a <= 0) {
return 0;
}
// 4 is completely arbitrary, it was eyeballed
return a + skew * factor;
});
}
}
return copy;
}
function areTimelinesTheSame(
timelineA: TimelineData,
timelineB?: TimelineData
) {
if (!timelineA || !timelineB) {
// for all purposes let's consider two empty timelines the same
// since we want to transform them
return false;
}
const dataA = timelineA.data;
const dataB = timelineB.data;
if (!dataA || !dataB) {
return false;
}
// Find the biggest one
const biggest = dataA.samples.length > dataB.samples.length ? dataA : dataB;
const smallest = dataA.samples.length < dataB.samples.length ? dataA : dataB;
const map = new Map(biggest.samples.map((a) => [a, true]));
return smallest.samples.every((a) => map.has(a));
}
export default TimelineChartWrapper;