@sybilion/uilib 1.2.26 → 1.3.1

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.
Files changed (137) hide show
  1. package/dist/esm/components/ui/Chart/Chart.js +5 -0
  2. package/dist/esm/components/ui/Chart/components/BaseChartWrapper.js +7 -32
  3. package/dist/esm/components/ui/Chart/components/ChartEmptyState/ChartEmptyState.js +21 -0
  4. package/dist/esm/components/ui/Chart/components/ChartEmptyState/ChartEmptyState.styl.js +7 -0
  5. package/dist/esm/components/ui/Chart/lightweight/LightweightForecastChart.js +460 -0
  6. package/dist/esm/components/ui/Chart/lightweight/LightweightForecastChart.styl.js +7 -0
  7. package/dist/esm/components/ui/Chart/lightweight/chartTime.js +16 -0
  8. package/dist/esm/components/ui/Chart/lightweight/lightweightForecastChart.helpers.js +114 -0
  9. package/dist/esm/components/ui/Chart/lightweight/quantileBandCustomSeries.js +147 -0
  10. package/dist/esm/components/ui/Chart/quantileBandConeChartData.js +131 -0
  11. package/dist/esm/components/ui/Chart/tools/chartPlotGeometry.js +65 -0
  12. package/dist/esm/components/ui/ChartAreaInteractive/ChartAreaInteractive.helpers.js +37 -1
  13. package/dist/esm/components/ui/ChartAreaInteractive/ChartAreaInteractive.js +5 -2
  14. package/dist/esm/components/ui/ChartAreaInteractive/TimeRangeBrushLayer.js +205 -0
  15. package/dist/esm/components/ui/ChartAreaInteractive/TimeRangeBrushLayer.styl.js +7 -0
  16. package/dist/esm/components/ui/ChartAreaInteractive/TimeRangeBrushLayout.helpers.js +37 -0
  17. package/dist/esm/components/ui/ChartAreaInteractive/overlays/IntervalsOverlay/IntervalsOverlay.hooks.js +1 -0
  18. package/dist/esm/components/ui/ChartAreaInteractive/overlays/PinOverlay/PinOverlay.js +7 -60
  19. package/dist/esm/components/ui/ChartAreaInteractive/overlays/PinOverlay/PinOverlay.styl.js +2 -2
  20. package/dist/esm/components/ui/ChartAreaInteractive/overlays/ThresholdsOverlay/ThresholdsOverlay.hooks.js +1 -0
  21. package/dist/esm/components/ui/ChartAreaInteractive/overlays/useChartYRange.js +2 -4
  22. package/dist/esm/components/ui/ChartAreaInteractive/overlays/useQuantileBands.js +4 -102
  23. package/dist/esm/components/ui/TimeRangeControls/TimeRangeControls.js +7 -2
  24. package/dist/esm/components/ui/WorldMap/WorldMap.js +11 -0
  25. package/dist/esm/components/ui/WorldMap/WorldMap.styl.js +7 -0
  26. package/dist/esm/components/widgets/DriverCard/DriverCard.js +89 -0
  27. package/dist/esm/components/widgets/DriverCard/DriverCard.styl.js +7 -0
  28. package/dist/esm/components/widgets/DriverCard/DriverPerformanceChart.js +83 -0
  29. package/dist/esm/components/widgets/DriverCard/DriverPerformanceChart.styl.js +7 -0
  30. package/dist/esm/components/widgets/DriverCard/driverPerformanceChartData.js +50 -0
  31. package/dist/esm/components/widgets/DriverMap/DriverMap.js +2 -2
  32. package/dist/esm/components/widgets/DriverMap/DriverMap.styl.js +2 -2
  33. package/dist/esm/index.js +4 -2
  34. package/dist/esm/types/src/components/ui/Chart/Chart.d.ts +2 -0
  35. package/dist/esm/types/src/components/ui/Chart/components/BaseChartWrapper.d.ts +2 -1
  36. package/dist/esm/types/src/components/ui/Chart/components/ChartEmptyState/ChartEmptyState.d.ts +14 -0
  37. package/dist/esm/types/src/components/ui/Chart/lightweight/LightweightForecastChart.d.ts +26 -0
  38. package/dist/esm/types/src/components/ui/Chart/lightweight/chartTime.d.ts +5 -0
  39. package/dist/esm/types/src/components/ui/Chart/lightweight/lightweightForecastChart.helpers.d.ts +13 -0
  40. package/dist/esm/types/src/components/ui/Chart/lightweight/quantileBandCustomSeries.d.ts +24 -0
  41. package/dist/esm/types/src/components/ui/Chart/quantileBandConeChartData.d.ts +7 -0
  42. package/dist/esm/types/src/components/ui/Chart/tools/chartPlotGeometry.d.ts +30 -0
  43. package/dist/esm/types/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.d.ts +1 -1
  44. package/dist/esm/types/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.helpers.d.ts +11 -2
  45. package/dist/esm/types/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.types.d.ts +2 -2
  46. package/dist/esm/types/src/components/ui/ChartAreaInteractive/TimeRangeBrushLayer.d.ts +15 -0
  47. package/dist/esm/types/src/components/ui/ChartAreaInteractive/TimeRangeBrushLayout.helpers.d.ts +14 -0
  48. package/dist/esm/types/src/components/ui/ChartAreaInteractive/overlays/PinOverlay/PinOverlay.d.ts +1 -1
  49. package/dist/esm/types/src/components/ui/Page/PageColumns/PageColumns.d.ts +1 -1
  50. package/dist/esm/types/src/components/ui/TimeRangeControls/TimeRangeControls.d.ts +5 -7
  51. package/dist/esm/types/src/components/ui/WorldMap/WorldMap.d.ts +4 -0
  52. package/dist/esm/types/src/components/ui/WorldMap/index.d.ts +2 -0
  53. package/dist/esm/types/src/components/widgets/DriverCard/DriverCard.d.ts +9 -0
  54. package/dist/esm/types/src/components/widgets/DriverCard/DriverPerformanceChart.d.ts +5 -0
  55. package/dist/esm/types/src/components/widgets/DriverCard/driverPerformanceChartData.d.ts +7 -0
  56. package/dist/esm/types/src/components/widgets/DriverCard/index.d.ts +1 -0
  57. package/dist/esm/types/src/components/widgets/DriverMap/index.d.ts +0 -2
  58. package/dist/esm/types/src/docs/pages/LightweightChartPage.d.ts +1 -0
  59. package/dist/esm/types/src/docs/pages/PageColumnsPage.d.ts +1 -0
  60. package/dist/esm/types/src/docs/pages/WorldMapPage.d.ts +1 -0
  61. package/dist/esm/types/src/index.d.ts +2 -0
  62. package/package.json +3 -2
  63. package/src/components/ui/Chart/Chart.tsx +9 -0
  64. package/src/components/ui/Chart/components/BaseChartWrapper.tsx +8 -41
  65. package/src/components/ui/Chart/components/ChartEmptyState/ChartEmptyState.styl +60 -0
  66. package/src/components/ui/Chart/components/ChartEmptyState/ChartEmptyState.styl.d.ts +15 -0
  67. package/src/components/ui/Chart/components/ChartEmptyState/ChartEmptyState.tsx +66 -0
  68. package/src/components/ui/Chart/lightweight/LightweightForecastChart.styl +25 -0
  69. package/src/components/ui/Chart/lightweight/LightweightForecastChart.styl.d.ts +11 -0
  70. package/src/components/ui/Chart/lightweight/LightweightForecastChart.tsx +721 -0
  71. package/src/components/ui/Chart/lightweight/chartTime.ts +18 -0
  72. package/src/components/ui/Chart/lightweight/lightweightForecastChart.helpers.ts +141 -0
  73. package/src/components/ui/Chart/lightweight/quantileBandCustomSeries.ts +215 -0
  74. package/src/components/ui/Chart/quantileBandConeChartData.ts +171 -0
  75. package/src/components/ui/Chart/tools/chartPlotGeometry.ts +89 -0
  76. package/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.helpers.ts +44 -2
  77. package/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.tsx +14 -1
  78. package/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.types.ts +2 -3
  79. package/src/components/ui/ChartAreaInteractive/TimeRangeBrushLayer.styl +21 -0
  80. package/src/components/{widgets/DriverMap/LoadingSpinner/LoadingSpinner.styl.d.ts → ui/ChartAreaInteractive/TimeRangeBrushLayer.styl.d.ts} +3 -3
  81. package/src/components/ui/ChartAreaInteractive/TimeRangeBrushLayer.tsx +285 -0
  82. package/src/components/ui/ChartAreaInteractive/TimeRangeBrushLayout.helpers.ts +55 -0
  83. package/src/components/ui/ChartAreaInteractive/overlays/IntervalsOverlay/IntervalsOverlay.hooks.ts +1 -0
  84. package/src/components/ui/ChartAreaInteractive/overlays/PinOverlay/PinOverlay.styl +2 -7
  85. package/src/components/ui/ChartAreaInteractive/overlays/PinOverlay/PinOverlay.styl.d.ts +0 -1
  86. package/src/components/ui/ChartAreaInteractive/overlays/PinOverlay/PinOverlay.tsx +7 -71
  87. package/src/components/ui/ChartAreaInteractive/overlays/ThresholdsOverlay/ThresholdsOverlay.hooks.ts +1 -0
  88. package/src/components/ui/ChartAreaInteractive/overlays/useChartYRange.ts +2 -3
  89. package/src/components/ui/ChartAreaInteractive/overlays/useQuantileBands.ts +5 -131
  90. package/src/components/ui/Page/PageColumns/PageColumns.tsx +1 -1
  91. package/src/components/ui/TimeRangeControls/TimeRangeControls.tsx +16 -17
  92. package/src/components/{widgets/DriverMap/MapBackground/MapBackground.styl → ui/WorldMap/WorldMap.styl} +1 -3
  93. package/src/components/{widgets/DriverMap/MapBackground/MapBackground.styl.d.ts → ui/WorldMap/WorldMap.styl.d.ts} +1 -1
  94. package/src/components/ui/WorldMap/WorldMap.tsx +22 -0
  95. package/src/components/ui/WorldMap/index.ts +2 -0
  96. package/src/components/widgets/DriverCard/DriverCard.styl +169 -0
  97. package/src/components/widgets/DriverCard/DriverCard.styl.d.ts +40 -0
  98. package/src/components/widgets/DriverCard/DriverCard.tsx +219 -0
  99. package/src/components/widgets/DriverCard/DriverPerformanceChart.styl +43 -0
  100. package/src/components/widgets/DriverCard/DriverPerformanceChart.styl.d.ts +13 -0
  101. package/src/components/widgets/DriverCard/DriverPerformanceChart.tsx +150 -0
  102. package/src/components/widgets/DriverCard/driverPerformanceChartData.ts +64 -0
  103. package/src/components/widgets/DriverCard/index.ts +1 -0
  104. package/src/components/widgets/DriverMap/DriverIcon/DriverIcon.tsx +0 -2
  105. package/src/components/widgets/DriverMap/DriverMap.styl +6 -1
  106. package/src/components/widgets/DriverMap/DriverMap.styl.d.ts +1 -0
  107. package/src/components/widgets/DriverMap/DriverMap.tsx +2 -4
  108. package/src/components/widgets/DriverMap/driverCategoryIcon.tsx +0 -2
  109. package/src/components/widgets/DriverMap/index.ts +0 -2
  110. package/src/declarations.d.ts +2 -0
  111. package/src/docs/config/webpack.config.js +26 -3
  112. package/src/docs/index.tsx +1 -1
  113. package/src/docs/pages/ChartAreaInteractivePage.tsx +2 -3
  114. package/src/docs/pages/DriverMapPage.tsx +214 -60
  115. package/src/docs/pages/LightweightChartPage.styl +18 -0
  116. package/src/docs/pages/LightweightChartPage.styl.d.ts +10 -0
  117. package/src/docs/pages/LightweightChartPage.tsx +195 -0
  118. package/src/docs/pages/PageColumnsPage.tsx +92 -0
  119. package/src/docs/pages/TimeRangeControlsPage.tsx +2 -3
  120. package/src/docs/pages/WorldMapPage.styl +14 -0
  121. package/src/docs/pages/WorldMapPage.styl.d.ts +8 -0
  122. package/src/docs/pages/WorldMapPage.tsx +26 -0
  123. package/src/docs/registry.ts +19 -1
  124. package/src/index.ts +2 -0
  125. package/dist/esm/components/widgets/DriverMap/LoadingSpinner/LoadingSpinner.js +0 -8
  126. package/dist/esm/components/widgets/DriverMap/LoadingSpinner/LoadingSpinner.styl.js +0 -7
  127. package/dist/esm/components/widgets/DriverMap/MapBackground/MapBackground.js +0 -10
  128. package/dist/esm/components/widgets/DriverMap/MapBackground/MapBackground.styl.js +0 -7
  129. package/dist/esm/types/src/components/widgets/DriverMap/LoadingSpinner/LoadingSpinner.d.ts +0 -1
  130. package/dist/esm/types/src/components/widgets/DriverMap/MapBackground/MapBackground.d.ts +0 -1
  131. package/src/components/widgets/DriverMap/LoadingSpinner/LoadingSpinner.styl +0 -24
  132. package/src/components/widgets/DriverMap/LoadingSpinner/LoadingSpinner.tsx +0 -11
  133. package/src/components/widgets/DriverMap/MapBackground/MapBackground.tsx +0 -18
  134. /package/dist/esm/components/{widgets/DriverMap/MapBackground → ui/WorldMap}/map.svg.js +0 -0
  135. /package/src/components/{widgets/DriverMap/MapBackground → ui/WorldMap}/map.svg +0 -0
  136. /package/src/components/{widgets/DriverMap/MapBackground → ui/WorldMap}/mapAspect.mixin.styl +0 -0
  137. /package/src/components/{widgets/DriverMap/MapBackground → ui/WorldMap}/mapAspect.mixin.styl.d.ts +0 -0
@@ -18,6 +18,7 @@ import {
18
18
  } from './ChartAreaInteractive.helpers';
19
19
  import S from './ChartAreaInteractive.styl';
20
20
  import { ChartAreaInteractiveProps } from './ChartAreaInteractive.types';
21
+ import { TimeRangeBrushHost } from './TimeRangeBrushLayer';
21
22
  import { IntervalsOverlay, PinOverlay, ThresholdsOverlay } from './overlays';
22
23
 
23
24
  export const chartConfig = {
@@ -68,6 +69,7 @@ export function ChartAreaInteractive({
68
69
  overlayForecastData = {},
69
70
  hiddenSeries,
70
71
  disableForecastHistoricalBridge,
72
+ overlayElements: overlayElementsProp,
71
73
  ...restProps
72
74
  }: ChartAreaInteractiveProps) {
73
75
  const seriesHidden = hiddenSeries ?? new Set<string>();
@@ -161,10 +163,14 @@ export function ChartAreaInteractive({
161
163
  loadingMessage,
162
164
  excludeLegendIds,
163
165
  forecastLineStyle,
166
+ overlayElements: overlayElementsProp,
164
167
  // loadingAnalyses,
165
168
  ...restProps,
166
169
  };
167
170
 
171
+ const brushEnabled =
172
+ !disableTimeRangeSelector && !loading && bridgedChartData.length > 1;
173
+
168
174
  const renderChart = () => {
169
175
  const overlayClassName = cn(chartContainerClassName);
170
176
 
@@ -237,7 +243,14 @@ export function ChartAreaInteractive({
237
243
  </div>
238
244
  )}
239
245
 
240
- {renderChart()}
246
+ <TimeRangeBrushHost
247
+ chartData={bridgedChartData}
248
+ onTimeRangeChange={onTimeRangeChange}
249
+ enabled={brushEnabled}
250
+ layoutKey={chartRenderId ?? null}
251
+ >
252
+ {renderChart()}
253
+ </TimeRangeBrushHost>
241
254
  </InteractionOverlay>
242
255
  );
243
256
  }
@@ -2,8 +2,6 @@ import { BaseChartWrapperProps } from '#uilib/components/ui/Chart/components/Bas
2
2
  import type { ForecastData } from '#uilib/types/forecast-data';
3
3
  import { LegendPayload } from 'recharts';
4
4
 
5
- import { TimeRange } from './ChartAreaInteractive.helpers';
6
-
7
5
  export type OverlayMode = 'pin' | 'intervals' | 'thresholds';
8
6
 
9
7
  export interface ChartDataPoint {
@@ -28,7 +26,8 @@ export interface Analysis {
28
26
 
29
27
  export interface ChartAreaInteractiveProps extends BaseChartWrapperProps {
30
28
  chartContainerClassName?: string;
31
- timeRange: TimeRange;
29
+ /** Preset (`6m`, `1y`, …, `All`) or `__drag:startMs,endMs` from chart brush. */
30
+ timeRange: string;
32
31
  onTimeRangeChange: (range: string) => void;
33
32
  pinMonth: string | undefined;
34
33
  onPinMonthChange: (month: string | undefined) => void;
@@ -0,0 +1,21 @@
1
+ .selection
2
+ position absolute
3
+ top 0
4
+ bottom 0
5
+ background-color var(--brand-color-500)
6
+ opacity 0.1
7
+ border 1px solid var(--ring)
8
+ pointer-events none
9
+ box-sizing border-box
10
+
11
+ .host
12
+ position relative
13
+ width 100%
14
+ overflow visible
15
+ touch-action pan-y
16
+
17
+ .plotBox
18
+ position absolute
19
+ z-index 8
20
+ pointer-events none
21
+ box-sizing border-box
@@ -1,9 +1,9 @@
1
1
  // This file is automatically generated.
2
2
  // Please do not change this file!
3
3
  interface CssExports {
4
- 'loadingSpinner': string;
5
- 'loadingSpinnerContainer': string;
6
- 'spin': string;
4
+ 'host': string;
5
+ 'plotBox': string;
6
+ 'selection': string;
7
7
  }
8
8
  export const cssExports: CssExports;
9
9
  export default cssExports;
@@ -0,0 +1,285 @@
1
+ import {
2
+ type CSSProperties,
3
+ type ReactNode,
4
+ useLayoutEffect,
5
+ useRef,
6
+ useState,
7
+ } from 'react';
8
+
9
+ import type { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
10
+ import debounce from '#uilib/tools/debounce';
11
+
12
+ import { CHART_MARGINS } from './ChartAreaInteractive.constants';
13
+ import { encodeDragTimeRange } from './ChartAreaInteractive.helpers';
14
+ import S from './TimeRangeBrushLayer.styl';
15
+ import {
16
+ BRUSH_DOUBLE_TAP_MAX_DIST_PX,
17
+ BRUSH_DOUBLE_TAP_MS,
18
+ BRUSH_LAYOUT_RESIZE_DEBOUNCE_MS,
19
+ BRUSH_MIN_DRAG_PX,
20
+ type BrushPlotLayout,
21
+ brushClientXToDate,
22
+ brushPlotLayoutsEqual,
23
+ measureBrushPlotLayout,
24
+ resolveChartMargin,
25
+ } from './TimeRangeBrushLayout.helpers';
26
+
27
+ type LastUp = { t: number; x: number; y: number };
28
+
29
+ export interface TimeRangeBrushHostProps {
30
+ chartData: ChartDataPoint[];
31
+ onTimeRangeChange: (range: string) => void;
32
+ enabled: boolean;
33
+ /** Bumps layout sync when chart remounts (e.g. dataset / render id). */
34
+ layoutKey?: string | number | null;
35
+ children: ReactNode;
36
+ }
37
+
38
+ /**
39
+ * Wraps chart; pointerdown on SVG starts horizontal brush. Plot box tracks the
40
+ * painted grid / Recharts plot (see TimeRangeBrushLayout.helpers).
41
+ */
42
+ export function TimeRangeBrushHost({
43
+ chartData,
44
+ onTimeRangeChange,
45
+ enabled,
46
+ layoutKey,
47
+ children,
48
+ }: TimeRangeBrushHostProps) {
49
+ const hostRef = useRef<HTMLDivElement>(null);
50
+ const plotRef = useRef<HTMLDivElement>(null);
51
+ const lastUpRef = useRef<LastUp | null>(null);
52
+ const dragPointerIdRef = useRef<number | null>(null);
53
+ const startClientXRef = useRef(0);
54
+ const chartDataRef = useRef(chartData);
55
+ const onTimeRangeChangeRef = useRef(onTimeRangeChange);
56
+ const lastLayoutRef = useRef<BrushPlotLayout | null>(null);
57
+
58
+ chartDataRef.current = chartData;
59
+ onTimeRangeChangeRef.current = onTimeRangeChange;
60
+
61
+ const moveRef = useRef<(e: PointerEvent) => void>(() => {});
62
+ const upRef = useRef<(e: PointerEvent) => void>(() => {});
63
+
64
+ const onMoveStableRef = useRef<(e: PointerEvent) => void>((e: PointerEvent) =>
65
+ moveRef.current(e),
66
+ );
67
+ const onUpStableRef = useRef<(e: PointerEvent) => void>((e: PointerEvent) =>
68
+ upRef.current(e),
69
+ );
70
+
71
+ const [band, setBand] = useState<{ left: number; width: number } | null>(
72
+ null,
73
+ );
74
+ const [plotLayout, setPlotLayout] = useState<BrushPlotLayout | null>(null);
75
+
76
+ useLayoutEffect(() => {
77
+ if (!enabled) {
78
+ lastLayoutRef.current = null;
79
+ setPlotLayout(null);
80
+ return;
81
+ }
82
+
83
+ const host = hostRef.current;
84
+ if (!host) return;
85
+
86
+ const margin = resolveChartMargin(CHART_MARGINS);
87
+
88
+ let raf = 0;
89
+ let wrapperRo: ResizeObserver | null = null;
90
+ let observedWrapper: Element | null = null;
91
+
92
+ const commitLayout = (next: BrushPlotLayout | null) => {
93
+ if (brushPlotLayoutsEqual(next, lastLayoutRef.current)) return;
94
+ lastLayoutRef.current = next;
95
+ setPlotLayout(next);
96
+ };
97
+
98
+ const runSync = () => {
99
+ cancelAnimationFrame(raf);
100
+ raf = requestAnimationFrame(() => {
101
+ const h = hostRef.current;
102
+ if (!h) {
103
+ commitLayout(null);
104
+ return;
105
+ }
106
+
107
+ const wrappers = h.querySelectorAll('.recharts-wrapper');
108
+ const wrapper = wrappers[wrappers.length - 1];
109
+ if (!(wrapper instanceof HTMLElement)) {
110
+ if (wrapperRo) {
111
+ wrapperRo.disconnect();
112
+ wrapperRo = null;
113
+ }
114
+ observedWrapper = null;
115
+ commitLayout(null);
116
+ return;
117
+ }
118
+
119
+ if (wrapper !== observedWrapper) {
120
+ if (wrapperRo) {
121
+ wrapperRo.disconnect();
122
+ wrapperRo = null;
123
+ }
124
+ observedWrapper = wrapper;
125
+ wrapperRo = new ResizeObserver(() => runSync());
126
+ wrapperRo.observe(wrapper);
127
+ }
128
+
129
+ let next = measureBrushPlotLayout(h, margin);
130
+ if (!next) {
131
+ requestAnimationFrame(() => {
132
+ const h2 = hostRef.current;
133
+ if (!h2) {
134
+ commitLayout(null);
135
+ return;
136
+ }
137
+ commitLayout(measureBrushPlotLayout(h2, margin));
138
+ });
139
+ return;
140
+ }
141
+ commitLayout(next);
142
+ });
143
+ };
144
+
145
+ const debouncedWindowResize = debounce(
146
+ runSync,
147
+ BRUSH_LAYOUT_RESIZE_DEBOUNCE_MS,
148
+ { leading: false },
149
+ );
150
+
151
+ runSync();
152
+
153
+ const roHost = new ResizeObserver(() => runSync());
154
+ roHost.observe(host);
155
+
156
+ const mo = new MutationObserver(() => runSync());
157
+ mo.observe(host, { childList: true, subtree: true });
158
+
159
+ window.addEventListener('resize', debouncedWindowResize);
160
+
161
+ return () => {
162
+ cancelAnimationFrame(raf);
163
+ debouncedWindowResize.cancel();
164
+ roHost.disconnect();
165
+ mo.disconnect();
166
+ wrapperRo?.disconnect();
167
+ window.removeEventListener('resize', debouncedWindowResize);
168
+ };
169
+ }, [enabled, chartData.length, layoutKey]);
170
+
171
+ useLayoutEffect(() => {
172
+ moveRef.current = (e: PointerEvent) => {
173
+ if (e.pointerId !== dragPointerIdRef.current) return;
174
+ const plot = plotRef.current?.getBoundingClientRect();
175
+ if (!plot) return;
176
+ const x0 = startClientXRef.current;
177
+ const x1 = e.clientX;
178
+ const left = Math.min(x0, x1);
179
+ const right = Math.max(x0, x1);
180
+ const leftClamped = Math.max(plot.left, Math.min(plot.right, left));
181
+ const rightClamped = Math.max(plot.left, Math.min(plot.right, right));
182
+ setBand({
183
+ left: leftClamped - plot.left,
184
+ width: Math.max(0, rightClamped - leftClamped),
185
+ });
186
+ };
187
+
188
+ upRef.current = (e: PointerEvent) => {
189
+ if (e.pointerId !== dragPointerIdRef.current) return;
190
+ const mv = onMoveStableRef.current;
191
+ const up = onUpStableRef.current;
192
+ if (mv) window.removeEventListener('pointermove', mv);
193
+ if (up) {
194
+ window.removeEventListener('pointerup', up);
195
+ window.removeEventListener('pointercancel', up);
196
+ }
197
+ dragPointerIdRef.current = null;
198
+
199
+ const data = chartDataRef.current;
200
+ const plot = plotRef.current?.getBoundingClientRect();
201
+ const span = Math.abs(e.clientX - startClientXRef.current);
202
+ let committed = false;
203
+
204
+ if (plot && data.length > 1 && span >= BRUSH_MIN_DRAG_PX) {
205
+ const d0 = brushClientXToDate(startClientXRef.current, plot, data);
206
+ const d1 = brushClientXToDate(e.clientX, plot, data);
207
+ if (d0 && d1) {
208
+ onTimeRangeChangeRef.current(encodeDragTimeRange(d0, d1));
209
+ committed = true;
210
+ }
211
+ }
212
+
213
+ setBand(null);
214
+
215
+ if (committed) {
216
+ lastUpRef.current = null;
217
+ return;
218
+ }
219
+
220
+ const now = performance.now();
221
+ const prev = lastUpRef.current;
222
+ if (
223
+ prev &&
224
+ now - prev.t <= BRUSH_DOUBLE_TAP_MS &&
225
+ Math.hypot(e.clientX - prev.x, e.clientY - prev.y) <=
226
+ BRUSH_DOUBLE_TAP_MAX_DIST_PX
227
+ ) {
228
+ onTimeRangeChangeRef.current('All');
229
+ lastUpRef.current = null;
230
+ } else {
231
+ lastUpRef.current = { t: now, x: e.clientX, y: e.clientY };
232
+ }
233
+ };
234
+ }, []);
235
+
236
+ const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
237
+ if (!enabled || e.button !== 0) return;
238
+ if ((e.target as HTMLElement).tagName !== 'svg') return;
239
+ if (chartData.length < 2) return;
240
+
241
+ dragPointerIdRef.current = e.pointerId;
242
+ startClientXRef.current = e.clientX;
243
+
244
+ const plot = plotRef.current?.getBoundingClientRect();
245
+ if (plot) {
246
+ const x = Math.max(plot.left, Math.min(plot.right, e.clientX));
247
+ setBand({ left: x - plot.left, width: 0 });
248
+ }
249
+
250
+ window.addEventListener('pointermove', onMoveStableRef.current);
251
+ window.addEventListener('pointerup', onUpStableRef.current);
252
+ window.addEventListener('pointercancel', onUpStableRef.current);
253
+ };
254
+
255
+ if (!enabled) {
256
+ return <>{children}</>;
257
+ }
258
+
259
+ const plotBoxStyle: CSSProperties | undefined =
260
+ plotLayout && plotLayout.width > 0 && plotLayout.height > 0
261
+ ? {
262
+ position: 'absolute',
263
+ zIndex: 8,
264
+ pointerEvents: 'none',
265
+ left: plotLayout.left,
266
+ top: plotLayout.top,
267
+ width: plotLayout.width,
268
+ height: plotLayout.height,
269
+ }
270
+ : { display: 'none' };
271
+
272
+ return (
273
+ <div ref={hostRef} className={S.host} onPointerDown={onPointerDown}>
274
+ {children}
275
+ <div ref={plotRef} className={S.plotBox} style={plotBoxStyle} aria-hidden>
276
+ {band != null && band.width > 0 && (
277
+ <div
278
+ className={S.selection}
279
+ style={{ left: band.left, width: band.width }}
280
+ />
281
+ )}
282
+ </div>
283
+ </div>
284
+ );
285
+ }
@@ -0,0 +1,55 @@
1
+ import {
2
+ type ChartMargin,
3
+ type PlotRect,
4
+ measureHostRelativePlotRect,
5
+ resolveChartMargin,
6
+ } from '#uilib/components/ui/Chart/tools/chartPlotGeometry';
7
+ import type { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
8
+
9
+ /** Debounce for `window` resize only (RO uses rAF-coalesced sync). */
10
+ export const BRUSH_LAYOUT_RESIZE_DEBOUNCE_MS = 500;
11
+
12
+ export const BRUSH_MIN_DRAG_PX = 8;
13
+ export const BRUSH_DOUBLE_TAP_MS = 300;
14
+ export const BRUSH_DOUBLE_TAP_MAX_DIST_PX = 24;
15
+
16
+ export type { ChartMargin };
17
+ export { resolveChartMargin };
18
+
19
+ export type BrushPlotLayout = PlotRect;
20
+
21
+ /** Host-relative plot rect (grid-first, then wrapper + margins). */
22
+ export const measureBrushPlotLayout = measureHostRelativePlotRect;
23
+
24
+ export function brushPlotLayoutsEqual(
25
+ a: BrushPlotLayout | null,
26
+ b: BrushPlotLayout | null,
27
+ ): boolean {
28
+ if (a === b) return true;
29
+ if (!a || !b) return false;
30
+ return (
31
+ a.left === b.left &&
32
+ a.top === b.top &&
33
+ a.width === b.width &&
34
+ a.height === b.height
35
+ );
36
+ }
37
+
38
+ export function brushClientXToDate(
39
+ clientX: number,
40
+ plotRect: DOMRect,
41
+ chartData: ChartDataPoint[],
42
+ ): Date | null {
43
+ if (!chartData.length || plotRect.width <= 0) return null;
44
+ const rel = clientX - plotRect.left;
45
+ const pct = Math.max(0, Math.min(100, (rel / plotRect.width) * 100));
46
+ const n = chartData.length;
47
+ if (n === 1) {
48
+ const d = chartData[0]?.date;
49
+ return d ? new Date(d) : null;
50
+ }
51
+ const idx = Math.round((pct / 100) * (n - 1));
52
+ const clamped = Math.max(0, Math.min(n - 1, idx));
53
+ const raw = chartData[clamped]?.date;
54
+ return raw ? new Date(raw) : null;
55
+ }
@@ -134,6 +134,7 @@ export function useQuantileButton({
134
134
 
135
135
  const handleDragStart = useCallback(
136
136
  (e: PointerEvent) => {
137
+ e.stopPropagation();
137
138
  setIsDragging(true);
138
139
  if (buttonRef.current) {
139
140
  buttonRef.current.style.transition = 'none';
@@ -46,8 +46,6 @@
46
46
  transition background-color 300ms ease-out
47
47
  border-radius 12px
48
48
 
49
- .pinDragging &::after
50
- background-color var(--background-alpha-700)
51
49
  .pinAnimating &
52
50
  transition left 300ms ease-out
53
51
 
@@ -64,14 +62,11 @@
64
62
  border-radius 9999px
65
63
  transition transform 200ms ease-out
66
64
  background-color var(--background)
67
- cursor grab
65
+ cursor pointer
68
66
  user-select none
69
67
  pointer-events auto
70
68
  touch-action none
71
69
 
72
- .pinDragging &
73
- transition none
74
-
75
70
  .pinHovered &
76
71
  transform scale(1.1)
77
72
 
@@ -94,7 +89,7 @@
94
89
  margin-top -40px
95
90
  z-index 20
96
91
  pointer-events auto
97
- cursor grab
92
+ cursor pointer
98
93
  touch-action none
99
94
  // Debug: uncomment to visualize
100
95
  // background-color rgba(255, 0, 0, 0.1)
@@ -4,7 +4,6 @@ interface CssExports {
4
4
  'pinAnimating': string;
5
5
  'pinButton': string;
6
6
  'pinContainer': string;
7
- 'pinDragging': string;
8
7
  'pinHovered': string;
9
8
  'pinIcon': string;
10
9
  'pinLineBase': string;
@@ -4,7 +4,6 @@ import { useEffect, useRef, useState } from 'react';
4
4
  import { BaseChartWrapper } from '#uilib/components/ui/Chart/components';
5
5
  import { BaseChartWrapperProps } from '#uilib/components/ui/Chart/components/BaseChartWrapper';
6
6
  import { useDebounceCallback } from '#uilib/hooks/useDebounceCallback';
7
- import useDragElem, { Delta } from '#uilib/hooks/useDragElem';
8
7
  import { ChevronsLeftRight } from 'lucide-react';
9
8
 
10
9
  import CAS from '../../ChartAreaInteractive.styl';
@@ -23,7 +22,7 @@ export function PinOverlay({
23
22
  baseChartProps,
24
23
  pinMonth,
25
24
  onPinMonthChange,
26
- onPreviewMonthChange,
25
+ onPreviewMonthChange: _onPreviewMonthChange,
27
26
  className,
28
27
  }: PinOverlayProps) {
29
28
  const { chartData } = baseChartProps;
@@ -32,12 +31,9 @@ export function PinOverlay({
32
31
  const pinPlaceholderRef = useRef<HTMLDivElement>(null);
33
32
  const pinContainerRef = useRef<HTMLDivElement>(null);
34
33
 
35
- const [isDraggingPin, setIsDraggingPin] = useState(false);
36
- const [isPinAnimating, setIsPinAnimating] = useState(true);
37
34
  const [isPinHovered, setIsPinHovered] = useState(false);
38
35
 
39
36
  const pinPosRef = useRef<number>(0);
40
- const containerRectRef = useRef<DOMRect | null>(null);
41
37
  const currPinMonthRef = useRef<string | undefined>(pinMonth);
42
38
 
43
39
  const debouncedOnPinMonthChange = useDebounceCallback(
@@ -61,7 +57,6 @@ export function PinOverlay({
61
57
  }
62
58
  };
63
59
 
64
- // Get full month and year for pin position to send to parent component
65
60
  const getPinMonthAndYear = (position?: number) => {
66
61
  if (!chartData.length) return null;
67
62
 
@@ -79,7 +74,6 @@ export function PinOverlay({
79
74
  }
80
75
  };
81
76
 
82
- // Handle chart click to move pin to specific position
83
77
  const snapPinToPosition = (
84
78
  eventX: number,
85
79
  needMonthUpdate: boolean = true,
@@ -91,7 +85,6 @@ export function PinOverlay({
91
85
  const effectiveLeft = pinContainerRect.left;
92
86
  const effectiveWidth = pinContainerRect.width;
93
87
 
94
- // Calculate relative position within plotted area
95
88
  const relativeX = eventX - effectiveLeft;
96
89
  const rawPercentage =
97
90
  effectiveWidth > 0 ? (relativeX / effectiveWidth) * 100 : 0;
@@ -100,7 +93,6 @@ export function PinOverlay({
100
93
  Math.min(100, isNaN(rawPercentage) ? 0 : rawPercentage),
101
94
  );
102
95
 
103
- // Snap to nearest data point
104
96
  const totalPoints = chartData.length;
105
97
  if (totalPoints > 1) {
106
98
  const nearestIndex = Math.round((percentage / 100) * (totalPoints - 1));
@@ -110,12 +102,10 @@ export function PinOverlay({
110
102
  setPinPosition(isNaN(snappedPercentage) ? 0 : snappedPercentage);
111
103
 
112
104
  if (needMonthUpdate) {
113
- // Update news for all months (historical and forecast)
114
105
  const monthAndYear = getPinMonthAndYear();
115
106
  if (monthAndYear) {
116
107
  const isNewMonth = monthAndYear !== currPinMonthRef.current;
117
108
  if (isNewMonth) currPinMonthRef.current = monthAndYear;
118
- // When immediate (e.g. dragEnd), always notify parent so showFutureOutlook is correct
119
109
  if (immediateMonthUpdate) {
120
110
  onPinMonthChange?.(monthAndYear);
121
111
  } else if (isNewMonth) {
@@ -126,11 +116,9 @@ export function PinOverlay({
126
116
  }
127
117
  };
128
118
 
129
- // Update pin position when pinMonth prop changes
130
119
  useEffect(() => {
131
120
  if (!pinMonth || !chartData.length) return;
132
121
 
133
- // Find the data point index for the given month
134
122
  const dataPointIndex = chartData.findIndex(point => {
135
123
  const date = new Date(point.date);
136
124
  const month = date.toLocaleDateString('en-US', { month: 'short' });
@@ -147,59 +135,10 @@ export function PinOverlay({
147
135
  }
148
136
  }, [pinMonth, chartData]);
149
137
 
150
- useDragElem({
151
- elem: [chartRef],
152
- onDragStart: () => {
153
- setIsDraggingPin(true);
154
-
155
- containerRectRef.current =
156
- pinContainerRef.current?.getBoundingClientRect() || null;
157
- },
158
- onDrag: (delta: Delta) => {
159
- if (pinRef.current) {
160
- const containerRect = containerRectRef.current;
161
- if (!containerRect) return;
162
-
163
- const pinCurrentLeft = (pinPosRef.current / 100) * containerRect.width;
164
-
165
- const leftLimit = -pinCurrentLeft;
166
- const rightLimit = containerRect.width - pinCurrentLeft;
167
-
168
- const clampedDeltaX = Math.max(
169
- leftLimit,
170
- Math.min(rightLimit, delta.x),
171
- );
172
-
173
- setIsPinAnimating(false);
174
- pinRef.current.style.transform = `translateX(${clampedDeltaX}px)`;
175
-
176
- // Calculate preview month based on current drag position
177
- if (onPreviewMonthChange && chartData.length > 0) {
178
- const newPositionPixels = pinCurrentLeft + clampedDeltaX;
179
- const newPercentage = (newPositionPixels / containerRect.width) * 100;
180
- const clampedPercentage = Math.max(0, Math.min(100, newPercentage));
181
- const previewMonth = getPinMonthAndYear(clampedPercentage);
182
-
183
- if (previewMonth) {
184
- onPreviewMonthChange(previewMonth);
185
- }
186
- }
187
- }
188
- },
189
- onDragEnd: (e: PointerEvent) => {
190
- setIsDraggingPin(false);
191
- setTimeout(() => setIsPinAnimating(true), 200);
192
- if (pinRef.current) pinRef.current.style.transform = '';
193
- // Snap first so onPinMonthChange runs before we clear preview (avoids showFutureOutlook fallback)
194
- snapPinToPosition(e.clientX, true, true);
195
- if (onPreviewMonthChange) {
196
- onPreviewMonthChange(undefined);
197
- }
198
- },
199
- });
200
-
201
- const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
202
- if ((e.target as HTMLElement).tagName !== 'svg') return;
138
+ const onChartClick = (e: React.MouseEvent<HTMLDivElement>) => {
139
+ if (e.button !== 0) return;
140
+ const target = e.target as HTMLElement;
141
+ if (!target.closest?.('svg')) return;
203
142
  snapPinToPosition(e.clientX, true, false);
204
143
  };
205
144
 
@@ -220,7 +159,6 @@ export function PinOverlay({
220
159
  </div>
221
160
  </div>
222
161
  </div>
223
- {/* Hover placeholder for easier pin access - on top of everything */}
224
162
  <div
225
163
  className={S.pinPlaceholder}
226
164
  style={{ left: `${pinPosRef.current}%` }}
@@ -236,12 +174,10 @@ export function PinOverlay({
236
174
  className={cn(
237
175
  className,
238
176
  S.root,
239
- isDraggingPin && S.pinDragging,
240
- isPinAnimating && S.pinAnimating,
177
+ S.pinAnimating,
241
178
  isPinHovered && S.pinHovered,
242
179
  )}
243
- onPointerDown={onPointerDown}
244
- // onClick={e => snapPinToPosition(e.clientX)}
180
+ onClick={onChartClick}
245
181
  ref={chartRef}
246
182
  >
247
183
  <BaseChartWrapper
@@ -127,6 +127,7 @@ export function useThresholdButton({
127
127
 
128
128
  const handleDragStart = useCallback(
129
129
  (e: PointerEvent) => {
130
+ e.stopPropagation();
130
131
  initialValueRef.current = initialValue;
131
132
  startYRef.current = e.clientY;
132
133
  setIsDragging(true);
@@ -42,10 +42,9 @@ export function useChartYRange({
42
42
  Object.entries(point).forEach(([key, value]) => {
43
43
  if (key === 'date') return;
44
44
 
45
- // When selectedForecastId is provided, scale only from selected forecast + its quantile band
46
- // (exclude historical, other forecasts, and other forecasts' quantile values)
45
+ // When selectedForecastId is provided, scale from historical + selected forecast + its
46
+ // quantile band (exclude other forecasts and their quantile values only).
47
47
  if (forecastId !== undefined) {
48
- if (key === 'historical') return;
49
48
  if (key.startsWith('forecast_') && key !== `forecast_${forecastId}`)
50
49
  return;
51
50
  // Exclude q{quantile}_{otherAnalysisId} - only include selected forecast's quantiles