diff --git a/frontend-components/plotly/package-lock.json b/frontend-components/plotly/package-lock.json index e929f1cdcda9..1d0b054991a3 100644 --- a/frontend-components/plotly/package-lock.json +++ b/frontend-components/plotly/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@radix-ui/react-dialog": "^1.0.3", "dom-to-image": "^2.6.0", + "lodash": "^4.17.21", "plotly.js-dist-min": "^2.21.0", "posthog-js": "^1.55.0", "react": "^18.0.0", @@ -18,6 +19,7 @@ }, "devDependencies": { "@types/dom-to-image": "^2.6.4", + "@types/lodash": "^4.14.195", "@types/node": "^18.16.3", "@types/plotly.js-dist-min": "^2.3.1", "@types/react": "^18.0.27", @@ -1021,12 +1023,28 @@ "url": "https://opencollective.com/turf" } }, + "node_modules/@types/d3-dsv": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@types/d3-dsv/-/d3-dsv-3.0.1.tgz", + "integrity": "sha512-76pBHCMTvPLt44wFOieouXcGXWOF0AJCceUvaFkxSZEu4VDUdv93JfpMa6VGNFs01FHfuP4a5Ou68eRG1KBfTw==" + }, "node_modules/@types/dom-to-image": { "version": "2.6.4", "resolved": "https://registry.npmjs.org/@types/dom-to-image/-/dom-to-image-2.6.4.tgz", "integrity": "sha512-UddUdGF1qulrSDulkz3K2Ypq527MR6ixlgAzqLbxSiQ0icx0XDlIV+h4+edmjq/1dqn0KgN0xGSe1kI9t+vGuw==", "dev": true }, + "node_modules/@types/estree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.1.tgz", + "integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==" + }, + "node_modules/@types/lodash": { + "version": "4.14.195", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.195.tgz", + "integrity": "sha512-Hwx9EUgdwf2GLarOjQp5ZH8ZmblzcbTBC2wtQWNKARBSxM9ezRIAUpeDTgoQRAFB0+8CNWXVA9+MaSOzOF3nPg==", + "dev": true + }, "node_modules/@types/node": { "version": "18.16.17", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.16.17.tgz", @@ -1543,8 +1561,7 @@ "node_modules/commander": { "version": "2.20.3", "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", - "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", - "peer": true + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" }, "node_modules/concat-map": { "version": "0.0.1", @@ -2085,6 +2102,11 @@ "node": ">=4.0" } }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==" + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -2658,7 +2680,6 @@ "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "peer": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3" }, @@ -2912,6 +2933,11 @@ "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", "dev": true }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -3334,7 +3360,6 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, "engines": { "node": ">=8.6" }, @@ -3962,7 +3987,7 @@ "version": "3.24.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-3.24.1.tgz", "integrity": "sha512-REHe5dx30ERBRFS0iENPHy+t6wtSEYkjrhwNsLyh3qpRaZ1+aylvMUdMBUHWUD/RjjLmLzEvY8Z9XRlpcdIkHA==", - "dev": true, + "devOptional": true, "bin": { "rollup": "dist/bin/rollup" }, @@ -4000,8 +4025,7 @@ "node_modules/rw": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz", - "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==", - "peer": true + "integrity": "sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ==" }, "node_modules/safe-buffer": { "version": "5.2.1", @@ -4026,8 +4050,7 @@ "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", - "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", - "peer": true + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" }, "node_modules/sax": { "version": "1.2.4", @@ -4401,6 +4424,14 @@ "topoquantize": "bin/topoquantize" } }, + "node_modules/tosource": { + "version": "2.0.0-alpha.3", + "resolved": "https://registry.npmjs.org/tosource/-/tosource-2.0.0-alpha.3.tgz", + "integrity": "sha512-KAB2lrSS48y91MzFPFuDg4hLbvDiyTjOVgaK7Erw+5AmZXNq4sFRVn8r6yxSLuNs15PaokrDRpS61ERY9uZOug==", + "engines": { + "node": ">=10" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", diff --git a/frontend-components/plotly/package.json b/frontend-components/plotly/package.json index 201f02cbc402..1d6ee039e15e 100644 --- a/frontend-components/plotly/package.json +++ b/frontend-components/plotly/package.json @@ -12,6 +12,7 @@ "dependencies": { "@radix-ui/react-dialog": "^1.0.3", "dom-to-image": "^2.6.0", + "lodash": "^4.17.21", "plotly.js-dist-min": "^2.21.0", "posthog-js": "^1.55.0", "react": "^18.0.0", @@ -20,6 +21,7 @@ }, "devDependencies": { "@types/dom-to-image": "^2.6.4", + "@types/lodash": "^4.14.195", "@types/node": "^18.16.3", "@types/plotly.js-dist-min": "^2.3.1", "@types/react": "^18.0.27", diff --git a/frontend-components/plotly/src/components/AutoScaling.tsx b/frontend-components/plotly/src/components/AutoScaling.tsx index 378689963b56..d450cc060895 100644 --- a/frontend-components/plotly/src/components/AutoScaling.tsx +++ b/frontend-components/plotly/src/components/AutoScaling.tsx @@ -14,28 +14,41 @@ export default async function autoScaling( let y_min; let y_max; - const YaxisData = graphs.data.filter( - (trace) => trace.yaxis !== undefined, - ); + const get_all_yaxis_traces = {}; + const get_all_yaxis_annotations = {}; + let volumeTraceYaxis = null; + const yaxis_unique = [ ...new Set( - YaxisData.map( - (trace) => + graphs.data.map((trace) => { + if ( trace.yaxis || trace.y !== undefined || - trace.type === "candlestick", - ), + trace.type === "candlestick" + ) { + if (trace.type === "bar" && trace?.name?.trim() === "Volume") { + volumeTraceYaxis = `yaxis${trace.yaxis.replace("y", "")}`; + } + get_all_yaxis_traces[trace.yaxis] = + get_all_yaxis_traces[trace.yaxis] || []; + get_all_yaxis_traces[trace.yaxis].push(trace); + return trace.yaxis; + } + }), ), ]; - const get_all_yaxis_traces = (yaxis) => { - return graphs.data.filter( - (trace) => - trace.yaxis === yaxis && (trace.y || trace.type === "candlestick"), - ); - }; + graphs.layout.annotations.map((annotation, i) => { + if (annotation.yref !== undefined && annotation.yref !== "paper") { + annotation.index = i; + const yaxis = `yaxis${annotation.yref.replace("y", "")}`; + get_all_yaxis_annotations[yaxis] = + get_all_yaxis_annotations[yaxis] || []; + get_all_yaxis_annotations[yaxis].push(annotation); + } + }); - yaxis_unique.forEach((unique) => { + yaxis_unique.map((unique) => { if (typeof unique !== "string") { return; } @@ -44,7 +57,7 @@ export default async function autoScaling( let y_values = []; let log_scale = graphs.layout[yaxis].type === "log"; - get_all_yaxis_traces(unique).forEach((trace2) => { + get_all_yaxis_traces[unique].map((trace2) => { const x = trace2.x; log_scale = graphs.layout[yaxis].type === "log"; @@ -62,7 +75,23 @@ export default async function autoScaling( const yx_values = x.map((x, i) => { let out = null; - if (x >= x_min && x <= x_max) { + const isoDateRegex = new RegExp( + "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}", + ); + if (isoDateRegex.test(x)) { + const x_time = new Date(x).getTime(); + const x0_min = new Date(x_min.replace(" ", "T")).getTime(); + const x1_max = new Date(x_max.replace(" ", "T")).getTime(); + if (x_time >= x0_min && x_time <= x1_max) { + if (trace2.y !== undefined) { + out = y[i]; + } + if (trace2.type === "candlestick") { + y_candle.push(y_low[i]); + y_candle.push(y_high[i]); + } + } + } else if (x >= x_min && x <= x_max) { if (trace2.y !== undefined) { out = y[i]; } @@ -88,10 +117,6 @@ export default async function autoScaling( } const org_y_max = y_max; - const is_volume = - graphs.layout[yaxis].fixedrange !== undefined && - yaxis !== "yaxis" && - graphs.layout[yaxis].fixedrange === true; if (y_min !== undefined && y_max !== undefined) { const y_range = y_max - y_min; @@ -103,7 +128,7 @@ export default async function autoScaling( y_min -= y_range * y_mult; y_max += y_range * y_mult; - if (is_volume) { + if (yaxis === volumeTraceYaxis) { if (graphs.layout[yaxis].tickvals !== undefined) { const range_x = 7; const volume_ticks = org_y_max; @@ -137,6 +162,20 @@ export default async function autoScaling( to_update[`${yaxis}.range`] = [y_min, y_max]; to_update[`${yaxis}.fixedrange`] = true; yaxis_fixedrange.push(yaxis); + + if (get_all_yaxis_annotations[yaxis] !== undefined) { + get_all_yaxis_annotations[yaxis].map((annotation) => { + if (annotation.ay !== undefined) { + const yshift = annotation.ay; + const yshift_new = Math.min( + Math.max(yshift, y_min + y_range * 0.2), + y_max - y_range * 0.2, + ); + + to_update[`annotations[${annotation.index}].ay`] = yshift_new; + } + }); + } } }); diff --git a/frontend-components/plotly/src/components/Chart.tsx b/frontend-components/plotly/src/components/Chart.tsx index 42f40a2199d4..f418110ef931 100644 --- a/frontend-components/plotly/src/components/Chart.tsx +++ b/frontend-components/plotly/src/components/Chart.tsx @@ -1,9 +1,11 @@ // @ts-nocheck import clsx from "clsx"; +import { debounce } from "lodash"; import * as Plotly from "plotly.js-dist-min"; import { Icons as PlotlyIcons } from "plotly.js-dist-min"; import { usePostHog } from "posthog-js/react"; -import { useEffect, useState } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useHotkeys } from "react-hotkeys-hook"; import createPlotlyComponent from "react-plotly.js/factory"; import { init_annotation } from "../utils/addAnnotation"; import { non_blocking, saveImage } from "../utils/utils"; @@ -19,11 +21,52 @@ import { PlotConfig, hideModebar } from "./PlotlyConfig"; import ResizeHandler from "./ResizeHandler"; const Plot = createPlotlyComponent(Plotly); +class PlotComponent extends React.Component { + constructor(props) { + super(props); + this.state = { + data: props.data, + layout: props.layout, + frames: props.frames, + config: props.config, + useResizeHandler: props.useResizeHandler, + style: props.style, + className: props.className, + divId: props.divId, + revision: props.revision, + graphDiv: props.graphDiv, + debug: props.debug, + onInitialized: props.onInitialized, + }; + } + + render() { + return ( + this.setState(figure)} + onRelayout={(figure) => this.setState(figure)} + onPurge={(figure) => this.setState(figure)} + /> + ); + } +} function CreateDataXrange(data: Plotly.PlotData[], xrange?: any) { if (!xrange) { xrange = [ - data[0]?.x[data[0].x.length - 1000], + data[0]?.x[data[0].x.length - 2000], data[0]?.x[data[0].x.length - 1], ]; } @@ -43,7 +86,17 @@ function CreateDataXrange(data: Plotly.PlotData[], xrange?: any) { const xaxis = trace.x ? trace.x : []; const chunks = []; for (let i = 0; i < xaxis.length; i++) { - if (xaxis[i] >= xrange[0] && xaxis[i] <= xrange[1]) { + const isoDateRegex = new RegExp( + "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}", + ); + if (isoDateRegex.test(xaxis[i])) { + const x_time = new Date(xaxis[i]).getTime(); + const x0_min = new Date(xrange[0].replace(" ", "T")).getTime(); + const x1_max = new Date(xrange[1].replace(" ", "T")).getTime(); + if (x_time >= x0_min && x_time <= x1_max) { + chunks.push(i); + } + } else if (xaxis[i] >= xrange[0] && xaxis[i] <= xrange[1]) { chunks.push(i); } } @@ -83,16 +136,15 @@ async function DynamicLoad({ ); if (XDATA.length === 0) return figure; - // We get the xaxis range, if no event is passed, we get the last 1000 points + // We get the xaxis range, if no event is passed, we get the last 2000 points const xaxis_range = event ? [event["xaxis.range[0]"], event["xaxis.range[1]"]] : [ - XDATA[0]?.x[XDATA[0].x.length - 1000], + XDATA[0]?.x[XDATA[0].x.length - 2000], XDATA[0]?.x[XDATA[0].x.length - 1], ]; - const new_data = CreateDataXrange(figure.data, xaxis_range); - figure.data = new_data; + figure.data = CreateDataXrange(figure.data, xaxis_range); figure.layout.xaxis.range = xaxis_range; return figure; } catch (e) { @@ -100,7 +152,7 @@ async function DynamicLoad({ } } -export default function Chart({ +function Chart({ json, date, cmd, @@ -156,37 +208,104 @@ export default function Chart({ const [yaxisFixedRange, setYaxisFixedRange] = useState([]); const onClose = () => setModal({ name: "" }); + useHotkeys( + "ctrl+shift+t", + () => { + setModal({ name: "titleDialog" }); + }, + { preventDefault: true }, + ); + useHotkeys( + "ctrl+t", + () => { + setModal({ name: "textDialog" }); + }, + { preventDefault: true }, + ); + useHotkeys( + "ctrl+o", + () => { + setModal({ name: "overlayChart" }); + }, + { preventDefault: true }, + ); + useHotkeys( + ["ctrl+shift+h", "ctrl+h"], + () => { + hideModebar(); + }, + { preventDefault: true }, + ); + useHotkeys( + "ctrl+e", + () => { + changeColor(true); + }, + { preventDefault: true }, + ); + useHotkeys( + "ctrl+shift+s", + async () => { + setModal({ name: "downloadCsv" }); + await downloadCSV( + document.getElementById("plotlyChart") as any, + downloadFinished, + ); + }, + { preventDefault: true }, + ); + useHotkeys( + "ctrl+s", + async () => { + hideModebar(); + downloadImage("MainChart", hideModebar, Loading, downloadFinished); + }, + { preventDefault: true }, + ); + useHotkeys( + "ctrl+w", + () => { + window.close(); + }, + { preventDefault: true }, + ); // @ts-ignore - function onDeleteAnnotation(annotation) { - console.log("onDeleteAnnotation", annotation); - const index = plotData?.layout?.annotations?.findIndex( - (a: any) => a.text === annotation.text, - ); - console.log("index", index); - if (index > -1) { - plotData?.layout?.annotations?.splice(index, 1); - setPlotData({ ...plotData }); - setAnnotations(plotData?.layout?.annotations); - } - } + const onDeleteAnnotation = useCallback( + (annotation) => { + console.log("onDeleteAnnotation", annotation); + const index = plotData?.layout?.annotations?.findIndex( + (a: any) => a.text === annotation.text, + ); + console.log("index", index); + if (index > -1) { + plotData?.layout?.annotations?.splice(index, 1); + setPlotData({ ...plotData }); + setAnnotations(plotData?.layout?.annotations); + } + }, + [plotData], + ); // @ts-ignore - function onAddAnnotation(data) { - init_annotation({ - plotData, - popupData: data, - setPlotData, - setModal, - setOnAnnotationClick, - setAnnotations, - onAnnotationClick, - ohlcAnnotation, - setOhlcAnnotation, - annotations, - plotDiv, - }); - } + const onAddAnnotation = useCallback( + (data) => { + init_annotation({ + plotData, + popupData: data, + setPlotData, + setModal, + setOnAnnotationClick, + setAnnotations, + onAnnotationClick, + ohlcAnnotation, + setOhlcAnnotation, + annotations, + plotDiv, + }); + }, + [plotData, onAnnotationClick, ohlcAnnotation, annotations, plotDiv], + ); useEffect(() => { if (downloadFinished) { @@ -195,6 +314,12 @@ export default function Chart({ } }, [downloadFinished]); + useEffect(() => { + if (plotDiv) { + Plotly.update(plotDiv, plotData.data, plotData.layout, plotData.config); + } + }, [plotData]); + useEffect(() => { if (axesTitles && Object.keys(axesTitles).length > 0) { Object.keys(axesTitles).forEach((k) => { @@ -240,8 +365,30 @@ export default function Chart({ } setModeBarButtons({ ...barButtons, [title]: button }); } + const debouncedDynamicLoad = useCallback( + debounce(async (eventData, figure) => { + if (dateSliced) { + const data = { ...figure }; + await DynamicLoad({ + event: eventData, + figure: data, + }).then(async (toUpdate) => { + setPlotData(toUpdate); + Plotly.react(plotDiv, toUpdate.data, toUpdate.layout); + const scaled = await autoScaling(eventData, plotDiv); + setYaxisFixedRange(scaled.yaxis_fixedrange); + Plotly.update(plotDiv, {}, scaled.to_update); + }); + } else { + const scaled = await autoScaling(eventData, plotDiv); + setYaxisFixedRange(scaled.yaxis_fixedrange); + Plotly.update(plotDiv, {}, scaled.to_update); + } + }, 150), + [setPlotData, plotDiv, setYaxisFixedRange, dateSliced], + ); - function autoscaleButton() { + const autoscaleButton = useCallback(() => { // We need to check if the button is active or not const title = "Auto Scale (Ctrl+Shift+A)"; const button = @@ -253,26 +400,10 @@ export default function Chart({ active = false; plotDiv.on( "plotly_relayout", - non_blocking(async function (eventdata) { + debounce(async (eventdata) => { if (eventdata["xaxis.range[0]"] === undefined) return; - if (dateSliced) { - const data = { ...originalData }; - await DynamicLoad({ - event: eventdata, - figure: data, - }).then(async (to_update) => { - setPlotData(to_update); - Plotly.react(plotDiv, to_update.data, to_update.layout); - const scaled = await autoScaling(eventdata, plotDiv); - setYaxisFixedRange(scaled.yaxis_fixedrange); - Plotly.update(plotDiv, {}, scaled.to_update); - }); - } else { - const scaled = await autoScaling(eventdata, plotDiv); - setYaxisFixedRange(scaled.yaxis_fixedrange); - Plotly.update(plotDiv, {}, scaled.to_update); - } - }, 100), + debouncedDynamicLoad(eventdata, originalData); + }, 500), ); } // If the button isn't active, we remove the listener so @@ -286,23 +417,23 @@ export default function Chart({ if (dateSliced) { plotDiv.on( "plotly_relayout", - non_blocking(async function (eventdata) { + debounce(async (eventdata) => { if (eventdata["xaxis.range[0]"] === undefined) return; - const data = { ...originalData }; - await DynamicLoad({ - event: eventdata, - figure: data, - }).then(async (to_update) => { - setPlotData(to_update); - Plotly.react(plotDiv, to_update.data, to_update.layout); - }); - }, 100), + debouncedDynamicLoad(eventdata, originalData); + }, 500), ); } } button_pressed(title, active); - } + }, [ + barButtons, + dateSliced, + debouncedDynamicLoad, + originalData, + plotDiv, + yaxisFixedRange, + ]); function changecolorButton() { // We need to check if the button is active or not @@ -339,15 +470,15 @@ export default function Chart({ if (changeTheme) { try { console.log("changeTheme", changeTheme); - const TRACES = plotData?.data.filter( + const TRACES = originalData?.data.filter( (trace) => trace?.name?.trim() === "Volume", ); const darkmode = !darkMode; window.document.body.style.backgroundColor = darkmode ? "#000" : "#fff"; - plotData.layout.font = { - ...(plotData.layout.font || {}), + originalData.layout.font = { + ...(originalData.layout.font || {}), color: darkmode ? "#fff" : "#000", }; @@ -380,11 +511,11 @@ export default function Chart({ return volumeColors[color] || color; }); }); - plotData.layout.template = darkmode + originalData.layout.template = darkmode ? DARK_CHARTS_TEMPLATE : LIGHT_CHARTS_TEMPLATE; - setPlotData({ ...plotData }); - Plotly.react(plotDiv, plotData.data, plotData.layout); + setPlotData({ ...originalData }); + Plotly.react(plotDiv, originalData.data, originalData.layout); setDarkMode(darkmode); setChangeTheme(false); } catch (e) { @@ -487,7 +618,7 @@ export default function Chart({ if (Object.keys(layout_update).length > 0) { setPlotData(newPlotData); setVolumeBars(volume_update); - Plotly.relayout(plotDiv, layout_update); + Plotly.update(plotDiv, {}, layout_update); } }); @@ -500,7 +631,7 @@ export default function Chart({ ); if ( (originalData.data[0]?.x !== undefined && - originalData.data[0]?.x.length <= 1000) || + originalData.data[0]?.x.length <= 2000) || !traceTypes.includes(true) ) return; @@ -508,7 +639,7 @@ export default function Chart({ name: "alertDialog", data: { title: "Warning", - content: `Data has been truncated to 1000 points for performance reasons. + content: `Data has been truncated to 2000 points for performance reasons. Please use the zoom tool to see more data.`, }, }); @@ -519,6 +650,123 @@ export default function Chart({ } }, [plotLoaded]); + const plotComponent = useMemo( + () => ( + { + if (!plotDiv) { + const plot = document.getElementById("plotlyChart"); + console.log("plot", plot); + if (plot) setPlotDiv(plot); + plot.globals = globals; + } + if (!plotLoaded) setPlotLoaded(true); + }} + className="w-full h-full" + divId="plotlyChart" + data={plotData.data} + layout={plotData.layout} + config={PlotConfig({ + setModal: setModal, + changeTheme: setChangeTheme, + autoScaling: setAutoScaling, + Loading: setLoading, + changeColor: setChangeColor, + downloadFinished: setDownloadFinished, + })} + /> + ), + [ + plotDiv, + originalData, + plotLoaded, + plotData, + globals, + setPlotDiv, + setPlotLoaded, + setModal, + setChangeTheme, + setAutoScaling, + setLoading, + onChangeColor, + setDownloadFinished, + ], + ); + + const memoizedAlertDialog = useMemo(() => { + return ( + + ); + }, [modal, onClose]); + + const memoizedOverlayChartDialog = useMemo(() => { + return ( + { + console.log(overlay); + overlay.layout.showlegend = true; + setOriginalData(overlay); + setPlotData(overlay); + Plotly.react(plotDiv, overlay.data, overlay.layout); + }} + plotlyData={originalData} + setLoading={setLoading} + open={modal?.name === "overlayChart"} + close={onClose} + /> + ); + }, [modal, plotData, onClose, setPlotData, setLoading]); + + const memoizedTitleChartDialog = useMemo(() => { + return ( + setChartTitle(title)} + updateAxesTitles={(axesTitles) => setAxesTitles(axesTitles)} + defaultTitle={chartTitle} + plotlyData={plotData} + open={modal?.name === "titleDialog"} + close={onClose} + /> + ); + }, [modal, plotData, chartTitle, onClose]); + + const memoizedTextChartDialog = useMemo(() => { + return ( + onAddAnnotation(data)} + deleteAnnotation={(data) => onDeleteAnnotation(data)} + /> + ); + }, [ + modal, + onAddAnnotation, + onDeleteAnnotation, + onClose, + plotData, + setPlotData, + ]); + + const memoizedChangeColor = useMemo(() => { + return ; + }, [colorActive, onChangeColor]); + + const memoizedDownloadFinishedDialog = useMemo(() => { + return ( + + ); + }, [modal, onClose]); + return (
{loading && ( @@ -549,44 +797,12 @@ export default function Chart({
- - { - console.log(overlay); - plotData.layout.showlegend = true; - setPlotData(overlay); - setPlotLoaded(false); - }} - plotlyData={plotData} - setLoading={setLoading} - open={modal.name === "overlayChart"} - close={onClose} - /> - setChartTitle(title)} - updateAxesTitles={(axesTitles) => setAxesTitles(axesTitles)} - defaultTitle={chartTitle} - plotlyData={plotData} - open={modal.name === "titleDialog"} - close={onClose} - /> - onAddAnnotation(data)} - deleteAnnotation={(data) => onDeleteAnnotation(data)} - /> - - + {memoizedAlertDialog} + {memoizedOverlayChartDialog} + {memoizedTitleChartDialog} + {memoizedTextChartDialog} + {memoizedChangeColor} + {memoizedDownloadFinishedDialog}
@@ -632,31 +848,11 @@ export default function Chart({ "h-[calc(100%-50px)]": !maximizePlot, })} > - { - if (!plotDiv) { - const plot = document.getElementById("plotlyChart"); - console.log("plot", plot); - if (plot) setPlotDiv(plot); - plot.globals = globals; - } - if (!plotLoaded) setPlotLoaded(true); - }} - className="w-full h-full" - divId="plotlyChart" - data={plotData.data} - layout={plotData.layout} - config={PlotConfig({ - setModal: setModal, - changeTheme: setChangeTheme, - autoScaling: setAutoScaling, - Loading: setLoading, - changeColor: setChangeColor, - downloadFinished: setDownloadFinished, - })} - /> + {plotComponent}
); } + +export default React.memo(Chart); diff --git a/frontend-components/plotly/src/components/Dialogs/OverlayChartDialog.tsx b/frontend-components/plotly/src/components/Dialogs/OverlayChartDialog.tsx index 11a9f903c373..ca3da283aa5b 100644 --- a/frontend-components/plotly/src/components/Dialogs/OverlayChartDialog.tsx +++ b/frontend-components/plotly/src/components/Dialogs/OverlayChartDialog.tsx @@ -59,7 +59,7 @@ export default function OverlayChartDialog({ } function onSubmit() { - if (csvData.length == 0) { + if (csvData.length === 0) { document.getElementById("csv_file")?.focus(); document .getElementById("csv_file") diff --git a/frontend-components/plotly/src/components/PlotlyConfig.tsx b/frontend-components/plotly/src/components/PlotlyConfig.tsx index e5649cad90c2..6ede8a00652a 100644 --- a/frontend-components/plotly/src/components/PlotlyConfig.tsx +++ b/frontend-components/plotly/src/components/PlotlyConfig.tsx @@ -1,20 +1,20 @@ import { Icons as PlotlyIcons } from "plotly.js-dist-min"; -import { ICONS } from "./Config"; -import { useHotkeys } from "react-hotkeys-hook"; import { downloadCSV, downloadImage } from "../utils/utils"; +import { ICONS } from "./Config"; -export function hideModebar(hide = true) { +export function hideModebar(hide?: boolean) { return new Promise((resolve) => { if (!window.MODEBAR) { window.MODEBAR = window.document.getElementsByClassName( - "modebar-container" + "modebar-container", )[0] as HTMLElement; window.MODEBAR.style.cssText = `${window.MODEBAR.style.cssText}; display:flex;`; } - let includes_text = "display: none"; + if (window.MODEBAR) { - if (window.MODEBAR.style.cssText.includes("display: none") || !hide) { - includes_text = "display: flex"; + if (hide) { + window.MODEBAR.style.cssText = `${window.MODEBAR.style.cssText}; display:none;`; + } else if (window.MODEBAR.style.cssText.includes("none")) { window.MODEBAR.style.cssText = `${window.MODEBAR.style.cssText}; display:flex;`; } else { window.MODEBAR.style.cssText = `${window.MODEBAR.style.cssText}; display:none;`; @@ -39,65 +39,6 @@ export function PlotConfig({ changeColor: (change: boolean) => void; downloadFinished: (change: boolean) => void; }) { - useHotkeys( - "ctrl+shift+t", - () => { - setModal({ name: "titleDialog" }); - }, - { preventDefault: true } - ); - useHotkeys( - "ctrl+t", - () => { - setModal({ name: "textDialog" }); - }, - { preventDefault: true } - ); - useHotkeys( - "ctrl+o", - () => { - setModal({ name: "overlayChart" }); - }, - { preventDefault: true } - ); - useHotkeys( - ["ctrl+shift+h", "ctrl+h"], - () => { - hideModebar(); - }, - { preventDefault: true } - ); - useHotkeys( - "ctrl+e", - () => { - changeColor(true); - }, - { preventDefault: true } - ); - useHotkeys( - "ctrl+shift+s", - async () => { - setModal({ name: "downloadCsv" }); - await downloadCSV(document.getElementById("plotlyChart") as any, downloadFinished); - }, - { preventDefault: true } - ); - useHotkeys( - "ctrl+s", - async () => { - hideModebar(); - downloadImage("MainChart", hideModebar, Loading, downloadFinished); - }, - { preventDefault: true } - ); - useHotkeys( - "ctrl+w", - () => { - window.close(); - }, - { preventDefault: true } - ); - const CONFIG = { plotGlPixelRatio: 1, scrollZoom: true, @@ -118,8 +59,13 @@ export function PlotConfig({ name: "Download Chart as Image (Ctrl+S)", icon: ICONS.downloadImage, click: async function () { - hideModebar(); - await downloadImage("MainChart", hideModebar, Loading, downloadFinished); + hideModebar(true); + await downloadImage( + "MainChart", + hideModebar, + Loading, + downloadFinished, + ); }, }, // { diff --git a/frontend-components/plotly/src/components/ResizeHandler.tsx b/frontend-components/plotly/src/components/ResizeHandler.tsx index c916d1bbacdc..0db916ff3dfa 100644 --- a/frontend-components/plotly/src/components/ResizeHandler.tsx +++ b/frontend-components/plotly/src/components/ResizeHandler.tsx @@ -32,7 +32,7 @@ export default async function ResizeHandler({ const tick_size = height > 420 && width < 920 ? 8 : height > 420 && width < 500 ? 9 : 7; - if (width < 750) { + if (width < 850) { // We hide the modebar and set the number of ticks to 6 TRACES.forEach((trace) => { @@ -57,8 +57,11 @@ export default async function ResizeHandler({ }); setMaximizePlot(true); - await hideModebar(); - } else if (window.MODEBAR.style.cssText.includes("display: none")) { + await hideModebar(true); + } else if ( + width > 850 && + window.MODEBAR.style.cssText.includes("display: none") + ) { // We show the modebar await hideModebar(false); setMaximizePlot(false); diff --git a/frontend-components/plotly/src/main.tsx b/frontend-components/plotly/src/main.tsx index 5f039b2d09d7..2e3b7c31099c 100644 --- a/frontend-components/plotly/src/main.tsx +++ b/frontend-components/plotly/src/main.tsx @@ -1,11 +1,6 @@ -import React from "react"; import * as ReactDOM from "react-dom/client"; import App from "./App"; import "./index.css"; const rootElement = document.getElementById("root") as HTMLElement; -ReactDOM.createRoot(rootElement).render( - - - , -); +ReactDOM.createRoot(rootElement).render(); diff --git a/frontend-components/plotly/src/utils/addAnnotation.tsx b/frontend-components/plotly/src/utils/addAnnotation.tsx index 261fa2e9b8e4..eb5ee192506b 100644 --- a/frontend-components/plotly/src/utils/addAnnotation.tsx +++ b/frontend-components/plotly/src/utils/addAnnotation.tsx @@ -28,28 +28,28 @@ export function add_annotation({ popup_data: PopupData; current_text?: string; }) { - let x = popup_data.x; + const x = popup_data.x; let y = popup_data.y; - let yref = popup_data.yref; - let annotations = plotData?.layout?.annotations || []; + const yref = popup_data.yref; + const annotations = plotData?.layout?.annotations || []; let index = -1; for (let i = 0; i < annotations.length; i++) { if ( - annotations[i].x == x && - annotations[i].y == y && - annotations[i].text == current_text + annotations[i].x === x && + annotations[i].y === y && + annotations[i].text === current_text ) { index = i; break; } } - if (popup_data.high != undefined) { - y = popup_data.yanchor == "above" ? popup_data.high : popup_data.low; + if (popup_data.high !== undefined) { + y = popup_data.yanchor === "above" ? popup_data.high : popup_data.low; } - if (index == -1) { - let annotation: Annotations = { + if (index === -1) { + const annotation: Annotations = { x: x, y: y, xref: "x", @@ -109,19 +109,19 @@ export function plot_text({ console.log("plot_text: current_text", current_text); let output = undefined; - let yaxis = popup_data.yref.replace("y", "yaxis"); - let yrange = plotData.layout[yaxis].range; + const yaxis = popup_data.yref.replace("y", "yaxis"); + const yrange = plotData.layout[yaxis].range; let yshift = (yrange[1] - yrange[0]) * 0.2; - if (popup_data.yanchor == "below") { + if (popup_data.yanchor === "below") { yshift = -yshift; } popup_data.yshift = yshift; output = add_annotation({ plotData, popup_data, current_text }); - let to_update = { annotations: output.annotations, dragmode: "pan" }; - to_update[yaxis + ".type"] = "linear"; + const to_update = { annotations: output.annotations, dragmode: "pan" }; + to_update[`${yaxis}.type`] = "linear"; return { update: to_update, annotation: output.annotation }; } @@ -150,7 +150,7 @@ export function init_annotation({ annotations: Annotations[]; plotDiv: PlotlyHTMLElement; }) { - if (popupData.text != undefined && popupData.text != "") { + if (popupData.text !== undefined && popupData.text !== "") { popupData.text = popupData.text.replace(/\n/g, "
"); let popup_data: Partial; let inOhlc = false; @@ -165,11 +165,11 @@ export function init_annotation({ popupData.annotation.y < popupData.annotation.ay ? "above" : "below", ...popupData, }; - if (popupData.annotation.high != undefined) { + if (popupData.annotation.high !== undefined) { inOhlc = true; } console.log("popup_data", popup_data); - let to_update = plot_text({ + const to_update = plot_text({ plotData, popup_data: popup_data as PopupData, current_text: popupData.annotation.text, @@ -177,14 +177,14 @@ export function init_annotation({ if (inOhlc) { // we update the ohlcAnnotation - let ohlcAnnotationIndex = ohlcAnnotation.findIndex( + const ohlcAnnotationIndex = ohlcAnnotation.findIndex( (a) => - a.x == popupData.annotation.x && - a.y == popupData.annotation.y && - a.yref == popupData.annotation.yref + a.x === popupData.annotation.x && + a.y === popupData.annotation.y && + a.yref === popupData.annotation.yref, ); console.log("ohlcAnnotationIndex", ohlcAnnotationIndex); - if (ohlcAnnotationIndex == -1) { + if (ohlcAnnotationIndex === -1) { // we add the annotation to the ohlcAnnotation array setOhlcAnnotation([...ohlcAnnotation, to_update.annotation]); } else { @@ -195,7 +195,7 @@ export function init_annotation({ } setAnnotations( - [...annotations, to_update.annotation].filter((a) => a != undefined) + [...annotations, to_update.annotation].filter((a) => a !== undefined), ); plotData.layout.dragmode = "pan"; setPlotData({ ...plotData, ...to_update.update }); @@ -206,9 +206,9 @@ export function init_annotation({ plotDiv.on("plotly_clickannotation", (eventData) => { console.log("plotly_clickannotation", eventData); - let annotation = eventData.annotation; + const annotation = eventData.annotation; - if (annotation.text == undefined) { + if (annotation.text === undefined) { console.log("annotation.text is undefined"); return; } @@ -216,7 +216,7 @@ export function init_annotation({ // we replace
with \n so that the textarea can display the text properly annotation.text = annotation.text.replace(/
/g, "\n"); - let popup_data = { + const popup_data = { x: annotation.x, y: annotation.y, high: annotation?.high ?? undefined, @@ -237,19 +237,20 @@ export function init_annotation({ function clickHandler(eventData: PlotMouseEvent) { console.log("plotly_click", eventData); - let x = eventData.points[0].x; - let yaxis = eventData.points[0].fullData.yaxis; + const x = eventData.points[0].x; + const yaxis = eventData.points[0].fullData.yaxis; let y = 0; - let high, low; + let high; + let low; // We need to check if the trace is a candlestick or not // this is because the y value is stored in the high or low - if (eventData.points[0].y != undefined) { + if (eventData.points[0].y !== undefined) { y = eventData.points[0].y; - } else if (eventData.points[0].low != undefined) { + } else if (eventData.points[0].low !== undefined) { high = eventData.points[0].high; low = eventData.points[0].low; - if (popup_data?.yanchor == "below") { + if (popup_data?.yanchor === "below") { y = eventData.points[0].low; } else { y = eventData.points[0].high; @@ -265,21 +266,21 @@ export function init_annotation({ ...popupData, }; - if (high != undefined) { + if (high !== undefined) { // save the annotation to use later ohlcAnnotation.push(popup_data); setOhlcAnnotation(ohlcAnnotation); console.log("ohlcAnnotation", ohlcAnnotation); } - let to_update = plot_text({ + const to_update = plot_text({ plotData, popup_data: popup_data as PopupData, current_text: onAnnotationClick?.annotation?.text, }); setAnnotations( - [...annotations, to_update.annotation].filter((a) => a != undefined) + [...annotations, to_update.annotation].filter((a) => a !== undefined), ); plotData.layout.dragmode = "pan"; setPlotData({ ...plotData, ...to_update.update }); diff --git a/frontend-components/plotly/src/utils/utils.ts b/frontend-components/plotly/src/utils/utils.ts index c1710524977d..e292c09980e3 100644 --- a/frontend-components/plotly/src/utils/utils.ts +++ b/frontend-components/plotly/src/utils/utils.ts @@ -4,379 +4,381 @@ import * as Plotly from "plotly.js-dist-min"; import { Figure } from "react-plotly.js"; const exportNativeFileSystem = async ({ - fileHandle, - blob, + fileHandle, + blob, }: { - fileHandle?: FileSystemFileHandle | null; - blob: Blob; + fileHandle?: FileSystemFileHandle | null; + blob: Blob; }) => { - if (!fileHandle) { - return; - } + if (!fileHandle) { + return; + } - return writeFileHandler({ fileHandle, blob }); + return writeFileHandler({ fileHandle, blob }); }; const writeFileHandler = async ({ - fileHandle, - blob, + fileHandle, + blob, }: { - fileHandle: FileSystemFileHandle; - blob: Blob; + fileHandle: FileSystemFileHandle; + blob: Blob; }) => { - const writer = await fileHandle.createWritable(); - await writer.write(blob); - await writer.close(); + const writer = await fileHandle.createWritable(); + await writer.write(blob); + await writer.close(); }; const IMAGE_TYPE: FilePickerAcceptType[] = [ - { - description: "PNG Image", - accept: { - "image/png": [".png"], - }, - }, - { - description: "JPEG Image", - accept: { - "image/jpeg": [".jpeg"], - }, - }, - { - description: "SVG Image", - accept: { - "image/svg+xml": [".svg"], - }, - }, + { + description: "PNG Image", + accept: { + "image/png": [".png"], + }, + }, + { + description: "JPEG Image", + accept: { + "image/jpeg": [".jpeg"], + }, + }, + { + description: "SVG Image", + accept: { + "image/svg+xml": [".svg"], + }, + }, ]; const getNewFileHandle = ({ - filename, - is_image, + filename, + is_image, }: { - filename: string; - is_image?: boolean; + filename: string; + is_image?: boolean; }): Promise => { - try { - if ("showSaveFilePicker" in window) { - const opts: SaveFilePickerOptions = { - suggestedName: filename, - types: is_image - ? IMAGE_TYPE - : [ - { - description: "CSV File", - accept: { - "image/csv": [".csv"], - }, - }, - ], - excludeAcceptAllOption: true, - }; - - return showSaveFilePicker(opts); - } - } catch (error) { - console.error(error); - } - - return new Promise((resolve) => { - resolve(null); - }); + try { + if ("showSaveFilePicker" in window) { + const opts: SaveFilePickerOptions = { + suggestedName: filename, + types: is_image + ? IMAGE_TYPE + : [ + { + description: "CSV File", + accept: { + "image/csv": [".csv"], + }, + }, + ], + excludeAcceptAllOption: true, + }; + + return showSaveFilePicker(opts); + } + } catch (error) { + console.error(error); + } + + return new Promise((resolve) => { + resolve(null); + }); }; export const saveToFile = ( - blob: Blob, - fileName: string, - fileHandle?: FileSystemFileHandle | null, + blob: Blob, + fileName: string, + fileHandle?: FileSystemFileHandle | null, ) => { - try { - if (fileHandle === null) { - throw new Error("Cannot access filesystem"); - } - return exportNativeFileSystem({ fileHandle, blob }); - } catch (error) { - console.error("oops, something went wrong!", error); - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.setAttribute("href", url); - link.setAttribute("download", fileName); - link.style.visibility = "hidden"; - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - } - return new Promise((resolve) => { - resolve(true); - }); + try { + if (fileHandle === null) { + throw new Error("Cannot access filesystem"); + } + return exportNativeFileSystem({ fileHandle, blob }); + } catch (error) { + console.error("oops, something went wrong!", error); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.setAttribute("href", url); + link.setAttribute("download", fileName); + link.style.visibility = "hidden"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + return new Promise((resolve) => { + resolve(true); + }); }; export async function downloadCSV( - gd: Figure, - downloadFinished: (change: boolean) => void, + gd: Figure, + downloadFinished: (change: boolean) => void, ) { - const data = gd.data; - let columns: string[] = []; - const rows: any[] = []; - - const xaxis = - "title" in gd.layout["xaxis"] && - gd.layout["xaxis"]["title"]["text"] !== undefined - ? gd.layout["xaxis"]["title"]["text"] - : "x"; - - const yaxis = - "title" in gd.layout["yaxis"] && - gd.layout["yaxis"]["title"]["text"] !== undefined - ? gd.layout["yaxis"]["title"]["text"] - : "y"; - - data.forEach(function (trace) { - if (trace.type === "candlestick") { - if (columns.length === 0) { - columns = ["Date", "Open", "High", "Low", "Close"]; - } - trace.x.forEach(function (x, i) { - rows.push([ - x, - trace.open[i], - trace.high[i], - trace.low[i], - trace.close[i], - ]); - }); - } - - if (["scatter", "bar"].includes(trace.type)) { - if (columns.length === 0) { - columns.push(xaxis); - } - columns.push(trace.name !== undefined ? trace.name : yaxis); - trace.x.forEach(function (x, i) { - if (rows[i] === undefined) { - rows[i] = [x]; - } - rows[i].push(trace.y[i]); - }); - } - }); - - return await downloadData(columns, rows, downloadFinished); + const data = gd.data; + let columns: string[] = []; + const rows: any[] = []; + + const xaxis = + "title" in gd.layout["xaxis"] && + gd.layout["xaxis"]["title"]["text"] !== undefined + ? gd.layout["xaxis"]["title"]["text"] + : "x"; + + const yaxis = + "title" in gd.layout["yaxis"] && + gd.layout["yaxis"]["title"]["text"] !== undefined + ? gd.layout["yaxis"]["title"]["text"] + : "y"; + + data.forEach(function (trace) { + if (trace.type === "candlestick") { + if (columns.length === 0) { + columns = ["Date", "Open", "High", "Low", "Close"]; + } + trace.x.forEach(function (x, i) { + rows.push([ + x, + trace.open[i], + trace.high[i], + trace.low[i], + trace.close[i], + ]); + }); + } + + if (["scatter", "bar"].includes(trace.type)) { + if (columns.length === 0) { + columns.push(xaxis); + } + columns.push(trace.name !== undefined ? trace.name : yaxis); + trace.x.forEach(function (x, i) { + if (rows[i] === undefined) { + rows[i] = [x]; + } + rows[i].push(trace.y[i]); + }); + } + }); + + return await downloadData(columns, rows, downloadFinished); } export async function downloadData( - columns: any, - data: any, - downloadFinished: (change: boolean) => void, + columns: any, + data: any, + downloadFinished: (change: boolean) => void, ) { - const headers = columns; - const rows = data.map((row: any) => - row.map((cell: any) => { - if (cell == null) { - return ""; - } else if (typeof cell === "object") { - return JSON.stringify(cell); - } else { - return cell.toString().replace(/"/g, '""'); - } - }), - ); - const csvData = [headers, ...rows]; - - const csvContent = csvData.map((e) => e.join(",")).join("\n"); - const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); - const filename = `${window.title}.csv`; - try { - const fileHandle = await getNewFileHandle({ - filename: filename, - }); - - await loadingOverlay("Saving CSV"); - - non_blocking(async function () { - // @ts-ignore - saveToFile(blob, filename, fileHandle).then(async function () { - await new Promise((resolve) => setTimeout(resolve, 1500)); - if (!fileHandle) { - downloadFinished(true); - } - await loadingOverlay("", true); - }); - }, 2)(); - } catch (error) { - console.error(error); - } + const headers = columns; + const rows = data.map((row: any) => + row.map((cell: any) => { + if (cell == null) { + return ""; + } else if (typeof cell === "object") { + return JSON.stringify(cell); + } else { + return cell.toString().replace(/"/g, '""'); + } + }), + ); + const csvData = [headers, ...rows]; + + const csvContent = csvData.map((e) => e.join(",")).join("\n"); + const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" }); + const filename = `${window.title}.csv`; + try { + const fileHandle = await getNewFileHandle({ + filename: filename, + }); + + await loadingOverlay("Saving CSV"); + + non_blocking(async function () { + // @ts-ignore + saveToFile(blob, filename, fileHandle).then(async function () { + await new Promise((resolve) => setTimeout(resolve, 1500)); + if (!fileHandle) { + downloadFinished(true); + } + await loadingOverlay("", true); + }); + }, 2)(); + } catch (error) { + console.error(error); + } } function loadingOverlay(message?: string, is_close?: boolean) { - const loading = window.document.getElementById("loading") as HTMLElement; - const loading_text = window.document.getElementById( - "loading_text", - ) as HTMLElement; - return new Promise((resolve) => { - if (is_close) { - loading.classList.remove("show"); - } else { - // @ts-ignore - loading_text.innerHTML = message; - loading.classList.add("show"); - } - - const is_loaded = setInterval(function () { - if ( - is_close - ? !loading.classList.contains("show") - : loading.classList.contains("show") - ) { - clearInterval(is_loaded); - resolve(true); - } - }, 0.01); - }); + const loading = window.document.getElementById("loading") as HTMLElement; + const loading_text = window.document.getElementById( + "loading_text", + ) as HTMLElement; + return new Promise((resolve) => { + if (is_close) { + loading.classList.remove("show"); + } else { + // @ts-ignore + loading_text.innerHTML = message; + loading.classList.add("show"); + } + + const is_loaded = setInterval(function () { + if ( + is_close + ? !loading.classList.contains("show") + : loading.classList.contains("show") + ) { + clearInterval(is_loaded); + resolve(true); + } + }, 0.01); + }); } export const non_blocking = (func: Function, delay: number) => { - let timeout: number; - return function () { - // @ts-ignore - const context = this; - const args = arguments; - clearTimeout(timeout); - timeout = setTimeout(() => func.apply(context, args), delay); - }; + let timeout: number; + return function () { + // @ts-ignore + const context = this; + const args = arguments; + clearTimeout(timeout); + timeout = setTimeout(() => func.apply(context, args), delay); + }; }; const openbb_watermark = { - yref: "paper", - xref: "paper", - x: 1, - y: 0, - text: "OpenBB Terminal", - font_size: 17, - font_color: "gray", - opacity: 0.5, - xanchor: "right", - yanchor: "bottom", - yshift: -80, - xshift: 40, + yref: "paper", + xref: "paper", + x: 1, + y: 0, + text: "OpenBB Terminal", + font_size: 17, + font_color: "gray", + opacity: 0.5, + xanchor: "right", + yanchor: "bottom", + yshift: -80, + xshift: 40, }; async function setWatermarks(margin, old_index, init = false) { - const chart = document.getElementById("plotlyChart") as HTMLElement; - - if (init) { - chart.layout.annotations.push(openbb_watermark); - if ( - chart.globals.cmd_idx !== undefined && - chart.globals.cmd_src !== undefined - ) { - chart.layout.annotations[chart.globals.cmd_idx].text = - chart.globals.cmd_src; - } - - Plotly.relayout(chart, { - "title.text": chart.globals.title, - margin: chart.globals.old_margin, - }); - } - - if (!init) { - if ( - chart.globals.cmd_idx !== undefined && - chart.globals.cmd_src !== undefined - ) { - chart.layout.annotations[chart.globals.cmd_idx].text = ""; - } - chart.layout.annotations.splice(old_index, 1); - - Plotly.relayout(chart, { - "title.text": "", - margin: margin, - }); - } + const chart = document.getElementById("plotlyChart") as HTMLElement; + + if (init) { + chart.layout.annotations.push(openbb_watermark); + if ( + chart.globals.cmd_idx !== undefined && + chart.globals.cmd_src !== undefined + ) { + chart.layout.annotations[chart.globals.cmd_idx].text = + chart.globals.cmd_src; + } + + Plotly.relayout(chart, { + "title.text": chart.globals.title, + margin: chart.globals.old_margin, + }); + } + + if (!init) { + if ( + chart.globals.cmd_idx !== undefined && + chart.globals.cmd_src !== undefined + ) { + chart.layout.annotations[chart.globals.cmd_idx].text = ""; + } + chart.layout.annotations.splice(old_index, 1); + + Plotly.relayout(chart, { + "title.text": "", + margin: margin, + }); + } } export async function saveImage( - id: string, - filename: string, - extension: string, + id: string, + filename: string, + extension: string, ) { - const chart = document.getElementById(id) as HTMLElement; - - if (["svg", "pdf"].includes(extension)) { - const chart = document.getElementById("plotlyChart") as HTMLElement; - const margin = chart.layout.margin; - const old_index = chart.layout.annotations.length; - - await setWatermarks(margin, old_index, true); - - Plotly.downloadImage(chart, { - format: "svg", - height: chart.clientHeight, - width: chart.clientWidth, - filename: window.title, - }); - - await setWatermarks(margin, old_index, false); - await loadingOverlay("", true); - return; - } - - non_blocking(async function () { - domtoimage.toBlob(chart).then(function (blob: Blob) { - saveToFile(blob, filename, null); - }); - }, 2)(); + const chart = document.getElementById(id) as HTMLElement; + + if (["svg", "pdf"].includes(extension)) { + const chart = document.getElementById("plotlyChart") as HTMLElement; + const margin = chart.layout.margin; + const old_index = chart.layout.annotations.length; + + await setWatermarks(margin, old_index, true); + + Plotly.downloadImage(chart, { + format: "svg", + height: chart.clientHeight, + width: chart.clientWidth, + filename: window.title, + }); + + await setWatermarks(margin, old_index, false); + await loadingOverlay("", true); + return; + } + + non_blocking(async function () { + domtoimage.toBlob(chart).then(function (blob: Blob) { + saveToFile(blob, filename, null); + }); + }, 2)(); } export async function downloadImage( - id: string, - hidemodebar: () => void, - loading: (bool: boolean) => void, - downloadFinished: (bool: boolean) => void, + id: string, + hidemodebar: () => void, + loading: (bool: boolean) => void, + downloadFinished: (bool: boolean) => void, ) { - const chart = document.getElementById(id) as HTMLElement; - const filename = `${window.title}.png`; - - try { - loading(true); - const fileHandle = await getNewFileHandle({ - filename: filename, - is_image: true, - }); - let extension = "png"; - if (fileHandle !== null) { - // @ts-ignore - extension = fileHandle.name.split(".").pop(); - } - await loadingOverlay(`Saving ${extension.toUpperCase()}`); - - if (["svg", "pdf"].includes(extension)) { - await saveImage(id, filename, extension); - hidemodebar(false); - loading(false); - if (!fileHandle) { - downloadFinished(true); - } - return; - } - - non_blocking(async function () { - domtoimage.toBlob(chart).then(function (blob: Blob) { - saveToFile(blob, filename, fileHandle).then(async function () { - await loadingOverlay("", true); - hidemodebar(false); - loading(false); - if (!fileHandle) { - downloadFinished(true); - } - }); - }); - }, 2)(); - } catch (error) { - console.error(error); - hidemodebar(false); - loading(false); - } - loading(false); + const chart = document.getElementById(id) as HTMLElement; + const filename = `${window.title}.png`; + + try { + loading(true); + const fileHandle = await getNewFileHandle({ + filename: filename, + is_image: true, + }); + let extension = "png"; + if (fileHandle !== null) { + // @ts-ignore + extension = fileHandle.name.split(".").pop(); + } + await loadingOverlay(`Saving ${extension.toUpperCase()}`).then( + setTimeout(async function () { + if (["svg", "pdf"].includes(extension)) { + await saveImage(id, filename, extension); + hidemodebar(false); + loading(false); + if (!fileHandle) { + downloadFinished(true); + } + return; + } + + non_blocking(async function () { + domtoimage.toBlob(chart).then(function (blob: Blob) { + saveToFile(blob, filename, fileHandle).then(async function () { + await loadingOverlay("", true); + hidemodebar(false); + loading(false); + if (!fileHandle) { + downloadFinished(true); + } + }); + }); + }, 2)(); + }, 100), + ); + } catch (error) { + console.error(error); + hidemodebar(false); + loading(false); + } + loading(false); } diff --git a/frontend-components/tables/src/components/Table/ColumnHeader.tsx b/frontend-components/tables/src/components/Table/ColumnHeader.tsx index fc1695b7504d..eaec84875102 100644 --- a/frontend-components/tables/src/components/Table/ColumnHeader.tsx +++ b/frontend-components/tables/src/components/Table/ColumnHeader.tsx @@ -1,314 +1,375 @@ -import { flexRender } from "@tanstack/react-table"; +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; +import { Table, flexRender } from "@tanstack/react-table"; import clsx from "clsx"; import { FC } from "react"; -import { formatNumberMagnitude, includesDateNames } from "../../utils/utils"; import { useDrag, useDrop } from "react-dnd"; -import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; +import { includesDateNames } from "../../utils/utils"; export const magnitudeRegex = new RegExp("^([0-9]+)(\\s)([kKmMbBtT])$"); +export const isoYearRegex = new RegExp("^\\d{4}$"); +export const isoDateRegex = new RegExp( + "^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}|\\d{4}-\\d{2}-\\d{2}$", +); function Filter({ - column, - table, - numberOfColumns, + column, + table, + numberOfColumns, }: { - column: any; - table: any; - numberOfColumns: number; + column: any; + table: Table; + numberOfColumns: number; }) { - function getTime(value: string | number | Date) { - if (!value) return null; - const date = new Date(value); - const year = date.getFullYear(); - const month = - date.getMonth() + 1 > 9 ? date.getMonth() + 1 : `0${date.getMonth() + 1}`; - const day = date.getDate() > 9 ? date.getDate() : `0${date.getDate()}`; - return `${year}-${month}-${day}`; - } + function getTime(value: string | number | Date) { + if (!value) return null; + const datetime = new Date(value); + const date = datetime.toISOString().split("T")[0]; + const time = datetime.toTimeString().split(" ")[0]; + return `${date} ${time}`; + } + + const values = table.getPreFilteredRowModel().flatRows.map( + (row: { getValue: (arg0: any) => any }) => + // @ts-ignore + row.original[column.id], + ); + + const areAllValuesString = values.every( + (value: null) => typeof value === "string" || value === null, + ); - const values = table - .getPreFilteredRowModel() - .flatRows.map((row: { getValue: (arg0: any) => any }) => - row.getValue(column.id), - ); + const areAllValuesNumber = values.every( + (value: null | number | string) => + typeof value === "number" || + magnitudeRegex.test(value as string) || + value === null || + value === "", + ); - const areAllValuesString = values.every( - (value: null) => typeof value === "string" || value === null, - ); + const valuesContainStringWithSpaces = values.some( + (value: string | string[]) => + typeof value === "string" && value.includes(" "), + ); - const areAllValuesNumber = values.every( - (value: null | number | string) => - typeof value === "number" || - magnitudeRegex.test(value as string) || - value === null || - value === "", - ); + const columnFilterValue = column.getFilterValue(); - const valuesContainStringWithSpaces = values.some( - (value: string | string[]) => - typeof value === "string" && value.includes(" "), - ); + let dateType = "date"; - const columnFilterValue = column.getFilterValue(); + const isProbablyDate = values.every((value: string) => { + const only_numbers = value?.toString().replace(/[^0-9]/g, "").trim(); + if (isoDateRegex.test(value?.toString())) { + dateType = "datetime-local"; + } + if (isoYearRegex.test(value?.toString())) { + dateType = "number"; + } + return ( + only_numbers?.length >= 4 && + (includesDateNames(column.id) || + (column.id.toLowerCase() === "index" && !valuesContainStringWithSpaces)) + ); + }); - const isProbablyDate = values.every((value: string) => { - const only_numbers = value?.toString().replace(/[^0-9]/g, "").trim(); - return ( - only_numbers?.length >= 4 && - (includesDateNames(column.id) || - (column.id.toLowerCase() === "index" && !valuesContainStringWithSpaces)) - ); - }); + if (isProbablyDate && dateType === "number") { + return ( +
+ { + column.setFilterValue((old: [string, string]) => [ + `${e.target.value}`, + `${old?.[1]}`, + ]); + }} + min={values.reduce( + (acc: number, value: string) => + Math.min(acc, parseInt(value, 10)), + Infinity, + )} + max={values.reduce( + (acc: number, value: string) => + Math.max(acc, parseInt(value, 10)), + -Infinity, + )} + placeholder={"Start year"} + className="_input" + /> + { + column.setFilterValue((old: [string, string]) => [ + `${old?.[0]}`, + `${e.target.value}`, + ]); + }} + min={values.reduce( + (acc: number, value: string) => + Math.min(acc, parseInt(value, 10)), + Infinity, + )} + max={values.reduce( + (acc: number, value: string) => + Math.max(acc, parseInt(value, 10)), + -Infinity, + )} + placeholder={"End year"} + className="_input" + /> +
+ ); + } - if (isProbablyDate) { - return ( -
- { - const value = new Date(e.target.value).getTime(); - column.setFilterValue((old: [string, string]) => [value, old?.[1]]); - }} - placeholder={"Start date"} - className="_input" - /> - { - const value = new Date(e.target.value).getTime(); - column.setFilterValue((old: [string, string]) => [old?.[0], value]); - }} - placeholder={"End date"} - className="_input" - /> -
- ); - } + if (isProbablyDate && dateType !== "number") { + return ( +
+ { + const value = new Date(e.target.value).getTime(); + column.setFilterValue((old: [string, string]) => [value, old?.[1]]); + }} + placeholder={"Start date"} + className="_input" + /> + { + const value = new Date(e.target.value).getTime(); + column.setFilterValue((old: [string, string]) => [old?.[0], value]); + }} + placeholder={"End date"} + className="_input" + /> +
+ ); + } - if (areAllValuesNumber) { - return ( -
- - column.setFilterValue((old: [number, number]) => [ - e.target.value, - old?.[1], - ]) - } - placeholder={"Min"} - className="_input p-0.5" - /> - - column.setFilterValue((old: [number, number]) => [ - old?.[0], - e.target.value, - ]) - } - placeholder={"Max"} - className="_input p-0.5" - /> -
- ); - } - if (areAllValuesString) { - return ( -
- column.setFilterValue(e.target.value)} - placeholder={"Search..."} - className="_input" - /> -
- ); - } - return
; + if (areAllValuesNumber) { + return ( +
+ + column.setFilterValue((old: [number, number]) => [ + e.target.value, + old?.[1], + ]) + } + placeholder={"Min"} + className="_input p-0.5" + /> + + column.setFilterValue((old: [number, number]) => [ + old?.[0], + e.target.value, + ]) + } + placeholder={"Max"} + className="_input p-0.5" + /> +
+ ); + } + if (areAllValuesString) { + return ( +
+ column.setFilterValue(e.target.value)} + placeholder={"Search..."} + className="_input" + /> +
+ ); + } + return
; } const reorderColumn = ( - draggedColumnId: string, - targetColumnId: string, - columnOrder: string[], + draggedColumnId: string, + targetColumnId: string, + columnOrder: string[], ) => { - columnOrder.splice( - columnOrder.indexOf(targetColumnId), - 0, - columnOrder.splice(columnOrder.indexOf(draggedColumnId), 1)[0] as string, - ); - return [...columnOrder]; + columnOrder.splice( + columnOrder.indexOf(targetColumnId), + 0, + columnOrder.splice(columnOrder.indexOf(draggedColumnId), 1)[0] as string, + ); + return [...columnOrder]; }; const DraggableColumnHeader: FC<{ - header: any; - table: any; - advanced: boolean; - idx: number; - lockFirstColumn: boolean; - setLockFirstColumn: (value: boolean) => void; + header: any; + table: any; + advanced: boolean; + idx: number; + lockFirstColumn: boolean; + setLockFirstColumn: (value: boolean) => void; }> = ({ - header, - table, - advanced, - idx, - lockFirstColumn, - setLockFirstColumn, + header, + table, + advanced, + idx, + lockFirstColumn, + setLockFirstColumn, }) => { - const { getState, setColumnOrder } = table; - const { columnOrder } = getState(); - const { column } = header; + const { getState, setColumnOrder } = table; + const { columnOrder } = getState(); + const { column } = header; - const [, dropRef] = useDrop({ - accept: "column", - drop: (draggedColumn: any) => { - const newColumnOrder = reorderColumn( - draggedColumn.id, - column.id, - columnOrder, - ); - setColumnOrder(newColumnOrder); - }, - }); + const [, dropRef] = useDrop({ + accept: "column", + drop: (draggedColumn: any) => { + const newColumnOrder = reorderColumn( + draggedColumn.id, + column.id, + columnOrder, + ); + setColumnOrder(newColumnOrder); + }, + }); - const [{ isDragging }, dragRef, previewRef] = useDrag({ - collect: (monitor) => ({ - isDragging: monitor.isDragging(), - }), - item: () => column, - type: "column", - }); + const [{ isDragging }, dragRef, previewRef] = useDrag({ + collect: (monitor) => ({ + isDragging: monitor.isDragging(), + }), + item: () => column, + type: "column", + }); - const renderField = () => ( -
- {header.isPlaceholder ? null : ( - <> -
-
- {flexRender(column.columnDef.header, header.getContext())} - {column.getCanSort() && ( -
- - -
- )} -
- {advanced && column.id !== "select" && ( - - )} -
- {advanced && column.getCanFilter() ? ( -
- -
- ) : null} - - )} -
- ); + const renderField = () => ( +
+ {header.isPlaceholder ? null : ( + <> +
+
+ {flexRender(column.columnDef.header, header.getContext())} + {column.getCanSort() && ( +
+ + +
+ )} +
+ {advanced && column.id !== "select" && ( + + )} +
+ {advanced && column.getCanFilter() ? ( +
+ +
+ ) : null} + + )} +
+ ); - return ( - - {idx === 0 ? ( - - - {renderField()} - - - -
- -
-
-
-
- ) : ( - renderField() - )} - +
+ + + + ) : ( + renderField() + )} +