@sybilion/uilib 1.0.27 → 1.0.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/components/ui/Chart/Chart.styl.js +1 -1
- package/dist/esm/components/ui/Chart/components/BaseChartWrapper.js +117 -170
- package/dist/esm/components/ui/ChartAreaInteractive/ChartAreaInteractive.helpers.js +101 -1
- package/dist/esm/components/ui/ChartAreaInteractive/ChartAreaInteractive.js +12 -4
- package/dist/esm/types/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.helpers.d.ts +6 -1
- package/dist/esm/types/src/docs/contexts/theme-context.d.ts +1 -0
- package/package.json +1 -1
- package/src/components/ui/Chart/Chart.styl +7 -4
- package/src/components/ui/Chart/components/BaseChartWrapper.tsx +156 -193
- package/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.helpers.ts +90 -40
- package/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.tsx +15 -3
- package/src/docs/contexts/theme-context.tsx +9 -1
- package/src/docs/pages/ChartAreaInteractivePage.tsx +27 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import styleInject from 'style-inject';
|
|
2
2
|
|
|
3
|
-
var css_248z = ".Chart_chartContainer__--q1l{aspect-ratio:16/9;display:flex;font-size:.75rem;justify-content:center;line-height:1rem;max-width:100%;touch-action:none;width:100%}.Chart_chartContainer__--q1l .recharts-cartesian-axis-tick text{fill:var(--muted-foreground)}.Chart_chartContainer__--q1l .recharts-cartesian-grid line[stroke=\"#ccc\"]{stroke:var(--border)}.dark .Chart_chartContainer__--q1l .recharts-cartesian-grid line[stroke=\"#ccc\"]{stroke:var(--sb-slate-900)}.Chart_chartContainer__--q1l .recharts-curve.recharts-tooltip-cursor,.Chart_chartContainer__--q1l .recharts-polar-grid [stroke=\"#ccc\"]{stroke:var(--border)}.Chart_chartContainer__--q1l .recharts-radial-bar-background-sector,.Chart_chartContainer__--q1l .recharts-rectangle.recharts-tooltip-cursor{fill:var(--muted)}.Chart_chartContainer__--q1l .recharts-reference-line [stroke=\"#ccc\"]{stroke:var(--border)}.Chart_chartContainer__--q1l .recharts-dot[stroke=\"#fff\"]{stroke:transparent}.Chart_chartContainer__--q1l .recharts-layer,.Chart_chartContainer__--q1l .recharts-sector{outline:none}.Chart_chartContainer__--q1l .recharts-sector[stroke=\"#fff\"]{stroke:transparent}.Chart_chartContainer__--q1l .recharts-surface{outline:none}.Chart_chartContainer__--q1l .recharts-wrapper{position:relative}.Chart_chartContainer__--q1l .recharts-surface{position:relative;z-index:1}.Chart_chartContainer__--q1l .recharts-tooltip-wrapper{
|
|
3
|
+
var css_248z = ".Chart_chartContainer__--q1l{aspect-ratio:16/9;display:flex;font-size:.75rem;justify-content:center;line-height:1rem;max-width:100%;touch-action:none;width:100%}.Chart_chartContainer__--q1l .recharts-cartesian-axis-tick text{fill:var(--muted-foreground)}.Chart_chartContainer__--q1l .recharts-cartesian-grid line[stroke=\"#ccc\"]{stroke:var(--border)}.dark .Chart_chartContainer__--q1l .recharts-cartesian-grid line[stroke=\"#ccc\"]{stroke:var(--sb-slate-900)}.Chart_chartContainer__--q1l .recharts-curve.recharts-tooltip-cursor,.Chart_chartContainer__--q1l .recharts-polar-grid [stroke=\"#ccc\"]{stroke:var(--border)}.Chart_chartContainer__--q1l .recharts-radial-bar-background-sector,.Chart_chartContainer__--q1l .recharts-rectangle.recharts-tooltip-cursor{fill:var(--muted)}.Chart_chartContainer__--q1l .recharts-reference-line [stroke=\"#ccc\"]{stroke:var(--border)}.Chart_chartContainer__--q1l .recharts-dot[stroke=\"#fff\"]{stroke:transparent}.Chart_chartContainer__--q1l .recharts-layer,.Chart_chartContainer__--q1l .recharts-sector{outline:none}.Chart_chartContainer__--q1l .recharts-sector[stroke=\"#fff\"]{stroke:transparent}.Chart_chartContainer__--q1l .recharts-surface{outline:none}.Chart_chartContainer__--q1l .recharts-wrapper{position:relative}.Chart_chartContainer__--q1l .recharts-surface{position:relative;z-index:1}.Chart_chartContainer__--q1l .recharts-tooltip-wrapper{z-index:3!important}.Chart_chartContainer__--q1l .recharts-active-dot{z-index:3}.Chart_chartGrid__t52WF{stroke-width:.6}.Chart_tooltipContainer__6tc0q{align-items:start;box-sizing:border-box;display:grid;max-width:100%;min-width:0;overflow-wrap:break-word;width:-moz-max-content;width:max-content;word-break:break-word;grid-gap:.375rem;background-color:var(--background);border:1px solid var(--border)/.5;border-radius:.5rem;box-shadow:0 10px 10px -5px rgba(0,0,0,.3),0 0 2px 0 rgba(0,0,0,.5);font-size:.75rem;gap:.375rem;line-height:1rem;opacity:0;padding:.375rem .625rem;transition:opacity .5s ease-out}.Chart_chartContainer__--q1l:hover .Chart_tooltipContainer__6tc0q{opacity:1;transition-duration:.5s}.Chart_tooltipItem__j8I9T{align-items:stretch;display:flex;flex-wrap:wrap;gap:.5rem;width:100%}.Chart_tooltipItem__j8I9T>svg{color:var(--muted-foreground);height:.625rem;width:.625rem}.Chart_tooltipIndicator__Z-JWp{background-color:var(--color-bg);border-color:var(--color-border);border-radius:2px;border-width:1px;flex-shrink:0}.Chart_tooltipIndicator__Z-JWp.Chart_indicator-dot__MWcmW{height:.625rem;width:.625rem}.Chart_tooltipIndicator__Z-JWp.Chart_indicator-line__MO3ul{width:.25rem}.Chart_tooltipIndicator__Z-JWp.Chart_indicator-dashed__2LqIN{background-color:transparent;border-style:dashed;border-width:1.5px;width:0}.Chart_tooltipIndicator__Z-JWp.Chart_indicator-dashed__2LqIN.Chart_nested__7EWWk{margin-bottom:.125rem;margin-top:.125rem}.Chart_tooltipContent__M3R-W{display:flex;flex:1 1 0%;justify-content:space-between;line-height:1}.Chart_tooltipLabel__zMpjZ{display:grid;grid-gap:.375rem;gap:.375rem}.Chart_tooltipValue__vTQxU{color:var(--foreground);font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:\"tnum\";font-variant-numeric:tabular-nums;font-weight:500;margin-left:var(--p-3)}.Chart_legendContainer__u1J3U{align-items:center;display:flex;gap:1rem;justify-content:center}.Chart_legendItem__0CSyC{align-items:center;display:flex;gap:.375rem}.Chart_legendItem__0CSyC>svg{color:var(--muted-foreground);height:.75rem;width:.75rem}.Chart_legendIndicator__erzzP{border-radius:2px;flex-shrink:0;height:.5rem;width:.5rem}.chart-line-blinking path{animation:chart-line-blink 1s ease-in-out infinite;animation-direction:alternate}@keyframes Chart_chart-line-blink__4EI-g{0%{opacity:.5}to{opacity:1}}";
|
|
4
4
|
var S = {"chartContainer":"Chart_chartContainer__--q1l","chartGrid":"Chart_chartGrid__t52WF","tooltipContainer":"Chart_tooltipContainer__6tc0q","tooltipItem":"Chart_tooltipItem__j8I9T","tooltipIndicator":"Chart_tooltipIndicator__Z-JWp","indicator-dot":"Chart_indicator-dot__MWcmW","indicator-line":"Chart_indicator-line__MO3ul","indicator-dashed":"Chart_indicator-dashed__2LqIN","nested":"Chart_nested__7EWWk","tooltipContent":"Chart_tooltipContent__M3R-W","tooltipLabel":"Chart_tooltipLabel__zMpjZ","tooltipValue":"Chart_tooltipValue__vTQxU","legendContainer":"Chart_legendContainer__u1J3U","legendItem":"Chart_legendItem__0CSyC","legendIndicator":"Chart_legendIndicator__erzzP","chart-line-blink":"Chart_chart-line-blink__4EI-g"};
|
|
5
5
|
styleInject(css_248z);
|
|
6
6
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx, jsxs } from 'react/jsx-runtime';
|
|
2
2
|
import cn from 'classnames';
|
|
3
|
-
import { forwardRef, useRef,
|
|
3
|
+
import { forwardRef, useState, useRef, useMemo, useEffect } from 'react';
|
|
4
4
|
import { ChartTooltip } from '../Chart.js';
|
|
5
5
|
import { QuantileBands } from './QuantileBands.js';
|
|
6
6
|
import { getForecastColor, ChartLines } from '../../ChartAreaInteractive/ChartLines.js';
|
|
@@ -16,6 +16,50 @@ import { ChartContainer } from './ChartContainer.js';
|
|
|
16
16
|
import { CustomChartLegend } from './CustomChartLegend/CustomChartLegend.js';
|
|
17
17
|
import { ChartTooltipContent } from './ChartTooltipContent.js';
|
|
18
18
|
|
|
19
|
+
const DEFAULT_CHART_MARGIN = {
|
|
20
|
+
top: 5,
|
|
21
|
+
right: 5,
|
|
22
|
+
bottom: 5,
|
|
23
|
+
left: 5,
|
|
24
|
+
};
|
|
25
|
+
function resolveChartMargin(margin) {
|
|
26
|
+
return {
|
|
27
|
+
top: margin?.top ?? DEFAULT_CHART_MARGIN.top,
|
|
28
|
+
right: margin?.right ?? DEFAULT_CHART_MARGIN.right,
|
|
29
|
+
bottom: margin?.bottom ?? DEFAULT_CHART_MARGIN.bottom,
|
|
30
|
+
left: margin?.left ?? DEFAULT_CHART_MARGIN.left,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** Plot box inside `.recharts-wrapper`, same convention as Recharts cartesian viewBox. */
|
|
34
|
+
function getPlotViewBox(wrapper, m) {
|
|
35
|
+
const w = wrapper.clientWidth;
|
|
36
|
+
const h = wrapper.clientHeight;
|
|
37
|
+
return {
|
|
38
|
+
x: m.left,
|
|
39
|
+
y: m.top,
|
|
40
|
+
width: Math.max(0, w - m.left - m.right),
|
|
41
|
+
height: Math.max(0, h - m.top - m.bottom),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function clampTooltipTranslate(args) {
|
|
45
|
+
const { coordinate, viewBox, tooltipWidth: tw, tooltipHeight: th, offset, edgeMargin, } = args;
|
|
46
|
+
const minX = viewBox.x + edgeMargin;
|
|
47
|
+
const maxX = viewBox.x + viewBox.width - tw - edgeMargin;
|
|
48
|
+
const minY = viewBox.y + edgeMargin;
|
|
49
|
+
const maxY = viewBox.y + viewBox.height - th - edgeMargin;
|
|
50
|
+
const clamp = (v, lo, hi) => Math.min(Math.max(v, lo), Math.max(lo, hi));
|
|
51
|
+
let tx = coordinate.x + offset;
|
|
52
|
+
if (tx + tw > viewBox.x + viewBox.width - edgeMargin) {
|
|
53
|
+
tx = coordinate.x - tw - offset;
|
|
54
|
+
}
|
|
55
|
+
tx = clamp(tx, minX, maxX);
|
|
56
|
+
let ty = coordinate.y + offset;
|
|
57
|
+
if (ty + th > viewBox.y + viewBox.height - edgeMargin) {
|
|
58
|
+
ty = coordinate.y - th - offset;
|
|
59
|
+
}
|
|
60
|
+
ty = clamp(ty, minY, maxY);
|
|
61
|
+
return { x: tx, y: ty };
|
|
62
|
+
}
|
|
19
63
|
/**
|
|
20
64
|
* Simple loading component without hooks for better performance
|
|
21
65
|
* Rendered when chart is waiting in render queue
|
|
@@ -30,16 +74,8 @@ BaseChartWrapperLoading.displayName = 'BaseChartWrapperLoading';
|
|
|
30
74
|
*/
|
|
31
75
|
const BaseChartWrapperContent = forwardRef((props, ref) => {
|
|
32
76
|
const { chartConfig = {}, chartData, historicalLineColor, forecastData = [], loading, hasCombinedData, renderId, isDarkTheme, height, className, loadingComponentClassName, footerClassName, chartClassName, xAxisClassName, yAxisClassName, legendClassName, footerActions, quantileBands, quantileBandKey, xMin, xMax, yMin, yMax, autoScaleYAxis = true, formatNumber, formatDate: formatDateFn = formatDate, labelFormatter, onLegendClick, margin, chartType = 'composed', disableAnimation = false, showGrid = true, showAxes = true, showTooltip = true, showLegend = true, showChartAxesLegend = true, xAxisLabel, yAxisLabel, showActiveDots = true, overlayElements, hiddenSeries, excludeLegendIds, onAnalysisSelect, onFailedAnalysisClick, containerProps, error, loadingMessage, noDataMessage = 'No data available', onGridHeightChange, forecastLineStyle = 'dashed', disableHistoricalAnimation = false, onShowAll: _onShowAll, onShowOnly: _onShowOnly, maxVisibleItems, preventDeselection, legendVariant = 'default', legendWidth = 1000, legendMarginLeft = 0, } = props;
|
|
33
|
-
const activeDataRef = useRef(null);
|
|
34
|
-
/** Raw cursor position from Recharts (tooltip anchor). */
|
|
35
|
-
const rawTooltipCoordinateRef = useRef(null);
|
|
36
|
-
/** Last transform actually applied after viewport/boundary adjustment. */
|
|
37
|
-
const lastAppliedTooltipPositionRef = useRef(null);
|
|
38
|
-
const isTooltipActiveRef = useRef(false);
|
|
39
|
-
// const [activeDotsData, setActiveDotsData] = useState<ActiveDot[]>([]);
|
|
40
77
|
const [shouldAnimate, setShouldAnimate] = useState(false);
|
|
41
78
|
const rootRef = useRef(null);
|
|
42
|
-
// Merge forwarded ref with internal rootRef using callback ref
|
|
43
79
|
const setRefs = (node) => {
|
|
44
80
|
rootRef.current = node;
|
|
45
81
|
if (typeof ref === 'function') {
|
|
@@ -49,107 +85,83 @@ const BaseChartWrapperContent = forwardRef((props, ref) => {
|
|
|
49
85
|
ref.current = node;
|
|
50
86
|
}
|
|
51
87
|
};
|
|
52
|
-
|
|
53
|
-
const tooltipWrapperRef = useRef(null);
|
|
88
|
+
const resolvedChartMargin = useMemo(() => resolveChartMargin(margin), [margin?.top, margin?.right, margin?.bottom, margin?.left]);
|
|
54
89
|
const TOOLTIP_EDGE_MARGIN = 8;
|
|
55
|
-
const
|
|
90
|
+
const TOOLTIP_OFFSET = 10;
|
|
91
|
+
const tooltipWrapperRef = useRef(null);
|
|
92
|
+
const rawTooltipCoordinateRef = useRef(null);
|
|
93
|
+
const tooltipSizeRef = useRef({
|
|
94
|
+
width: 0,
|
|
95
|
+
height: 0,
|
|
96
|
+
});
|
|
97
|
+
const tooltipResizeObserverRef = useRef(null);
|
|
98
|
+
const tooltipResizeObservedTargetRef = useRef(null);
|
|
99
|
+
const applyTooltipPosition = () => {
|
|
56
100
|
const wrapper = tooltipWrapperRef.current;
|
|
57
|
-
const
|
|
58
|
-
const
|
|
59
|
-
if (!wrapper || !
|
|
101
|
+
const coord = rawTooltipCoordinateRef.current;
|
|
102
|
+
const chartWrapper = rootRef.current?.querySelector('.recharts-wrapper');
|
|
103
|
+
if (!wrapper || !coord || !(chartWrapper instanceof HTMLElement))
|
|
60
104
|
return;
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
setTransform(wrapper, raw.x, raw.y);
|
|
66
|
-
if (opts?.skipBoundaryAdjust || !root)
|
|
105
|
+
const measured = wrapper.getBoundingClientRect();
|
|
106
|
+
const width = tooltipSizeRef.current.width || measured.width;
|
|
107
|
+
const height = tooltipSizeRef.current.height || measured.height;
|
|
108
|
+
if (width <= 0 || height <= 0)
|
|
67
109
|
return;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
let x = rCoord.x;
|
|
77
|
-
const y = rCoord.y;
|
|
78
|
-
w.style.transform = `translate(${x}px, ${y}px)`;
|
|
79
|
-
let tooltipRect = w.getBoundingClientRect();
|
|
80
|
-
if (tooltipRect.right > rightBound - TOOLTIP_EDGE_MARGIN) {
|
|
81
|
-
x -= tooltipRect.right - rightBound + TOOLTIP_EDGE_MARGIN;
|
|
82
|
-
}
|
|
83
|
-
const leftBound = Math.max(rootRect.left, 0);
|
|
84
|
-
w.style.transform = `translate(${x}px, ${y}px)`;
|
|
85
|
-
tooltipRect = w.getBoundingClientRect();
|
|
86
|
-
if (tooltipRect.left < leftBound + TOOLTIP_EDGE_MARGIN) {
|
|
87
|
-
x += leftBound + TOOLTIP_EDGE_MARGIN - tooltipRect.left;
|
|
88
|
-
}
|
|
89
|
-
setTransform(w, x, y);
|
|
90
|
-
};
|
|
91
|
-
requestAnimationFrame(() => {
|
|
92
|
-
requestAnimationFrame(runAdjust);
|
|
110
|
+
tooltipSizeRef.current = { width, height };
|
|
111
|
+
const next = clampTooltipTranslate({
|
|
112
|
+
coordinate: coord,
|
|
113
|
+
viewBox: getPlotViewBox(chartWrapper, resolvedChartMargin),
|
|
114
|
+
tooltipWidth: width,
|
|
115
|
+
tooltipHeight: height,
|
|
116
|
+
offset: TOOLTIP_OFFSET,
|
|
117
|
+
edgeMargin: TOOLTIP_EDGE_MARGIN,
|
|
93
118
|
});
|
|
119
|
+
wrapper.style.transform = `translate(${next.x}px, ${next.y}px)`;
|
|
94
120
|
};
|
|
95
121
|
const applyTooltipPositionRef = useRef(applyTooltipPosition);
|
|
96
122
|
applyTooltipPositionRef.current = applyTooltipPosition;
|
|
97
|
-
//
|
|
123
|
+
// Own only the final transform: smooth follow cursor, but clamp in local chart coords first.
|
|
98
124
|
useEffect(() => {
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
if (tooltipWrapperRef.current && pos) {
|
|
103
|
-
const currentTransform = tooltipWrapperRef.current.style.transform;
|
|
104
|
-
const expectedTransform = `translate(${pos.x}px, ${pos.y}px)`;
|
|
105
|
-
// Always restore position if transform is missing, reset, or doesn't match expected position
|
|
106
|
-
// This prevents Recharts from resetting the tooltip position
|
|
107
|
-
if (!currentTransform ||
|
|
108
|
-
currentTransform === 'none' ||
|
|
109
|
-
currentTransform === 'translate(0px, 0px)' ||
|
|
110
|
-
currentTransform !== expectedTransform) {
|
|
111
|
-
tooltipWrapperRef.current.style.transform = expectedTransform;
|
|
112
|
-
}
|
|
125
|
+
const connectResizeObserver = (wrapperEl) => {
|
|
126
|
+
if (tooltipResizeObservedTargetRef.current === wrapperEl) {
|
|
127
|
+
return;
|
|
113
128
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
wrapper.style.opacity = isActive ? '1' : '0';
|
|
128
|
-
wrapper.style.pointerEvents = isActive ? 'auto' : 'none';
|
|
129
|
-
// Always restore position if we have a last position
|
|
130
|
-
restorePosition();
|
|
131
|
-
if (rawTooltipCoordinateRef.current) {
|
|
132
|
-
applyTooltipPositionRef.current();
|
|
133
|
-
}
|
|
129
|
+
const resizeRo = tooltipResizeObserverRef.current ??
|
|
130
|
+
new ResizeObserver(entries => {
|
|
131
|
+
const entry = entries[0];
|
|
132
|
+
if (!entry)
|
|
133
|
+
return;
|
|
134
|
+
const { width, height } = entry.contentRect;
|
|
135
|
+
tooltipSizeRef.current = { width, height };
|
|
136
|
+
requestAnimationFrame(() => applyTooltipPositionRef.current());
|
|
137
|
+
});
|
|
138
|
+
tooltipResizeObserverRef.current = resizeRo;
|
|
139
|
+
const prevObserved = tooltipResizeObservedTargetRef.current;
|
|
140
|
+
if (prevObserved && prevObserved !== wrapperEl) {
|
|
141
|
+
resizeRo.unobserve(prevObserved);
|
|
134
142
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
143
|
+
tooltipResizeObservedTargetRef.current = wrapperEl;
|
|
144
|
+
resizeRo.observe(wrapperEl);
|
|
145
|
+
if (wrapperEl instanceof HTMLElement) {
|
|
146
|
+
tooltipWrapperRef.current = wrapperEl;
|
|
147
|
+
wrapperEl.style.transition =
|
|
148
|
+
'transform 0.2s ease-out, opacity 0.2s ease-out';
|
|
149
|
+
requestAnimationFrame(() => applyTooltipPositionRef.current());
|
|
140
150
|
}
|
|
141
151
|
};
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
152
|
+
const tryObserveTooltipWrapper = () => {
|
|
153
|
+
const wrapper = rootRef.current?.querySelector('.recharts-tooltip-wrapper');
|
|
154
|
+
if (wrapper)
|
|
155
|
+
connectResizeObserver(wrapper);
|
|
156
|
+
};
|
|
157
|
+
tryObserveTooltipWrapper();
|
|
145
158
|
const observer = new MutationObserver(mutations => {
|
|
146
|
-
|
|
147
|
-
// If transform attribute changed, restore position if needed
|
|
159
|
+
tryObserveTooltipWrapper();
|
|
148
160
|
mutations.forEach(mutation => {
|
|
149
161
|
if (mutation.type === 'attributes' &&
|
|
150
162
|
mutation.attributeName === 'style' &&
|
|
151
163
|
mutation.target === tooltipWrapperRef.current) {
|
|
152
|
-
|
|
164
|
+
requestAnimationFrame(() => applyTooltipPositionRef.current());
|
|
153
165
|
}
|
|
154
166
|
});
|
|
155
167
|
});
|
|
@@ -161,27 +173,17 @@ const BaseChartWrapperContent = forwardRef((props, ref) => {
|
|
|
161
173
|
attributeFilter: ['style'],
|
|
162
174
|
});
|
|
163
175
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
let rafId = null;
|
|
167
|
-
const monitorPosition = () => {
|
|
168
|
-
if (rawTooltipCoordinateRef.current && tooltipWrapperRef.current) {
|
|
169
|
-
restorePosition();
|
|
170
|
-
rafId = requestAnimationFrame(monitorPosition);
|
|
171
|
-
}
|
|
172
|
-
else {
|
|
173
|
-
rafId = null;
|
|
174
|
-
}
|
|
176
|
+
const onWinResize = () => {
|
|
177
|
+
requestAnimationFrame(() => applyTooltipPositionRef.current());
|
|
175
178
|
};
|
|
176
|
-
|
|
177
|
-
if (rawTooltipCoordinateRef.current) {
|
|
178
|
-
rafId = requestAnimationFrame(monitorPosition);
|
|
179
|
-
}
|
|
179
|
+
window.addEventListener('resize', onWinResize);
|
|
180
180
|
return () => {
|
|
181
181
|
observer.disconnect();
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
182
|
+
tooltipResizeObserverRef.current?.disconnect();
|
|
183
|
+
tooltipResizeObserverRef.current = null;
|
|
184
|
+
tooltipResizeObservedTargetRef.current = null;
|
|
185
|
+
tooltipWrapperRef.current = null;
|
|
186
|
+
window.removeEventListener('resize', onWinResize);
|
|
185
187
|
};
|
|
186
188
|
}, []);
|
|
187
189
|
const renderTooltipContent = (props) => {
|
|
@@ -191,73 +193,18 @@ const BaseChartWrapperContent = forwardRef((props, ref) => {
|
|
|
191
193
|
// If no valid payload items, render ChartTooltipContent with active=false and empty payload
|
|
192
194
|
// This allows ChartTooltipContent to clear its lastTooltipData state
|
|
193
195
|
if (!filteredPayload || filteredPayload.length === 0) {
|
|
194
|
-
// Update refs to reflect inactive state
|
|
195
|
-
if (isTooltipActiveRef.current) {
|
|
196
|
-
isTooltipActiveRef.current = false;
|
|
197
|
-
// Always maintain last position when tooltip becomes inactive
|
|
198
|
-
const pos = lastAppliedTooltipPositionRef.current ??
|
|
199
|
-
rawTooltipCoordinateRef.current;
|
|
200
|
-
if (pos && tooltipWrapperRef.current) {
|
|
201
|
-
const wrapper = tooltipWrapperRef.current;
|
|
202
|
-
wrapper.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
|
|
203
|
-
wrapper.style.visibility = 'visible';
|
|
204
|
-
wrapper.style.opacity = '0';
|
|
205
|
-
wrapper.style.pointerEvents = 'none';
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
// Render ChartTooltipContent with active=false and empty payload to trigger cleanup
|
|
209
|
-
// This ensures lastTooltipData is cleared when there's no valid data
|
|
210
196
|
return (jsx(ChartTooltipContent, { active: false, payload: [], label: props.label, labelFormatter: labelFormatter || defaultLabelFormatter, indicator: "dot" }));
|
|
211
197
|
}
|
|
212
|
-
// Store tooltip data in ref (not state) to avoid re-render
|
|
213
|
-
// Use filtered payload instead of original props.payload
|
|
214
|
-
activeDataRef.current = {
|
|
215
|
-
...props,
|
|
216
|
-
payload: filteredPayload,
|
|
217
|
-
};
|
|
218
|
-
const wasActive = isTooltipActiveRef.current;
|
|
219
198
|
const isActive = props.active === true;
|
|
220
|
-
// When tooltip becomes active and has coordinate, update last position
|
|
221
199
|
if (isActive && props.coordinate) {
|
|
222
|
-
|
|
200
|
+
rawTooltipCoordinateRef.current = {
|
|
223
201
|
x: props.coordinate.x,
|
|
224
202
|
y: props.coordinate.y,
|
|
225
203
|
};
|
|
226
|
-
|
|
227
|
-
isTooltipActiveRef.current = true;
|
|
228
|
-
if (tooltipWrapperRef.current) {
|
|
229
|
-
tooltipWrapperRef.current.style.visibility = 'visible';
|
|
230
|
-
tooltipWrapperRef.current.style.opacity = '1';
|
|
231
|
-
tooltipWrapperRef.current.style.pointerEvents = 'auto';
|
|
232
|
-
applyTooltipPosition();
|
|
233
|
-
}
|
|
204
|
+
requestAnimationFrame(() => applyTooltipPositionRef.current());
|
|
234
205
|
}
|
|
235
|
-
else
|
|
236
|
-
|
|
237
|
-
// Always maintain last position when tooltip becomes inactive
|
|
238
|
-
const pos = lastAppliedTooltipPositionRef.current ??
|
|
239
|
-
rawTooltipCoordinateRef.current;
|
|
240
|
-
if (pos && tooltipWrapperRef.current) {
|
|
241
|
-
const wrapper = tooltipWrapperRef.current;
|
|
242
|
-
wrapper.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
|
|
243
|
-
// Keep visibility visible but hide with opacity transition
|
|
244
|
-
wrapper.style.visibility = 'visible';
|
|
245
|
-
wrapper.style.opacity = '0';
|
|
246
|
-
wrapper.style.pointerEvents = 'none';
|
|
247
|
-
}
|
|
248
|
-
}
|
|
249
|
-
else if (!isActive && !wasActive) {
|
|
250
|
-
// Ensure opacity is 0 when inactive, but keep visibility visible and maintain position
|
|
251
|
-
if (tooltipWrapperRef.current) {
|
|
252
|
-
tooltipWrapperRef.current.style.visibility = 'visible';
|
|
253
|
-
tooltipWrapperRef.current.style.opacity = '0';
|
|
254
|
-
tooltipWrapperRef.current.style.pointerEvents = 'none';
|
|
255
|
-
const pos = lastAppliedTooltipPositionRef.current ??
|
|
256
|
-
rawTooltipCoordinateRef.current;
|
|
257
|
-
if (pos) {
|
|
258
|
-
tooltipWrapperRef.current.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
|
|
259
|
-
}
|
|
260
|
-
}
|
|
206
|
+
else {
|
|
207
|
+
rawTooltipCoordinateRef.current = null;
|
|
261
208
|
}
|
|
262
209
|
return (jsx(ChartTooltipContent, { active: props.active, payload: filteredPayload, label: props.label, labelFormatter: labelFormatter || defaultLabelFormatter, indicator: "dot" }));
|
|
263
210
|
};
|
|
@@ -385,7 +332,7 @@ const BaseChartWrapperContent = forwardRef((props, ref) => {
|
|
|
385
332
|
}
|
|
386
333
|
const ChartComponent = chartType === 'line' ? LineChart : ComposedChart;
|
|
387
334
|
const defaultLabelFormatter = (v) => formatDateFn(v, true);
|
|
388
|
-
return (jsxs("div", { className: cn(S.root, !showLegend && S.noLegend, !showChartAxesLegend && S.hideChartAxesLegend, isLoaded && S.loaded, className), ref: setRefs, children: [loading && (jsx("div", { className: S.loadingOverlay, children: jsx(Skeleton, {}) })), showGrid && (jsx(ChartContainer, { config: chartConfig, className: cn(S.gridLayer, chartClassName), style: height ? { height: `${height}px` } : undefined, children: jsxs(ChartComponent, { data: chartData, margin: margin, children: [jsx(ChartGrid, {}), showAxes && (jsx(ChartAxes, { formatDate: formatDateFn, formatNumber: formatNumber, xAxisClassName: xAxisClassName, yAxisClassName: yAxisClassName, xAxisLabel: xAxisLabel, yAxisLabel: yAxisLabel, xMin: xMin, xMax: xMax, yMin: yMin, yMax: yMax, autoScaleYAxis: effectiveAutoScale }))] }) })), jsx(ChartContainer, { config: chartConfig, className: cn(S.chartLayer, chartClassName), style: height ? { height: `${height}px` } : undefined, ...containerProps, children: jsxs(ChartComponent, { data: chartData, margin: margin, children: [showAxes && (jsx(ChartAxes, { formatDate: formatDateFn, formatNumber: formatNumber, xAxisClassName: cn(xAxisClassName), yAxisClassName: cn(yAxisClassName), xAxisLabel: xAxisLabel, yAxisLabel: yAxisLabel, xMin: xMin, xMax: xMax, yMin: yMin, yMax: yMax, autoScaleYAxis: effectiveAutoScale })), quantileBands?.[0] && (jsx(QuantileBands, { hiddenBands: hiddenSeries, quantileBandKey: quantileBandKey, animate: true, animationDuration: 150, animationBegin: 0, customBands: quantileBands, showLegend: showLegend })), jsx(ChartLines, { historicalLineColor: historicalLineColor, chartData: chartData, forecastData: forecastData, hiddenSeries: hiddenSeries, isDarkTheme: isDarkTheme, shouldAnimate: shouldAnimate, showLegend: showLegend, forecastLineStyle: forecastLineStyle }), showTooltip && (jsx("div", { children: jsx(ChartTooltip, { cursor: false, content: renderTooltipContent }) }))] }) }), overlayElements, jsxs("div", { className: cn(S.footer, footerClassName), children: [showLegend &&
|
|
335
|
+
return (jsxs("div", { className: cn(S.root, !showLegend && S.noLegend, !showChartAxesLegend && S.hideChartAxesLegend, isLoaded && S.loaded, className), ref: setRefs, children: [loading && (jsx("div", { className: S.loadingOverlay, children: jsx(Skeleton, {}) })), showGrid && (jsx(ChartContainer, { config: chartConfig, className: cn(S.gridLayer, chartClassName), style: height ? { height: `${height}px` } : undefined, children: jsxs(ChartComponent, { data: chartData, margin: margin, children: [jsx(ChartGrid, {}), showAxes && (jsx(ChartAxes, { formatDate: formatDateFn, formatNumber: formatNumber, xAxisClassName: xAxisClassName, yAxisClassName: yAxisClassName, xAxisLabel: xAxisLabel, yAxisLabel: yAxisLabel, xMin: xMin, xMax: xMax, yMin: yMin, yMax: yMax, autoScaleYAxis: effectiveAutoScale }))] }) })), jsx(ChartContainer, { config: chartConfig, className: cn(S.chartLayer, chartClassName), style: height ? { height: `${height}px` } : undefined, ...containerProps, children: jsxs(ChartComponent, { data: chartData, margin: margin, children: [showAxes && (jsx(ChartAxes, { formatDate: formatDateFn, formatNumber: formatNumber, xAxisClassName: cn(xAxisClassName), yAxisClassName: cn(yAxisClassName), xAxisLabel: xAxisLabel, yAxisLabel: yAxisLabel, xMin: xMin, xMax: xMax, yMin: yMin, yMax: yMax, autoScaleYAxis: effectiveAutoScale })), quantileBands?.[0] && (jsx(QuantileBands, { hiddenBands: hiddenSeries, quantileBandKey: quantileBandKey, animate: true, animationDuration: 150, animationBegin: 0, customBands: quantileBands, showLegend: showLegend })), jsx(ChartLines, { historicalLineColor: historicalLineColor, chartData: chartData, forecastData: forecastData, hiddenSeries: hiddenSeries, isDarkTheme: isDarkTheme, shouldAnimate: shouldAnimate, showLegend: showLegend, forecastLineStyle: forecastLineStyle }), showTooltip && (jsx("div", { children: jsx(ChartTooltip, { cursor: false, offset: TOOLTIP_OFFSET, allowEscapeViewBox: { x: false, y: false }, content: renderTooltipContent }) }))] }) }), overlayElements, jsxs("div", { className: cn(S.footer, footerClassName), children: [showLegend &&
|
|
389
336
|
(legendVariant === 'svg' ? (jsx(LegendSvg, { payload: legendPayload.map(p => ({
|
|
390
337
|
value: p.value,
|
|
391
338
|
color: p.color,
|
|
@@ -1,4 +1,104 @@
|
|
|
1
1
|
// Helper function to format large numbers with k/m abbreviations
|
|
2
|
+
const timeRangeToMonths = {
|
|
3
|
+
'6m': 6,
|
|
4
|
+
'1y': 12,
|
|
5
|
+
'3y': 36,
|
|
6
|
+
'5y': 60,
|
|
7
|
+
All: 12,
|
|
8
|
+
};
|
|
9
|
+
function isPlottableNumber(value) {
|
|
10
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
11
|
+
}
|
|
12
|
+
/** Any row the chart can draw a number for (excludes date-only or empty rows) */
|
|
13
|
+
function hasPlottableChartSeriesValue(item) {
|
|
14
|
+
for (const [key, v] of Object.entries(item)) {
|
|
15
|
+
if (key === 'date')
|
|
16
|
+
continue;
|
|
17
|
+
if (isPlottableNumber(v))
|
|
18
|
+
return true;
|
|
19
|
+
if (Array.isArray(v) && v.some(x => isPlottableNumber(x))) {
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Row counts toward the "end" of the window when anchoring the time range to a
|
|
27
|
+
* selected forecast (shared historical + that analysis line / quantiles).
|
|
28
|
+
*/
|
|
29
|
+
function rowContributesToAnchoredTimeRange(item, analysisId) {
|
|
30
|
+
if (isPlottableNumber(item.historical)) {
|
|
31
|
+
return true;
|
|
32
|
+
}
|
|
33
|
+
const forecastKey = `forecast_${analysisId}`;
|
|
34
|
+
if (isPlottableNumber(item[forecastKey])) {
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
for (const key of Object.keys(item)) {
|
|
38
|
+
if (key.startsWith('q') && key.endsWith(`_${analysisId}`)) {
|
|
39
|
+
if (isPlottableNumber(item[key])) {
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
function computeLatestPlottableDate(data, options) {
|
|
47
|
+
const anchorId = options?.endDateAnchorAnalysisId;
|
|
48
|
+
const pick = (item, useAnchor) => {
|
|
49
|
+
if (!item.date)
|
|
50
|
+
return null;
|
|
51
|
+
if (useAnchor && anchorId != null) {
|
|
52
|
+
if (!rowContributesToAnchoredTimeRange(item, anchorId)) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
else if (!hasPlottableChartSeriesValue(item)) {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
return new Date(item.date);
|
|
60
|
+
};
|
|
61
|
+
let latest = null;
|
|
62
|
+
for (const item of data) {
|
|
63
|
+
const d = pick(item, true);
|
|
64
|
+
if (d && (!latest || d > latest)) {
|
|
65
|
+
latest = d;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (latest == null && anchorId != null) {
|
|
69
|
+
for (const item of data) {
|
|
70
|
+
const d = pick(item, false);
|
|
71
|
+
if (d && (!latest || d > latest)) {
|
|
72
|
+
latest = d;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return latest;
|
|
77
|
+
}
|
|
78
|
+
const filterDataForTimeRange = (data, currentTimeRange, options) => {
|
|
79
|
+
if (currentTimeRange === 'All')
|
|
80
|
+
return data;
|
|
81
|
+
const latestDate = computeLatestPlottableDate(data, options);
|
|
82
|
+
// Pre-compute start date based on latest date in data
|
|
83
|
+
let startDate = null;
|
|
84
|
+
if (latestDate) {
|
|
85
|
+
const monthsToSubtract = timeRangeToMonths[currentTimeRange] || timeRangeToMonths.All;
|
|
86
|
+
startDate = new Date(latestDate);
|
|
87
|
+
startDate.setMonth(startDate.getMonth() - monthsToSubtract);
|
|
88
|
+
}
|
|
89
|
+
// Slice by date for every row. Rows with forecast_* keys must not bypass the
|
|
90
|
+
// window (e.g. spaghetti plots), or the X range stays stuck at full history.
|
|
91
|
+
const filteredData = data.filter(item => {
|
|
92
|
+
if (!item.date)
|
|
93
|
+
return false;
|
|
94
|
+
if (startDate) {
|
|
95
|
+
const date = new Date(item.date);
|
|
96
|
+
return date >= startDate;
|
|
97
|
+
}
|
|
98
|
+
return true;
|
|
99
|
+
});
|
|
100
|
+
return filteredData;
|
|
101
|
+
};
|
|
2
102
|
const shortDateFormatter = (value) => {
|
|
3
103
|
return new Date(value).toLocaleDateString('en-US', {
|
|
4
104
|
month: 'short',
|
|
@@ -12,4 +112,4 @@ const longDateFormatter = (value) => {
|
|
|
12
112
|
});
|
|
13
113
|
};
|
|
14
114
|
|
|
15
|
-
export { longDateFormatter, shortDateFormatter };
|
|
115
|
+
export { filterDataForTimeRange, longDateFormatter, shortDateFormatter };
|
|
@@ -5,7 +5,7 @@ import { BaseChartWrapper } from '../Chart/components/BaseChartWrapper.js';
|
|
|
5
5
|
import { InteractionOverlay } from '../InteractionOverlay/InteractionOverlay.js';
|
|
6
6
|
import { TimeRangeControls } from '../TimeRangeControls/TimeRangeControls.js';
|
|
7
7
|
import { ensureChartForecastBridge } from '../../../utils/chartConnectionPoint.js';
|
|
8
|
-
import { shortDateFormatter, longDateFormatter } from './ChartAreaInteractive.helpers.js';
|
|
8
|
+
import { filterDataForTimeRange, shortDateFormatter, longDateFormatter } from './ChartAreaInteractive.helpers.js';
|
|
9
9
|
import S from './ChartAreaInteractive.styl.js';
|
|
10
10
|
import { PinOverlay } from './overlays/PinOverlay/PinOverlay.js';
|
|
11
11
|
import { IntervalsOverlay } from './overlays/IntervalsOverlay/IntervalsOverlay.js';
|
|
@@ -37,11 +37,19 @@ function ChartAreaInteractive({ className, chartContainerClassName, legendClassN
|
|
|
37
37
|
ensureAnalysisSeriesVisible(selectedAnalysisId);
|
|
38
38
|
}
|
|
39
39
|
}, [selectedAnalysisId, ensureAnalysisSeriesVisible]);
|
|
40
|
+
const timeFilteredChartData = useMemo(() => {
|
|
41
|
+
const raw = selectedAnalysisId ?? selectedForecast?.id ?? null;
|
|
42
|
+
const anchorId = raw == null ? null : typeof raw === 'number' ? raw : Number(raw);
|
|
43
|
+
const opts = anchorId != null && Number.isFinite(anchorId)
|
|
44
|
+
? { endDateAnchorAnalysisId: anchorId }
|
|
45
|
+
: undefined;
|
|
46
|
+
return filterDataForTimeRange(chartData, timeRange, opts);
|
|
47
|
+
}, [chartData, timeRange, selectedAnalysisId, selectedForecast?.id]);
|
|
40
48
|
const bridgedChartData = useMemo(() => disableForecastHistoricalBridge
|
|
41
|
-
?
|
|
42
|
-
: ensureChartForecastBridge(
|
|
49
|
+
? timeFilteredChartData
|
|
50
|
+
: ensureChartForecastBridge(timeFilteredChartData, {
|
|
43
51
|
forecastSeriesIds: forecastData?.map(f => f.id),
|
|
44
|
-
}), [
|
|
52
|
+
}), [timeFilteredChartData, disableForecastHistoricalBridge, forecastData]);
|
|
45
53
|
// Extract quantileBands from restProps
|
|
46
54
|
// const quantileBands = (restProps as any).quantileBands;
|
|
47
55
|
const getLoadingMessage = () => {
|
package/dist/esm/types/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.helpers.d.ts
CHANGED
|
@@ -9,7 +9,12 @@ declare const timeRangeToMonths: {
|
|
|
9
9
|
readonly All: 12;
|
|
10
10
|
};
|
|
11
11
|
export type TimeRange = keyof typeof timeRangeToMonths;
|
|
12
|
-
export
|
|
12
|
+
export type FilterDataForTimeRangeOptions = {
|
|
13
|
+
/** When set (e.g. selected forecast on Forecast tab), the window ends at the
|
|
14
|
+
* latest point that has shared historical or that analysis — not at another run. */
|
|
15
|
+
endDateAnchorAnalysisId?: number | null;
|
|
16
|
+
};
|
|
17
|
+
export declare const filterDataForTimeRange: (data: ChartDataPoint[], currentTimeRange: TimeRange, options?: FilterDataForTimeRangeOptions) => ChartDataPoint[];
|
|
13
18
|
export declare const shortDateFormatter: (value: string) => string;
|
|
14
19
|
export declare const longDateFormatter: (value: string) => string;
|
|
15
20
|
/**
|
package/package.json
CHANGED
|
@@ -57,10 +57,9 @@
|
|
|
57
57
|
position relative
|
|
58
58
|
z-index 1
|
|
59
59
|
|
|
60
|
-
// Tooltip
|
|
60
|
+
// Tooltip portal positions with absolute + transform; do not override with relative.
|
|
61
61
|
.recharts-tooltip-wrapper
|
|
62
62
|
z-index 3 !important
|
|
63
|
-
position relative
|
|
64
63
|
|
|
65
64
|
// Active dots and cursor
|
|
66
65
|
.recharts-active-dot
|
|
@@ -75,8 +74,12 @@
|
|
|
75
74
|
// Tooltip styles
|
|
76
75
|
.tooltipContainer
|
|
77
76
|
display grid
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
box-sizing border-box
|
|
78
|
+
width max-content
|
|
79
|
+
max-width 100%
|
|
80
|
+
min-width 0
|
|
81
|
+
overflow-wrap break-word
|
|
82
|
+
word-break break-word
|
|
80
83
|
align-items start
|
|
81
84
|
gap 0.375rem /* gap-1.5 */
|
|
82
85
|
padding 0.375rem 0.625rem /* py-1.5 px-2.5 */
|
|
@@ -35,6 +35,78 @@ import { ChartAxes } from './ChartAxes';
|
|
|
35
35
|
import { ChartGrid } from './ChartGrid';
|
|
36
36
|
import { LegendSvg } from './LegendSvg/LegendSvg';
|
|
37
37
|
|
|
38
|
+
type ChartMargin = { top: number; right: number; bottom: number; left: number };
|
|
39
|
+
|
|
40
|
+
const DEFAULT_CHART_MARGIN: ChartMargin = {
|
|
41
|
+
top: 5,
|
|
42
|
+
right: 5,
|
|
43
|
+
bottom: 5,
|
|
44
|
+
left: 5,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
function resolveChartMargin(
|
|
48
|
+
margin: Partial<ChartMargin> | undefined,
|
|
49
|
+
): ChartMargin {
|
|
50
|
+
return {
|
|
51
|
+
top: margin?.top ?? DEFAULT_CHART_MARGIN.top,
|
|
52
|
+
right: margin?.right ?? DEFAULT_CHART_MARGIN.right,
|
|
53
|
+
bottom: margin?.bottom ?? DEFAULT_CHART_MARGIN.bottom,
|
|
54
|
+
left: margin?.left ?? DEFAULT_CHART_MARGIN.left,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Plot box inside `.recharts-wrapper`, same convention as Recharts cartesian viewBox. */
|
|
59
|
+
function getPlotViewBox(wrapper: HTMLElement, m: ChartMargin) {
|
|
60
|
+
const w = wrapper.clientWidth;
|
|
61
|
+
const h = wrapper.clientHeight;
|
|
62
|
+
return {
|
|
63
|
+
x: m.left,
|
|
64
|
+
y: m.top,
|
|
65
|
+
width: Math.max(0, w - m.left - m.right),
|
|
66
|
+
height: Math.max(0, h - m.top - m.bottom),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function clampTooltipTranslate(args: {
|
|
71
|
+
coordinate: { x: number; y: number };
|
|
72
|
+
viewBox: { x: number; y: number; width: number; height: number };
|
|
73
|
+
tooltipWidth: number;
|
|
74
|
+
tooltipHeight: number;
|
|
75
|
+
offset: number;
|
|
76
|
+
edgeMargin: number;
|
|
77
|
+
}): { x: number; y: number } {
|
|
78
|
+
const {
|
|
79
|
+
coordinate,
|
|
80
|
+
viewBox,
|
|
81
|
+
tooltipWidth: tw,
|
|
82
|
+
tooltipHeight: th,
|
|
83
|
+
offset,
|
|
84
|
+
edgeMargin,
|
|
85
|
+
} = args;
|
|
86
|
+
|
|
87
|
+
const minX = viewBox.x + edgeMargin;
|
|
88
|
+
const maxX = viewBox.x + viewBox.width - tw - edgeMargin;
|
|
89
|
+
const minY = viewBox.y + edgeMargin;
|
|
90
|
+
const maxY = viewBox.y + viewBox.height - th - edgeMargin;
|
|
91
|
+
|
|
92
|
+
const clamp = (v: number, lo: number, hi: number) =>
|
|
93
|
+
Math.min(Math.max(v, lo), Math.max(lo, hi));
|
|
94
|
+
|
|
95
|
+
let tx = coordinate.x + offset;
|
|
96
|
+
if (tx + tw > viewBox.x + viewBox.width - edgeMargin) {
|
|
97
|
+
tx = coordinate.x - tw - offset;
|
|
98
|
+
}
|
|
99
|
+
tx = clamp(tx, minX, maxX);
|
|
100
|
+
|
|
101
|
+
let ty = coordinate.y + offset;
|
|
102
|
+
if (ty + th > viewBox.y + viewBox.height - edgeMargin) {
|
|
103
|
+
ty = coordinate.y - th - offset;
|
|
104
|
+
}
|
|
105
|
+
ty = clamp(ty, minY, maxY);
|
|
106
|
+
|
|
107
|
+
return { x: tx, y: ty };
|
|
108
|
+
}
|
|
109
|
+
|
|
38
110
|
export interface BaseChartWrapperProps {
|
|
39
111
|
renderId?: string; // unique id for the render, used to identify the render in the render queue
|
|
40
112
|
chartConfig?: ChartConfig;
|
|
@@ -200,21 +272,10 @@ const BaseChartWrapperContent = forwardRef<
|
|
|
200
272
|
legendMarginLeft = 0,
|
|
201
273
|
} = props;
|
|
202
274
|
|
|
203
|
-
const activeDataRef = useRef<any>(null);
|
|
204
|
-
/** Raw cursor position from Recharts (tooltip anchor). */
|
|
205
|
-
const rawTooltipCoordinateRef = useRef<{ x: number; y: number } | null>(null);
|
|
206
|
-
/** Last transform actually applied after viewport/boundary adjustment. */
|
|
207
|
-
const lastAppliedTooltipPositionRef = useRef<{ x: number; y: number } | null>(
|
|
208
|
-
null,
|
|
209
|
-
);
|
|
210
|
-
const isTooltipActiveRef = useRef<boolean>(false);
|
|
211
|
-
|
|
212
|
-
// const [activeDotsData, setActiveDotsData] = useState<ActiveDot[]>([]);
|
|
213
275
|
const [shouldAnimate, setShouldAnimate] = useState(false);
|
|
214
276
|
|
|
215
277
|
const rootRef = useRef<HTMLDivElement>(null);
|
|
216
278
|
|
|
217
|
-
// Merge forwarded ref with internal rootRef using callback ref
|
|
218
279
|
const setRefs = (node: HTMLDivElement | null) => {
|
|
219
280
|
rootRef.current = node;
|
|
220
281
|
if (typeof ref === 'function') {
|
|
@@ -223,137 +284,105 @@ const BaseChartWrapperContent = forwardRef<
|
|
|
223
284
|
ref.current = node;
|
|
224
285
|
}
|
|
225
286
|
};
|
|
226
|
-
|
|
227
|
-
const
|
|
287
|
+
|
|
288
|
+
const resolvedChartMargin = useMemo(
|
|
289
|
+
() => resolveChartMargin(margin),
|
|
290
|
+
[margin?.top, margin?.right, margin?.bottom, margin?.left],
|
|
291
|
+
);
|
|
228
292
|
|
|
229
293
|
const TOOLTIP_EDGE_MARGIN = 8;
|
|
294
|
+
const TOOLTIP_OFFSET = 10;
|
|
230
295
|
|
|
231
|
-
const
|
|
296
|
+
const tooltipWrapperRef = useRef<HTMLElement | null>(null);
|
|
297
|
+
const rawTooltipCoordinateRef = useRef<{ x: number; y: number } | null>(null);
|
|
298
|
+
const tooltipSizeRef = useRef<{ width: number; height: number }>({
|
|
299
|
+
width: 0,
|
|
300
|
+
height: 0,
|
|
301
|
+
});
|
|
302
|
+
const tooltipResizeObserverRef = useRef<ResizeObserver | null>(null);
|
|
303
|
+
const tooltipResizeObservedTargetRef = useRef<Element | null>(null);
|
|
304
|
+
|
|
305
|
+
const applyTooltipPosition = () => {
|
|
232
306
|
const wrapper = tooltipWrapperRef.current;
|
|
233
|
-
const
|
|
234
|
-
const
|
|
235
|
-
if (!wrapper || !raw) return;
|
|
307
|
+
const coord = rawTooltipCoordinateRef.current;
|
|
308
|
+
const chartWrapper = rootRef.current?.querySelector('.recharts-wrapper');
|
|
236
309
|
|
|
237
|
-
|
|
238
|
-
el.style.transform = `translate(${x}px, ${y}px)`;
|
|
239
|
-
lastAppliedTooltipPositionRef.current = { x, y };
|
|
240
|
-
};
|
|
310
|
+
if (!wrapper || !coord || !(chartWrapper instanceof HTMLElement)) return;
|
|
241
311
|
|
|
242
|
-
|
|
312
|
+
const measured = wrapper.getBoundingClientRect();
|
|
313
|
+
const width = tooltipSizeRef.current.width || measured.width;
|
|
314
|
+
const height = tooltipSizeRef.current.height || measured.height;
|
|
243
315
|
|
|
244
|
-
if (
|
|
316
|
+
if (width <= 0 || height <= 0) return;
|
|
245
317
|
|
|
246
|
-
|
|
247
|
-
const w = tooltipWrapperRef.current;
|
|
248
|
-
const rEl = rootRef.current;
|
|
249
|
-
const rCoord = rawTooltipCoordinateRef.current;
|
|
250
|
-
if (!w || !rEl || !rCoord) return;
|
|
251
|
-
|
|
252
|
-
const rootRect = rEl.getBoundingClientRect();
|
|
253
|
-
const rightBound = Math.min(rootRect.right, window.innerWidth);
|
|
254
|
-
let x = rCoord.x;
|
|
255
|
-
const y = rCoord.y;
|
|
256
|
-
|
|
257
|
-
w.style.transform = `translate(${x}px, ${y}px)`;
|
|
258
|
-
let tooltipRect = w.getBoundingClientRect();
|
|
259
|
-
|
|
260
|
-
if (tooltipRect.right > rightBound - TOOLTIP_EDGE_MARGIN) {
|
|
261
|
-
x -= tooltipRect.right - rightBound + TOOLTIP_EDGE_MARGIN;
|
|
262
|
-
}
|
|
318
|
+
tooltipSizeRef.current = { width, height };
|
|
263
319
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
setTransform(w, x, y);
|
|
273
|
-
};
|
|
274
|
-
|
|
275
|
-
requestAnimationFrame(() => {
|
|
276
|
-
requestAnimationFrame(runAdjust);
|
|
320
|
+
const next = clampTooltipTranslate({
|
|
321
|
+
coordinate: coord,
|
|
322
|
+
viewBox: getPlotViewBox(chartWrapper, resolvedChartMargin),
|
|
323
|
+
tooltipWidth: width,
|
|
324
|
+
tooltipHeight: height,
|
|
325
|
+
offset: TOOLTIP_OFFSET,
|
|
326
|
+
edgeMargin: TOOLTIP_EDGE_MARGIN,
|
|
277
327
|
});
|
|
328
|
+
|
|
329
|
+
wrapper.style.transform = `translate(${next.x}px, ${next.y}px)`;
|
|
278
330
|
};
|
|
279
331
|
|
|
280
332
|
const applyTooltipPositionRef = useRef(applyTooltipPosition);
|
|
281
333
|
applyTooltipPositionRef.current = applyTooltipPosition;
|
|
282
334
|
|
|
283
|
-
//
|
|
335
|
+
// Own only the final transform: smooth follow cursor, but clamp in local chart coords first.
|
|
284
336
|
useEffect(() => {
|
|
285
|
-
const
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
rawTooltipCoordinateRef.current;
|
|
289
|
-
if (tooltipWrapperRef.current && pos) {
|
|
290
|
-
const currentTransform = tooltipWrapperRef.current.style.transform;
|
|
291
|
-
const expectedTransform = `translate(${pos.x}px, ${pos.y}px)`;
|
|
292
|
-
|
|
293
|
-
// Always restore position if transform is missing, reset, or doesn't match expected position
|
|
294
|
-
// This prevents Recharts from resetting the tooltip position
|
|
295
|
-
if (
|
|
296
|
-
!currentTransform ||
|
|
297
|
-
currentTransform === 'none' ||
|
|
298
|
-
currentTransform === 'translate(0px, 0px)' ||
|
|
299
|
-
currentTransform !== expectedTransform
|
|
300
|
-
) {
|
|
301
|
-
tooltipWrapperRef.current.style.transform = expectedTransform;
|
|
302
|
-
}
|
|
337
|
+
const connectResizeObserver = (wrapperEl: Element) => {
|
|
338
|
+
if (tooltipResizeObservedTargetRef.current === wrapperEl) {
|
|
339
|
+
return;
|
|
303
340
|
}
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
341
|
+
const resizeRo =
|
|
342
|
+
tooltipResizeObserverRef.current ??
|
|
343
|
+
new ResizeObserver(entries => {
|
|
344
|
+
const entry = entries[0];
|
|
345
|
+
if (!entry) return;
|
|
346
|
+
const { width, height } = entry.contentRect;
|
|
347
|
+
tooltipSizeRef.current = { width, height };
|
|
348
|
+
requestAnimationFrame(() => applyTooltipPositionRef.current());
|
|
349
|
+
});
|
|
350
|
+
tooltipResizeObserverRef.current = resizeRo;
|
|
310
351
|
|
|
311
|
-
|
|
312
|
-
|
|
352
|
+
const prevObserved = tooltipResizeObservedTargetRef.current;
|
|
353
|
+
if (prevObserved && prevObserved !== wrapperEl) {
|
|
354
|
+
resizeRo.unobserve(prevObserved);
|
|
355
|
+
}
|
|
356
|
+
tooltipResizeObservedTargetRef.current = wrapperEl;
|
|
357
|
+
resizeRo.observe(wrapperEl);
|
|
313
358
|
|
|
314
|
-
|
|
315
|
-
|
|
359
|
+
if (wrapperEl instanceof HTMLElement) {
|
|
360
|
+
tooltipWrapperRef.current = wrapperEl;
|
|
361
|
+
wrapperEl.style.transition =
|
|
316
362
|
'transform 0.2s ease-out, opacity 0.2s ease-out';
|
|
317
|
-
|
|
318
|
-
// Override Recharts' visibility: hidden with visibility: visible
|
|
319
|
-
// We'll control visibility through opacity instead
|
|
320
|
-
wrapper.style.visibility = 'visible';
|
|
321
|
-
|
|
322
|
-
// Set initial opacity based on active state
|
|
323
|
-
const isActive = activeDataRef.current?.active === true;
|
|
324
|
-
wrapper.style.opacity = isActive ? '1' : '0';
|
|
325
|
-
wrapper.style.pointerEvents = isActive ? 'auto' : 'none';
|
|
326
|
-
|
|
327
|
-
// Always restore position if we have a last position
|
|
328
|
-
restorePosition();
|
|
329
|
-
if (rawTooltipCoordinateRef.current) {
|
|
330
|
-
applyTooltipPositionRef.current();
|
|
331
|
-
}
|
|
363
|
+
requestAnimationFrame(() => applyTooltipPositionRef.current());
|
|
332
364
|
}
|
|
365
|
+
};
|
|
333
366
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
}
|
|
367
|
+
const tryObserveTooltipWrapper = () => {
|
|
368
|
+
const wrapper = rootRef.current?.querySelector(
|
|
369
|
+
'.recharts-tooltip-wrapper',
|
|
370
|
+
);
|
|
371
|
+
if (wrapper) connectResizeObserver(wrapper);
|
|
340
372
|
};
|
|
341
373
|
|
|
342
|
-
|
|
343
|
-
findAndSetupTooltipWrapper();
|
|
374
|
+
tryObserveTooltipWrapper();
|
|
344
375
|
|
|
345
|
-
// Watch for tooltip wrapper changes and style mutations
|
|
346
376
|
const observer = new MutationObserver(mutations => {
|
|
347
|
-
|
|
377
|
+
tryObserveTooltipWrapper();
|
|
348
378
|
|
|
349
|
-
// If transform attribute changed, restore position if needed
|
|
350
379
|
mutations.forEach(mutation => {
|
|
351
380
|
if (
|
|
352
381
|
mutation.type === 'attributes' &&
|
|
353
382
|
mutation.attributeName === 'style' &&
|
|
354
383
|
mutation.target === tooltipWrapperRef.current
|
|
355
384
|
) {
|
|
356
|
-
|
|
385
|
+
requestAnimationFrame(() => applyTooltipPositionRef.current());
|
|
357
386
|
}
|
|
358
387
|
});
|
|
359
388
|
});
|
|
@@ -367,28 +396,18 @@ const BaseChartWrapperContent = forwardRef<
|
|
|
367
396
|
});
|
|
368
397
|
}
|
|
369
398
|
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
let rafId: number | null = null;
|
|
373
|
-
const monitorPosition = () => {
|
|
374
|
-
if (rawTooltipCoordinateRef.current && tooltipWrapperRef.current) {
|
|
375
|
-
restorePosition();
|
|
376
|
-
rafId = requestAnimationFrame(monitorPosition);
|
|
377
|
-
} else {
|
|
378
|
-
rafId = null;
|
|
379
|
-
}
|
|
399
|
+
const onWinResize = () => {
|
|
400
|
+
requestAnimationFrame(() => applyTooltipPositionRef.current());
|
|
380
401
|
};
|
|
381
|
-
|
|
382
|
-
// Start monitoring if we already have a position
|
|
383
|
-
if (rawTooltipCoordinateRef.current) {
|
|
384
|
-
rafId = requestAnimationFrame(monitorPosition);
|
|
385
|
-
}
|
|
402
|
+
window.addEventListener('resize', onWinResize);
|
|
386
403
|
|
|
387
404
|
return () => {
|
|
388
405
|
observer.disconnect();
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
406
|
+
tooltipResizeObserverRef.current?.disconnect();
|
|
407
|
+
tooltipResizeObserverRef.current = null;
|
|
408
|
+
tooltipResizeObservedTargetRef.current = null;
|
|
409
|
+
tooltipWrapperRef.current = null;
|
|
410
|
+
window.removeEventListener('resize', onWinResize);
|
|
392
411
|
};
|
|
393
412
|
}, []);
|
|
394
413
|
|
|
@@ -403,23 +422,6 @@ const BaseChartWrapperContent = forwardRef<
|
|
|
403
422
|
// If no valid payload items, render ChartTooltipContent with active=false and empty payload
|
|
404
423
|
// This allows ChartTooltipContent to clear its lastTooltipData state
|
|
405
424
|
if (!filteredPayload || filteredPayload.length === 0) {
|
|
406
|
-
// Update refs to reflect inactive state
|
|
407
|
-
if (isTooltipActiveRef.current) {
|
|
408
|
-
isTooltipActiveRef.current = false;
|
|
409
|
-
// Always maintain last position when tooltip becomes inactive
|
|
410
|
-
const pos =
|
|
411
|
-
lastAppliedTooltipPositionRef.current ??
|
|
412
|
-
rawTooltipCoordinateRef.current;
|
|
413
|
-
if (pos && tooltipWrapperRef.current) {
|
|
414
|
-
const wrapper = tooltipWrapperRef.current;
|
|
415
|
-
wrapper.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
|
|
416
|
-
wrapper.style.visibility = 'visible';
|
|
417
|
-
wrapper.style.opacity = '0';
|
|
418
|
-
wrapper.style.pointerEvents = 'none';
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
// Render ChartTooltipContent with active=false and empty payload to trigger cleanup
|
|
422
|
-
// This ensures lastTooltipData is cleared when there's no valid data
|
|
423
425
|
return (
|
|
424
426
|
<ChartTooltipContent
|
|
425
427
|
active={false}
|
|
@@ -431,60 +433,16 @@ const BaseChartWrapperContent = forwardRef<
|
|
|
431
433
|
);
|
|
432
434
|
}
|
|
433
435
|
|
|
434
|
-
// Store tooltip data in ref (not state) to avoid re-render
|
|
435
|
-
// Use filtered payload instead of original props.payload
|
|
436
|
-
activeDataRef.current = {
|
|
437
|
-
...props,
|
|
438
|
-
payload: filteredPayload,
|
|
439
|
-
};
|
|
440
|
-
|
|
441
|
-
const wasActive = isTooltipActiveRef.current;
|
|
442
436
|
const isActive = props.active === true;
|
|
443
437
|
|
|
444
|
-
// When tooltip becomes active and has coordinate, update last position
|
|
445
438
|
if (isActive && props.coordinate) {
|
|
446
|
-
|
|
439
|
+
rawTooltipCoordinateRef.current = {
|
|
447
440
|
x: props.coordinate.x,
|
|
448
441
|
y: props.coordinate.y,
|
|
449
442
|
};
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
if (tooltipWrapperRef.current) {
|
|
455
|
-
tooltipWrapperRef.current.style.visibility = 'visible';
|
|
456
|
-
tooltipWrapperRef.current.style.opacity = '1';
|
|
457
|
-
tooltipWrapperRef.current.style.pointerEvents = 'auto';
|
|
458
|
-
applyTooltipPosition();
|
|
459
|
-
}
|
|
460
|
-
} else if (!isActive && wasActive) {
|
|
461
|
-
isTooltipActiveRef.current = false;
|
|
462
|
-
|
|
463
|
-
// Always maintain last position when tooltip becomes inactive
|
|
464
|
-
const pos =
|
|
465
|
-
lastAppliedTooltipPositionRef.current ??
|
|
466
|
-
rawTooltipCoordinateRef.current;
|
|
467
|
-
if (pos && tooltipWrapperRef.current) {
|
|
468
|
-
const wrapper = tooltipWrapperRef.current;
|
|
469
|
-
wrapper.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
|
|
470
|
-
// Keep visibility visible but hide with opacity transition
|
|
471
|
-
wrapper.style.visibility = 'visible';
|
|
472
|
-
wrapper.style.opacity = '0';
|
|
473
|
-
wrapper.style.pointerEvents = 'none';
|
|
474
|
-
}
|
|
475
|
-
} else if (!isActive && !wasActive) {
|
|
476
|
-
// Ensure opacity is 0 when inactive, but keep visibility visible and maintain position
|
|
477
|
-
if (tooltipWrapperRef.current) {
|
|
478
|
-
tooltipWrapperRef.current.style.visibility = 'visible';
|
|
479
|
-
tooltipWrapperRef.current.style.opacity = '0';
|
|
480
|
-
tooltipWrapperRef.current.style.pointerEvents = 'none';
|
|
481
|
-
const pos =
|
|
482
|
-
lastAppliedTooltipPositionRef.current ??
|
|
483
|
-
rawTooltipCoordinateRef.current;
|
|
484
|
-
if (pos) {
|
|
485
|
-
tooltipWrapperRef.current.style.transform = `translate(${pos.x}px, ${pos.y}px)`;
|
|
486
|
-
}
|
|
487
|
-
}
|
|
443
|
+
requestAnimationFrame(() => applyTooltipPositionRef.current());
|
|
444
|
+
} else {
|
|
445
|
+
rawTooltipCoordinateRef.current = null;
|
|
488
446
|
}
|
|
489
447
|
|
|
490
448
|
return (
|
|
@@ -753,7 +711,12 @@ const BaseChartWrapperContent = forwardRef<
|
|
|
753
711
|
|
|
754
712
|
{showTooltip && (
|
|
755
713
|
<div>
|
|
756
|
-
<ChartTooltip
|
|
714
|
+
<ChartTooltip
|
|
715
|
+
cursor={false}
|
|
716
|
+
offset={TOOLTIP_OFFSET}
|
|
717
|
+
allowEscapeViewBox={{ x: false, y: false }}
|
|
718
|
+
content={renderTooltipContent}
|
|
719
|
+
/>
|
|
757
720
|
</div>
|
|
758
721
|
)}
|
|
759
722
|
</ChartComponent>
|
|
@@ -67,22 +67,99 @@ const timeRangeToMonths = {
|
|
|
67
67
|
|
|
68
68
|
export type TimeRange = keyof typeof timeRangeToMonths;
|
|
69
69
|
|
|
70
|
+
function isPlottableNumber(value: unknown): value is number {
|
|
71
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Any row the chart can draw a number for (excludes date-only or empty rows) */
|
|
75
|
+
function hasPlottableChartSeriesValue(item: ChartDataPoint): boolean {
|
|
76
|
+
for (const [key, v] of Object.entries(item)) {
|
|
77
|
+
if (key === 'date') continue;
|
|
78
|
+
if (isPlottableNumber(v)) return true;
|
|
79
|
+
if (Array.isArray(v) && v.some(x => isPlottableNumber(x))) {
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return false;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Row counts toward the "end" of the window when anchoring the time range to a
|
|
88
|
+
* selected forecast (shared historical + that analysis line / quantiles).
|
|
89
|
+
*/
|
|
90
|
+
function rowContributesToAnchoredTimeRange(
|
|
91
|
+
item: ChartDataPoint,
|
|
92
|
+
analysisId: number,
|
|
93
|
+
): boolean {
|
|
94
|
+
if (isPlottableNumber(item.historical)) {
|
|
95
|
+
return true;
|
|
96
|
+
}
|
|
97
|
+
const forecastKey = `forecast_${analysisId}`;
|
|
98
|
+
if (isPlottableNumber(item[forecastKey])) {
|
|
99
|
+
return true;
|
|
100
|
+
}
|
|
101
|
+
for (const key of Object.keys(item)) {
|
|
102
|
+
if (key.startsWith('q') && key.endsWith(`_${analysisId}`)) {
|
|
103
|
+
if (isPlottableNumber(item[key])) {
|
|
104
|
+
return true;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function computeLatestPlottableDate(
|
|
112
|
+
data: ChartDataPoint[],
|
|
113
|
+
options?: { endDateAnchorAnalysisId?: number | null },
|
|
114
|
+
): Date | null {
|
|
115
|
+
const anchorId = options?.endDateAnchorAnalysisId;
|
|
116
|
+
|
|
117
|
+
const pick = (item: ChartDataPoint, useAnchor: boolean): Date | null => {
|
|
118
|
+
if (!item.date) return null;
|
|
119
|
+
if (useAnchor && anchorId != null) {
|
|
120
|
+
if (!rowContributesToAnchoredTimeRange(item, anchorId)) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
} else if (!hasPlottableChartSeriesValue(item)) {
|
|
124
|
+
return null;
|
|
125
|
+
}
|
|
126
|
+
return new Date(item.date);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
let latest: Date | null = null;
|
|
130
|
+
for (const item of data) {
|
|
131
|
+
const d = pick(item, true);
|
|
132
|
+
if (d && (!latest || d > latest)) {
|
|
133
|
+
latest = d;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (latest == null && anchorId != null) {
|
|
138
|
+
for (const item of data) {
|
|
139
|
+
const d = pick(item, false);
|
|
140
|
+
if (d && (!latest || d > latest)) {
|
|
141
|
+
latest = d;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return latest;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export type FilterDataForTimeRangeOptions = {
|
|
150
|
+
/** When set (e.g. selected forecast on Forecast tab), the window ends at the
|
|
151
|
+
* latest point that has shared historical or that analysis — not at another run. */
|
|
152
|
+
endDateAnchorAnalysisId?: number | null;
|
|
153
|
+
};
|
|
154
|
+
|
|
70
155
|
export const filterDataForTimeRange = (
|
|
71
156
|
data: ChartDataPoint[],
|
|
72
157
|
currentTimeRange: TimeRange,
|
|
73
|
-
|
|
158
|
+
options?: FilterDataForTimeRangeOptions,
|
|
74
159
|
) => {
|
|
75
160
|
if (currentTimeRange === 'All') return data;
|
|
76
161
|
|
|
77
|
-
|
|
78
|
-
const latestDate = data.reduce(
|
|
79
|
-
(latest, item) => {
|
|
80
|
-
if (!item.date) return latest;
|
|
81
|
-
const itemDate = new Date(item.date);
|
|
82
|
-
return !latest || itemDate > latest ? itemDate : latest;
|
|
83
|
-
},
|
|
84
|
-
null as Date | null,
|
|
85
|
-
);
|
|
162
|
+
const latestDate = computeLatestPlottableDate(data, options);
|
|
86
163
|
|
|
87
164
|
// Pre-compute start date based on latest date in data
|
|
88
165
|
let startDate: Date | null = null;
|
|
@@ -93,41 +170,14 @@ export const filterDataForTimeRange = (
|
|
|
93
170
|
startDate.setMonth(startDate.getMonth() - monthsToSubtract);
|
|
94
171
|
}
|
|
95
172
|
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
-
const hasManyAnalyses = availableAnalyses.length > 10;
|
|
99
|
-
const hasAnalyses = availableAnalyses.length > 0;
|
|
100
|
-
const forecastKeysSet = hasManyAnalyses
|
|
101
|
-
? new Set(availableAnalyses.map(id => `${forecastKeyPrefix}${id}`))
|
|
102
|
-
: null;
|
|
103
|
-
|
|
173
|
+
// Slice by date for every row. Rows with forecast_* keys must not bypass the
|
|
174
|
+
// window (e.g. spaghetti plots), or the X range stays stuck at full history.
|
|
104
175
|
const filteredData = data.filter(item => {
|
|
105
|
-
if (
|
|
106
|
-
// Optimize forecast data check for many analyses
|
|
107
|
-
let hasForecastData = false;
|
|
108
|
-
if (hasManyAnalyses && forecastKeysSet) {
|
|
109
|
-
// Check if any forecast key exists in the item
|
|
110
|
-
hasForecastData = Object.keys(item).some(key =>
|
|
111
|
-
forecastKeysSet.has(key),
|
|
112
|
-
);
|
|
113
|
-
} else {
|
|
114
|
-
// Original approach for small number of analyses
|
|
115
|
-
hasForecastData = availableAnalyses.some(
|
|
116
|
-
analysisId => item[`forecast_${analysisId}`] !== undefined,
|
|
117
|
-
);
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
if (hasForecastData) {
|
|
121
|
-
return true;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// For historical data, apply time range filtering
|
|
176
|
+
if (!item.date) return false;
|
|
126
177
|
if (startDate) {
|
|
127
178
|
const date = new Date(item.date);
|
|
128
179
|
return date >= startDate;
|
|
129
180
|
}
|
|
130
|
-
|
|
131
181
|
return true;
|
|
132
182
|
});
|
|
133
183
|
return filteredData;
|
|
@@ -11,6 +11,7 @@ import { TimeRangeControls } from '#uilib/components/ui/TimeRangeControls/TimeRa
|
|
|
11
11
|
import { ensureChartForecastBridge } from '#uilib/utils/chartConnectionPoint';
|
|
12
12
|
|
|
13
13
|
import {
|
|
14
|
+
filterDataForTimeRange,
|
|
14
15
|
longDateFormatter,
|
|
15
16
|
shortDateFormatter,
|
|
16
17
|
} from './ChartAreaInteractive.helpers';
|
|
@@ -94,14 +95,25 @@ export function ChartAreaInteractive({
|
|
|
94
95
|
}
|
|
95
96
|
}, [selectedAnalysisId, ensureAnalysisSeriesVisible]);
|
|
96
97
|
|
|
98
|
+
const timeFilteredChartData = useMemo(() => {
|
|
99
|
+
const raw = selectedAnalysisId ?? selectedForecast?.id ?? null;
|
|
100
|
+
const anchorId =
|
|
101
|
+
raw == null ? null : typeof raw === 'number' ? raw : Number(raw);
|
|
102
|
+
const opts =
|
|
103
|
+
anchorId != null && Number.isFinite(anchorId)
|
|
104
|
+
? { endDateAnchorAnalysisId: anchorId }
|
|
105
|
+
: undefined;
|
|
106
|
+
return filterDataForTimeRange(chartData, timeRange, opts);
|
|
107
|
+
}, [chartData, timeRange, selectedAnalysisId, selectedForecast?.id]);
|
|
108
|
+
|
|
97
109
|
const bridgedChartData = useMemo(
|
|
98
110
|
() =>
|
|
99
111
|
disableForecastHistoricalBridge
|
|
100
|
-
?
|
|
101
|
-
: ensureChartForecastBridge(
|
|
112
|
+
? timeFilteredChartData
|
|
113
|
+
: ensureChartForecastBridge(timeFilteredChartData, {
|
|
102
114
|
forecastSeriesIds: forecastData?.map(f => f.id),
|
|
103
115
|
}),
|
|
104
|
-
[
|
|
116
|
+
[timeFilteredChartData, disableForecastHistoricalBridge, forecastData],
|
|
105
117
|
);
|
|
106
118
|
|
|
107
119
|
// Extract quantileBands from restProps
|
|
@@ -8,9 +8,11 @@ export type ThemeMode = 'light' | 'dark';
|
|
|
8
8
|
|
|
9
9
|
const ThemeContext = createContext<{
|
|
10
10
|
theme: ThemeMode;
|
|
11
|
+
isDarkMode: boolean;
|
|
11
12
|
setTheme: (theme: ThemeMode) => void;
|
|
12
13
|
}>({
|
|
13
14
|
theme: 'light',
|
|
15
|
+
isDarkMode: false,
|
|
14
16
|
setTheme: () => {},
|
|
15
17
|
});
|
|
16
18
|
|
|
@@ -37,7 +39,13 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
|
|
|
37
39
|
}, [theme]);
|
|
38
40
|
|
|
39
41
|
return (
|
|
40
|
-
<ThemeContext.Provider
|
|
42
|
+
<ThemeContext.Provider
|
|
43
|
+
value={{
|
|
44
|
+
theme,
|
|
45
|
+
isDarkMode: theme === 'dark',
|
|
46
|
+
setTheme,
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
41
49
|
<ThemeRoot config={currThemeConfig} />
|
|
42
50
|
{children}
|
|
43
51
|
</ThemeContext.Provider>
|
|
@@ -9,6 +9,7 @@ import type {
|
|
|
9
9
|
import { ForecastItemData } from '#uilib/components/ui/ChartAreaInteractive/ChartLines';
|
|
10
10
|
import { PageContentSection } from '#uilib/components/ui/Page';
|
|
11
11
|
import { Tabs, TabsList, TabsTrigger } from '#uilib/components/ui/Tabs';
|
|
12
|
+
import { useTheme } from '#uilib/docs/contexts/theme-context';
|
|
12
13
|
import type { ForecastData } from '#uilib/types/forecast-data';
|
|
13
14
|
|
|
14
15
|
import { AppPageHeader } from '../components/AppPageHeader/AppPageHeader';
|
|
@@ -48,6 +49,30 @@ const DEMO_DISCRETE_THRESHOLDS = (() => {
|
|
|
48
49
|
})();
|
|
49
50
|
|
|
50
51
|
const INITIAL_CHART: ChartDataPoint[] = [
|
|
52
|
+
{ date: '2021-01-01', historical: 6 },
|
|
53
|
+
{ date: '2021-02-01', historical: 8 },
|
|
54
|
+
{ date: '2021-03-01', historical: 7 },
|
|
55
|
+
{ date: '2021-04-01', historical: 10 },
|
|
56
|
+
{ date: '2021-05-01', historical: 9 },
|
|
57
|
+
{ date: '2021-06-01', historical: 10 },
|
|
58
|
+
{ date: '2021-07-01', historical: 11 },
|
|
59
|
+
{ date: '2021-08-01', historical: 10 },
|
|
60
|
+
{ date: '2021-09-01', historical: 12 },
|
|
61
|
+
{ date: '2021-10-01', historical: 11 },
|
|
62
|
+
{ date: '2021-11-01', historical: 13 },
|
|
63
|
+
{ date: '2021-12-01', historical: 12 },
|
|
64
|
+
{ date: '2022-01-01', historical: 8 },
|
|
65
|
+
{ date: '2022-02-01', historical: 10 },
|
|
66
|
+
{ date: '2022-03-01', historical: 9 },
|
|
67
|
+
{ date: '2022-04-01', historical: 12 },
|
|
68
|
+
{ date: '2022-05-01', historical: 11 },
|
|
69
|
+
{ date: '2022-06-01', historical: 12 },
|
|
70
|
+
{ date: '2022-07-01', historical: 13 },
|
|
71
|
+
{ date: '2022-08-01', historical: 12 },
|
|
72
|
+
{ date: '2022-09-01', historical: 14 },
|
|
73
|
+
{ date: '2022-10-01', historical: 13 },
|
|
74
|
+
{ date: '2022-11-01', historical: 15 },
|
|
75
|
+
{ date: '2022-12-01', historical: 14 },
|
|
51
76
|
{ date: '2023-01-01', historical: 10 },
|
|
52
77
|
{ date: '2023-02-01', historical: 12 },
|
|
53
78
|
{ date: '2023-03-01', historical: 11 },
|
|
@@ -65,6 +90,7 @@ const DEMO_FORECAST_ITEMS: ForecastItemData[] = [
|
|
|
65
90
|
type DemoMode = 'none' | OverlayMode;
|
|
66
91
|
|
|
67
92
|
export default function ChartAreaInteractivePage() {
|
|
93
|
+
const { isDarkMode } = useTheme();
|
|
68
94
|
const [timeRange, setTimeRange] = useState<TimeRange>('1y');
|
|
69
95
|
const [pinMonth, setPinMonth] = useState<string | undefined>(undefined);
|
|
70
96
|
const [demoMode, setDemoMode] = useState<DemoMode>('none');
|
|
@@ -139,7 +165,7 @@ export default function ChartAreaInteractivePage() {
|
|
|
139
165
|
chartData={chartData}
|
|
140
166
|
forecastData={DEMO_FORECAST_ITEMS}
|
|
141
167
|
loading={false}
|
|
142
|
-
isDarkTheme={
|
|
168
|
+
isDarkTheme={isDarkMode}
|
|
143
169
|
toggleLegendSeries={toggleLegendSeries}
|
|
144
170
|
ensureAnalysisSeriesVisible={ensureAnalysisSeriesVisible}
|
|
145
171
|
hiddenSeries={hidden}
|