From 5a63d729538da5474e0fb615230ab99b72bbf377 Mon Sep 17 00:00:00 2001 From: Praveen K B Date: Sat, 18 Jan 2025 08:45:36 +0530 Subject: [PATCH] feat: Moving Event graph to Chart.js for more interactions --- package.json | 4 + pnpm-lock.yaml | 67 ++++++ .../components/SavedCorrelationItem.tsx | 6 +- .../Stream/components/AreaChartComponent.tsx | 201 ++++++++++++++++++ .../Stream/components/EventTimeLineGraph.tsx | 62 ++---- .../Stream/components/SecondaryToolbar.tsx | 2 +- 6 files changed, 288 insertions(+), 54 deletions(-) create mode 100644 src/pages/Stream/components/AreaChartComponent.tsx diff --git a/package.json b/package.json index 3ad4baa9..4ded7fe9 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,9 @@ "@tabler/icons-react": "^3.3.0", "@types/js-cookie": "^3.0.3", "axios": "^1.4.0", + "chart.js": "^4.4.7", + "chartjs-plugin-annotation": "^3.1.0", + "chartjs-plugin-zoom": "^2.2.0", "dayjs": "^1.11.10", "embla-carousel-react": "7.1.0", "html2canvas": "^1.4.1", @@ -48,6 +51,7 @@ "protobufjs": "^7.2.5", "react": "^18.2.0", "react-beautiful-dnd": "^13.1.1", + "react-chartjs-2": "^5.3.0", "react-dom": "^18.2.0", "react-error-boundary": "^4.0.10", "react-grid-layout": "^1.4.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6d99ddf1..c76b1ec2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,15 @@ importers: axios: specifier: ^1.4.0 version: 1.4.0 + chart.js: + specifier: ^4.4.7 + version: 4.4.7 + chartjs-plugin-annotation: + specifier: ^3.1.0 + version: 3.1.0(chart.js@4.4.7) + chartjs-plugin-zoom: + specifier: ^2.2.0 + version: 2.2.0(chart.js@4.4.7) dayjs: specifier: ^1.11.10 version: 1.11.10 @@ -107,6 +116,9 @@ importers: react-beautiful-dnd: specifier: ^13.1.1 version: 13.1.1(react-dom@18.2.0(react@18.2.0))(react@18.2.0) + react-chartjs-2: + specifier: ^5.3.0 + version: 5.3.0(chart.js@4.4.7)(react@18.2.0) react-dom: specifier: ^18.2.0 version: 18.2.0(react@18.2.0) @@ -480,6 +492,9 @@ packages: '@humanwhocodes/object-schema@1.2.1': resolution: {integrity: sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==} + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@mantine/carousel@7.8.1': resolution: {integrity: sha512-l+DOvh7ofb7yc+ZkSICujSw0/CukEWP09j4axI+7k3egKbHLc1fBNHSrTLurfsqXKsfhS6GNe6VLGxKizbkJRw==} peerDependencies: @@ -753,6 +768,9 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/hammerjs@2.0.46': + resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} + '@types/hoist-non-react-statics@3.3.1': resolution: {integrity: sha512-iMIqiko6ooLrTh1joXodJK5X9xeEALT1kM5G3ZLhD3hszxBdIEd5C75U834D9mLcINgD4OyZf5uQXjkuYydWvA==} @@ -1013,6 +1031,20 @@ packages: resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} engines: {node: '>=10'} + chart.js@4.4.7: + resolution: {integrity: sha512-pwkcKfdzTMAU/+jNosKhNL2bHtJc/sSmYgVbuGTEDhzkrhmyihmP7vUc/5ZK9WopidMDHNe3Wm7jOd/WhuHWuw==} + engines: {pnpm: '>=8'} + + chartjs-plugin-annotation@3.1.0: + resolution: {integrity: sha512-EkAed6/ycXD/7n0ShrlT1T2Hm3acnbFhgkIEJLa0X+M6S16x0zwj1Fv4suv/2bwayCT3jGPdAtI9uLcAMToaQQ==} + peerDependencies: + chart.js: '>=4.0.0' + + chartjs-plugin-zoom@2.2.0: + resolution: {integrity: sha512-in6kcdiTlP6npIVLMd4zXZ08PDUXC52gZ4FAy5oyjk1zX3gKarXMAof7B9eFiisf9WOC3bh2saHg+J5WtLXZeA==} + peerDependencies: + chart.js: '>=3.2.0' + clsx@1.2.1: resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} engines: {node: '>=6'} @@ -1487,6 +1519,10 @@ packages: graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + hammerjs@2.0.8: + resolution: {integrity: sha512-tSQXBXS/MWQOn/RKckawJ61vvsDpCom87JgxiYdGwHdOa0ht0vzUWDlfioofFCRU0L+6NGDt6XzbgoJvZkMeRQ==} + engines: {node: '>=0.8.0'} + has-bigints@1.0.2: resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} @@ -2034,6 +2070,12 @@ packages: react: ^16.8.5 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.5 || ^17.0.0 || ^18.0.0 + react-chartjs-2@5.3.0: + resolution: {integrity: sha512-UfZZFnDsERI3c3CZGxzvNJd02SHjaSJ8kgW1djn65H1KK8rehwTjyrRKOG3VTMG8wtHZ5rgAO5oTHtHi9GCCmw==} + peerDependencies: + chart.js: ^4.1.1 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom@18.2.0: resolution: {integrity: sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==} peerDependencies: @@ -2812,6 +2854,8 @@ snapshots: '@humanwhocodes/object-schema@1.2.1': {} + '@kurkle/color@0.3.4': {} + '@mantine/carousel@7.8.1(@mantine/core@7.8.1(@mantine/hooks@7.8.1(react@18.2.0))(@types/react@18.2.14)(react-dom@18.2.0(react@18.2.0))(react@18.2.0))(@mantine/hooks@7.8.1(react@18.2.0))(embla-carousel-react@7.1.0(react@18.2.0))(react-dom@18.2.0(react@18.2.0))(react@18.2.0)': dependencies: '@mantine/core': 7.8.1(@mantine/hooks@7.8.1(react@18.2.0))(@types/react@18.2.14)(react-dom@18.2.0(react@18.2.0))(react@18.2.0) @@ -3044,6 +3088,8 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/hammerjs@2.0.46': {} + '@types/hoist-non-react-statics@3.3.1': dependencies: '@types/react': 18.2.14 @@ -3346,6 +3392,20 @@ snapshots: ansi-styles: 4.3.0 supports-color: 7.2.0 + chart.js@4.4.7: + dependencies: + '@kurkle/color': 0.3.4 + + chartjs-plugin-annotation@3.1.0(chart.js@4.4.7): + dependencies: + chart.js: 4.4.7 + + chartjs-plugin-zoom@2.2.0(chart.js@4.4.7): + dependencies: + '@types/hammerjs': 2.0.46 + chart.js: 4.4.7 + hammerjs: 2.0.8 + clsx@1.2.1: {} clsx@2.0.0: {} @@ -3913,6 +3973,8 @@ snapshots: graphemer@1.4.0: {} + hammerjs@2.0.8: {} + has-bigints@1.0.2: {} has-flag@3.0.0: {} @@ -4440,6 +4502,11 @@ snapshots: transitivePeerDependencies: - react-native + react-chartjs-2@5.3.0(chart.js@4.4.7)(react@18.2.0): + dependencies: + chart.js: 4.4.7 + react: 18.2.0 + react-dom@18.2.0(react@18.2.0): dependencies: loose-envify: 1.4.0 diff --git a/src/pages/Correlation/components/SavedCorrelationItem.tsx b/src/pages/Correlation/components/SavedCorrelationItem.tsx index 0286f7b8..e2240a8e 100644 --- a/src/pages/Correlation/components/SavedCorrelationItem.tsx +++ b/src/pages/Correlation/components/SavedCorrelationItem.tsx @@ -1,6 +1,6 @@ import { Stack, Box, Button, Text, px, Code } from '@mantine/core'; import { IconClock, IconEye, IconEyeOff, IconTrash, IconX } from '@tabler/icons-react'; -import { useState, useCallback, Fragment } from 'react'; +import { useState, useCallback, Fragment, FC } from 'react'; import classes from '../styles/SavedCorrelationItem.module.css'; import { Correlation } from '@/@types/parseable/api/correlation'; import dayjs from 'dayjs'; @@ -42,7 +42,7 @@ interface JoinConfig { joinConditions: JoinCondition[]; } -const SelectedFields: React.FC<{ tableConfigs: TableConfig[] }> = ({ tableConfigs }) => { +const SelectedFields: FC<{ tableConfigs: TableConfig[] }> = ({ tableConfigs }) => { const fields = tableConfigs.flatMap((config) => config.selectedFields.map((field) => ({ key: `${config.tableName}-${field}`, @@ -63,7 +63,7 @@ const SelectedFields: React.FC<{ tableConfigs: TableConfig[] }> = ({ tableConfig ); }; -const JoinConditions: React.FC<{ joinConfig: JoinConfig }> = ({ joinConfig }) => { +const JoinConditions: FC<{ joinConfig: JoinConfig }> = ({ joinConfig }) => { return ( <> {joinConfig.joinConditions.map((join, index) => { diff --git a/src/pages/Stream/components/AreaChartComponent.tsx b/src/pages/Stream/components/AreaChartComponent.tsx new file mode 100644 index 00000000..20aa3d0c --- /dev/null +++ b/src/pages/Stream/components/AreaChartComponent.tsx @@ -0,0 +1,201 @@ +import React from 'react'; +import { Line } from 'react-chartjs-2'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + PointElement, + LineElement, + Tooltip, + ChartOptions, + Filler, +} from 'chart.js'; +import Annotation from 'chartjs-plugin-annotation'; +import zoomPlugin from 'chartjs-plugin-zoom'; +import { HumanizeNumber } from '@/utils/formatBytes'; +import timeRangeUtils from '@/utils/timeRangeUtils'; + +const { makeTimeRangeLabel } = timeRangeUtils; + +ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Tooltip, Annotation, Filler, zoomPlugin); + +interface ChartComponentProps { + graphData: any; + avgEventCount: number; + setTimeRangeFromGraph: (minute: string) => void; + hasData: boolean; + onZoomOrPanComplete: (startTime: string, endTime: string) => void; +} + +const AreaChartComponent: React.FC = ({ + graphData, + avgEventCount, + setTimeRangeFromGraph, + hasData, + onZoomOrPanComplete, +}) => { + const chartData = { + labels: graphData.map((item: any) => item.minute), + datasets: [ + { + label: 'Events', + data: graphData.map((item: any) => item.events), + fill: true, + // backgroundColor: 'rgba(99, 102, 241, 0.5)', + borderColor: 'rgb(99, 102, 241)', + borderWidth: 1.25, + pointRadius: 2.5, + pointBorderWidth: 1, + pointBackgroundColor: 'rgb(99, 102, 241)', + tension: 0.4, + backgroundColor: (context: any) => { + const ctx = context.chart.ctx; + const gradient = ctx.createLinearGradient(0, 0, 0, context.chart.height); + gradient.addColorStop(0, 'rgba(99, 102, 241, 0.5)'); + gradient.addColorStop(1, 'rgba(99, 102, 241, 0)'); + return gradient; + }, + }, + ], + }; + + const handleRangeComplete = (chart: any) => { + const { min, max } = chart.scales.x; + const startIndex = Math.floor(min); + const endIndex = Math.ceil(max); + // Get the time range for the zoomed area + if (startIndex >= 0 && endIndex < graphData.length) { + const startTime = graphData[startIndex].startTime; + const endTime = graphData[endIndex].endTime; + onZoomOrPanComplete(startTime, endTime); + } + }; + + const chartOptions: ChartOptions<'line'> = { + responsive: true, + maintainAspectRatio: false, + plugins: { + tooltip: { + position: 'nearest', + backgroundColor: 'white', + titleColor: 'black', + bodyColor: 'black', + footerColor: 'black', + borderColor: 'rgb(99, 102, 241)', + borderWidth: 1, + callbacks: { + title: (tooltipItems: any) => { + const index = tooltipItems[0].dataIndex; + const { startTime, endTime } = graphData[index]; + return makeTimeRangeLabel(new Date(startTime), new Date(endTime)); + }, + label: (tooltipItem: any) => { + return `Events: ${new Intl.NumberFormat('en-US').format(tooltipItem.raw)}`; + }, + // footer: (tooltipItems: any) => { + // const index = tooltipItems[0].dataIndex; + // const { aboveAvgPercent } = graphData[index]; + // const isAboveAvg = aboveAvgPercent > 0; + // return `${isAboveAvg ? '+' : ''}${aboveAvgPercent}% ${ + // isAboveAvg ? 'above' : 'below' + // } average in the given time-range`; + // }, + }, + }, + annotation: { + annotations: { + avgLine: { + type: 'line', + yMin: avgEventCount, + yMax: avgEventCount, + borderColor: 'rgb(156, 163, 175)', + borderWidth: 1, + label: { + content: 'Avg', + position: 'start', + backgroundColor: 'transparent', + color: 'black', + font: { + size: 12, + }, + }, + }, + }, + }, + zoom: { + limits: { + x: { min: 'original', max: 'original' }, + y: { min: 'original', max: 'original' }, + }, + zoom: { + wheel: { + enabled: false, + }, + pinch: { + enabled: true, + }, + mode: 'x', + drag: { + enabled: true, + backgroundColor: 'rgba(99, 102, 241, 0.1)', + }, + onZoomComplete: ({ chart }) => { + handleRangeComplete(chart); + }, + }, + }, + }, + scales: { + x: { + type: 'category', + display: false, + }, + y: { + display: hasData, + ticks: { + count: 2, + callback: (value: string | number) => { + const numericValue = typeof value === 'number' ? value : parseFloat(value); + return HumanizeNumber(numericValue); + }, + }, + grid: { + drawTicks: false, + }, + }, + }, + elements: { + line: { + borderWidth: 1.25, + }, + point: { + radius: 2.5, + hitRadius: 6, + }, + }, + layout: { + padding: { + top: 0, + }, + }, + hover: { + mode: 'nearest', + axis: 'x', + intersect: false, + }, + onClick: (_event, elements) => { + if (elements && elements.length > 0) { + const index = elements[0].index; + setTimeRangeFromGraph(graphData[index].minute); + } + }, + }; + + return ( +
+ +
+ ); +}; + +export default AreaChartComponent; diff --git a/src/pages/Stream/components/EventTimeLineGraph.tsx b/src/pages/Stream/components/EventTimeLineGraph.tsx index d5f778ad..ed167604 100644 --- a/src/pages/Stream/components/EventTimeLineGraph.tsx +++ b/src/pages/Stream/components/EventTimeLineGraph.tsx @@ -1,19 +1,16 @@ -import { Paper, Skeleton, Stack, Text } from '@mantine/core'; +import { Skeleton, Stack, Text } from '@mantine/core'; import classes from '../styles/EventTimeLineGraph.module.css'; import { useGraphData } from '@/hooks/useQueryResult'; import { useCallback, useEffect, useMemo, useState } from 'react'; import dayjs from 'dayjs'; -import { ChartTooltipProps, AreaChart } from '@mantine/charts'; -import { HumanizeNumber } from '@/utils/formatBytes'; import { logsStoreReducers, useLogsStore } from '../providers/LogsProvider'; import { appStoreReducers, useAppStore } from '@/layouts/MainLayout/providers/AppProvider'; import { LogsResponseWithHeaders } from '@/@types/parseable/api/query'; import _ from 'lodash'; -import timeRangeUtils from '@/utils/timeRangeUtils'; import { useStreamStore } from '../providers/StreamProvider'; +import AreaChartComponent from './AreaChartComponent'; const { setTimeRange } = appStoreReducers; -const { makeTimeRangeLabel } = timeRangeUtils; const { getCleanStoreForRefetch } = logsStoreReducers; type CompactInterval = 'minute' | 'day' | 'hour' | 'quarter-hour' | 'half-hour' | 'month'; @@ -132,31 +129,6 @@ const parseGraphData = (data: LogsResponseWithHeaders | undefined, avg: number, return parsedData; }; -function ChartTooltip({ payload }: ChartTooltipProps) { - if (!payload || (Array.isArray(payload) && payload.length === 0)) return null; - - const { aboveAvgPercent, events, startTime, endTime } = payload[0]?.payload as GraphTickItem; - const isAboveAvg = aboveAvgPercent > 0; - const label = makeTimeRangeLabel(startTime.toDate(), endTime.toDate()); - - return ( - - - {label} - - - Events - {events} - - - {`${isAboveAvg ? '+' : ''}${aboveAvgPercent}% ${ - isAboveAvg ? 'above' : 'below' - } average in the given time-range`} - - - ); -} - const EventTimeLineGraph = () => { const { fetchGraphDataMutation } = useGraphData(); const [currentStream] = useAppStore((store) => store.currentStream); @@ -223,27 +195,17 @@ const EventTimeLineGraph = () => { w={isLoading ? '98%' : '100%'} style={isLoading ? { marginLeft: '1.8rem', alignSelf: 'center' } : !hasData ? { marginLeft: '1rem' } : {}}> {hasData ? ( - , - position: { y: -20 }, + 0} + onZoomOrPanComplete={(start, end) => { + setLogStore((store) => getCleanStoreForRefetch(store)); + setAppStore((store) => + setTimeRange(store, { type: 'custom', startTime: dayjs(start), endTime: dayjs(end) }), + ); }} - valueFormatter={(value) => new Intl.NumberFormat('en-US').format(value)} - withXAxis={false} - withYAxis={hasData} - yAxisProps={{ tickCount: 2, tickFormatter: (value) => `${HumanizeNumber(value)}` }} - referenceLines={[{ y: avgEventCount, color: 'gray.5', label: 'Avg' }]} - tickLine="none" - areaChartProps={{ onClick: setTimeRangeFromGraph, style: { cursor: 'pointer' } }} - gridAxis="xy" - fillOpacity={0.5} - strokeWidth={1.25} - dotProps={{ strokeWidth: 1, r: 2.5 }} /> ) : ( diff --git a/src/pages/Stream/components/SecondaryToolbar.tsx b/src/pages/Stream/components/SecondaryToolbar.tsx index 79489d64..daf86877 100644 --- a/src/pages/Stream/components/SecondaryToolbar.tsx +++ b/src/pages/Stream/components/SecondaryToolbar.tsx @@ -5,7 +5,7 @@ import EventTimeLineGraph from './EventTimeLineGraph'; const SecondaryToolbar = () => { return ( - + );