mirror of
https://github.com/OpenBB-finance/OpenBB.git
synced 2026-10-28 13:01:38 +00:00
[Feature] Use Plotly V6 For Charting Extension + (#7132)
* update charting extension for plotly v6 and do some cleanup * pylint * formatting * plotly.html
This commit is contained in:
62
frontend-components/plotly/src/App.tsx
vendored
62
frontend-components/plotly/src/App.tsx
vendored
@@ -1,5 +1,4 @@
|
||||
//@ts-nocheck
|
||||
import { PostHogProvider } from "posthog-js/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Chart from "./components/Chart";
|
||||
import { candlestickMockup } from "./data/mockup";
|
||||
@@ -92,7 +91,6 @@ function App() {
|
||||
date: new Date(),
|
||||
globals: globals,
|
||||
cmd: data.command_location,
|
||||
posthog: data.posthog,
|
||||
python_version: data.python_version,
|
||||
pywry_version: data.pywry_version,
|
||||
terminal_version: data.terminal_version,
|
||||
@@ -104,51 +102,7 @@ function App() {
|
||||
const transformedData = transformData(json_data);
|
||||
|
||||
if (transformedData) {
|
||||
if (transformedData.posthog.collect_logs && !options) {
|
||||
const opts = {
|
||||
api_host: "https://app.posthog.com",
|
||||
autocapture: {
|
||||
css_selector_allowlist: [".ph-capture"],
|
||||
},
|
||||
capture_pageview: false,
|
||||
loaded: function (posthog: any) {
|
||||
const log_id = transformedData?.log_id || "";
|
||||
|
||||
if (log_id !== "" && log_id !== "REPLACE_ME")
|
||||
posthog.identify(log_id);
|
||||
|
||||
posthog.onFeatureFlags(function () {
|
||||
if (
|
||||
!posthog.isFeatureEnabled("record-pywry", { send_event: false })
|
||||
)
|
||||
posthog.stopSessionRecording();
|
||||
|
||||
if (
|
||||
!posthog.isFeatureEnabled("collect-logs-pywry", {
|
||||
send_event: false,
|
||||
})
|
||||
)
|
||||
posthog.opt_out_capturing();
|
||||
else if (posthog.has_opted_out_capturing())
|
||||
posthog.opt_in_capturing();
|
||||
});
|
||||
},
|
||||
};
|
||||
setOptions(opts);
|
||||
}
|
||||
|
||||
const info = {
|
||||
INFO: {
|
||||
command: transformedData.cmd,
|
||||
title: transformedData.title,
|
||||
date: transformedData.date,
|
||||
python_version: transformedData.python_version,
|
||||
pywry_version: transformedData.pywry_version,
|
||||
terminal_version: transformedData.terminal_version,
|
||||
},
|
||||
};
|
||||
|
||||
const chartDiv = (
|
||||
return (
|
||||
<Chart
|
||||
json={transformedData.data}
|
||||
date={transformedData.date}
|
||||
@@ -156,22 +110,8 @@ function App() {
|
||||
title={transformedData.title}
|
||||
globals={transformedData.globals}
|
||||
theme={transformedData.theme}
|
||||
info={info}
|
||||
/>
|
||||
);
|
||||
|
||||
if (transformedData.posthog.collect_logs && options) {
|
||||
return (
|
||||
<PostHogProvider
|
||||
apiKey="phc_vhssDAMod5qIplznQ75Kdgz4aB1qPFmeVmfEOZ4hkRw"
|
||||
options={options}
|
||||
>
|
||||
{chartDiv}
|
||||
</PostHogProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return chartDiv;
|
||||
} else
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-[100]">
|
||||
|
||||
178
frontend-components/plotly/src/components/Chart.tsx
vendored
178
frontend-components/plotly/src/components/Chart.tsx
vendored
@@ -3,22 +3,30 @@ 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 React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import createPlotlyComponent from "react-plotly.js/factory";
|
||||
import { init_annotation } from "../utils/addAnnotation";
|
||||
import { non_blocking, saveImage } from "../utils/utils";
|
||||
import { non_blocking } from "../utils/utils";
|
||||
import autoScaling, { isoDateRegex } from "./AutoScaling";
|
||||
import ChangeColor from "./ChangeColor";
|
||||
import { DARK_CHARTS_TEMPLATE, ICONS, LIGHT_CHARTS_TEMPLATE } from "./Config";
|
||||
import AlertDialog from "./Dialogs/AlertDialog";
|
||||
import DownloadFinishedDialog from "./Dialogs/DownloadFinishedDialog";
|
||||
import OverlayChartDialog from "./Dialogs/OverlayChartDialog";
|
||||
import TextChartDialog from "./Dialogs/TextChartDialog";
|
||||
import TitleChartDialog from "./Dialogs/TitleChartDialog";
|
||||
import { PlotConfig, hideModebar, ChartHotkeys } from "./PlotlyConfig";
|
||||
import ResizeHandler from "./ResizeHandler";
|
||||
|
||||
// Add logging to help debug why annotations aren't working
|
||||
console.log = ((oldLog) => {
|
||||
return function(...args) {
|
||||
if (args[0] === "plotly_click") {
|
||||
console.trace("plotly_click called with:", args[1]);
|
||||
}
|
||||
return oldLog.apply(console, args);
|
||||
};
|
||||
})(console.log);
|
||||
|
||||
const Plot = createPlotlyComponent(Plotly);
|
||||
class PlotComponent extends React.Component {
|
||||
constructor(props) {
|
||||
@@ -62,6 +70,41 @@ class PlotComponent extends React.Component {
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a chart is a scatter plot to handle annotations differently
|
||||
function isScatterPlot(data) {
|
||||
if (!data || !data.data) return false;
|
||||
|
||||
// Check if chart is primarily scatter plots
|
||||
return data.data.some(trace =>
|
||||
(trace.type === 'scatter' || trace.mode === 'markers' || trace.mode === 'lines+markers') &&
|
||||
!(trace.type === 'candlestick' || trace.type === 'ohlc')
|
||||
);
|
||||
}
|
||||
|
||||
// Debug function to check annotation structure
|
||||
function debugAnnotation(annotation, label = "Annotation Debug") {
|
||||
console.log(`[${label}]`, {
|
||||
text: annotation.text,
|
||||
visible: annotation.visible,
|
||||
x: annotation.x,
|
||||
y: annotation.y,
|
||||
layer: annotation.layer,
|
||||
font: annotation.font,
|
||||
arrowcolor: annotation.arrowcolor
|
||||
});
|
||||
}
|
||||
|
||||
// Exported for external use
|
||||
window.debugPlotlyAnnotations = function() {
|
||||
if (window.Plotly && window.Plotly.d3.select('#plotlyChart').node()._fullLayout) {
|
||||
const annotations = window.Plotly.d3.select('#plotlyChart').node()._fullLayout.annotations || [];
|
||||
console.log("[All Annotations]", annotations);
|
||||
annotations.forEach(a => debugAnnotation(a));
|
||||
return annotations;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export const getXRange = (min: string, max: string) => {
|
||||
if (isoDateRegex.test(min.replace(" ", "T").split(".")[0])) {
|
||||
const check_min = new Date(min.replace(" ", "T").split(".")[0]);
|
||||
@@ -88,6 +131,10 @@ export const getXRange = (min: string, max: string) => {
|
||||
};
|
||||
|
||||
function CreateDataXrange(figure: Figure, xrange?: any) {
|
||||
if (figure.frames && figure.frames.length > 0) {
|
||||
// Don't filter data for animated charts
|
||||
return figure;
|
||||
}
|
||||
const new_figure = { ...figure };
|
||||
const data = new_figure.data;
|
||||
if (!xrange) {
|
||||
@@ -165,6 +212,10 @@ async function DynamicLoad({
|
||||
event?: any;
|
||||
figure: any;
|
||||
}) {
|
||||
if (figure.frames && figure.frames.length > 0) {
|
||||
// Don't filter data for animated charts
|
||||
return figure;
|
||||
}
|
||||
try {
|
||||
const XDATA = figure.data.filter(
|
||||
(trace) =>
|
||||
@@ -206,7 +257,6 @@ function Chart({
|
||||
title,
|
||||
globals,
|
||||
theme,
|
||||
info,
|
||||
}: {
|
||||
// @ts-ignore
|
||||
json: Figure;
|
||||
@@ -215,14 +265,7 @@ function Chart({
|
||||
title: string;
|
||||
globals: any;
|
||||
theme: string;
|
||||
info?: any;
|
||||
}) {
|
||||
const posthog = usePostHog();
|
||||
|
||||
useEffect(() => {
|
||||
if (posthog) posthog.capture("chart", info);
|
||||
}, []);
|
||||
|
||||
json.layout.width = undefined;
|
||||
json.layout.height = undefined;
|
||||
if (json.layout?.title?.text) {
|
||||
@@ -240,7 +283,6 @@ function Chart({
|
||||
const [plotDiv, setPlotDiv] = useState(null);
|
||||
const [volumeBars, setVolumeBars] = useState({ old_nticks: {} });
|
||||
const [maximizePlot, setMaximizePlot] = useState(false);
|
||||
const [downloadFinished, setDownloadFinished] = useState(false);
|
||||
const [dateSliced, setDateSliced] = useState(false);
|
||||
|
||||
const [plotData, setPlotDataState] = useState(originalData);
|
||||
@@ -252,12 +294,11 @@ function Chart({
|
||||
const [colorActive, setColorActive] = useState(false);
|
||||
const [onAnnotationClick, setOnAnnotationClick] = useState({});
|
||||
const [ohlcAnnotation, setOhlcAnnotation] = useState([]);
|
||||
const [yaxisFixedRange, setYaxisFixedRange] = useState([]);
|
||||
|
||||
function setPlotData(data: any) {
|
||||
const [yaxisFixedRange, setYaxisFixedRange] = useState([]); function setPlotData(data: any) {
|
||||
data.layout.datarevision = data.layout.datarevision
|
||||
? data.layout.datarevision + 1
|
||||
: 1;
|
||||
|
||||
setPlotDataState(data);
|
||||
if (plotDiv && plotData) {
|
||||
Plotly.react(plotDiv, data.data, data.layout);
|
||||
@@ -281,11 +322,12 @@ function Chart({
|
||||
}
|
||||
},
|
||||
[plotData],
|
||||
);
|
||||
|
||||
// @ts-ignore
|
||||
); // @ts-ignore
|
||||
const onAddAnnotation = useCallback(
|
||||
(data) => {
|
||||
console.log("onAddAnnotation being called with data:", data);
|
||||
|
||||
// Use the standard annotation flow
|
||||
init_annotation({
|
||||
plotData,
|
||||
popupData: data,
|
||||
@@ -301,27 +343,26 @@ function Chart({
|
||||
});
|
||||
},
|
||||
[plotData, onAnnotationClick, ohlcAnnotation, annotations, plotDiv],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (downloadFinished) {
|
||||
setModal({ name: "downloadFinished" });
|
||||
setDownloadFinished(false);
|
||||
}
|
||||
}, [downloadFinished]);
|
||||
|
||||
useEffect(() => {
|
||||
); useEffect(() => {
|
||||
if (axesTitles && Object.keys(axesTitles).length > 0) {
|
||||
const layoutUpdate = {};
|
||||
// Update the layout with the new titles
|
||||
Object.keys(axesTitles).forEach((k) => {
|
||||
plotData.layout[k].title = {
|
||||
...(plotData.layout[k].title || {}),
|
||||
text: axesTitles[k],
|
||||
};
|
||||
plotData.layout[k].showticklabels = true;
|
||||
layoutUpdate[`${k}.title.text`] = axesTitles[k];
|
||||
});
|
||||
|
||||
if (plotDiv && Object.keys(layoutUpdate).length > 0) {
|
||||
Plotly.relayout(plotDiv, layoutUpdate);
|
||||
}
|
||||
|
||||
setAxesTitles({});
|
||||
}
|
||||
}, [axesTitles]);
|
||||
}, [axesTitles, plotDiv]);
|
||||
|
||||
function onChangeColor(color) {
|
||||
// updates the color of the last added shape
|
||||
@@ -522,6 +563,12 @@ function Chart({
|
||||
originalData.layout.template = darkmode
|
||||
? DARK_CHARTS_TEMPLATE
|
||||
: LIGHT_CHARTS_TEMPLATE;
|
||||
|
||||
// Preserve existing annotations as-is (no modifications)
|
||||
if (plotData.layout.annotations && plotData.layout.annotations.length > 0) {
|
||||
originalData.layout.annotations = [...plotData.layout.annotations];
|
||||
}
|
||||
|
||||
setPlotData({ ...originalData });
|
||||
setDarkMode(darkmode);
|
||||
setChangeTheme(false);
|
||||
@@ -529,15 +576,13 @@ function Chart({
|
||||
console.log("error", e);
|
||||
}
|
||||
}
|
||||
}, [changeTheme]);
|
||||
}, [changeTheme, plotData.layout.annotations]);
|
||||
|
||||
useEffect(() => {
|
||||
if (plotLoaded) {
|
||||
setDarkMode(true);
|
||||
setAutoScaling(false);
|
||||
const captureButtons = [
|
||||
"Download CSV",
|
||||
"Download Chart as Image",
|
||||
"Overlay chart from CSV",
|
||||
"Add Text",
|
||||
"Change Titles",
|
||||
@@ -561,6 +606,23 @@ function Chart({
|
||||
|
||||
window.MODEBAR.style.cssText = `${window.MODEBAR.style.cssText}; display:flex;`;
|
||||
|
||||
// Add annotation click handler to ensure editing works on scatter plots
|
||||
if (plotDiv) {
|
||||
// When an annotation is clicked, open the edit dialog
|
||||
plotDiv.on('plotly_clickannotation', function(data) {
|
||||
console.log("Annotation clicked:", data);
|
||||
if (data && data.annotation && data.annotation.text) {
|
||||
setModal({
|
||||
name: "textDialog",
|
||||
data: {
|
||||
annotation_dict: data.annotation,
|
||||
mode: "edit"
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (modeBarButtons) {
|
||||
const barbuttons: any = {};
|
||||
for (let i = 0; i < modeBarButtons.length; i++) {
|
||||
@@ -578,11 +640,6 @@ function Chart({
|
||||
if (plotData.layout.yaxis.type === "log" && !LogYaxis) {
|
||||
console.log("yaxis.type changed to log");
|
||||
setLogYaxis(true);
|
||||
|
||||
// const layout_update = {
|
||||
// "yaxis.exponentformat": "none"
|
||||
// };
|
||||
// Plotly.update(plotDiv, {}, layout_update);
|
||||
}
|
||||
if (plotData.layout.yaxis.type === "linear" && LogYaxis) {
|
||||
console.log("yaxis.type changed to linear");
|
||||
@@ -599,19 +656,6 @@ function Chart({
|
||||
}
|
||||
}
|
||||
|
||||
// We check to see if window.export_image is defined
|
||||
if (window.export_image !== undefined) {
|
||||
// We get the extension of the file and check if it is valid
|
||||
const filename = window.export_image.split("/").pop();
|
||||
const extension = filename.split(".").pop().replace("jpg", "jpeg");
|
||||
|
||||
if (["jpeg", "png", "svg"].includes(extension))
|
||||
return non_blocking(async function () {
|
||||
await hideModebar();
|
||||
await saveImage("MainChart", filename.split(".")[0], extension);
|
||||
}, 2)();
|
||||
}
|
||||
|
||||
window.addEventListener("resize", async function () {
|
||||
const update = await ResizeHandler({
|
||||
plotData,
|
||||
@@ -635,7 +679,12 @@ function Chart({
|
||||
}
|
||||
}, [plotLoaded]);
|
||||
|
||||
const plotComponent = useMemo(
|
||||
useEffect(() => {
|
||||
// This effect ensures annotations appear correctly on all chart types
|
||||
if (plotDiv && plotData?.layout?.annotations?.length > 0) {
|
||||
Plotly.relayout(plotDiv, {'annotations': plotData.layout.annotations});
|
||||
}
|
||||
}, [plotData.layout.annotations, plotDiv]); const plotComponent = useMemo(
|
||||
() => (
|
||||
<PlotComponent
|
||||
onInitialized={(_figure, graphDiv) => {
|
||||
@@ -643,6 +692,17 @@ function Chart({
|
||||
if (graphDiv) {
|
||||
graphDiv.globals = globals;
|
||||
setPlotDiv(graphDiv);
|
||||
graphDiv.on('plotly_clickannotation', function(data) {
|
||||
if (data && data.annotation && data.annotation.text) {
|
||||
setModal({
|
||||
name: "textDialog",
|
||||
data: {
|
||||
annotation_dict: data.annotation,
|
||||
mode: "edit"
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!plotLoaded) setPlotLoaded(true);
|
||||
@@ -651,13 +711,13 @@ function Chart({
|
||||
divId="plotlyChart"
|
||||
data={plotData.data}
|
||||
layout={plotData.layout}
|
||||
frames={plotData.frames}
|
||||
config={PlotConfig({
|
||||
setModal: setModal,
|
||||
changeTheme: setChangeTheme,
|
||||
autoScaling: setAutoScaling,
|
||||
Loading: setLoading,
|
||||
changeColor: setChangeColor,
|
||||
downloadFinished: setDownloadFinished,
|
||||
})}
|
||||
/>
|
||||
),
|
||||
@@ -674,7 +734,6 @@ function Chart({
|
||||
setAutoScaling,
|
||||
setLoading,
|
||||
onChangeColor,
|
||||
setDownloadFinished,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -742,25 +801,15 @@ function Chart({
|
||||
return <ChangeColor open={colorActive} onColorChange={onChangeColor} />;
|
||||
}, [colorActive, onChangeColor]);
|
||||
|
||||
const memoizedDownloadFinishedDialog = useMemo(() => {
|
||||
return (
|
||||
<DownloadFinishedDialog
|
||||
open={modal?.name === "downloadFinished"}
|
||||
close={onClose}
|
||||
/>
|
||||
);
|
||||
}, [modal, onClose]);
|
||||
|
||||
const memoizedChartHotkeys = useMemo(() => {
|
||||
return (
|
||||
<ChartHotkeys
|
||||
setModal={setModal}
|
||||
Loading={setLoading}
|
||||
changeColor={setChangeColor}
|
||||
downloadFinished={setDownloadFinished}
|
||||
/>
|
||||
);
|
||||
}, [setModal, setLoading, setChangeColor, setDownloadFinished]);
|
||||
}, [setModal, setLoading, setChangeColor]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full">
|
||||
@@ -797,7 +846,6 @@ function Chart({
|
||||
{memoizedTitleChartDialog}
|
||||
{memoizedTextChartDialog}
|
||||
{memoizedChangeColor}
|
||||
{memoizedDownloadFinishedDialog}
|
||||
{memoizedChartHotkeys}
|
||||
|
||||
<div className="relative h-full" id="MainChart">
|
||||
|
||||
110
frontend-components/plotly/src/components/Config.tsx
vendored
110
frontend-components/plotly/src/components/Config.tsx
vendored
@@ -117,11 +117,15 @@ export const DARK_CHARTS_TEMPLATE = {
|
||||
],
|
||||
dragmode: "pan",
|
||||
font: {
|
||||
family: "Fira Code",
|
||||
size: 18,
|
||||
family: "Arial, Helvetica, sans-serif",
|
||||
size: 16,
|
||||
},
|
||||
hoverlabel: {
|
||||
align: "left",
|
||||
font: {
|
||||
family: "Arial, Helvetica, sans-serif",
|
||||
size: 14,
|
||||
},
|
||||
},
|
||||
mapbox: {
|
||||
style: "dark",
|
||||
@@ -134,7 +138,8 @@ export const DARK_CHARTS_TEMPLATE = {
|
||||
y: 0.99,
|
||||
yanchor: "bottom",
|
||||
font: {
|
||||
size: 15,
|
||||
family: "Arial, Helvetica, sans-serif",
|
||||
size: 14,
|
||||
},
|
||||
},
|
||||
paper_bgcolor: "#000000",
|
||||
@@ -148,6 +153,7 @@ export const DARK_CHARTS_TEMPLATE = {
|
||||
showgrid: true,
|
||||
showline: true,
|
||||
tickfont: {
|
||||
family: "Arial, Helvetica, sans-serif",
|
||||
size: 14,
|
||||
},
|
||||
zeroline: false,
|
||||
@@ -155,6 +161,10 @@ export const DARK_CHARTS_TEMPLATE = {
|
||||
title: {
|
||||
standoff: 20,
|
||||
text: "",
|
||||
font: {
|
||||
family: "Arial, Helvetica, sans-serif",
|
||||
size: 16,
|
||||
},
|
||||
},
|
||||
gridcolor: "#283442",
|
||||
linecolor: "#F5EFF3",
|
||||
@@ -171,11 +181,16 @@ export const DARK_CHARTS_TEMPLATE = {
|
||||
side: "right",
|
||||
tick0: 0.5,
|
||||
tickfont: {
|
||||
family: "Arial, Helvetica, sans-serif",
|
||||
size: 14,
|
||||
},
|
||||
title: {
|
||||
standoff: 20,
|
||||
text: "",
|
||||
font: {
|
||||
family: "Arial, Helvetica, sans-serif",
|
||||
size: 16,
|
||||
},
|
||||
},
|
||||
gridcolor: "#283442",
|
||||
linecolor: "#F5EFF3",
|
||||
@@ -289,27 +304,6 @@ export const LIGHT_CHARTS_TEMPLATE = {
|
||||
type: "contour",
|
||||
},
|
||||
],
|
||||
heatmapgl: [
|
||||
{
|
||||
colorbar: {
|
||||
outlinewidth: 0,
|
||||
ticks: "",
|
||||
},
|
||||
colorscale: [
|
||||
[0.0, "#0d0887"],
|
||||
[0.1111111111111111, "#46039f"],
|
||||
[0.2222222222222222, "#7201a8"],
|
||||
[0.3333333333333333, "#9c179e"],
|
||||
[0.4444444444444444, "#bd3786"],
|
||||
[0.5555555555555556, "#d8576b"],
|
||||
[0.6666666666666666, "#ed7953"],
|
||||
[0.7777777777777778, "#fb9f3a"],
|
||||
[0.8888888888888888, "#fdca26"],
|
||||
[1.0, "#f0f921"],
|
||||
],
|
||||
type: "heatmapgl",
|
||||
},
|
||||
],
|
||||
heatmap: [
|
||||
{
|
||||
colorbar: {
|
||||
@@ -647,7 +641,8 @@ export const LIGHT_CHARTS_TEMPLATE = {
|
||||
"#9edae5"
|
||||
],
|
||||
font: {
|
||||
color: "#2a3f5f",
|
||||
family: "Arial, Helvetica, sans-serif",
|
||||
size: 16,
|
||||
},
|
||||
geo: {
|
||||
bgcolor: "white",
|
||||
@@ -659,6 +654,10 @@ export const LIGHT_CHARTS_TEMPLATE = {
|
||||
},
|
||||
hoverlabel: {
|
||||
align: "left",
|
||||
font: {
|
||||
family: "Arial, Helvetica, sans-serif",
|
||||
size: 14,
|
||||
},
|
||||
},
|
||||
hovermode: "x",
|
||||
mapbox: {
|
||||
@@ -738,49 +737,53 @@ export const LIGHT_CHARTS_TEMPLATE = {
|
||||
automargin: true,
|
||||
autorange: true,
|
||||
rangeslider: {
|
||||
visible: false
|
||||
visible: false
|
||||
},
|
||||
showgrid: true,
|
||||
showline: true,
|
||||
tickfont: {
|
||||
size: 14
|
||||
family: "Arial, Helvetica, sans-serif",
|
||||
size: 14,
|
||||
},
|
||||
zeroline: false,
|
||||
tick0: 1,
|
||||
title: {
|
||||
standoff: 20,
|
||||
font: {
|
||||
size: 16
|
||||
}
|
||||
standoff: 20,
|
||||
font: {
|
||||
family: "Arial, Helvetica, sans-serif",
|
||||
size: 16,
|
||||
},
|
||||
},
|
||||
gridcolor: "#283442",
|
||||
linecolor: "#A9A9A9",
|
||||
mirror: true,
|
||||
ticks: "outside"
|
||||
},
|
||||
yaxis: {
|
||||
anchor: "x",
|
||||
automargin: true,
|
||||
fixedrange: false,
|
||||
zeroline: false,
|
||||
showgrid: true,
|
||||
showline: true,
|
||||
side: "right",
|
||||
tick0: 0.5,
|
||||
tickfont: {
|
||||
size: 14
|
||||
},
|
||||
title: {
|
||||
yaxis: {
|
||||
anchor: "x",
|
||||
automargin: true,
|
||||
fixedrange: false,
|
||||
zeroline: false,
|
||||
showgrid: true,
|
||||
showline: true,
|
||||
side: "right",
|
||||
tick0: 0.5,
|
||||
tickfont: {
|
||||
family: "Arial, Helvetica, sans-serif",
|
||||
size: 14,
|
||||
},
|
||||
title: {
|
||||
standoff: 20,
|
||||
font: {
|
||||
size: 16
|
||||
}
|
||||
family: "Arial, Helvetica, sans-serif",
|
||||
size: 16,
|
||||
},
|
||||
},
|
||||
gridcolor: "rgba(128, 128, 128, 0.33)",
|
||||
linecolor: "#A9A9A9",
|
||||
mirror: true,
|
||||
ticks: "outside"
|
||||
},
|
||||
gridcolor: "rgba(128, 128, 128, 0.33)",
|
||||
linecolor: "#A9A9A9",
|
||||
mirror: true,
|
||||
ticks: "outside"
|
||||
},
|
||||
dragmode: "pan",
|
||||
legend: {
|
||||
bgcolor: "rgba(255, 255, 255, 0)",
|
||||
@@ -789,8 +792,9 @@ export const LIGHT_CHARTS_TEMPLATE = {
|
||||
y: 1.02,
|
||||
yanchor: "bottom",
|
||||
font: {
|
||||
size: 12
|
||||
}
|
||||
family: "Arial, Helvetica, sans-serif",
|
||||
size: 14,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import CommonDialog from "../Dialogs/CommonDialog";
|
||||
|
||||
export default function DownloadFinishedDialog({
|
||||
open,
|
||||
close,
|
||||
}: {
|
||||
open: boolean;
|
||||
close: () => void;
|
||||
}) {
|
||||
const userHomeDir = window.download_path || "~/OpenBBUserData/exports";
|
||||
return (
|
||||
<CommonDialog title="Success" description="" open={open} close={close}>
|
||||
<div
|
||||
id="popup_title"
|
||||
className="popup_content"
|
||||
style={{ padding: "0px 2px 2px 5px", marginTop: 5 }}
|
||||
>
|
||||
<div style={{ display: "flex", flexDirection: "column", gap: 0 }}>
|
||||
<div>
|
||||
<label htmlFor="title_text">
|
||||
<b>{window.title}</b> has been downloaded to
|
||||
<br />
|
||||
<br />
|
||||
<a
|
||||
style={{ color: "#FFDD00", marginTop: 15 }}
|
||||
href={`${userHomeDir}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
window.pywry.open_file(userHomeDir);
|
||||
}}
|
||||
>
|
||||
{userHomeDir}
|
||||
</a>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ float: "right", marginTop: 20 }}>
|
||||
<button
|
||||
className="_btn"
|
||||
style={{
|
||||
padding: "8px 16px",
|
||||
width: "100%",
|
||||
}}
|
||||
onClick={close}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</CommonDialog>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import CommonDialog from "./CommonDialog";
|
||||
import { useState } from "react";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
|
||||
const style = {
|
||||
padding: "5px 2px 2px 5px",
|
||||
@@ -17,65 +17,118 @@ export default function TextChartDialog({
|
||||
open: boolean;
|
||||
close: () => void;
|
||||
addAnnotation: (annotation: any) => void;
|
||||
updateAnnotation: (annotation: any) => void;
|
||||
updateAnnotation?: (annotation: any) => void;
|
||||
deleteAnnotation: (annotation: any) => void;
|
||||
popupData: any | null;
|
||||
}) {
|
||||
// Prevent multiple renderings
|
||||
const hasLoaded = useRef(false);
|
||||
|
||||
const defaultPopupData = {
|
||||
text: "",
|
||||
color: "#0088CC",
|
||||
size: 18,
|
||||
bordercolor: "#822661",
|
||||
arrowcolor: "#822661",
|
||||
bgcolor: "#000000",
|
||||
arrowsize: 1,
|
||||
arrowwidth: 2,
|
||||
yanchor: "above",
|
||||
};
|
||||
const [popUpData, setPopUpData] = useState<any>(defaultPopupData);
|
||||
const [newPopupData, setNewPopupData] = useState<any>(defaultPopupData);
|
||||
|
||||
if (popupData && popupData !== popUpData) {
|
||||
if (popupData.annotation) {
|
||||
popupData.annotation = popupData?.annotation || {};
|
||||
setPopUpData(popupData);
|
||||
setNewPopupData(popupData);
|
||||
// Use a single state object to hold all form data
|
||||
const [formData, setFormData] = useState<any>(defaultPopupData);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
|
||||
// Handle initialization when dialog opens
|
||||
useEffect(() => {
|
||||
if (open && popupData?.annotation && !hasLoaded.current) {
|
||||
const annotation = popupData.annotation;
|
||||
|
||||
// Get properties from annotation for editing
|
||||
let data = {
|
||||
text: annotation.text || "",
|
||||
color: annotation.font?.color || defaultPopupData.color,
|
||||
size: annotation.font?.size || defaultPopupData.size,
|
||||
bordercolor: annotation.bordercolor || defaultPopupData.bordercolor,
|
||||
bgcolor: annotation.bgcolor || defaultPopupData.bgcolor,
|
||||
arrowcolor: annotation.arrowcolor || defaultPopupData.arrowcolor,
|
||||
arrowsize: annotation.arrowsize || defaultPopupData.arrowsize,
|
||||
arrowwidth: annotation.arrowwidth || defaultPopupData.arrowwidth,
|
||||
yanchor: "above",
|
||||
};
|
||||
|
||||
// Determine position based on annotation coordinates
|
||||
if (annotation.y !== undefined && annotation.ay !== undefined) {
|
||||
data.yanchor = annotation.y < annotation.ay ? "above" : "below";
|
||||
}
|
||||
|
||||
setFormData(data);
|
||||
setEditMode(true);
|
||||
hasLoaded.current = true;
|
||||
} else if (!open) {
|
||||
// Reset when dialog closes
|
||||
setFormData(defaultPopupData);
|
||||
setEditMode(false);
|
||||
hasLoaded.current = false;
|
||||
} else if (open && !popupData?.annotation) {
|
||||
// Reset for new annotations
|
||||
setFormData(defaultPopupData);
|
||||
setEditMode(false);
|
||||
}
|
||||
}, [open, popupData]);
|
||||
|
||||
function onChange(e: any) {
|
||||
const name = e.target.id.replace("addtext_", "");
|
||||
let value = e.target.value;
|
||||
|
||||
// Convert numeric values
|
||||
if (name === "size" || name === "arrowsize" || name === "arrowwidth") {
|
||||
value = parseFloat(value);
|
||||
}
|
||||
|
||||
setFormData((prev: any) => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
}
|
||||
|
||||
function onClose() {
|
||||
console.log("closing");
|
||||
setPopUpData(defaultPopupData);
|
||||
setNewPopupData(defaultPopupData);
|
||||
close();
|
||||
}
|
||||
|
||||
function onChange(e: any) {
|
||||
console.log(e.target.id.replace("addtext_", ""), e.target.value);
|
||||
const name = e.target.id.replace("addtext_", "");
|
||||
const value = e.target.value;
|
||||
setNewPopupData({ ...newPopupData, [name]: value });
|
||||
}
|
||||
|
||||
function onSubmit() {
|
||||
console.log("submitting", newPopupData);
|
||||
if (newPopupData.text !== "") {
|
||||
if (popUpData?.annotation) {
|
||||
setNewPopupData({ ...newPopupData, annotation: popUpData.annotation });
|
||||
if (formData.text) {
|
||||
const dataToSubmit = { ...formData };
|
||||
|
||||
// Add the annotation reference for editing
|
||||
if (editMode && popupData?.annotation) {
|
||||
dataToSubmit.annotation = popupData.annotation;
|
||||
}
|
||||
addAnnotation(newPopupData);
|
||||
|
||||
addAnnotation(dataToSubmit);
|
||||
close();
|
||||
} else {
|
||||
document.getElementById("popup_textarea_warning")!.style.display =
|
||||
"block";
|
||||
document.getElementById("addtext_text")!.style.border = "1px solid red";
|
||||
if (document.getElementById("popup_textarea_warning")) {
|
||||
document.getElementById("popup_textarea_warning")!.style.display = "block";
|
||||
}
|
||||
if (document.getElementById("addtext_text")) {
|
||||
document.getElementById("addtext_text")!.style.border = "1px solid red";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onDelete() {
|
||||
deleteAnnotation(popUpData);
|
||||
onClose();
|
||||
if (editMode && popupData) {
|
||||
deleteAnnotation(popupData);
|
||||
}
|
||||
close();
|
||||
}
|
||||
|
||||
return (
|
||||
<CommonDialog
|
||||
title="Add Text to Chart"
|
||||
description="Change the titles on the chart."
|
||||
title={editMode ? "Edit Annotation" : "Add Text to Chart"}
|
||||
description="Add or edit text annotations on the chart."
|
||||
open={open}
|
||||
close={onClose}
|
||||
>
|
||||
@@ -84,7 +137,7 @@ export default function TextChartDialog({
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<label htmlFor="popup_text">
|
||||
<b>Text:</b>
|
||||
<div id="popup_textarea_warning" className="popup_warning">
|
||||
<div id="popup_textarea_warning" className="popup_warning" style={{display: "none"}}>
|
||||
Text is required
|
||||
</div>
|
||||
</label>
|
||||
@@ -101,65 +154,127 @@ export default function TextChartDialog({
|
||||
cols={50}
|
||||
placeholder="Enter text here"
|
||||
onChange={onChange}
|
||||
defaultValue={popUpData?.annotation?.text || newPopupData?.text}
|
||||
value={formData.text || ""}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
gap: 15,
|
||||
alignItems: "center",
|
||||
flexWrap: "wrap",
|
||||
columnCount: 2,
|
||||
justifyContent: "space-between",
|
||||
display: "grid",
|
||||
gridTemplateColumns: "1fr 1fr",
|
||||
gap: "12px",
|
||||
marginBottom: 20,
|
||||
|
||||
}}
|
||||
>
|
||||
<label htmlFor="addtext_color">
|
||||
<b>Font color</b>
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
id="addtext_color"
|
||||
style={{ margin: "2px 2px 2px 15px" }}
|
||||
defaultValue={popUpData?.annotation?.color || newPopupData?.color}
|
||||
onChange={onChange}
|
||||
></input>
|
||||
<label htmlFor="addtext_bordercolor" style={{ marginLeft: 20 }}>
|
||||
<b>Border color</b>
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
id="addtext_bordercolor"
|
||||
style={{ margin: "2px 2px 10px 15px" }}
|
||||
defaultValue={
|
||||
popUpData?.annotation?.bordercolor || newPopupData?.bordercolor
|
||||
}
|
||||
onChange={onChange}
|
||||
></input>
|
||||
{/* Row 1 */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<label htmlFor="addtext_color">
|
||||
<b>Font color</b>
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
id="addtext_color"
|
||||
style={{ margin: "2px 0" }}
|
||||
value={formData.color}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label htmlFor="addtext_size">
|
||||
<b>Font size</b>
|
||||
</label>
|
||||
<input
|
||||
style={{ ...style, width: "52px", margin: "0px 0px 0px 2px" }}
|
||||
type="number"
|
||||
id="addtext_size"
|
||||
onChange={onChange}
|
||||
defaultValue={popUpData?.annotation?.size || newPopupData?.size}
|
||||
></input>
|
||||
<div>
|
||||
<label htmlFor="addtext_yanchor" style={{ marginRight: 31 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<label htmlFor="addtext_size">
|
||||
<b>Font size</b>
|
||||
</label>
|
||||
<input
|
||||
style={{ width: "52px" }}
|
||||
type="number"
|
||||
id="addtext_size"
|
||||
value={formData.size}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<label htmlFor="addtext_bgcolor">
|
||||
<b>Background</b>
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
id="addtext_bgcolor"
|
||||
style={{ margin: "2px 0" }}
|
||||
value={formData.bgcolor}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<label htmlFor="addtext_bordercolor">
|
||||
<b>Border color</b>
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
id="addtext_bordercolor"
|
||||
style={{ margin: "2px 0" }}
|
||||
value={formData.bordercolor}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 3 */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<label htmlFor="addtext_arrowcolor">
|
||||
<b>Arrow color</b>
|
||||
</label>
|
||||
<input
|
||||
type="color"
|
||||
id="addtext_arrowcolor"
|
||||
style={{ margin: "2px 0" }}
|
||||
value={formData.arrowcolor}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<label htmlFor="addtext_arrowsize">
|
||||
<b>Arrow size</b>
|
||||
</label>
|
||||
<input
|
||||
style={{ width: "52px" }}
|
||||
type="number"
|
||||
id="addtext_arrowsize"
|
||||
min="0.1"
|
||||
max="5"
|
||||
step="0.1"
|
||||
value={formData.arrowsize}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 4 */}
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<label htmlFor="addtext_arrowwidth">
|
||||
<b>Arrow width</b>
|
||||
</label>
|
||||
<input
|
||||
style={{ width: "52px" }}
|
||||
type="number"
|
||||
id="addtext_arrowwidth"
|
||||
min="1"
|
||||
max="10"
|
||||
value={formData.arrowwidth}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between" }}>
|
||||
<label htmlFor="addtext_yanchor">
|
||||
<b>Position</b>
|
||||
</label>
|
||||
<select
|
||||
id="addtext_yanchor"
|
||||
name="yanchor"
|
||||
style={{ width: "100px" }}
|
||||
defaultValue={
|
||||
popUpData?.annotation?.yanchor || newPopupData?.yanchor
|
||||
}
|
||||
value={formData.yanchor}
|
||||
onChange={onChange}
|
||||
>
|
||||
<option value="above">Above</option>
|
||||
@@ -177,13 +292,15 @@ export default function TextChartDialog({
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="_btn ph-capture"
|
||||
id="title_delete"
|
||||
onClick={onDelete}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{editMode && (
|
||||
<button
|
||||
className="_btn ph-capture"
|
||||
id="title_delete"
|
||||
onClick={onDelete}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="_btn ph-capture"
|
||||
id="title_submit"
|
||||
|
||||
@@ -123,8 +123,28 @@ export default function TitleChartDialog({
|
||||
className="_btn ph-capture"
|
||||
id="title_submit"
|
||||
onClick={() => {
|
||||
// Update parent state - this will trigger the useEffect in Chart.tsx
|
||||
updateTitle(title);
|
||||
updateAxesTitles(axesTitles);
|
||||
|
||||
// Force an immediate update to the plotly chart directly
|
||||
if (window.Plotly && document.getElementById('plotlyChart')) {
|
||||
const chart = document.getElementById('plotlyChart');
|
||||
|
||||
// Only update axis titles, not the main chart title
|
||||
const updateObj: { [key: string]: string } = {};
|
||||
|
||||
// Add all axis title changes
|
||||
Object.entries(axesTitles).forEach(([axis, text]) => {
|
||||
updateObj[`${axis}.title.text`] = String(text);
|
||||
});
|
||||
|
||||
if (Object.keys(updateObj).length > 0) {
|
||||
console.log("Applying immediate axis title updates:", updateObj);
|
||||
window.Plotly.relayout(chart, updateObj);
|
||||
}
|
||||
}
|
||||
|
||||
close();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Icons as PlotlyIcons } from "plotly.js-dist-min";
|
||||
import { downloadCSV, downloadImage } from "../utils/utils";
|
||||
import * as Plotly from "plotly.js-dist-min";
|
||||
import { useHotkeys } from "react-hotkeys-hook";
|
||||
import { ICONS } from "./Config";
|
||||
|
||||
@@ -32,59 +32,31 @@ export function PlotConfig({
|
||||
autoScaling,
|
||||
Loading,
|
||||
changeColor,
|
||||
downloadFinished,
|
||||
}: {
|
||||
setModal: (modal: { name: string; data?: any }) => void;
|
||||
changeTheme: (change: boolean) => void;
|
||||
autoScaling: (change: boolean) => void;
|
||||
Loading: (change: boolean) => void;
|
||||
changeColor: (change: boolean) => void;
|
||||
downloadFinished: (change: boolean) => void;
|
||||
}) {
|
||||
const CONFIG = {
|
||||
plotGlPixelRatio: 1,
|
||||
scrollZoom: true,
|
||||
responsive: true,
|
||||
displaylogo: false,
|
||||
displayModeBar: true,
|
||||
displayModeBar: "hover",
|
||||
edits: {
|
||||
legendPosition: true,
|
||||
legendText: true,
|
||||
colorbarPosition: true,
|
||||
annotationPosition: true,
|
||||
annotationTail: true,
|
||||
annotationText: true,
|
||||
},
|
||||
showTips: false,
|
||||
setBackground: "transparent",
|
||||
modeBarButtonsToRemove: ["lasso2d", "select2d", "downloadImage"],
|
||||
modeBarButtonsToRemove: ["lasso2d", "select2d", "saveImage"],
|
||||
modeBarButtons: [
|
||||
[
|
||||
{
|
||||
name: "Download CSV (Ctrl+Shift+S)",
|
||||
icon: ICONS.downloadCsv,
|
||||
click: async function (gd: any) {
|
||||
await downloadCSV(gd, downloadFinished);
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "Download Chart as Image (Ctrl+S)",
|
||||
icon: ICONS.downloadImage,
|
||||
click: async function () {
|
||||
hideModebar(true);
|
||||
await downloadImage(
|
||||
"MainChart",
|
||||
hideModebar,
|
||||
Loading,
|
||||
downloadFinished,
|
||||
);
|
||||
},
|
||||
},
|
||||
// {
|
||||
// name: "Upload Image (Ctrl+U)",
|
||||
// icon: Plotly.Icons.uploadImage,
|
||||
// click: function (gd) {
|
||||
// downloadImage();
|
||||
// },
|
||||
// },
|
||||
],
|
||||
[
|
||||
{
|
||||
name: "Edit Color (Ctrl+E)",
|
||||
@@ -154,12 +126,10 @@ export function ChartHotkeys({
|
||||
setModal,
|
||||
Loading,
|
||||
changeColor,
|
||||
downloadFinished,
|
||||
}: {
|
||||
setModal: (modal: { name: string; data?: any }) => void;
|
||||
Loading: (change: boolean) => void;
|
||||
changeColor: (change: boolean) => void;
|
||||
downloadFinished: (change: boolean) => void;
|
||||
}) {
|
||||
useHotkeys(
|
||||
"ctrl+shift+t",
|
||||
@@ -188,6 +158,41 @@ export function ChartHotkeys({
|
||||
hideModebar();
|
||||
},
|
||||
{ preventDefault: true },
|
||||
); useHotkeys(
|
||||
"ctrl+l",
|
||||
() => {
|
||||
// Toggle log scale when Ctrl+L is pressed
|
||||
const plotDiv = document.getElementById("plotlyChart") as any;
|
||||
if (plotDiv && plotDiv._fullLayout) {
|
||||
// Check if this is an OHLC/Candle chart or a time series Scatter
|
||||
const isOHLCOrCandle = plotDiv._fullData.some((trace: any) =>
|
||||
trace.type === 'ohlc' || trace.type === 'candlestick'
|
||||
);
|
||||
|
||||
const isTimeSeriesScatter = plotDiv._fullData.some((trace: any) =>
|
||||
trace.type === 'scatter' && (trace.mode === 'lines' || trace.mode === 'lines+markers') &&
|
||||
trace.x && trace.x.length > 0 && (typeof trace.x[0] === 'string' || trace.x[0] instanceof Date)
|
||||
);
|
||||
|
||||
if (isOHLCOrCandle || isTimeSeriesScatter) {
|
||||
// Only toggle the main y-axis (yaxis or y1)
|
||||
const currentType = plotDiv._fullLayout.yaxis?.type || 'linear';
|
||||
const newType = currentType === 'linear' ? 'log' : 'linear';
|
||||
|
||||
// Only modify the main y-axis, leaving all others unchanged
|
||||
const updateObj: any = {
|
||||
'yaxis.type': newType
|
||||
};
|
||||
|
||||
// Apply change ONLY to main y-axis
|
||||
console.log("Changing main y-axis scale to:", newType);
|
||||
Plotly.relayout(plotDiv, updateObj as any);
|
||||
} else {
|
||||
console.log("Log scale toggle is only available for OHLC/Candle charts or time series Scatter plots");
|
||||
}
|
||||
}
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
useHotkeys(
|
||||
"ctrl+e",
|
||||
@@ -196,25 +201,17 @@ export function ChartHotkeys({
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
useHotkeys(
|
||||
"ctrl+shift+s",
|
||||
async () => {
|
||||
setModal({ name: "downloadCsv" });
|
||||
await downloadCSV(
|
||||
document.getElementById("plotlyChart") as any,
|
||||
downloadFinished,
|
||||
);
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
// Removed the ctrl+shift+s export shortcut
|
||||
|
||||
useHotkeys(
|
||||
"ctrl+s",
|
||||
async () => {
|
||||
hideModebar();
|
||||
downloadImage("MainChart", hideModebar, Loading, downloadFinished);
|
||||
() => {
|
||||
// Download feature removed
|
||||
},
|
||||
{ preventDefault: true },
|
||||
);
|
||||
|
||||
useHotkeys(
|
||||
"ctrl+w",
|
||||
() => {
|
||||
|
||||
14
frontend-components/plotly/src/index.css
vendored
14
frontend-components/plotly/src/index.css
vendored
@@ -2,12 +2,6 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@font-face {
|
||||
font-family: "Fira Code";
|
||||
src: url("https://fonts.googleapis.com/css2?family=Fira+Code:wght@400;700&display=swap") format("truetype");
|
||||
font-weight: 400 700;
|
||||
font-stretch: 50%;
|
||||
}
|
||||
|
||||
@layer components {
|
||||
._input {
|
||||
@@ -96,14 +90,14 @@ html {
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
|
||||
"Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
|
||||
"Ubuntu", "Cantarell", "Droid Sans", "Helvetica Neue",
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Fira Code", monospace, "Arial Black";
|
||||
font-family: "Arial", monospace, "Arial Black";
|
||||
font-size: 12px;
|
||||
font-size: 1.2rem;
|
||||
color: #ffffff;
|
||||
@@ -155,7 +149,7 @@ code {
|
||||
padding: 40px;
|
||||
box-sizing: border-box;
|
||||
display: none;
|
||||
font-family: "Fira Code", monospace, "Arial Black";
|
||||
font-family: "Arial", monospace, "Arial Black";
|
||||
animation: popup 0.3s ease-in-out;
|
||||
box-shadow: 0 3px 10px rgb(0 0 0 / 0.2);
|
||||
z-index: 2;
|
||||
@@ -176,7 +170,7 @@ code {
|
||||
color: #ff0000;
|
||||
font-size: 12px;
|
||||
font-size: 0.8rem;
|
||||
font-family: "Fira Code", monospace, "Arial Black";
|
||||
font-family: "Arial", monospace, "Arial Black";
|
||||
display: none;
|
||||
background-color: rgba(255, 0, 0, 0.1);
|
||||
border-radius: 4px;
|
||||
|
||||
@@ -16,7 +16,13 @@ type PopupData = {
|
||||
bordercolor: string;
|
||||
color: string;
|
||||
size: number;
|
||||
bgcolor?: string;
|
||||
arrowcolor?: string;
|
||||
arrowsize?: number;
|
||||
arrowwidth?: number;
|
||||
annotation?: any;
|
||||
high?: number;
|
||||
low?: number;
|
||||
};
|
||||
|
||||
export function add_annotation({
|
||||
@@ -58,14 +64,15 @@ export function add_annotation({
|
||||
text: popup_data.text,
|
||||
showarrow: true,
|
||||
arrowhead: 2,
|
||||
arrowsize: 1,
|
||||
arrowwidth: 2,
|
||||
arrowsize: popup_data.arrowsize || 1,
|
||||
arrowwidth: popup_data.arrowwidth || 2,
|
||||
ax: x,
|
||||
ay: y + popup_data.yshift,
|
||||
ayref: yref,
|
||||
axref: "x",
|
||||
bordercolor: popup_data.bordercolor,
|
||||
bgcolor: "#000000",
|
||||
bgcolor: popup_data.bgcolor || "#000000",
|
||||
arrowcolor: popup_data.arrowcolor || popup_data.bordercolor,
|
||||
borderwidth: 2,
|
||||
borderpad: 4,
|
||||
opacity: 0.8,
|
||||
@@ -86,6 +93,10 @@ export function add_annotation({
|
||||
annotations[index].font.size = popup_data.size;
|
||||
annotations[index].ay = y + popup_data.yshift;
|
||||
annotations[index].bordercolor = popup_data.bordercolor;
|
||||
annotations[index].bgcolor = popup_data.bgcolor || "#000000";
|
||||
annotations[index].arrowcolor = popup_data.arrowcolor || popup_data.bordercolor;
|
||||
annotations[index].arrowsize = popup_data.arrowsize || 1;
|
||||
annotations[index].arrowwidth = popup_data.arrowwidth || 2;
|
||||
annotations[index].high = popup_data.high || undefined;
|
||||
annotations[index].low = popup_data.low || undefined;
|
||||
}
|
||||
@@ -204,6 +215,11 @@ export function init_annotation({
|
||||
return;
|
||||
}
|
||||
|
||||
// First remove any existing click handlers to avoid duplicates
|
||||
plotDiv.removeAllListeners("plotly_clickannotation");
|
||||
plotDiv.removeAllListeners("plotly_click");
|
||||
|
||||
// Add handler for clicking on existing annotations
|
||||
plotDiv.on("plotly_clickannotation", (eventData) => {
|
||||
console.log("plotly_clickannotation", eventData);
|
||||
const annotation = eventData.annotation;
|
||||
@@ -235,6 +251,7 @@ export function init_annotation({
|
||||
setOnAnnotationClick({});
|
||||
});
|
||||
|
||||
// Add handler for adding a new annotation on click
|
||||
function clickHandler(eventData: PlotMouseEvent) {
|
||||
console.log("plotly_click", eventData);
|
||||
const x = eventData.points[0].x;
|
||||
@@ -282,13 +299,23 @@ export function init_annotation({
|
||||
setAnnotations(
|
||||
[...annotations, to_update.annotation].filter((a) => a !== undefined),
|
||||
);
|
||||
|
||||
// Important: update plotData with the new annotations to make them visible
|
||||
plotData.layout.dragmode = "pan";
|
||||
setPlotData({ ...plotData, ...to_update.update });
|
||||
|
||||
// Force a relayout to ensure annotations appear
|
||||
Plotly.relayout(plotDiv, {'annotations': to_update.update.annotations, dragmode: "pan"});
|
||||
|
||||
// Remove click handler after creating the annotation
|
||||
plotDiv.removeAllListeners("plotly_click");
|
||||
}
|
||||
|
||||
// Set up dragmode and add the click handler
|
||||
plotData.layout.dragmode = "select";
|
||||
setPlotData({ ...plotData });
|
||||
|
||||
// Ensure we add the click handler
|
||||
plotDiv.on("plotly_click", clickHandler);
|
||||
}
|
||||
}
|
||||
|
||||
371
frontend-components/plotly/src/utils/utils.ts
vendored
371
frontend-components/plotly/src/utils/utils.ts
vendored
@@ -1,239 +1,4 @@
|
||||
// @ts-nocheck
|
||||
import domtoimage from "dom-to-image";
|
||||
import * as Plotly from "plotly.js-dist-min";
|
||||
import { Figure } from "react-plotly.js";
|
||||
|
||||
const exportNativeFileSystem = async ({
|
||||
fileHandle,
|
||||
blob,
|
||||
}: {
|
||||
fileHandle?: FileSystemFileHandle | null;
|
||||
blob: Blob;
|
||||
}) => {
|
||||
if (!fileHandle) {
|
||||
return;
|
||||
}
|
||||
|
||||
return writeFileHandler({ fileHandle, blob });
|
||||
};
|
||||
|
||||
const writeFileHandler = async ({
|
||||
fileHandle,
|
||||
blob,
|
||||
}: {
|
||||
fileHandle: FileSystemFileHandle;
|
||||
blob: Blob;
|
||||
}) => {
|
||||
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"],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const getNewFileHandle = ({
|
||||
filename,
|
||||
is_image,
|
||||
}: {
|
||||
filename: string;
|
||||
is_image?: boolean;
|
||||
}): Promise<FileSystemFileHandle | 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,
|
||||
) => {
|
||||
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,
|
||||
) {
|
||||
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,
|
||||
) {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
export const non_blocking = (func: Function, delay: number) => {
|
||||
let timeout: number;
|
||||
@@ -246,139 +11,3 @@ export const non_blocking = (func: Function, delay: number) => {
|
||||
};
|
||||
};
|
||||
|
||||
const openbb_watermark = {
|
||||
yref: "paper",
|
||||
xref: "paper",
|
||||
x: 1,
|
||||
y: 0,
|
||||
text: "OpenBB",
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function saveImage(
|
||||
id: string,
|
||||
filename: string,
|
||||
extension: string,
|
||||
) {
|
||||
const chart = document.getElementById(id) as HTMLElement;
|
||||
|
||||
if (["svg"].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,
|
||||
) {
|
||||
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"].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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user