Skip to content

Commit f01ba24

Browse files
committed
feat(chart): add chart tooltip
1 parent 18bb98d commit f01ba24

File tree

3 files changed

+160
-0
lines changed

3 files changed

+160
-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: 121 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,110 @@ 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+
const priceData = param.seriesData.get(price);
430+
const confidenceHighData = param.seriesData.get(confidenceHigh);
431+
const confidenceLowData = param.seriesData.get(confidenceLow);
432+
433+
const hasPrice = priceData && "value" in priceData;
434+
const hasTime = param.time !== undefined;
435+
const hasPoint = param.point && "x" in param.point && "y" in param.point;
436+
437+
if (!hasPrice || !hasTime || !hasPoint) {
438+
tooltip.style.display = "none";
439+
return;
440+
}
441+
442+
const priceValue = priceData.value;
443+
const date = new Date(Number(param.time) * 1000);
444+
const formattedDate = dateFormatter.format(date);
445+
const formattedPrice = priceFormatter.format(priceValue);
446+
447+
let confidenceText = "N/A";
448+
if (
449+
confidenceHighData &&
450+
"value" in confidenceHigh &&
451+
confidenceLowData &&
452+
"value" in confidenceLow
453+
) {
454+
confidenceText = ${priceFormatter.format(confidenceHighData.value - priceValue)}`;
455+
}
456+
457+
tooltip.innerHTML = `
458+
<table class="${styles.tooltipTable ?? ""}">
459+
<tbody>
460+
<tr>
461+
<td colspan="2">${formattedDate}</td>
462+
</tr>
463+
<tr>
464+
<tr>
465+
<td>Price</td>
466+
<td>${formattedPrice}</td>
467+
</tr>
468+
<tr>
469+
<td>Confidence</td>
470+
<td>${confidenceText}</td>
471+
</tr>
472+
</tbody>
473+
</table>
474+
`;
475+
476+
const tooltipRect = tooltip.getBoundingClientRect();
477+
const containerRect = chartElem.getBoundingClientRect();
478+
const tooltipMargin = 20;
479+
480+
const left = Math.max(
481+
0,
482+
Math.min(
483+
containerRect.width - tooltipRect.width,
484+
param.point.x - tooltipRect.width / 2,
485+
),
486+
);
487+
488+
const coordinate = price.priceToCoordinate(priceValue);
489+
if (coordinate === null) {
490+
tooltip.style.display = "none";
491+
return;
492+
}
493+
const top = Math.max(0, coordinate - tooltipRect.height - tooltipMargin);
494+
495+
tooltip.style.left = `${String(left)}px`;
496+
tooltip.style.top = `${String(top)}px`;
497+
tooltip.style.display = "block";
498+
};
499+
500+
chart.subscribeCrosshairMove(handleCrosshairMove);
501+
502+
return () => {
503+
chart.unsubscribeCrosshairMove(handleCrosshairMove);
504+
};
505+
}, [priceFormatter, chartContainerRef, dateFormatter]);
506+
386507
return { chartRef, chartContainerRef };
387508
};
388509

0 commit comments

Comments
 (0)