Skip to content

Commit 6c9bbf4

Browse files
committed
feat(chart): add chart tooltip
1 parent 4d779cc commit 6c9bbf4

File tree

3 files changed

+167
-0
lines changed

3 files changed

+167
-0
lines changed

apps/insights/src/components/PriceFeed/Chart/chart-page.module.scss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
height: theme.spacing(140);
77
border-radius: theme.border-radius("xl");
88
overflow: hidden;
9+
position: relative;
910

1011
.spinnerContainer {
1112
width: 100%;

apps/insights/src/components/PriceFeed/Chart/chart.module.scss

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,41 @@
1414
--chart-series-muted-light: #{theme.pallette-color("violet", 100)};
1515
--chart-series-muted-dark: #{theme.pallette-color("violet", 950)};
1616
}
17+
18+
.tooltip {
19+
@include theme.elevation("default", 2);
20+
21+
background-color: theme.color("background", "tooltip");
22+
border-radius: theme.border-radius("md");
23+
color: theme.color("tooltip");
24+
font-size: theme.font-size("xs");
25+
padding: theme.spacing(3);
26+
pointer-events: none;
27+
position: absolute;
28+
z-index: 1000;
29+
width: theme.spacing(60);
30+
overflow: hidden;
31+
}
32+
33+
.tooltipTable {
34+
color-scheme: dark;
35+
white-space: nowrap;
36+
width: 100%;
37+
38+
td:last-child {
39+
color: theme.color("foreground");
40+
font-weight: theme.font-weight("medium");
41+
text-align: right;
42+
font-variant-numeric: tabular-nums;
43+
}
44+
45+
td:first-child {
46+
color: theme.color("muted");
47+
font-weight: theme.font-weight("normal");
48+
text-align: left;
49+
}
50+
}
51+
52+
:global([data-theme="dark"]) .tooltipTable {
53+
color-scheme: light;
54+
}

apps/insights/src/components/PriceFeed/Chart/chart.tsx

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import {
2626
import { useTheme } from "next-themes";
2727
import type { RefObject } from "react";
2828
import { useCallback, useEffect, useRef } from "react";
29+
import { useDateFormatter } from "react-aria";
2930
import { z } from "zod";
3031

3132
import styles from "./chart.module.scss";
@@ -68,6 +69,7 @@ const useChartElem = (symbol: string, feedId: string) => {
6869
const [resolution] = useChartResolution();
6970
const chartContainerRef = useRef<HTMLDivElement | null>(null);
7071
const chartRef = useRef<ChartRefContents | undefined>(undefined);
72+
const tooltipRef = useRef<HTMLDivElement | undefined>(undefined);
7173
const isBackfilling = useRef(false);
7274
const abortControllerRef = useRef<AbortController | undefined>(undefined);
7375
// Lightweight charts has [a
@@ -264,6 +266,16 @@ const useChartElem = (symbol: string, feedId: string) => {
264266
if (chartRef.current) {
265267
chartRef.current.chart.remove();
266268
}
269+
if (tooltipRef.current) {
270+
tooltipRef.current.remove();
271+
}
272+
273+
const tooltip = document.createElement("div");
274+
tooltip.className = styles.tooltip ?? "";
275+
tooltip.style.display = "none";
276+
chartElem.append(tooltip);
277+
tooltipRef.current = tooltip;
278+
267279
const chart = createChart(chartElem, {
268280
layout: {
269281
attributionLogo: false,
@@ -283,6 +295,7 @@ const useChartElem = (symbol: string, feedId: string) => {
283295
},
284296
localization: {
285297
priceFormatter: priceFormatter.format,
298+
dateFormat: "dd MMM yy,",
286299
},
287300
});
288301

@@ -337,6 +350,10 @@ const useChartElem = (symbol: string, feedId: string) => {
337350

338351
return () => {
339352
chart.remove();
353+
if (tooltipRef.current) {
354+
tooltipRef.current.remove();
355+
tooltipRef.current = undefined;
356+
}
340357
};
341358
});
342359

@@ -383,6 +400,117 @@ const useChartElem = (symbol: string, feedId: string) => {
383400
}
384401
}, [livePriceData?.exponent, priceFormatter]);
385402

403+
const dateFormatter = useDateFormatter({
404+
year: "2-digit",
405+
month: "short",
406+
day: "numeric",
407+
hour: "2-digit",
408+
minute: "2-digit",
409+
second: "2-digit",
410+
hour12: false,
411+
timeZone: "UTC",
412+
});
413+
414+
// Subscribe to crosshair move for tooltip updates
415+
useEffect(() => {
416+
const chartData = chartRef.current;
417+
const tooltip = tooltipRef.current;
418+
const chartElem = chartContainerRef.current;
419+
420+
if (!chartData || !tooltip || !chartElem) {
421+
return;
422+
}
423+
424+
const { chart, price, confidenceHigh, confidenceLow } = chartData;
425+
426+
const handleCrosshairMove: Parameters<
427+
typeof chart.subscribeCrosshairMove
428+
>[0] = (param) => {
429+
if (
430+
!param.time ||
431+
param.point === undefined ||
432+
param.point.x < 0 ||
433+
param.point.y < 0
434+
) {
435+
tooltip.style.display = "none";
436+
return;
437+
}
438+
439+
const priceData = param.seriesData.get(price);
440+
const confHighData = param.seriesData.get(confidenceHigh);
441+
const confLowData = param.seriesData.get(confidenceLow);
442+
443+
if (!priceData || !("value" in priceData)) {
444+
tooltip.style.display = "none";
445+
return;
446+
}
447+
448+
const priceValue = priceData.value;
449+
const date = new Date(Number(param.time) * 1000);
450+
const formattedDate = dateFormatter.format(date);
451+
const formattedPrice = priceFormatter.format(priceValue);
452+
453+
let confidenceText = "N/A";
454+
if (
455+
confHighData &&
456+
"value" in confHighData &&
457+
confLowData &&
458+
"value" in confLowData
459+
) {
460+
const confidence = confHighData.value - priceValue;
461+
confidenceText = ${priceFormatter.format(confidence)}`;
462+
}
463+
464+
tooltip.innerHTML = `
465+
<table class="${styles.tooltipTable ?? ""}">
466+
<tbody>
467+
<tr>
468+
<td colspan="2">${formattedDate}</td>
469+
</tr>
470+
<tr>
471+
<tr>
472+
<td>Price</td>
473+
<td>${formattedPrice}</td>
474+
</tr>
475+
<tr>
476+
<td>Confidence</td>
477+
<td>${confidenceText}</td>
478+
</tr>
479+
</tbody>
480+
</table>
481+
`;
482+
483+
const tooltipRect = tooltip.getBoundingClientRect();
484+
const containerRect = chartElem.getBoundingClientRect();
485+
const tooltipMargin = 20;
486+
487+
const left = Math.max(
488+
0,
489+
Math.min(
490+
containerRect.width - tooltipRect.width,
491+
param.point.x - tooltipRect.width / 2,
492+
),
493+
);
494+
495+
const coordinate = price.priceToCoordinate(priceValue);
496+
if (coordinate === null) {
497+
tooltip.style.display = "none";
498+
return;
499+
}
500+
const top = Math.max(0, coordinate - tooltipRect.height - tooltipMargin);
501+
502+
tooltip.style.left = `${String(left)}px`;
503+
tooltip.style.top = `${String(top)}px`;
504+
tooltip.style.display = "block";
505+
};
506+
507+
chart.subscribeCrosshairMove(handleCrosshairMove);
508+
509+
return () => {
510+
chart.unsubscribeCrosshairMove(handleCrosshairMove);
511+
};
512+
}, [priceFormatter, chartContainerRef, dateFormatter]);
513+
386514
return { chartRef, chartContainerRef };
387515
};
388516

0 commit comments

Comments
 (0)