From 757cbb38f6d591cbd78e1a3c3751c00cf7fe6d2c Mon Sep 17 00:00:00 2001 From: pavelpashkovsky Date: Fri, 7 Oct 2022 16:50:34 +0300 Subject: [PATCH] feat: Annotations flot plugin --- .../TimelineChart/Annotation.module.scss | 15 - .../TimelineChart/Annotation.spec.tsx | 107 ------- .../components/TimelineChart/Annotation.tsx | 92 ------ .../TimelineChart/Annotations.plugin.tsx | 262 ++++++++++++++++++ .../TimelineChart/ContextMenu.plugin.tsx | 102 ------- .../TimelineChart/Selection.plugin.ts | 83 +++--- .../TimelineChart/TimelineChart.tsx | 2 +- .../TimelineChart/TimelineChartWrapper.tsx | 63 ++--- .../components/TimelineChart/extractRange.ts | 44 +++ .../components/TimelineChart/markings.spec.ts | 28 +- .../components/TimelineChart/markings.ts | 22 -- .../components/TimelineChart/types.ts | 11 + .../javascript/pages/ContinuousSingleView.tsx | 16 +- .../contextMenu/AddAnnotation.menuitem.tsx | 90 +++--- .../continuous/contextMenu/AnnotationInfo.tsx | 74 +++++ .../continuous/contextMenu/ContextMenu.tsx | 2 +- .../contextMenu/useAnnotationForm.ts | 48 ++++ 17 files changed, 544 insertions(+), 517 deletions(-) delete mode 100644 webapp/javascript/components/TimelineChart/Annotation.module.scss delete mode 100644 webapp/javascript/components/TimelineChart/Annotation.spec.tsx delete mode 100644 webapp/javascript/components/TimelineChart/Annotation.tsx create mode 100644 webapp/javascript/components/TimelineChart/Annotations.plugin.tsx delete mode 100644 webapp/javascript/components/TimelineChart/ContextMenu.plugin.tsx create mode 100644 webapp/javascript/components/TimelineChart/extractRange.ts create mode 100644 webapp/javascript/pages/continuous/contextMenu/AnnotationInfo.tsx create mode 100644 webapp/javascript/pages/continuous/contextMenu/useAnnotationForm.ts diff --git a/webapp/javascript/components/TimelineChart/Annotation.module.scss b/webapp/javascript/components/TimelineChart/Annotation.module.scss deleted file mode 100644 index 0314413561..0000000000 --- a/webapp/javascript/components/TimelineChart/Annotation.module.scss +++ /dev/null @@ -1,15 +0,0 @@ -.wrapper { - overflow: hidden; - max-width: 300px; - max-height: 125px; // same height as the canvas -} - -.body { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.header { - font-size: 10px; -} diff --git a/webapp/javascript/components/TimelineChart/Annotation.spec.tsx b/webapp/javascript/components/TimelineChart/Annotation.spec.tsx deleted file mode 100644 index c43b41de32..0000000000 --- a/webapp/javascript/components/TimelineChart/Annotation.spec.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import AnnotationTooltipBody, { THRESHOLD } from './Annotation'; - -describe('AnnotationTooltipBody', () => { - it('return null when theres no annotation', () => { - const { container } = render( - - ); - - expect(container.querySelector('div')).toBeNull(); - }); - - it('return nothing when no annotation match', () => { - const annotations = [ - { - timestamp: 0, - content: 'annotation 1', - }, - ]; - const coordsToCanvasPos = jest.fn(); - - // reference position - coordsToCanvasPos.mockReturnValueOnce({ left: 100 }); - // our annotation position, point is to be outside the threshold - coordsToCanvasPos.mockReturnValueOnce({ left: 100 + THRESHOLD }); - - const { container } = render( - - ); - - expect(container.querySelector('div')).toBeNull(); - }); - - describe('rendering annotation', () => { - it('return an annotation', () => { - const annotations = [ - { - timestamp: 1663000000, - content: 'annotation 1', - }, - ]; - const coordsToCanvasPos = jest.fn(); - - // reference position - coordsToCanvasPos.mockReturnValueOnce({ left: 100 }); - - render( - - ); - - expect(screen.queryByText(/annotation 1/i)).toBeInTheDocument(); - }); - - it('renders the closest annotation', () => { - const furthestAnnotation = { - timestamp: 1663000010, - content: 'annotation 1', - }; - const closestAnnotation = { - timestamp: 1663000009, - content: 'annotation closest', - }; - const annotations = [furthestAnnotation, closestAnnotation]; - const values = [{ closest: [1663000000] }]; - const coordsToCanvasPos = jest.fn(); - - coordsToCanvasPos.mockImplementation((a) => { - // our reference point - if (a.x === furthestAnnotation.timestamp) { - return { left: 100 }; - } - - // closest - if (a.x === closestAnnotation.timestamp) { - return { left: 99 }; - } - }); - - render( - - ); - - expect(screen.queryByText(/annotation closest/i)).toBeInTheDocument(); - }); - }); -}); diff --git a/webapp/javascript/components/TimelineChart/Annotation.tsx b/webapp/javascript/components/TimelineChart/Annotation.tsx deleted file mode 100644 index c0ab0b8334..0000000000 --- a/webapp/javascript/components/TimelineChart/Annotation.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import React from 'react'; -import { Maybe } from 'true-myth'; -import { format } from 'date-fns'; -import { Annotation } from '@webapp/models/annotation'; -import { getUTCdate, timezoneToOffset } from '@webapp/util/formatDate'; -import styles from './Annotation.module.scss'; - -// TODO(eh-am): what are these units? -export const THRESHOLD = 3; - -interface AnnotationTooltipBodyProps { - /** list of annotations */ - annotations: { timestamp: number; content: string }[]; - - /** given a timestamp, it returns the offset within the canvas */ - coordsToCanvasPos: jquery.flot.axis['p2c']; - - /* where in the canvas the mouse is */ - canvasX: number; - - timezone: 'browser' | 'utc'; -} - -export default function Annotations(props: AnnotationTooltipBodyProps) { - if (!props.annotations?.length) { - return null; - } - - return getClosestAnnotation( - props.annotations, - props.coordsToCanvasPos, - props.canvasX - ) - .map((annotation: Annotation) => ( - - )) - .unwrapOr(null); -} - -function AnnotationComponent({ - timestamp, - content, - timezone, -}: { - timestamp: number; - content: string; - timezone: AnnotationTooltipBodyProps['timezone']; -}) { - // TODO: these don't account for timezone - return ( -
-
- {format( - getUTCdate(new Date(timestamp), timezoneToOffset(timezone)), - 'yyyy-MM-dd HH:mm' - )} -
-
{content}
-
- ); -} - -function getClosestAnnotation( - annotations: { timestamp: number; content: string }[], - coordsToCanvasPos: AnnotationTooltipBodyProps['coordsToCanvasPos'], - canvasX: number -): Maybe { - if (!annotations.length) { - return Maybe.nothing(); - } - - // pointOffset requires a y position, even though we don't use it - const dummyY = -1; - - // Create a score based on how distant it is from the timestamp - // Then get the first value (the closest to the timestamp) - const f = annotations - .map((a) => ({ - ...a, - score: Math.abs( - coordsToCanvasPos({ x: a.timestamp, y: dummyY }).left - canvasX - ), - })) - .filter((a) => a.score < THRESHOLD) - .sort((a, b) => a.score - b.score); - - return Maybe.of(f[0]); -} diff --git a/webapp/javascript/components/TimelineChart/Annotations.plugin.tsx b/webapp/javascript/components/TimelineChart/Annotations.plugin.tsx new file mode 100644 index 0000000000..59469f85f4 --- /dev/null +++ b/webapp/javascript/components/TimelineChart/Annotations.plugin.tsx @@ -0,0 +1,262 @@ +import React from 'react'; +import * as ReactDOM from 'react-dom'; +import { randomId } from '@webapp/util/randomId'; +import { PlotType, CtxType } from './types'; +import extractRange from './extractRange'; + +type AnnotationType = { + content: string; + timestamp: number; + type: 'message'; + color: string; +}; + +interface IFlotOptions extends jquery.flot.plotOptions { + annotations?: AnnotationType[]; + ContextMenu?: React.FC; +} + +type AnnotationPosition = { + fromX: number; + toX: number; + fromY: number; + toY: number; + timestamp: number; + content: string; +}; + +export interface ContextMenuProps { + click: { + /** The X position in the window where the click originated */ + pageX: number; + /** The Y position in the window where the click originated */ + pageY: number; + }; + timestamp: number; + containerEl: HTMLElement; + value?: { + timestamp: number; + content: string; + } | null; +} + +const WRAPPER_ID = randomId('contextMenu'); + +const getIconByAnnotationType = (type: string) => { + switch (type) { + case 'message': + default: + return 'data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0idXRmLTgiPz4NCjwhLS0gU3ZnIFZlY3RvciBJY29ucyA6IGh0dHA6Ly93d3cub25saW5ld2ViZm9udHMuY29tL2ljb24gLS0+DQo8IURPQ1RZUEUgc3ZnIFBVQkxJQyAiLS8vVzNDLy9EVEQgU1ZHIDEuMS8vRU4iICJodHRwOi8vd3d3LnczLm9yZy9HcmFwaGljcy9TVkcvMS4xL0RURC9zdmcxMS5kdGQiPg0KPHN2ZyB2ZXJzaW9uPSIxLjEiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyIgeG1sbnM6eGxpbms9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkveGxpbmsiIHg9IjBweCIgeT0iMHB4Ig0KICAgIHZpZXdCb3g9IjAgMCAxMDAwIDEwMDAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDEwMDAgMTAwMCIgeG1sOnNwYWNlPSJwcmVzZXJ2ZSI+DQogICAgPG1ldGFkYXRhPiBTdmcgVmVjdG9yIEljb25zIDogaHR0cDovL3d3dy5vbmxpbmV3ZWJmb250cy5jb20vaWNvbiA8L21ldGFkYXRhPg0KICAgIDxnPg0KICAgICAgICA8cGF0aCBmaWxsPSIjZmZmIg0KICAgICAgICAgICAgZD0iTTg5Miw4MTguNGgtNzkuM2wtNzAuOCwxMjIuN0w1MjkuNCw4MTguNEgxMDhjLTU0LjEsMC05OC00My45LTk4LTk4VjE1Ni45YzAtNTQuMSw0My45LTk4LDk4LTk4aDc4NGM1NC4xLDAsOTgsNDMuOSw5OCw5OHY1NjMuNUM5OTAsNzc0LjUsOTQ2LjEsODE4LjQsODkyLDgxOC40eiBNOTE2LjUsMTMyLjRoLTgzM3Y2MTIuNWg0NjMuOWwxNzAuMSw5OC4ybDU2LjctOTguMmgxNDIuNFYxMzIuNHogTTE4MS41LDU4NS43YzAtMjAuMywxNi41LTM2LjgsMzYuOC0zNi44aDU2My41YzIwLjMsMCwzNi44LDE2LjUsMzYuOCwzNi44YzAsMjAuMy0xNi41LDM2LjgtMzYuOCwzNi44SDIxOC4zQzE5OCw2MjIuNCwxODEuNSw2MDYsMTgxLjUsNTg1Ljd6IE03ODEuOCw0NzUuNEgyMTguM2MtMjAuMywwLTM2LjgtMTYuNS0zNi44LTM2LjhjMC0yMC4zLDE2LjUtMzYuOCwzNi44LTM2LjhoNTYzLjVjMjAuMywwLDM2LjgsMTYuNSwzNi44LDM2LjhDODE4LjUsNDU5LDgwMiw0NzUuNCw3ODEuOCw0NzUuNHogTTU4NS44LDMyOC40SDIxOC4zYy0yMC4zLDAtMzYuOC0xNi41LTM2LjgtMzYuN2MwLTIwLjMsMTYuNS0zNi44LDM2LjgtMzYuOGgzNjcuNWMyMC4zLDAsMzYuOCwxNi41LDM2LjgsMzYuOEM2MjIuNSwzMTIsNjA2LDMyOC40LDU4NS44LDMyOC40eiIgLz4NCiAgICA8L2c+DQo8L3N2Zz4='; + } +}; + +const shouldStartAnnotationsFunctionality = (annotations?: AnnotationType[]) => + Array.isArray(annotations); + +const inject = ($: JQueryStatic) => { + const alreadyInitialized = $(`#${WRAPPER_ID}`).length > 0; + + if (alreadyInitialized) { + return $(`#${WRAPPER_ID}`); + } + + const body = $('body'); + return $(`
`).appendTo(body); +}; + +const getCursorPositionInPx = ( + plot: PlotType, + positionInTimestamp: { x: number; y: number } +) => { + const axes = plot.getAxes(); + const extractedX = extractRange(plot, axes, 'x'); + const extractedY = extractRange(plot, axes, 'y'); + const plotOffset = plot.getPlotOffset() as { + top: number; + left: number; + }; + + return { + x: Math.floor(extractedX.axis.p2c(positionInTimestamp.x)) + plotOffset.left, + y: Math.floor(extractedY.axis.p2c(positionInTimestamp.y)) + plotOffset.top, + }; +}; + +const findAnnotationByCursorPosition = ( + x: number, + y: number, + list: AnnotationPosition[] = [] +) => { + return list?.find((an) => { + return x >= an.fromX && x <= an.toX && y >= an.fromY && y <= an.toY; + }); +}; + +(function ($) { + function init(plot: jquery.flot.plot & jquery.flot.plotOptions) { + const annotationsPositions: AnnotationPosition[] = []; + const placeholder = plot.getPlaceholder(); + + function onHover(_: unknown, pos: { x: number; y: number }) { + if (annotationsPositions?.length) { + const { x, y } = getCursorPositionInPx( + plot as PlotType & jquery.flot.plot, + pos + ); + + const annotation = findAnnotationByCursorPosition( + x, + y, + annotationsPositions + ); + + if (annotation) { + placeholder.trigger('hoveringOnAnnotation', [{ hovering: true }]); + } else { + placeholder.trigger('hoveringOnAnnotation', [{ hovering: false }]); + } + } + } + + function onClick( + _: unknown, + pos: { x: number; pageX: number; pageY: number; y: number } + ) { + const options: IFlotOptions = plot.getOptions(); + const container = inject($); + const containerEl = container?.[0]; + + ReactDOM.unmountComponentAtNode(containerEl); + + const ContextMenu = options?.ContextMenu; + + const { x, y } = getCursorPositionInPx( + plot as PlotType & jquery.flot.plot, + pos + ); + + const annotation = findAnnotationByCursorPosition( + x, + y, + annotationsPositions + ); + + if (ContextMenu && containerEl) { + const timestamp = Math.round(pos.x / 1000); + const value = annotation + ? { + timestamp: annotation.timestamp, + content: annotation.content, + } + : null; + + ReactDOM.render( + , + containerEl + ); + } + } + + plot.hooks!.draw!.push((plot, ctx: CtxType) => { + const o: IFlotOptions = plot.getOptions(); + + if (o.annotations?.length) { + const axes = plot.getAxes(); + const plotOffset: { top: number; left: number } = plot.getPlotOffset(); + const extractedX = extractRange( + plot as PlotType & jquery.flot.plot, + axes, + 'x' + ); + const extractedY = extractRange( + plot as PlotType & jquery.flot.plot, + axes, + 'y' + ); + + o.annotations.forEach((a: AnnotationType) => { + const left: number = + Math.floor(extractedX.axis.p2c(a.timestamp * 1000)) + + plotOffset.left; + const yMax = + Math.floor(extractedY.axis.p2c(extractedY.axis.min)) + + plotOffset.top; + const yMin = 0 + plotOffset.top; + const lineWidth = 2; + const subPixel = lineWidth / 2 || 0; + const squareHeight = 30; + const squareWidth = 34; + + // draw vertical line + ctx.beginPath(); + ctx.strokeStyle = a.color; + ctx.lineWidth = lineWidth; + ctx.moveTo(left + subPixel, yMax); + ctx.lineTo(left + subPixel, yMin); + ctx.stroke(); + + // draw icon square + ctx.beginPath(); + ctx.fillStyle = a.color; + const rectParams = { + fromX: left + 1 - squareWidth / 2, + toX: left + 1 + squareWidth / 2, + fromY: 0, + toY: squareHeight, + ...a, + }; + ctx.fillRect( + rectParams.fromX, + rectParams.fromY, + squareWidth, + squareHeight + ); + ctx.stroke(); + annotationsPositions.push(rectParams); + + // draw icon + const img = new Image(); + img.onload = () => { + ctx.drawImage(img, left - squareWidth / 2 + 4, 1, 28, 28); + }; + + img.src = getIconByAnnotationType(a.type); + }); + } + }); + + plot.hooks!.bindEvents!.push((plot) => { + const o: IFlotOptions = plot.getOptions(); + + if (shouldStartAnnotationsFunctionality(o.annotations)) { + placeholder.bind('plothover', onHover); + placeholder.bind('plotclick', onClick); + } + }); + + plot.hooks!.shutdown!.push((plot) => { + const o: IFlotOptions = plot.getOptions(); + + if (shouldStartAnnotationsFunctionality(o.annotations)) { + placeholder.unbind('plothover', onHover); + placeholder.unbind('plotclick', onClick); + + const container = inject($); + + ReactDOM.unmountComponentAtNode(container?.[0]); + } + }); + } + + $.plot.plugins.push({ + init, + options: {}, + name: 'annotations', + version: '1.0', + }); +})(jQuery); diff --git a/webapp/javascript/components/TimelineChart/ContextMenu.plugin.tsx b/webapp/javascript/components/TimelineChart/ContextMenu.plugin.tsx deleted file mode 100644 index 4cbdf7fdb0..0000000000 --- a/webapp/javascript/components/TimelineChart/ContextMenu.plugin.tsx +++ /dev/null @@ -1,102 +0,0 @@ -import React from 'react'; -import * as ReactDOM from 'react-dom'; -import { randomId } from '@webapp/util/randomId'; -import { Provider } from 'react-redux'; -import store from '@webapp/redux/store'; - -// Pre calculated once -// TODO(eh-am): does this work with multiple contextMenus? -const WRAPPER_ID = randomId('contextMenu'); - -export interface ContextMenuProps { - click: { - /** The X position in the window where the click originated */ - pageX: number; - /** The Y position in the window where the click originated */ - pageY: number; - }; - timestamp: number; - containerEl: HTMLElement; -} - -(function ($: JQueryStatic) { - function init(plot: jquery.flot.plot & jquery.flot.plotOptions) { - function onClick( - event: unknown, - pos: { x: number; pageX: number; pageY: number } - ) { - const container = inject($); - const containerEl = container?.[0]; - - // unmount any previous menus - ReactDOM.unmountComponentAtNode(containerEl); - - // TODO(eh-am): improve typing - const ContextMenu = (plot.getOptions() as ShamefulAny).ContextMenu as - | React.FC - | undefined; - - if (ContextMenu && containerEl) { - // TODO(eh-am): why do we need this conversion? - const timestamp = Math.round(pos.x / 1000); - - // Add a Provider (reux) so that we can communicate with the main app via actions - // idea from https://stackoverflow.com/questions/52660770/how-to-communicate-reactdom-render-with-other-reactdom-render - // TODO(eh-am): add a global Context too? - ReactDOM.render( - - - , - containerEl - ); - } - } - - const flotEl = plot.getPlaceholder(); - - // Register events and shutdown - // It's important to bind/unbind to the SAME element - // Since a plugin may be register/unregistered multiple times due to react re-rendering - - // TODO: not entirely sure when these are disabled - if (plot.hooks?.bindEvents) { - plot.hooks.bindEvents.push(function () { - flotEl.bind('plotclick', onClick); - }); - } - - if (plot.hooks?.shutdown) { - plot.hooks.shutdown.push(function () { - flotEl.unbind('plotclick', onClick); - - const container = inject($); - const containerEl = container?.[0]; - - // unmount any previous menus - ReactDOM.unmountComponentAtNode(containerEl); - }); - } - } - - $.plot.plugins.push({ - init, - options: {}, - name: 'context_menu', - version: '1.0', - }); -})(jQuery); - -const inject = ($: JQueryStatic) => { - const alreadyInitialized = $(`#${WRAPPER_ID}`).length > 0; - - if (alreadyInitialized) { - return $(`#${WRAPPER_ID}`); - } - - const body = $('body'); - return $(`
`).appendTo(body); -}; diff --git a/webapp/javascript/components/TimelineChart/Selection.plugin.ts b/webapp/javascript/components/TimelineChart/Selection.plugin.ts index 17ed22094c..6f81bb3b44 100644 --- a/webapp/javascript/components/TimelineChart/Selection.plugin.ts +++ b/webapp/javascript/components/TimelineChart/Selection.plugin.ts @@ -2,12 +2,14 @@ // extending logic of Flot's selection plugin (react-flot/flot/jquery.flot.selection) import { PlotType, CtxType, EventHolderType, EventType } from './types'; import clamp from './clamp'; +import extractRange from './extractRange'; const handleWidth = 4; const handleHeight = 22; (function ($) { function init(plot: PlotType) { + const placeholder = plot.getPlaceholder(); var selection = { first: { x: -1, y: -1 }, second: { x: -1, y: -1 }, @@ -15,6 +17,7 @@ const handleHeight = 22; active: false, selectingSide: null, }; + var hoveringOnAnnotation = false; // FIXME: The drag handling implemented here should be // abstracted out, there's some similar code from a library in @@ -36,7 +39,7 @@ const handleHeight = 22; function getCursorPositionX(e: EventType) { const plotOffset = plot.getPlotOffset(); - const offset = plot.getPlaceholder().offset(); + const offset = placeholder.offset(); return clamp(0, plot.width(), e.pageX - offset.left - plotOffset.left); } @@ -46,7 +49,7 @@ const handleHeight = 22; const o = plot.getOptions(); const axes = plot.getAxes(); const plotOffset = plot.getPlotOffset(); - const extractedX = extractRange(axes, 'x'); + const extractedX = extractRange(plot, axes, 'x'); return { left: @@ -95,7 +98,7 @@ const handleHeight = 22; if (dragSide) { setCursor('grab'); } else { - setCursor('crosshair'); + setCursor(hoveringOnAnnotation ? 'pointer' : 'crosshair'); } } @@ -108,7 +111,7 @@ const handleHeight = 22; setCursor('crosshair'); } - plot.getPlaceholder().trigger('plotselecting', [getSelection()]); + placeholder.trigger('plotselecting', [getSelection()]); } } @@ -137,7 +140,7 @@ const handleHeight = 22; }; } - const offset = plot.getPlaceholder().offset(); + const offset = placeholder.offset(); const plotOffset = plot.getPlotOffset(); const { left, right } = getPlotSelection(); const clickX = getCursorPositionX(e); @@ -190,8 +193,8 @@ const handleHeight = 22; if (selectionIsSane()) triggerSelectedEvent(); else { // this counts as a clear - plot.getPlaceholder().trigger('plotunselected', []); - plot.getPlaceholder().trigger('plotselecting', [null]); + placeholder.trigger('plotunselected', []); + placeholder.trigger('plotselecting', [null]); } setCursor('crosshair'); @@ -220,11 +223,11 @@ const handleHeight = 22; function triggerSelectedEvent() { var r: any = getSelection(); - plot.getPlaceholder().trigger('plotselected', [r]); + placeholder.trigger('plotselected', [r]); // backwards-compat stuff, to be removed in future if (r.xaxis && r.yaxis) - plot.getPlaceholder().trigger('selected', [ + placeholder.trigger('selected', [ { x1: r.xaxis.from, y1: r.yaxis.from, @@ -236,7 +239,7 @@ const handleHeight = 22; function setSelectionPos(pos: { x: number; y: number }, e: EventType) { var o = plot.getOptions(); - var offset = plot.getPlaceholder().offset(); + var offset = placeholder.offset(); var plotOffset = plot.getPlotOffset(); pos.x = clamp(0, plot.width(), e.pageX - offset.left - plotOffset.left); pos.y = clamp(0, plot.height(), e.pageY - offset.top - plotOffset.top); @@ -262,48 +265,10 @@ const handleHeight = 22; if (selection.show) { selection.show = false; plot.triggerRedrawOverlay(); - if (!preventEvent) plot.getPlaceholder().trigger('plotunselected', []); + if (!preventEvent) placeholder.trigger('plotunselected', []); } } - // function taken from markings support in Flot - function extractRange(ranges: { [x: string]: any }, coord: string) { - var axis, - from, - to, - key, - axes = plot.getAxes(); - - for (var k in axes) { - axis = axes[k]; - if (axis.direction == coord) { - key = coord + axis.n + 'axis'; - if (!ranges[key] && axis.n == 1) key = coord + 'axis'; // support x1axis as xaxis - if (ranges[key]) { - from = ranges[key].from; - to = ranges[key].to; - break; - } - } - } - - // backwards-compat stuff - to be removed in future - if (!ranges[key as string]) { - axis = coord == 'x' ? plot.getXAxes()[0] : plot.getYAxes()[0]; - from = ranges[coord + '1']; - to = ranges[coord + '2']; - } - - // auto-reverse as an added bonus - if (from != null && to != null && from > to) { - var tmp = from; - from = to; - to = tmp; - } - - return { from: from, to: to, axis: axis }; - } - function setSelection(ranges: any, preventEvent: any) { var axis, range, @@ -313,7 +278,7 @@ const handleHeight = 22; selection.first.x = 0; selection.second.x = plot.width(); } else { - range = extractRange(ranges, 'x'); + range = extractRange(plot, ranges, 'x'); selection.first.x = range.axis.p2c(range.from); selection.second.x = range.axis.p2c(range.to); @@ -323,7 +288,7 @@ const handleHeight = 22; selection.first.y = 0; selection.second.y = plot.height(); } else { - range = extractRange(ranges, 'y'); + range = extractRange(plot, ranges, 'y'); selection.first.y = range.axis.p2c(range.from); selection.second.y = range.axis.p2c(range.to); @@ -346,6 +311,18 @@ const handleHeight = 22; plot.setSelection = setSelection; plot.getSelection = getSelection; + function onHoveringOnAnnotation( + _: EventType, + value?: { hovering: boolean } + ) { + // during further complication this logic should be moved to separated plugin + // which only handles cursor state over Flot + // because cursor setters conflict between each other + if (value) { + hoveringOnAnnotation = value?.hovering; + } + } + plot.hooks.bindEvents.push(function ( plot: PlotType, eventHolder: EventHolderType @@ -354,6 +331,7 @@ const handleHeight = 22; if (o.selection.mode != null) { eventHolder.mousemove(onMouseMove); eventHolder.mousedown(onMouseDown); + placeholder.bind('hoveringOnAnnotation', onHoveringOnAnnotation); } }); @@ -428,7 +406,7 @@ const handleHeight = 22; ) { const axes = plot.getAxes(); const plotOffset = plot.getPlotOffset(); - const extractedY = extractRange(axes, 'y'); + const extractedY = extractRange(plot, axes, 'y'); const { left, right } = getPlotSelection(); const yMax = @@ -465,6 +443,7 @@ const handleHeight = 22; ) { eventHolder.unbind('mousemove', onMouseMove); eventHolder.unbind('mousedown', onMouseDown); + placeholder.unbind('hoveringOnAnnotation', onHoveringOnAnnotation); if (mouseUpHandler) ($ as any)(document).unbind('mouseup', mouseUpHandler); diff --git a/webapp/javascript/components/TimelineChart/TimelineChart.tsx b/webapp/javascript/components/TimelineChart/TimelineChart.tsx index 153b400a74..7f5b002781 100644 --- a/webapp/javascript/components/TimelineChart/TimelineChart.tsx +++ b/webapp/javascript/components/TimelineChart/TimelineChart.tsx @@ -8,7 +8,7 @@ import './Selection.plugin'; import 'react-flot/flot/jquery.flot.crosshair.min'; import './TimelineChartPlugin'; import './Tooltip.plugin'; -import './ContextMenu.plugin'; +import './Annotations.plugin'; interface TimelineChartProps { onSelect: (from: string, until: string) => void; diff --git a/webapp/javascript/components/TimelineChart/TimelineChartWrapper.tsx b/webapp/javascript/components/TimelineChart/TimelineChartWrapper.tsx index 2f5404893f..392adad235 100644 --- a/webapp/javascript/components/TimelineChart/TimelineChartWrapper.tsx +++ b/webapp/javascript/components/TimelineChart/TimelineChartWrapper.tsx @@ -10,10 +10,9 @@ import type { ExploreTooltipProps } from '@webapp/components/TimelineChart/Explo import type { ITooltipWrapperProps } from './TooltipWrapper'; import TooltipWrapper from './TooltipWrapper'; import TimelineChart from './TimelineChart'; -import Annotation from './Annotation'; import styles from './TimelineChartWrapper.module.css'; -import { markingsFromAnnotations, markingsFromSelection } from './markings'; -import { ContextMenuProps } from './ContextMenu.plugin'; +import { markingsFromSelection, ANNOTATION_COLOR } from './markings'; +import { ContextMenuProps } from './Annotations.plugin'; export interface TimelineGroupData { data: Group; @@ -148,6 +147,7 @@ class TimelineChartWrapper extends React.Component< // a position and a nearby data item object as parameters. clickable: true, }, + annotations: null, yaxis: { show: false, min: 0, @@ -203,6 +203,7 @@ class TimelineChartWrapper extends React.Component< this.state = { flotOptions }; this.state.flotOptions.grid.markings = this.plotMarkings(); + this.state.flotOptions.annotations = this.composeAnnotationsList(); } componentDidUpdate(prevProps: TimelineChartWrapperProps) { @@ -212,10 +213,22 @@ class TimelineChartWrapper extends React.Component< ) { const newFlotOptions = this.state.flotOptions; newFlotOptions.grid.markings = this.plotMarkings(); + newFlotOptions.annotations = this.composeAnnotationsList(); 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, + })) + : null; + }; + plotMarkings = () => { const selectionMarkings = markingsFromSelection( this.props.selectionType, @@ -223,16 +236,13 @@ class TimelineChartWrapper extends React.Component< this.props.selection?.right ); - const annotationsMarkings = markingsFromAnnotations(this.props.annotations); - - return [...selectionMarkings, ...annotationsMarkings]; + return [...selectionMarkings]; }; setOnHoverDisplayTooltip = ( data: ITooltipWrapperProps & ExploreTooltipProps ) => { - const { timezone } = this.props; - let tooltipContent = []; + const tooltipContent = []; const TooltipBody: React.FC | undefined = this.props?.onHoverDisplayTooltip; @@ -247,43 +257,6 @@ class TimelineChartWrapper extends React.Component< ); } - // convert to the format we are expecting - const annotations = - this.props.annotations?.map((a) => ({ - ...a, - timestamp: a.timestamp * 1000, - })) || []; - - if (this.props.annotations) { - if ( - this.props.mode === 'singles' && - data.coordsToCanvasPos && - data.canvasX - ) { - const an = Annotation({ - timezone, - annotations, - canvasX: data.canvasX, - coordsToCanvasPos: data.coordsToCanvasPos, - }); - - // if available, only render annotation - // so that the tooltip is not bloated - if (an) { - // Rerender as tsx to make use of key - tooltipContent = [ - , - ]; - } - } - } - if (tooltipContent.length) { return ( to) { + var tmp = from; + from = to; + to = tmp; + } + + return { from: from, to: to, axis: axis }; +} diff --git a/webapp/javascript/components/TimelineChart/markings.spec.ts b/webapp/javascript/components/TimelineChart/markings.spec.ts index a6d40806fc..1445aa4724 100644 --- a/webapp/javascript/components/TimelineChart/markings.spec.ts +++ b/webapp/javascript/components/TimelineChart/markings.spec.ts @@ -1,31 +1,5 @@ import Color from 'color'; -import { - ANNOTATION_COLOR, - ANNOTATION_WIDTH, - markingsFromAnnotations, - markingsFromSelection, -} from './markings'; - -describe('markingsFromAnnotations', () => { - it('works', () => { - const timestamp = 1663000000; - const annotations = [ - { - timestamp, - }, - ]; - expect(markingsFromAnnotations(annotations)).toStrictEqual([ - { - lineWidth: ANNOTATION_WIDTH, - color: ANNOTATION_COLOR, - xaxis: { - from: timestamp * 1000, - to: timestamp * 1000, - }, - }, - ]); - }); -}); +import { markingsFromSelection } from './markings'; // Tests are definitely confusing, but that's due to the nature of the implementation // TODO: refactor implementatino diff --git a/webapp/javascript/components/TimelineChart/markings.ts b/webapp/javascript/components/TimelineChart/markings.ts index ff50c123f6..85703f9a57 100644 --- a/webapp/javascript/components/TimelineChart/markings.ts +++ b/webapp/javascript/components/TimelineChart/markings.ts @@ -3,7 +3,6 @@ import Color from 'color'; // Same green as button export const ANNOTATION_COLOR = Color('#2ecc40'); -export const ANNOTATION_WIDTH = '2px'; type FlotMarkings = { xaxis: { @@ -17,27 +16,6 @@ type FlotMarkings = { color: Color; }[]; -/** - * generate markings in flotjs format - */ -export function markingsFromAnnotations( - annotations?: { timestamp: number }[] -): FlotMarkings { - if (!annotations?.length) { - return []; - } - - return annotations.map((a) => ({ - xaxis: { - // TODO(eh-am): look this up - from: a.timestamp * 1000, - to: a.timestamp * 1000, - }, - lineWidth: ANNOTATION_WIDTH, - color: ANNOTATION_COLOR, - })); -} - // Unify these types interface Selection { from: string; diff --git a/webapp/javascript/components/TimelineChart/types.ts b/webapp/javascript/components/TimelineChart/types.ts index 7f7cd95722..5c2911b304 100644 --- a/webapp/javascript/components/TimelineChart/types.ts +++ b/webapp/javascript/components/TimelineChart/types.ts @@ -33,6 +33,17 @@ export type CtxType = { fillRect: (arg0: number, arg1: number, arg2: number, arg3: number) => void; strokeRect: (arg0: number, arg1: number, arg2: number, arg3: number) => void; restore: () => void; + beginPath: () => void; + moveTo: (x: number, y: number) => void; + lineTo: (x: number, y: number) => void; + stroke: () => void; + drawImage: ( + img: HTMLImageElement, + x: number, + y: number, + width?: number, + height?: number + ) => void; }; export type EventHolderType = { diff --git a/webapp/javascript/pages/ContinuousSingleView.tsx b/webapp/javascript/pages/ContinuousSingleView.tsx index 53101ca456..a1f30c4df4 100644 --- a/webapp/javascript/pages/ContinuousSingleView.tsx +++ b/webapp/javascript/pages/ContinuousSingleView.tsx @@ -19,7 +19,7 @@ import TimelineTitle from '@webapp/components/TimelineTitle'; import useExportToFlamegraphDotCom from '@webapp/components/exportToFlamegraphDotCom.hook'; import useTimeZone from '@webapp/hooks/timeZone.hook'; import PageTitle from '@webapp/components/PageTitle'; -import { ContextMenuProps } from '@webapp/components/TimelineChart/ContextMenu.plugin'; +import { ContextMenuProps } from '@webapp/components/TimelineChart/Annotations.plugin'; import { isExportToFlamegraphDotComEnabled, isAnnotationsEnabled, @@ -27,6 +27,7 @@ import { import { formatTitle } from './formatTitle'; import ContextMenu from './continuous/contextMenu/ContextMenu'; import AddAnnotationMenuItem from './continuous/contextMenu/AddAnnotation.menuitem'; +import AnnotationInfo from './continuous/contextMenu/AnnotationInfo'; function ContinuousSingleView() { const dispatch = useAppDispatch(); @@ -114,6 +115,19 @@ function ContinuousSingleView() { if (!isAnnotationsEnabled) { return null; } + + if (props.value) { + return ( + + ); + } + return ( - Add annotation - -
{ - onCreateAnnotation(d.content); - })} - > - - - -
- - - + {isPopoverOpen ? ( + <> + Add annotation + +
{ + onCreateAnnotation(d.content as string); + })} + > + + + +
+ + + + + ) : null}
diff --git a/webapp/javascript/pages/continuous/contextMenu/AnnotationInfo.tsx b/webapp/javascript/pages/continuous/contextMenu/AnnotationInfo.tsx new file mode 100644 index 0000000000..9f5a518745 --- /dev/null +++ b/webapp/javascript/pages/continuous/contextMenu/AnnotationInfo.tsx @@ -0,0 +1,74 @@ +/* eslint-disable react/jsx-props-no-spreading */ +import React, { useState, useEffect } from 'react'; +import { MenuItem, applyStatics } from '@webapp/ui/Menu'; +import { Popover, PopoverBody, PopoverFooter } from '@webapp/ui/Popover'; +import Button from '@webapp/ui/Button'; +import { Portal } from '@webapp/ui/Portal'; +import TextField from '@webapp/ui/Form/TextField'; +import { AddAnnotationProps } from './AddAnnotation.menuitem'; +import { useAnnotationForm } from './useAnnotationForm'; + +interface AnnotationInfo { + container: AddAnnotationProps['container']; + popoverAnchorPoint: AddAnnotationProps['popoverAnchorPoint']; + timestamp: AddAnnotationProps['timestamp']; + timezone: AddAnnotationProps['timezone']; + value: { content: string; timestamp: number }; +} + +const AnnotationInfo = ({ + container, + popoverAnchorPoint, + value, + timezone, +}: AnnotationInfo) => { + const [isPopoverOpen, setPopoverOpen] = useState(false); + const { register, errors } = useAnnotationForm({ value, timezone }); + + useEffect(() => { + if (value) { + setPopoverOpen(true); + } + }, []); + + return ( + + + +
+ + + +
+ + + +
+
+ ); +}; + +applyStatics(MenuItem)(AnnotationInfo); + +export default AnnotationInfo; diff --git a/webapp/javascript/pages/continuous/contextMenu/ContextMenu.tsx b/webapp/javascript/pages/continuous/contextMenu/ContextMenu.tsx index bece3a3889..dc07a6d69a 100644 --- a/webapp/javascript/pages/continuous/contextMenu/ContextMenu.tsx +++ b/webapp/javascript/pages/continuous/contextMenu/ContextMenu.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { ControlledMenu } from '@webapp/ui/Menu'; -import { ContextMenuProps as PluginContextMenuProps } from '@webapp/components/TimelineChart/ContextMenu.plugin'; +import { ContextMenuProps as PluginContextMenuProps } from '@webapp/components/TimelineChart/Annotations.plugin'; interface ContextMenuProps { /** position */ diff --git a/webapp/javascript/pages/continuous/contextMenu/useAnnotationForm.ts b/webapp/javascript/pages/continuous/contextMenu/useAnnotationForm.ts new file mode 100644 index 0000000000..f50ea5c46c --- /dev/null +++ b/webapp/javascript/pages/continuous/contextMenu/useAnnotationForm.ts @@ -0,0 +1,48 @@ +import * as z from 'zod'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { format } from 'date-fns'; +import { getUTCdate, timezoneToOffset } from '@webapp/util/formatDate'; + +interface IseAnnotationFormProps { + timezone: 'browser' | 'utc'; + value: { + content?: string; + timestamp: number; + }; +} + +const newAnnotationFormSchema = z.object({ + content: z.string().min(1, { message: 'Required' }), +}); + +export const useAnnotationForm = ({ + value, + timezone, +}: IseAnnotationFormProps) => { + const { + register, + handleSubmit, + formState: { errors }, + setFocus, + } = useForm({ + resolver: zodResolver(newAnnotationFormSchema), + defaultValues: { + content: value?.content, + timestamp: format( + getUTCdate( + new Date(value?.timestamp * 1000), + timezoneToOffset(timezone) + ), + 'yyyy-MM-dd HH:mm' + ), + }, + }); + + return { + register, + handleSubmit, + errors, + setFocus, + }; +};