[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:
Danglewood
2025-05-24 12:48:37 -07:00
committed by GitHub
parent a4319a6dd5
commit d91c9b9c35
27 changed files with 2154 additions and 2467 deletions

View File

@@ -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]">

View File

@@ -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">

View File

@@ -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,
},
},
},
};

View File

@@ -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>
);
}

View File

@@ -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"

View File

@@ -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();
}}
>

View File

@@ -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",
() => {

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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);
}