@sybilion/uilib 1.0.27 → 1.0.29

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.
@@ -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{position:relative;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;display:grid;min-width:8rem;width:minmax(300px,100%);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}}";
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, useState, useEffect, useMemo } from 'react';
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
- // const prevGridHeightRef = useRef<number>(0);
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 applyTooltipPosition = (opts) => {
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 root = rootRef.current;
58
- const raw = rawTooltipCoordinateRef.current;
59
- if (!wrapper || !raw)
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 setTransform = (el, x, y) => {
62
- el.style.transform = `translate(${x}px, ${y}px)`;
63
- lastAppliedTooltipPositionRef.current = { x, y };
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
- const runAdjust = () => {
69
- const w = tooltipWrapperRef.current;
70
- const rEl = rootRef.current;
71
- const rCoord = rawTooltipCoordinateRef.current;
72
- if (!w || !rEl || !rCoord)
73
- return;
74
- const rootRect = rEl.getBoundingClientRect();
75
- const rightBound = Math.min(rootRect.right, window.innerWidth);
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
- // Effect to watch for tooltip wrapper and apply transforms/opacity
123
+ // Own only the final transform: smooth follow cursor, but clamp in local chart coords first.
98
124
  useEffect(() => {
99
- const restorePosition = () => {
100
- const pos = lastAppliedTooltipPositionRef.current ??
101
- rawTooltipCoordinateRef.current;
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
- const findAndSetupTooltipWrapper = () => {
116
- const wrapper = rootRef.current?.querySelector('.recharts-tooltip-wrapper');
117
- if (wrapper && wrapper !== tooltipWrapperRef.current) {
118
- tooltipWrapperRef.current = wrapper;
119
- // Add transition for smooth movement and opacity
120
- wrapper.style.transition =
121
- 'transform 0.2s ease-out, opacity 0.2s ease-out';
122
- // Override Recharts' visibility: hidden with visibility: visible
123
- // We'll control visibility through opacity instead
124
- wrapper.style.visibility = 'visible';
125
- // Set initial opacity based on active state
126
- const isActive = activeDataRef.current?.active === true;
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
- // Always ensure visibility is visible (Recharts sets it to hidden)
136
- if (tooltipWrapperRef.current) {
137
- tooltipWrapperRef.current.style.visibility = 'visible';
138
- // Continuously restore position to prevent resets
139
- restorePosition();
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
- // Initial check
143
- findAndSetupTooltipWrapper();
144
- // Watch for tooltip wrapper changes and style mutations
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
- findAndSetupTooltipWrapper();
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
- restorePosition();
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
- // Use requestAnimationFrame to continuously monitor and restore position
165
- // Only run when we have a last position to maintain
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
- // Start monitoring if we already have a position
177
- if (rawTooltipCoordinateRef.current) {
178
- rafId = requestAnimationFrame(monitorPosition);
179
- }
179
+ window.addEventListener('resize', onWinResize);
180
180
  return () => {
181
181
  observer.disconnect();
182
- if (rafId !== null) {
183
- cancelAnimationFrame(rafId);
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
- const newCoordinate = {
200
+ rawTooltipCoordinateRef.current = {
223
201
  x: props.coordinate.x,
224
202
  y: props.coordinate.y,
225
203
  };
226
- rawTooltipCoordinateRef.current = newCoordinate;
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 if (!isActive && wasActive) {
236
- isTooltipActiveRef.current = false;
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
- ? chartData
42
- : ensureChartForecastBridge(chartData, {
49
+ ? timeFilteredChartData
50
+ : ensureChartForecastBridge(timeFilteredChartData, {
43
51
  forecastSeriesIds: forecastData?.map(f => f.id),
44
- }), [chartData, disableForecastHistoricalBridge, forecastData]);
52
+ }), [timeFilteredChartData, disableForecastHistoricalBridge, forecastData]);
45
53
  // Extract quantileBands from restProps
46
54
  // const quantileBands = (restProps as any).quantileBands;
47
55
  const getLoadingMessage = () => {
@@ -9,7 +9,12 @@ declare const timeRangeToMonths: {
9
9
  readonly All: 12;
10
10
  };
11
11
  export type TimeRange = keyof typeof timeRangeToMonths;
12
- export declare const filterDataForTimeRange: (data: ChartDataPoint[], currentTimeRange: TimeRange, availableAnalyses: number[]) => ChartDataPoint[];
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
  /**
@@ -4,5 +4,6 @@ export declare function ThemeProvider({ children }: {
4
4
  }): import("react/jsx-runtime").JSX.Element;
5
5
  export declare const useTheme: () => {
6
6
  theme: ThemeMode;
7
+ isDarkMode: boolean;
7
8
  setTheme: (theme: ThemeMode) => void;
8
9
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sybilion/uilib",
3
- "version": "1.0.27",
3
+ "version": "1.0.29",
4
4
  "description": "Sybilion Design System — React UI components (Webpack + Stylus)",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -57,10 +57,9 @@
57
57
  position relative
58
58
  z-index 1
59
59
 
60
- // Tooltip wrapper is positioned absolutely, can have higher z-index
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
- width minmax(300px, 100%)
79
- min-width 8rem
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
- // const prevGridHeightRef = useRef<number>(0);
227
- const tooltipWrapperRef = useRef<HTMLElement | null>(null);
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 applyTooltipPosition = (opts?: { skipBoundaryAdjust?: boolean }) => {
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 root = rootRef.current;
234
- const raw = rawTooltipCoordinateRef.current;
235
- if (!wrapper || !raw) return;
307
+ const coord = rawTooltipCoordinateRef.current;
308
+ const chartWrapper = rootRef.current?.querySelector('.recharts-wrapper');
236
309
 
237
- const setTransform = (el: HTMLElement, x: number, y: number) => {
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
- setTransform(wrapper, raw.x, raw.y);
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 (opts?.skipBoundaryAdjust || !root) return;
316
+ if (width <= 0 || height <= 0) return;
245
317
 
246
- const runAdjust = () => {
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
- const leftBound = Math.max(rootRect.left, 0);
265
- w.style.transform = `translate(${x}px, ${y}px)`;
266
- tooltipRect = w.getBoundingClientRect();
267
-
268
- if (tooltipRect.left < leftBound + TOOLTIP_EDGE_MARGIN) {
269
- x += leftBound + TOOLTIP_EDGE_MARGIN - tooltipRect.left;
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
- // Effect to watch for tooltip wrapper and apply transforms/opacity
335
+ // Own only the final transform: smooth follow cursor, but clamp in local chart coords first.
284
336
  useEffect(() => {
285
- const restorePosition = () => {
286
- const pos =
287
- lastAppliedTooltipPositionRef.current ??
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
- const findAndSetupTooltipWrapper = () => {
307
- const wrapper = rootRef.current?.querySelector(
308
- '.recharts-tooltip-wrapper',
309
- ) as HTMLElement | null;
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
- if (wrapper && wrapper !== tooltipWrapperRef.current) {
312
- tooltipWrapperRef.current = wrapper;
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
- // Add transition for smooth movement and opacity
315
- wrapper.style.transition =
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
- // Always ensure visibility is visible (Recharts sets it to hidden)
335
- if (tooltipWrapperRef.current) {
336
- tooltipWrapperRef.current.style.visibility = 'visible';
337
- // Continuously restore position to prevent resets
338
- restorePosition();
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
- // Initial check
343
- findAndSetupTooltipWrapper();
374
+ tryObserveTooltipWrapper();
344
375
 
345
- // Watch for tooltip wrapper changes and style mutations
346
376
  const observer = new MutationObserver(mutations => {
347
- findAndSetupTooltipWrapper();
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
- restorePosition();
385
+ requestAnimationFrame(() => applyTooltipPositionRef.current());
357
386
  }
358
387
  });
359
388
  });
@@ -367,28 +396,18 @@ const BaseChartWrapperContent = forwardRef<
367
396
  });
368
397
  }
369
398
 
370
- // Use requestAnimationFrame to continuously monitor and restore position
371
- // Only run when we have a last position to maintain
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
- if (rafId !== null) {
390
- cancelAnimationFrame(rafId);
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
- const newCoordinate = {
439
+ rawTooltipCoordinateRef.current = {
447
440
  x: props.coordinate.x,
448
441
  y: props.coordinate.y,
449
442
  };
450
-
451
- rawTooltipCoordinateRef.current = newCoordinate;
452
- isTooltipActiveRef.current = true;
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 cursor={false} content={renderTooltipContent} />
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
- availableAnalyses: number[],
158
+ options?: FilterDataForTimeRangeOptions,
74
159
  ) => {
75
160
  if (currentTimeRange === 'All') return data;
76
161
 
77
- // Find the latest date in the data
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
- // Create a Set for faster lookup when there are many analyses
97
- const forecastKeyPrefix = 'forecast_';
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 (hasAnalyses) {
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
- ? chartData
101
- : ensureChartForecastBridge(chartData, {
112
+ ? timeFilteredChartData
113
+ : ensureChartForecastBridge(timeFilteredChartData, {
102
114
  forecastSeriesIds: forecastData?.map(f => f.id),
103
115
  }),
104
- [chartData, disableForecastHistoricalBridge, forecastData],
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 value={{ theme, setTheme }}>
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={false}
168
+ isDarkTheme={isDarkMode}
143
169
  toggleLegendSeries={toggleLegendSeries}
144
170
  ensureAnalysisSeriesVisible={ensureAnalysisSeriesVisible}
145
171
  hiddenSeries={hidden}