// @ts-nocheck import clsx from "clsx"; import { debounce } from "lodash"; import * as Plotly from "plotly.js-dist-min"; import { Icons as PlotlyIcons } from "plotly.js-dist-min"; import React, { useCallback, useEffect, useMemo, useState } from "react"; import createPlotlyComponent from "react-plotly.js/factory"; import { init_annotation } from "../utils/addAnnotation"; 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 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) { super(props); this.state = { data: props.data, layout: props.layout, frames: props.frames, config: props.config, useResizeHandler: props.useResizeHandler, style: props.style, className: props.className, divId: props.divId, revision: props.revision, graphDiv: props.graphDiv, debug: props.debug, onInitialized: props.onInitialized, }; } render() { return ( this.setState(figure)} onRelayout={(figure) => this.setState(figure)} onPurge={(figure) => this.setState(figure)} /> ); } } // 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]); const check_max = new Date(max.replace(" ", "T").split(".")[0]); check_min.setSeconds(0); check_max.setSeconds(0); check_min.setMilliseconds(0); check_max.setMilliseconds(0); const multiplier = [5, 0, 1].includes(check_min.getDay()) || [4, 5, 6].includes(check_max.getDay()) ? 2 : 0; const x0_min = new Date(check_min.getTime() - 86400000 * multiplier); const x1_max = new Date(check_max.getTime() + 86400000 * multiplier); const xrange = [x0_min.toISOString(), x1_max.toISOString()]; return { x0_min, x1_max, xrange }; } return { x0_min: min, x1_max: max, xrange: [min, max] }; }; 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) { xrange = [ data[0]?.x[data[0].x.length - 2000], data[0]?.x[data[0].x.length - 1], ]; } const { x0_min, x1_max, range } = getXRange(xrange[0], xrange[1]); xrange = range; const new_data = []; data.forEach((trace) => { const new_trace = { ...trace }; const data_keys = [ "x", "y", "low", "high", "open", "close", "text", "customdata", ]; const xaxis: any[] = trace.x ? trace.x : []; const chunks = []; for (let i = 0; i < xaxis.length; i++) { const xval = xaxis[i]; if (isoDateRegex.test(xval)) { const x_time = new Date(xval).getTime(); if (x_time >= x0_min.getTime() && x_time <= x1_max.getTime()) { chunks.push(i); } } else if (xval >= xrange[0] && xval <= xrange[1]) { chunks.push(i); } } data_keys.forEach((key) => { if (trace[key] !== undefined && Array.isArray(trace[key])) { new_trace[key] = trace[key].filter((_, i) => chunks.includes(i)); } }); const color_keys = ["marker", "line"]; color_keys.forEach((key) => { if (trace[key]?.color && Array.isArray(trace[key].color)) { new_trace[key] = { ...trace[key] }; new_trace[key].color = trace[key].color.filter((_, i) => chunks.includes(i), ); } }); if (chunks.length > 0) new_data.push(new_trace); }); if (new_data.length === 0) return { ...figure, layout: { ...figure.layout, xaxis: { ...figure.layout.xaxis, range: xrange }, }, }; new_figure.layout.xaxis.range = xrange; new_figure.data = new_data; return new_figure; } async function DynamicLoad({ event, figure, }: { 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) => trace.x !== undefined && trace.x.length > 0 && trace.x[0] !== undefined, ); if (XDATA.length === 0) return figure; // We get the xaxis range, if no event is passed, we get the last 1000 points const xaxis_range = event ? [event["xaxis.range[0]"], event["xaxis.range[1]"]] : [ XDATA[0]?.x[XDATA[0].x.length - 1000], XDATA[0]?.x[XDATA[0].x.length - 1], ]; figure = CreateDataXrange(figure, xaxis_range); return figure; } catch (e) { console.log("error", e); } } function formatDate(date) { const d = new Date(date); const month = `${d.getMonth() + 1}`.padStart(2, "0"); const day = `${d.getDate()}`.padStart(2, "0"); const year = d.getFullYear(); const hour = `${d.getHours()}`.padStart(2, "0"); const minute = `${d.getMinutes()}`.padStart(2, "0"); const second = `${d.getSeconds()}`.padStart(2, "0"); return `${year}-${month}-${day} ${hour}:${minute}:${second}`; } function Chart({ json, date, cmd, title, globals, theme, }: { // @ts-ignore json: Figure; date: Date; cmd: string; title: string; globals: any; theme: string; }) { json.layout.width = undefined; json.layout.height = undefined; if (json.layout?.title?.text) { json.layout.title.text = ""; } const [originalData, setOriginalData] = useState(json); const [barButtons, setModeBarButtons] = useState({}); const [LogYaxis, setLogYaxis] = useState(false); const [chartTitle, setChartTitle] = useState(title); const [axesTitles, setAxesTitles] = useState({}); const [plotLoaded, setPlotLoaded] = useState(false); const [modal, setModal] = useState({ name: "" }); const [loading, setLoading] = useState(false); const [plotDiv, setPlotDiv] = useState(null); const [volumeBars, setVolumeBars] = useState({ old_nticks: {} }); const [maximizePlot, setMaximizePlot] = useState(false); const [dateSliced, setDateSliced] = useState(false); const [plotData, setPlotDataState] = useState(originalData); const [annotations, setAnnotations] = useState([]); const [changeTheme, setChangeTheme] = useState(false); const [darkMode, setDarkMode] = useState(true); const [autoScale, setAutoScaling] = useState(false); const [changeColor, setChangeColor] = useState(false); const [colorActive, setColorActive] = useState(false); const [onAnnotationClick, setOnAnnotationClick] = useState({}); const [ohlcAnnotation, setOhlcAnnotation] = useState([]); 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); } } const onClose = () => setModal({ name: "" }); // @ts-ignore const onDeleteAnnotation = useCallback( (annotation) => { console.log("onDeleteAnnotation", annotation); const index = plotData?.layout?.annotations?.findIndex( (a: any) => a.text === annotation.text, ); console.log("index", index); if (index > -1) { plotData?.layout?.annotations?.splice(index, 1); setPlotData({ ...plotData }); setAnnotations(plotData?.layout?.annotations); } }, [plotData], ); // @ts-ignore const onAddAnnotation = useCallback( (data) => { console.log("onAddAnnotation being called with data:", data); // Use the standard annotation flow init_annotation({ plotData, popupData: data, setPlotData, setModal, setOnAnnotationClick, setAnnotations, onAnnotationClick, ohlcAnnotation, setOhlcAnnotation, annotations, plotDiv, }); }, [plotData, onAnnotationClick, ohlcAnnotation, annotations, plotDiv], ); 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, plotDiv]); function onChangeColor(color) { // updates the color of the last added shape // this function is called when the color picker is used // if there are no shapes, we remove the color picker const shapes = plotDiv.layout.shapes; if (!shapes || shapes.length === 0) { return; } // we change last added shape color const last_shape = shapes[shapes.length - 1]; last_shape.line.color = color; Plotly.update(plotDiv, {}, { shapes: shapes }); } function button_pressed(title, active = false) { // changes the style of the button when it is pressed // title is the title of the button // active is true if the button is active, false otherwise const button = barButtons[title] || document.querySelector(`[data-title="${title}"]`); if (!active) { button.style.border = "1px solid rgba(0, 151, 222, 1.0)"; button.style.borderRadius = "5px"; button.style.borderpadding = "5px"; button.style.boxShadow = "0 0 5px rgba(0, 151, 222, 1.0)"; } else { button.style.border = "transparent"; button.style.boxShadow = "none"; } setModeBarButtons({ ...barButtons, [title]: button }); } const debouncedDynamicLoad = async (eventData, figure) => { if (dateSliced) { const data = { ...figure }; DynamicLoad({ event: eventData, figure: data, }).then(async (toUpdate) => { autoScaling(eventData, toUpdate).then((scaled) => { if (!scaled.to_update) return; setYaxisFixedRange(scaled.yaxis_fixedrange); setPlotData({ ...toUpdate, layout: scaled.to_update }); }); }); } else { const scaled = await autoScaling(eventData, figure); if (!scaled.to_update) return; setYaxisFixedRange(scaled.yaxis_fixedrange); setPlotData({ ...figure, layout: scaled.to_update }); } }; const autoscaleButton = useCallback(() => { // We need to check if the button is active or not const title = "Auto Scale (Ctrl+Shift+A)"; const button = barButtons[title] || document.querySelector(`[data-title="${title}"]`); let active = true; if (button.style.border === "transparent") { plotDiv.removeAllListeners("plotly_relayout"); active = false; plotDiv.on("plotly_relayout", async (eventdata) => { if (eventdata["xaxis.range[0]"] === undefined) return; const debounceTimer = eventdata["relayout"] ? 0 : 300; if ( !eventdata["relayout"] && isoDateRegex.test( eventdata["xaxis.range[0]"].toString().replace(" ", "T"), ) ) { const date1 = new Date(eventdata["xaxis.range[0]"].replace(" ", "T")); const date2 = new Date(eventdata["xaxis.range[1]"].replace(" ", "T")); if (date2.getTime() - date1.getTime() < 3600000 * 2) { const d1 = new Date(date1.getTime() - 3600000 * 2); const d2 = new Date(date2.getTime() + 3600000 * 2); eventdata["xaxis.range[0]"] = formatDate(d1); eventdata["xaxis.range[1]"] = formatDate(d2); eventdata["relayout"] = true; return Plotly.relayout(plotDiv, eventdata); } } debounce(async () => { debouncedDynamicLoad(eventdata, originalData); }, debounceTimer)(); }); } // If the button isn't active, we remove the listener so // the graphs don't autoscale anymore else { plotDiv.removeAllListeners("plotly_relayout"); yaxisFixedRange.forEach((yaxis) => { plotDiv.layout[yaxis].fixedrange = false; }); setYaxisFixedRange([]); if (dateSliced) { plotDiv.on( "plotly_relayout", debounce(async (eventdata) => { if (eventdata["xaxis.range[0]"] === undefined) return; debouncedDynamicLoad(eventdata, originalData); }, 300), ); } } button_pressed(title, active); }, [ barButtons, dateSliced, debouncedDynamicLoad, originalData, plotDiv, yaxisFixedRange, ]); function changecolorButton() { // We need to check if the button is active or not const title = "Edit Color (Ctrl+E)"; const button = barButtons[title] || document.querySelector(`[data-title="${title}"]`); let active = true; if (button.style.border === "transparent") { active = false; } setColorActive(!active); button_pressed(title, active); } useEffect(() => { if (autoScale) { const scale = !autoScale; console.log("activateAutoScale", scale); autoscaleButton(); setAutoScaling(false); } }, [autoScale]); useEffect(() => { if (changeColor) { changecolorButton(); setChangeColor(false); } }, [changeColor]); useEffect(() => { if (changeTheme) { try { console.log("changeTheme", changeTheme); const TRACES = originalData?.data.filter( (trace) => trace?.name?.trim() === "Volume", ); const darkmode = !darkMode; window.document.body.style.backgroundColor = darkmode ? "#000" : "#fff"; originalData.layout.font = { ...(originalData.layout.font || {}), color: darkmode ? "#fff" : "#000", }; const changeIcon = darkmode ? ICONS.sunIcon : ICONS.moonIcon; document .querySelector('[data-title="Change Theme"]') .getElementsByTagName("path")[0] .setAttribute("d", changeIcon.path); document .querySelector('[data-title="Change Theme"]') .getElementsByTagName("svg")[0] .setAttribute("viewBox", changeIcon.viewBox); const volumeColorsDark = { "#00ACFF0": "#00ACFF", "#e4003a": "#e4003a", }; const volumeColorsLight = { "#e4003a": "#e4003a", "#00ACFF": "#00ACFF", }; const volumeColors = darkmode ? volumeColorsDark : volumeColorsLight; TRACES.forEach((trace) => { if (trace.type === "bar" && Array.isArray(trace.marker.color)) trace.marker.color = trace.marker.color.map((color) => { return volumeColors[color] || color; }); }); 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); } catch (e) { console.log("error", e); } } }, [changeTheme, plotData.layout.annotations]); useEffect(() => { if (plotLoaded) { setDarkMode(true); setAutoScaling(false); const captureButtons = [ "Overlay chart from CSV", "Add Text", "Change Titles", "Auto Scale (Ctrl+Shift+A)", "Reset Axes", ]; const autoscale = document.querySelector('[data-title="Autoscale"]'); if (autoscale) { autoscale .getElementsByTagName("path")[0] .setAttribute("d", PlotlyIcons.home.path); autoscale.setAttribute("data-title", "Reset Axes"); } window.MODEBAR = document.getElementsByClassName( "modebar-container", )[0] as HTMLElement; const modeBarButtons = window.MODEBAR.getElementsByClassName( "modebar-btn", ) as HTMLCollectionOf; 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++) { const btn = modeBarButtons[i]; if (captureButtons.includes(btn.getAttribute("data-title"))) { btn.classList.add("ph-capture"); } btn.style.border = "transparent"; barbuttons[btn.getAttribute("data-title")] = btn; } setModeBarButtons(barbuttons); } if (plotData?.layout?.yaxis?.type !== undefined) { if (plotData.layout.yaxis.type === "log" && !LogYaxis) { console.log("yaxis.type changed to log"); setLogYaxis(true); } if (plotData.layout.yaxis.type === "linear" && LogYaxis) { console.log("yaxis.type changed to linear"); setLogYaxis(false); // We update the yaxis exponent format to none, // set the tickformat to null and the exponentbase to 10 const layout_update = { "yaxis.exponentformat": "none", "yaxis.tickformat": null, "yaxis.exponentbase": 10, }; Plotly.update(plotDiv, {}, layout_update); } } window.addEventListener("resize", async function () { const update = await ResizeHandler({ plotData, volumeBars, setMaximizePlot, }); const layout_update = update.layout_update; const newPlotData = update.plotData; const volume_update = update.volume_update; if (Object.keys(layout_update).length > 0) { setPlotData(newPlotData); setVolumeBars(volume_update); Plotly.update(plotDiv, {}, layout_update); } }); if (theme !== "dark") { setChangeTheme(true); } } }, [plotLoaded]); 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( () => ( { if (!plotDiv) { 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); }} className="w-full h-full" divId="plotlyChart" data={plotData.data} layout={plotData.layout} frames={plotData.frames} config={PlotConfig({ setModal: setModal, changeTheme: setChangeTheme, autoScaling: setAutoScaling, Loading: setLoading, changeColor: setChangeColor, })} /> ), [ plotDiv, originalData, plotLoaded, plotData, globals, setPlotDiv, setPlotLoaded, setModal, setChangeTheme, setAutoScaling, setLoading, onChangeColor, ], ); const memoizedAlertDialog = useMemo(() => { return ( ); }, [modal, onClose]); const memoizedOverlayChartDialog = useMemo(() => { return ( { console.log(overlay); overlay.layout.showlegend = true; setOriginalData(overlay); setPlotData(overlay); }} plotlyData={originalData} setLoading={setLoading} open={modal?.name === "overlayChart"} close={onClose} /> ); }, [modal, plotData, onClose, setPlotData, setLoading]); const memoizedTitleChartDialog = useMemo(() => { return ( setChartTitle(title)} updateAxesTitles={(axesTitles) => setAxesTitles(axesTitles)} defaultTitle={chartTitle} plotlyData={plotData} open={modal?.name === "titleDialog"} close={onClose} /> ); }, [modal, plotData, chartTitle, onClose]); const memoizedTextChartDialog = useMemo(() => { return ( onAddAnnotation(data)} deleteAnnotation={(data) => onDeleteAnnotation(data)} /> ); }, [ modal, onAddAnnotation, onDeleteAnnotation, onClose, plotData, setPlotData, ]); const memoizedChangeColor = useMemo(() => { return ; }, [colorActive, onChangeColor]); const memoizedChartHotkeys = useMemo(() => { return ( ); }, [setModal, setLoading, setChangeColor]); return (
{loading && (
)}
{memoizedAlertDialog} {memoizedOverlayChartDialog} {memoizedTitleChartDialog} {memoizedTextChartDialog} {memoizedChangeColor} {memoizedChartHotkeys}

{chartTitle} {/* {source && ( {`[${source}]`} )} */}

{new Intl.DateTimeFormat("en-GB", { dateStyle: "full", timeStyle: "long", }) .format(date) .replace(/:\d\d /, " ")}
{cmd}

{/* {source && typeof source === "string" && source.includes("*") && (

*not affiliated

)} */}
{plotComponent}
); } export default React.memo(Chart);