@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
@@ -0,0 +1,18 @@
1
+ import type { UTCTimestamp } from 'lightweight-charts';
2
+
3
+ /**
4
+ * Parse `YYYY-MM-DD` dates as UTC midnight → Lightweight Charts unix seconds.
5
+ */
6
+ export function chartDateToUtcTimestamp(dateStr: string): UTCTimestamp {
7
+ const trimmed = dateStr.slice(0, 10);
8
+ const [y, m, d] = trimmed.split('-').map(Number);
9
+ if (
10
+ !Number.isFinite(y) ||
11
+ !Number.isFinite(m) ||
12
+ !Number.isFinite(d) ||
13
+ trimmed.length !== 10
14
+ ) {
15
+ return Math.floor(new Date(dateStr).getTime() / 1000) as UTCTimestamp;
16
+ }
17
+ return Math.floor(Date.UTC(y, m - 1, d, 0, 0, 0, 0) / 1000) as UTCTimestamp;
18
+ }
@@ -0,0 +1,141 @@
1
+ import type { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
2
+ import type {
3
+ ChartOptions,
4
+ DeepPartial,
5
+ LineData,
6
+ UTCTimestamp,
7
+ } from 'lightweight-charts';
8
+ import { ColorType } from 'lightweight-charts';
9
+
10
+ import { applyQuantileBandConeToChartData } from '../quantileBandConeChartData';
11
+ import { chartDateToUtcTimestamp } from './chartTime';
12
+ import type { QuantileBandCustomData } from './quantileBandCustomSeries';
13
+
14
+ export function buildLightweightChartOptions(args: {
15
+ isDarkTheme: boolean;
16
+ width: number;
17
+ height: number;
18
+ autoSize?: boolean;
19
+ }): DeepPartial<ChartOptions> {
20
+ const { isDarkTheme, width, height, autoSize = false } = args;
21
+ return {
22
+ autoSize,
23
+ width: Math.max(1, Math.floor(width)),
24
+ height: Math.max(1, Math.floor(height)),
25
+ layout: {
26
+ background: {
27
+ type: ColorType.Solid,
28
+ color: 'transparent',
29
+ },
30
+ textColor: isDarkTheme ? '#d8dee9' : '#191919',
31
+ },
32
+ grid: {
33
+ vertLines: {
34
+ color: isDarkTheme ? '#2f3440' : '#e6edf3',
35
+ },
36
+ horzLines: {
37
+ color: isDarkTheme ? '#2f3440' : '#e6edf3',
38
+ },
39
+ },
40
+ rightPriceScale: {
41
+ borderVisible: false,
42
+ scaleMargins: {
43
+ top: 0.08,
44
+ bottom: 0.08,
45
+ },
46
+ },
47
+ timeScale: {
48
+ borderVisible: false,
49
+ timeVisible: true,
50
+ secondsVisible: false,
51
+ fixLeftEdge: true,
52
+ fixRightEdge: true,
53
+ },
54
+ localization: {
55
+ locale:
56
+ typeof navigator !== 'undefined' && navigator.language
57
+ ? navigator.language
58
+ : 'en-US',
59
+ },
60
+ };
61
+ }
62
+
63
+ export function buildHistoricalLineData(
64
+ rows: ChartDataPoint[],
65
+ ): LineData<UTCTimestamp>[] {
66
+ const out: LineData<UTCTimestamp>[] = [];
67
+ for (const row of rows) {
68
+ const v = row.historical;
69
+ if (typeof v !== 'number' || !Number.isFinite(v)) continue;
70
+ out.push({
71
+ time: chartDateToUtcTimestamp(row.date),
72
+ value: v,
73
+ });
74
+ }
75
+ out.sort((a, b) => Number(a.time) - Number(b.time));
76
+ return out;
77
+ }
78
+
79
+ export function buildForecastLineData(
80
+ rows: ChartDataPoint[],
81
+ forecastKey: string,
82
+ ): LineData<UTCTimestamp>[] {
83
+ const out: LineData<UTCTimestamp>[] = [];
84
+ for (const row of rows) {
85
+ const v = row[forecastKey];
86
+ if (typeof v !== 'number' || !Number.isFinite(v)) continue;
87
+ out.push({
88
+ time: chartDateToUtcTimestamp(row.date),
89
+ value: v,
90
+ });
91
+ }
92
+ out.sort((a, b) => Number(a.time) - Number(b.time));
93
+ return out;
94
+ }
95
+
96
+ export function buildQuantileBandCustomData(
97
+ rows: ChartDataPoint[],
98
+ bandKey: string,
99
+ ): QuantileBandCustomData[] {
100
+ const coned = applyQuantileBandConeToChartData(rows, bandKey);
101
+ const out: QuantileBandCustomData[] = [];
102
+ for (const row of coned) {
103
+ const v = row[bandKey];
104
+ if (
105
+ !Array.isArray(v) ||
106
+ v.length !== 2 ||
107
+ typeof v[0] !== 'number' ||
108
+ typeof v[1] !== 'number' ||
109
+ !Number.isFinite(v[0]) ||
110
+ !Number.isFinite(v[1])
111
+ ) {
112
+ continue;
113
+ }
114
+ out.push({
115
+ time: chartDateToUtcTimestamp(row.date),
116
+ lower: v[0],
117
+ upper: v[1],
118
+ });
119
+ }
120
+ out.sort((a, b) => Number(a.time) - Number(b.time));
121
+ return out;
122
+ }
123
+
124
+ export function findNearestChartRow(
125
+ rows: ChartDataPoint[],
126
+ time: UTCTimestamp,
127
+ ): ChartDataPoint | null {
128
+ if (!rows.length) return null;
129
+ const target = Number(time) * 1000;
130
+ let best: ChartDataPoint | null = null;
131
+ let bestDist = Infinity;
132
+ for (const row of rows) {
133
+ const t = Number(chartDateToUtcTimestamp(row.date)) * 1000;
134
+ const dist = Math.abs(t - target);
135
+ if (dist < bestDist) {
136
+ bestDist = dist;
137
+ best = row;
138
+ }
139
+ }
140
+ return best;
141
+ }
@@ -0,0 +1,215 @@
1
+ import type { CanvasRenderingTarget2D } from 'fancy-canvas';
2
+ import type {
3
+ CustomData,
4
+ CustomSeriesOptions,
5
+ CustomSeriesWhitespaceData,
6
+ ICustomSeriesPaneRenderer,
7
+ ICustomSeriesPaneView,
8
+ PaneRendererCustomData,
9
+ PriceToCoordinateConverter,
10
+ UTCTimestamp,
11
+ } from 'lightweight-charts';
12
+ import { customSeriesDefaultOptions } from 'lightweight-charts';
13
+
14
+ export interface QuantileBandCustomData extends CustomData<UTCTimestamp> {
15
+ lower: number;
16
+ upper: number;
17
+ }
18
+
19
+ export type QuantileBandStyle = {
20
+ fill: string;
21
+ stroke?: string;
22
+ strokeWidth: number;
23
+ strokeDasharray?: string;
24
+ strokeOpacity?: number;
25
+ };
26
+
27
+ type MediaXY = { x: number; y: number };
28
+
29
+ /** Catmull–Rom segment chain → cubic Béziers (matches curved line feel). */
30
+ function appendCatmullRomBezierChain(
31
+ ctx: CanvasRenderingContext2D,
32
+ pts: MediaXY[],
33
+ startWithMoveTo: boolean,
34
+ ): void {
35
+ if (pts.length < 2) return;
36
+
37
+ if (pts.length === 2) {
38
+ if (startWithMoveTo) ctx.moveTo(pts[0].x, pts[0].y);
39
+ ctx.lineTo(pts[1].x, pts[1].y);
40
+ return;
41
+ }
42
+
43
+ if (startWithMoveTo) {
44
+ ctx.moveTo(pts[0].x, pts[0].y);
45
+ }
46
+
47
+ for (let i = 0; i < pts.length - 1; i += 1) {
48
+ const p0 = pts[Math.max(0, i - 1)];
49
+ const p1 = pts[i];
50
+ const p2 = pts[i + 1];
51
+ const p3 = pts[Math.min(pts.length - 1, i + 2)];
52
+ const cp1x = p1.x + (p2.x - p0.x) / 6;
53
+ const cp1y = p1.y + (p2.y - p0.y) / 6;
54
+ const cp2x = p2.x - (p3.x - p1.x) / 6;
55
+ const cp2y = p2.y - (p3.y - p1.y) / 6;
56
+ ctx.bezierCurveTo(cp1x, cp1y, cp2x, cp2y, p2.x, p2.y);
57
+ }
58
+ }
59
+
60
+ class QuantileBandPaneRenderer implements ICustomSeriesPaneRenderer {
61
+ public constructor(
62
+ private readonly getData: () =>
63
+ | PaneRendererCustomData<UTCTimestamp, QuantileBandCustomData>
64
+ | undefined,
65
+ private readonly getStyle: () => QuantileBandStyle,
66
+ private readonly isWhitespaceFn: (
67
+ data: QuantileBandCustomData | CustomSeriesWhitespaceData<UTCTimestamp>,
68
+ ) => data is CustomSeriesWhitespaceData<UTCTimestamp>,
69
+ ) {}
70
+
71
+ public draw(
72
+ target: CanvasRenderingTarget2D,
73
+ priceConverter: PriceToCoordinateConverter,
74
+ ): void {
75
+ const pane = this.getData() as
76
+ | PaneRendererCustomData<UTCTimestamp, QuantileBandCustomData>
77
+ | undefined;
78
+ if (!pane?.bars?.length) {
79
+ return;
80
+ }
81
+
82
+ const style = this.getStyle();
83
+ const segments: Array<{ x: number; yU: number; yL: number }> = [];
84
+
85
+ for (const bar of pane.bars) {
86
+ const d = bar.originalData;
87
+ if (this.isWhitespaceFn(d)) {
88
+ continue;
89
+ }
90
+ const p = d as QuantileBandCustomData;
91
+ const yU = priceConverter(p.upper);
92
+ const yL = priceConverter(p.lower);
93
+ if (yU === null || yL === null) {
94
+ continue;
95
+ }
96
+ segments.push({ x: bar.x, yU, yL });
97
+ }
98
+
99
+ if (segments.length < 2) {
100
+ return;
101
+ }
102
+
103
+ target.useMediaCoordinateSpace(({ context }) => {
104
+ const opacity =
105
+ typeof style.strokeOpacity === 'number'
106
+ ? style.strokeOpacity
107
+ : undefined;
108
+ const prevAlpha = context.globalAlpha;
109
+ const upper: MediaXY[] = segments.map(s => ({ x: s.x, y: s.yU }));
110
+ const lowerRev: MediaXY[] = segments
111
+ .map(s => ({ x: s.x, y: s.yL }))
112
+ .reverse();
113
+
114
+ context.beginPath();
115
+ appendCatmullRomBezierChain(context, upper, true);
116
+ const last = segments[segments.length - 1]!;
117
+ context.lineTo(last.x, last.yL);
118
+ appendCatmullRomBezierChain(context, lowerRev, false);
119
+ context.closePath();
120
+ context.fillStyle = style.fill;
121
+ context.fill();
122
+
123
+ const sw = style.strokeWidth;
124
+ if (sw > 0) {
125
+ if (opacity !== undefined) context.globalAlpha = opacity;
126
+ context.lineWidth = sw;
127
+ context.strokeStyle = style.stroke ?? style.fill;
128
+ if (style.strokeDasharray) {
129
+ const parts = style.strokeDasharray
130
+ .split(/[\s,]+/)
131
+ .map(Number)
132
+ .filter(n => Number.isFinite(n));
133
+ if (parts.length) context.setLineDash(parts);
134
+ else context.setLineDash([]);
135
+ } else {
136
+ context.setLineDash([]);
137
+ }
138
+ context.stroke();
139
+ context.setLineDash([]);
140
+ }
141
+ context.globalAlpha = prevAlpha;
142
+ });
143
+ }
144
+ }
145
+
146
+ export class QuantileBandPaneView implements ICustomSeriesPaneView<
147
+ UTCTimestamp,
148
+ QuantileBandCustomData,
149
+ CustomSeriesOptions
150
+ > {
151
+ private _paneData:
152
+ | PaneRendererCustomData<UTCTimestamp, QuantileBandCustomData>
153
+ | undefined;
154
+
155
+ private _style!: QuantileBandStyle;
156
+
157
+ public constructor(initialStyle: QuantileBandStyle) {
158
+ this._style = initialStyle;
159
+ }
160
+
161
+ public updateStyle(style: QuantileBandStyle): void {
162
+ this._style = style;
163
+ }
164
+
165
+ public renderer(): ICustomSeriesPaneRenderer {
166
+ return new QuantileBandPaneRenderer(
167
+ () => this._paneData,
168
+ () => this._style,
169
+ (d): d is CustomSeriesWhitespaceData<UTCTimestamp> =>
170
+ this.isWhitespace(d),
171
+ );
172
+ }
173
+
174
+ public update(
175
+ paneData: PaneRendererCustomData<UTCTimestamp, QuantileBandCustomData>,
176
+ seriesOptions: CustomSeriesOptions,
177
+ ): void {
178
+ this._paneData = paneData;
179
+ const c = seriesOptions.color;
180
+ if (typeof c === 'string') {
181
+ this._style = { ...this._style, fill: c };
182
+ }
183
+ }
184
+
185
+ public priceValueBuilder(plotRow: QuantileBandCustomData): number[] {
186
+ if (this.isWhitespace(plotRow)) {
187
+ return [];
188
+ }
189
+ const p = plotRow as QuantileBandCustomData;
190
+ return [p.lower, p.upper, (p.lower + p.upper) / 2];
191
+ }
192
+
193
+ public isWhitespace(
194
+ data: QuantileBandCustomData | CustomSeriesWhitespaceData<UTCTimestamp>,
195
+ ): data is CustomSeriesWhitespaceData<UTCTimestamp> {
196
+ const row = data as QuantileBandCustomData;
197
+ if (
198
+ typeof row.lower !== 'number' ||
199
+ typeof row.upper !== 'number' ||
200
+ !Number.isFinite(row.lower) ||
201
+ !Number.isFinite(row.upper)
202
+ ) {
203
+ return true;
204
+ }
205
+ return false;
206
+ }
207
+
208
+ public defaultOptions(): CustomSeriesOptions {
209
+ return customSeriesDefaultOptions;
210
+ }
211
+
212
+ public destroy(): void {
213
+ this._paneData = undefined;
214
+ }
215
+ }
@@ -0,0 +1,171 @@
1
+ import type { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
2
+
3
+ function isFiniteBandTuple(v: unknown): v is [number, number] {
4
+ return (
5
+ Array.isArray(v) &&
6
+ v.length === 2 &&
7
+ typeof v[0] === 'number' &&
8
+ typeof v[1] === 'number' &&
9
+ Number.isFinite(v[0]) &&
10
+ Number.isFinite(v[1])
11
+ );
12
+ }
13
+
14
+ /**
15
+ * Matches ChartAreaInteractive `mode="intervals"`: cone / fan chart hand-off — collapse the
16
+ * interval to a point at the historical→forecast boundary, then expand along forecast anchors.
17
+ * (Same rules as `useQuantileBands`, without requiring `ForecastData` — reads tuples from each row.)
18
+ */
19
+ export function applyQuantileBandConeToChartData(
20
+ chartData: ChartDataPoint[],
21
+ bandKey: string,
22
+ ): ChartDataPoint[] {
23
+ if (!chartData.length) return chartData;
24
+
25
+ const originalBandByDate = new Map<string, [number, number]>();
26
+ for (const p of chartData) {
27
+ const t = p[bandKey];
28
+ if (isFiniteBandTuple(t)) originalBandByDate.set(p.date, [t[0], t[1]]);
29
+ }
30
+
31
+ const forecastDateList = [...originalBandByDate.keys()].sort(
32
+ (a, b) => new Date(a).getTime() - new Date(b).getTime(),
33
+ );
34
+ if (forecastDateList.length === 0) return chartData;
35
+
36
+ const forecastDatesSet = new Set(forecastDateList);
37
+ const dateToQuantileIndex = new Map<string, number>();
38
+ forecastDateList.forEach((d, i) => dateToQuantileIndex.set(d, i));
39
+
40
+ const clonedData = [...chartData];
41
+
42
+ const historicalPoints = clonedData.filter(
43
+ p =>
44
+ p.historical !== undefined &&
45
+ p.historical !== null &&
46
+ typeof p.historical === 'number' &&
47
+ Number.isFinite(p.historical),
48
+ );
49
+ const lastHistoricalPoint =
50
+ historicalPoints.length > 0
51
+ ? historicalPoints[historicalPoints.length - 1]
52
+ : null;
53
+ const lastHistoricalDate = lastHistoricalPoint?.date;
54
+ const lastHistoricalValue = lastHistoricalPoint?.historical as
55
+ | number
56
+ | undefined;
57
+
58
+ const firstForecastDate = forecastDateList[0];
59
+ const firstForecastDateObj = firstForecastDate
60
+ ? new Date(firstForecastDate)
61
+ : null;
62
+ const lastHistoricalDateObj = lastHistoricalDate
63
+ ? new Date(lastHistoricalDate)
64
+ : null;
65
+
66
+ const hasGap =
67
+ !!lastHistoricalDate &&
68
+ !!firstForecastDate &&
69
+ lastHistoricalValue !== undefined &&
70
+ firstForecastDateObj &&
71
+ lastHistoricalDateObj &&
72
+ firstForecastDateObj.getTime() > lastHistoricalDateObj.getTime();
73
+
74
+ const needsBridgePoint =
75
+ !!lastHistoricalDate &&
76
+ !!firstForecastDate &&
77
+ lastHistoricalValue !== undefined &&
78
+ firstForecastDateObj &&
79
+ lastHistoricalDateObj &&
80
+ firstForecastDateObj.getTime() <= lastHistoricalDateObj.getTime();
81
+
82
+ let bridgePoint: (typeof historicalPoints)[number] | null = null;
83
+ let pointBeforeForecast: (typeof historicalPoints)[number] | null = null;
84
+ if (needsBridgePoint && historicalPoints.length > 0) {
85
+ bridgePoint =
86
+ firstForecastDateObj &&
87
+ lastHistoricalDateObj &&
88
+ firstForecastDateObj.getTime() === lastHistoricalDateObj.getTime()
89
+ ? lastHistoricalPoint
90
+ : [...historicalPoints]
91
+ .reverse()
92
+ .find(
93
+ p =>
94
+ firstForecastDateObj &&
95
+ new Date(p.date).getTime() < firstForecastDateObj.getTime(),
96
+ ) || lastHistoricalPoint;
97
+
98
+ if (firstForecastDateObj) {
99
+ pointBeforeForecast =
100
+ historicalPoints.findLast(
101
+ p => new Date(p.date).getTime() < firstForecastDateObj.getTime(),
102
+ ) ?? null;
103
+ }
104
+ }
105
+
106
+ return clonedData.map(point => {
107
+ const newPoint: ChartDataPoint = { ...point };
108
+
109
+ if (
110
+ hasGap &&
111
+ point.date === lastHistoricalDate &&
112
+ lastHistoricalValue !== undefined
113
+ ) {
114
+ newPoint[bandKey] = [lastHistoricalValue, lastHistoricalValue] as [
115
+ number,
116
+ number,
117
+ ];
118
+ }
119
+
120
+ const isBridgePointDate =
121
+ needsBridgePoint &&
122
+ bridgePoint &&
123
+ bridgePoint.historical !== undefined &&
124
+ point.date === bridgePoint.date;
125
+ const isPointBeforeForecast =
126
+ needsBridgePoint &&
127
+ pointBeforeForecast &&
128
+ pointBeforeForecast.historical !== undefined &&
129
+ point.date === pointBeforeForecast.date;
130
+ const isAlsoForecastDate = forecastDatesSet.has(point.date);
131
+
132
+ if (isPointBeforeForecast && !isAlsoForecastDate) {
133
+ newPoint[bandKey] = [
134
+ pointBeforeForecast.historical as number,
135
+ pointBeforeForecast.historical as number,
136
+ ];
137
+ } else if (isBridgePointDate && !isAlsoForecastDate) {
138
+ newPoint[bandKey] = [
139
+ bridgePoint.historical as number,
140
+ bridgePoint.historical as number,
141
+ ];
142
+ }
143
+
144
+ if (forecastDatesSet.has(point.date)) {
145
+ const quantileIndex = dateToQuantileIndex.get(point.date);
146
+ if (quantileIndex !== undefined) {
147
+ const bandValues = originalBandByDate.get(point.date);
148
+ if (bandValues) {
149
+ const isBridgePointDateForecast =
150
+ needsBridgePoint &&
151
+ bridgePoint &&
152
+ point.date === bridgePoint.date &&
153
+ bridgePoint.historical !== undefined;
154
+
155
+ if (isBridgePointDateForecast && quantileIndex === 0) {
156
+ newPoint[bandKey] = [
157
+ bridgePoint.historical as number,
158
+ bandValues[1],
159
+ ];
160
+ } else {
161
+ newPoint[bandKey] = bandValues;
162
+ }
163
+ } else {
164
+ delete newPoint[bandKey];
165
+ }
166
+ }
167
+ }
168
+
169
+ return newPoint;
170
+ });
171
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Shared Recharts plot / margin math (DOM-agnostic except measurement entry points).
3
+ * Keeps BaseChartWrapper tooltip clamp and ChartAreaInteractive brush in sync.
4
+ */
5
+
6
+ export type ChartMargin = {
7
+ top: number;
8
+ right: number;
9
+ bottom: number;
10
+ left: number;
11
+ };
12
+
13
+ export const DEFAULT_CHART_MARGIN: ChartMargin = {
14
+ top: 5,
15
+ right: 5,
16
+ bottom: 5,
17
+ left: 5,
18
+ };
19
+
20
+ export function resolveChartMargin(
21
+ margin: Partial<ChartMargin> | undefined,
22
+ ): ChartMargin {
23
+ return {
24
+ top: margin?.top ?? DEFAULT_CHART_MARGIN.top,
25
+ right: margin?.right ?? DEFAULT_CHART_MARGIN.right,
26
+ bottom: margin?.bottom ?? DEFAULT_CHART_MARGIN.bottom,
27
+ left: margin?.left ?? DEFAULT_CHART_MARGIN.left,
28
+ };
29
+ }
30
+
31
+ /** Plot box inside `.recharts-wrapper` (Recharts cartesian convention). */
32
+ export function getPlotViewBox(wrapper: HTMLElement, m: ChartMargin) {
33
+ const w = wrapper.clientWidth;
34
+ const h = wrapper.clientHeight;
35
+ return {
36
+ x: m.left,
37
+ y: m.top,
38
+ width: Math.max(0, w - m.left - m.right),
39
+ height: Math.max(0, h - m.top - m.bottom),
40
+ };
41
+ }
42
+
43
+ export type PlotRect = {
44
+ left: number;
45
+ top: number;
46
+ width: number;
47
+ height: number;
48
+ };
49
+
50
+ const GRID_BOUNDS_MIN_PX = 2;
51
+
52
+ /**
53
+ * Plot area in `host` local px: prefer painted `.recharts-cartesian-grid`, else
54
+ * last `.recharts-wrapper` + margins. One `hostRect` read; grid/wrapper rects as needed.
55
+ */
56
+ export function measureHostRelativePlotRect(
57
+ host: HTMLElement,
58
+ margin: ChartMargin,
59
+ ): PlotRect | null {
60
+ const hostRect = host.getBoundingClientRect();
61
+
62
+ const grid = host.querySelector<SVGGElement>('.recharts-cartesian-grid');
63
+ if (grid) {
64
+ const gr = grid.getBoundingClientRect();
65
+ if (gr.width > GRID_BOUNDS_MIN_PX && gr.height > GRID_BOUNDS_MIN_PX) {
66
+ return {
67
+ left: gr.left - hostRect.left,
68
+ top: gr.top - hostRect.top,
69
+ width: gr.width,
70
+ height: gr.height,
71
+ };
72
+ }
73
+ }
74
+
75
+ const wrappers = host.querySelectorAll('.recharts-wrapper');
76
+ const wrapper = wrappers[wrappers.length - 1];
77
+ if (!(wrapper instanceof HTMLElement)) return null;
78
+
79
+ const vb = getPlotViewBox(wrapper, margin);
80
+ if (vb.width <= 0 || vb.height <= 0) return null;
81
+
82
+ const wrapRect = wrapper.getBoundingClientRect();
83
+ return {
84
+ left: wrapRect.left - hostRect.left + vb.x,
85
+ top: wrapRect.top - hostRect.top + vb.y,
86
+ width: vb.width,
87
+ height: vb.height,
88
+ };
89
+ }
@@ -65,7 +65,37 @@ const timeRangeToMonths = {
65
65
  All: 12,
66
66
  } as const;
67
67
 
68
- export type TimeRange = keyof typeof timeRangeToMonths;
68
+ export type TimeRangePreset = keyof typeof timeRangeToMonths;
69
+
70
+ /** @deprecated Use `TimeRangePreset` or `string` for brush-encoded ranges. */
71
+ export type TimeRange = TimeRangePreset;
72
+
73
+ export const DRAG_TIME_RANGE_PREFIX = '__drag:' as const;
74
+
75
+ export function encodeDragTimeRange(a: Date, b: Date): string {
76
+ const t0 = Math.min(a.getTime(), b.getTime());
77
+ const t1 = Math.max(a.getTime(), b.getTime());
78
+ return `${DRAG_TIME_RANGE_PREFIX}${t0},${t1}`;
79
+ }
80
+
81
+ export function parseDragTimeRange(
82
+ s: string,
83
+ ): { start: Date; end: Date } | null {
84
+ if (!s.startsWith(DRAG_TIME_RANGE_PREFIX)) return null;
85
+ const body = s.slice(DRAG_TIME_RANGE_PREFIX.length);
86
+ const comma = body.indexOf(',');
87
+ if (comma === -1) return null;
88
+ const a = Number(body.slice(0, comma));
89
+ const b = Number(body.slice(comma + 1));
90
+ if (!Number.isFinite(a) || !Number.isFinite(b)) return null;
91
+ const t0 = Math.min(a, b);
92
+ const t1 = Math.max(a, b);
93
+ return { start: new Date(t0), end: new Date(t1) };
94
+ }
95
+
96
+ export function isTimeRangePreset(s: string): s is TimeRangePreset {
97
+ return Object.prototype.hasOwnProperty.call(timeRangeToMonths, s);
98
+ }
69
99
 
70
100
  function isPlottableNumber(value: unknown): value is number {
71
101
  return typeof value === 'number' && Number.isFinite(value);
@@ -154,11 +184,23 @@ export type FilterDataForTimeRangeOptions = {
154
184
 
155
185
  export const filterDataForTimeRange = (
156
186
  data: ChartDataPoint[],
157
- currentTimeRange: TimeRange,
187
+ currentTimeRange: string,
158
188
  options?: FilterDataForTimeRangeOptions,
159
189
  ) => {
190
+ const dragRange = parseDragTimeRange(currentTimeRange);
191
+ if (dragRange) {
192
+ const { start, end } = dragRange;
193
+ return data.filter(item => {
194
+ if (!item.date) return false;
195
+ const d = new Date(item.date);
196
+ return d >= start && d <= end;
197
+ });
198
+ }
199
+
160
200
  if (currentTimeRange === 'All') return data;
161
201
 
202
+ if (!isTimeRangePreset(currentTimeRange)) return data;
203
+
162
204
  const latestDate = computeLatestPlottableDate(data, options);
163
205
 
164
206
  // Pre-compute start date based on latest date in data