Jaspers Hub

jaspers / tradingview

pluginOfficialReviewed★ 0↓ 9

TradingView Chart: Shows TradingView's advanced chart widget in a frame, driven by panel state (symbol, interval, studies), reporting back the last quote.

4.2 KB
1import {
2 useEffect,
3 useRef,
4 useState,
5 useSyncExternalStore,
6 type ReactElement,
7} from "react";
8import { useBridge, useData, usePublish, type PanelRef } from "@jaspers-ai/sdk";
9import {
10 nextQuote,
11 QUOTE_EVERY_MS,
12 readQuote,
13 readState,
14 WIDGET_ORIGIN,
15 widgetUrl,
16 type Quote,
17 type Scheme,
18} from "./chart";
19import "./styles.css";
20
21// The view: TradingView's widget in a frame, at the address the panel's state makes, so changing the
22// state loads another chart. The widget posts quotes to this page; the latest goes out with the state
23// as the output, which is everything the orchestrator can know about what is drawn. The address also
24// carries the app's light or dark, so a switch loads the chart again in the other.
25
26/** Where the credit under the chart goes. */
27const TRADINGVIEW = "https://www.tradingview.com/";
28
29/**
30 * The panel's state arrives a round trip after the frame mounts. Until it does there is no chart to
31 * load, and publishing the defaults would say the view shows a chart it does not.
32 */
33export function ChartView({ panel }: { panel: PanelRef }): ReactElement {
34 const state = useData(
35 `workspaces/${panel.workspaceId}/panels/${panel.id}/state`,
36 );
37 if (state === undefined) {
38 return (
39 <div className="tv-root">
40 <p className="tv-empty">Loading…</p>
41 </div>
42 );
43 }
44 return <Chart panel={panel} raw={state} />;
45}
46
47function Chart({
48 panel,
49 raw,
50}: {
51 panel: PanelRef;
52 raw: unknown;
53}): ReactElement {
54 const bridge = useBridge();
55 const scheme = useColorScheme();
56 const state = readState(raw);
57 const url = state.symbol ? widgetUrl(state, scheme) : null;
58 const frame = useRef<HTMLIFrameElement>(null);
59 const latest = useRef<Quote | null>(null);
60 const shown = useRef<{ quote: Quote | null; at: number }>({
61 quote: null,
62 at: 0,
63 });
64 const [quote, setQuote] = useState<Quote | null>(null);
65
66 // Another address is another chart, or the same one loaded again in the other scheme: either way
67 // what the last page said about its price no longer holds.
68 useEffect(() => {
69 latest.current = null;
70 shown.current = { quote: null, at: 0 };
71 setQuote(null);
72 }, [url]);
73
74 useEffect(() => {
75 function offer(): void {
76 const now = Date.now();
77 const next = nextQuote(
78 shown.current.quote,
79 shown.current.at,
80 latest.current,
81 now,
82 );
83 if (!next) return;
84 shown.current = { quote: next, at: now };
85 setQuote(next);
86 }
87 function onMessage(event: MessageEvent): void {
88 // Only the widget in this view's own frame.
89 if (
90 event.origin !== WIDGET_ORIGIN ||
91 event.source !== frame.current?.contentWindow
92 )
93 return;
94 const posted = readQuote(event.data);
95 if (!posted) return;
96 latest.current = posted;
97 offer();
98 }
99 window.addEventListener("message", onMessage);
100 // A price that moved inside the interval goes out once the interval has passed, quote or no quote.
101 const timer = setInterval(offer, QUOTE_EVERY_MS);
102 return () => {
103 window.removeEventListener("message", onMessage);
104 clearInterval(timer);
105 };
106 }, []);
107
108 usePublish(panel, quote ? { ...state, quote } : { ...state });
109
110 return (
111 <div className="tv-root">
112 {url ? (
113 // Keyed on the address: the widget reads its chart from the hash once, so a new one is a new page.
114 <iframe
115 key={url}
116 ref={frame}
117 className="tv-frame"
118 src={url}
119 title={`${state.symbol} chart`}
120 />
121 ) : (
122 <p className="tv-empty">No symbol set.</p>
123 )}
124 </div>
125 );
126}
127
128// Which scheme the app shows. Settings > Appearance sets it in this frame too, through the OS, so the
129// media query is the one place to ask.
130const DARK = "(prefers-color-scheme: dark)";
131
132function subscribeColorScheme(onChange: () => void): () => void {
133 const query = matchMedia(DARK);
134 query.addEventListener("change", onChange);
135 return () => query.removeEventListener("change", onChange);
136}
137
138/** The app's light or dark, followed as it switches: the widget takes it as its theme, from the address. */
139function useColorScheme(): Scheme {
140 return useSyncExternalStore(subscribeColorScheme, () =>
141 matchMedia(DARK).matches ? "dark" : "light",
142 );
143}