import { useEffect, useRef, useState, useSyncExternalStore, type ReactElement, } from "react"; import { useBridge, useData, usePublish, type PanelRef } from "@jaspers-ai/sdk"; import { nextQuote, QUOTE_EVERY_MS, readQuote, readState, WIDGET_ORIGIN, widgetUrl, type Quote, type Scheme, } from "./chart"; import "./styles.css"; // The view: TradingView's widget in a frame, at the address the panel's state makes, so changing the // state loads another chart. The widget posts quotes to this page; the latest goes out with the state // as the output, which is everything the orchestrator can know about what is drawn. The address also // carries the app's light or dark, so a switch loads the chart again in the other. /** Where the credit under the chart goes. */ const TRADINGVIEW = "https://www.tradingview.com/"; /** * The panel's state arrives a round trip after the frame mounts. Until it does there is no chart to * load, and publishing the defaults would say the view shows a chart it does not. */ export function ChartView({ panel }: { panel: PanelRef }): ReactElement { const state = useData( `workspaces/${panel.workspaceId}/panels/${panel.id}/state`, ); if (state === undefined) { return (

Loading…

); } return ; } function Chart({ panel, raw, }: { panel: PanelRef; raw: unknown; }): ReactElement { const bridge = useBridge(); const scheme = useColorScheme(); const state = readState(raw); const url = state.symbol ? widgetUrl(state, scheme) : null; const frame = useRef(null); const latest = useRef(null); const shown = useRef<{ quote: Quote | null; at: number }>({ quote: null, at: 0, }); const [quote, setQuote] = useState(null); // Another address is another chart, or the same one loaded again in the other scheme: either way // what the last page said about its price no longer holds. useEffect(() => { latest.current = null; shown.current = { quote: null, at: 0 }; setQuote(null); }, [url]); useEffect(() => { function offer(): void { const now = Date.now(); const next = nextQuote( shown.current.quote, shown.current.at, latest.current, now, ); if (!next) return; shown.current = { quote: next, at: now }; setQuote(next); } function onMessage(event: MessageEvent): void { // Only the widget in this view's own frame. if ( event.origin !== WIDGET_ORIGIN || event.source !== frame.current?.contentWindow ) return; const posted = readQuote(event.data); if (!posted) return; latest.current = posted; offer(); } window.addEventListener("message", onMessage); // A price that moved inside the interval goes out once the interval has passed, quote or no quote. const timer = setInterval(offer, QUOTE_EVERY_MS); return () => { window.removeEventListener("message", onMessage); clearInterval(timer); }; }, []); usePublish(panel, quote ? { ...state, quote } : { ...state }); return (
{url ? ( // Keyed on the address: the widget reads its chart from the hash once, so a new one is a new page.