@sybilion/uilib 1.3.57 → 1.3.59
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/esm/components/ui/Chat/ChatSheet/useChatPanelChromeModel.js +30 -9
- package/dist/esm/components/ui/Sidebar/Sidebar.js +42 -3
- package/dist/esm/components/widgets/DriversComparisonChart/DriversComparisonChart.js +18 -4
- package/dist/esm/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.js +14 -5
- package/dist/esm/hooks/panelWidth.js +16 -1
- package/dist/esm/types/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.d.ts +3 -1
- package/dist/esm/types/src/hooks/panelWidth.d.ts +9 -0
- package/dist/esm/types/src/hooks/panelWidth.test.d.ts +1 -0
- package/package.json +1 -1
- package/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.tsx +34 -8
- package/src/components/ui/Sidebar/Sidebar.tsx +48 -2
- package/src/components/widgets/DriversComparisonChart/AGENT.md +2 -0
- package/src/components/widgets/DriversComparisonChart/DriversComparisonChart.tsx +30 -3
- package/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.test.ts +179 -0
- package/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.ts +26 -7
- package/src/hooks/panelWidth.test.ts +75 -0
- package/src/hooks/panelWidth.ts +28 -0
|
@@ -5,6 +5,7 @@ import { isGraphIntakeAssistantStepComplete, matchUserTextToQuickReply, isPreset
|
|
|
5
5
|
import { buildChatSendMessagePayload, displayTextFromSendPayload } from '../buildChatSendMessagePayload.js';
|
|
6
6
|
import { usedPresetIdsFromMessages } from '../chat-preset-utils.js';
|
|
7
7
|
import { useChatsForScopeId, useChat, useChatOutboundPending, useSyncChatPanelBusy, isChatEmpty } from '../../../../contexts/chat-context.js';
|
|
8
|
+
import { shellFitsSidebarsLayout } from '../../../../hooks/panelWidth.js';
|
|
8
9
|
import useEvent from '../../../../hooks/useEvent.js';
|
|
9
10
|
import { useIsMobile } from '../../../../hooks/useIsMobile.js';
|
|
10
11
|
import { useQueryParams } from '../../../../hooks/useQueryParams.js';
|
|
@@ -17,15 +18,13 @@ import { Chat } from '../Chat.js';
|
|
|
17
18
|
const NO_SCOPE_FALLBACK = '__uilib_chat_scope_unset__';
|
|
18
19
|
const SCRIPT_STEP_DELAY_MS = 1200;
|
|
19
20
|
const CHAT_NEW_SHORTCUT_KEY = 'o';
|
|
20
|
-
/** When chat panel is open, collapse left nav below this width so both don't crowd the viewport. */
|
|
21
|
-
const CHAT_NAV_COLLAPSE_BREAKPOINT_PX = 1400;
|
|
22
21
|
const CHAT_QUERY_PARAM = 'chat';
|
|
23
22
|
const CHAT_OPEN_VALUE = 'open';
|
|
24
23
|
const PROMPT_QUERY_PARAM = 'prompt';
|
|
25
24
|
function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onScriptComplete, renderMessageChart, emptyState, allowedAttachments, allowPdfAttachments, onAttachmentsDropped, slashCommandItems, onSlashItemCommand, transformSendPayload, }) {
|
|
26
25
|
const effectiveScopeId = scopeId ?? NO_SCOPE_FALLBACK;
|
|
27
26
|
const isMobile = useIsMobile();
|
|
28
|
-
const { chatPanelContainer, isOpen: sidebarNavOpen, setOpen: setSidebarNavOpen, chatWidthPx, setChatWidthPx, getShellWidth, setChatPanelOpen, } = useSidebar();
|
|
27
|
+
const { chatPanelContainer, isOpen: sidebarNavOpen, setOpen: setSidebarNavOpen, sidebarWidthPx, chatWidthPx, setChatWidthPx, getShellWidth, chatPanelOpen: shellChatPanelOpen, setChatPanelOpen, } = useSidebar();
|
|
29
28
|
const [localUiBusy, setLocalUiBusy] = useState(false);
|
|
30
29
|
const { chats, currentChatId, setCurrentChatId, newChat, sendMessage, addMessage, removeMessageById, } = useChatsForScopeId(effectiveScopeId);
|
|
31
30
|
const chat = useChat(effectiveScopeId, currentChatId);
|
|
@@ -634,6 +633,14 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
634
633
|
setIsOpen(chatOpen);
|
|
635
634
|
setChatPanelOpen(chatOpen);
|
|
636
635
|
}, [embedAsPage, chatOpen, setChatPanelOpen]);
|
|
636
|
+
/** Shell closed chat for space (e.g. nav opened); keep UI and URL in sync. */
|
|
637
|
+
useEffect(() => {
|
|
638
|
+
if (embedAsPage || shellChatPanelOpen || !isOpen) {
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
setIsOpen(false);
|
|
642
|
+
removeSearchParams(CHAT_QUERY_PARAM);
|
|
643
|
+
}, [embedAsPage, shellChatPanelOpen, isOpen, removeSearchParams]);
|
|
637
644
|
/** When this instance unmounts (e.g. route hides ChatSheet), release shell layout — URL sync alone does not run. */
|
|
638
645
|
useEffect(() => {
|
|
639
646
|
return () => {
|
|
@@ -648,16 +655,30 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
|
|
|
648
655
|
}
|
|
649
656
|
if (!isOpen || !sidebarNavOpen)
|
|
650
657
|
return;
|
|
651
|
-
const
|
|
652
|
-
|
|
658
|
+
const collapseNavIfNoSpace = () => {
|
|
659
|
+
const shellW = getShellWidth();
|
|
660
|
+
if (!shellFitsSidebarsLayout(shellW, {
|
|
661
|
+
mainSidebarOpen: true,
|
|
662
|
+
chatPanelOpen: true,
|
|
663
|
+
sidebarWidthPx,
|
|
664
|
+
chatWidthPx,
|
|
665
|
+
})) {
|
|
653
666
|
// Chat open uses `startViewTransition`; nested transition here aborts first → AbortError overlay.
|
|
654
667
|
setSidebarNavOpen(false, { viewTransition: false });
|
|
655
668
|
}
|
|
656
669
|
};
|
|
657
|
-
|
|
658
|
-
window.addEventListener('resize',
|
|
659
|
-
return () => window.removeEventListener('resize',
|
|
660
|
-
}, [
|
|
670
|
+
collapseNavIfNoSpace();
|
|
671
|
+
window.addEventListener('resize', collapseNavIfNoSpace);
|
|
672
|
+
return () => window.removeEventListener('resize', collapseNavIfNoSpace);
|
|
673
|
+
}, [
|
|
674
|
+
embedAsPage,
|
|
675
|
+
isOpen,
|
|
676
|
+
sidebarNavOpen,
|
|
677
|
+
setSidebarNavOpen,
|
|
678
|
+
getShellWidth,
|
|
679
|
+
sidebarWidthPx,
|
|
680
|
+
chatWidthPx,
|
|
681
|
+
]);
|
|
661
682
|
const renderPresets = (layout = 'fixed') => {
|
|
662
683
|
if (!presetsWithFreeform?.length)
|
|
663
684
|
return null;
|
|
@@ -5,7 +5,7 @@ import { Button } from '../Button/Button.js';
|
|
|
5
5
|
import { Separator } from '../Separator/Separator.js';
|
|
6
6
|
import { Sheet, SheetContent, SheetTitle } from '../Sheet/Sheet.js';
|
|
7
7
|
import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from '../Tooltip/Tooltip.js';
|
|
8
|
-
import { clampSidebarWidthPx, CHAT_WIDTH_STORAGE_KEY, SIDEBAR_WIDTH_DEFAULT_PX, SIDEBAR_WIDTH_MIN_PX, SIDEBAR_WIDTH_STORAGE_KEY_PX, SIDEBAR_WIDTH_STORAGE_KEY_PCT, CHAT_WIDTH_DEFAULT_PX, CHAT_WIDTH_MIN_PX, defaultChatWidthPx, clampChatWidthPx } from '../../../hooks/panelWidth.js';
|
|
8
|
+
import { clampSidebarWidthPx, CHAT_WIDTH_STORAGE_KEY, shellFitsSidebarsLayout, SIDEBAR_WIDTH_DEFAULT_PX, SIDEBAR_WIDTH_MIN_PX, SIDEBAR_WIDTH_STORAGE_KEY_PX, SIDEBAR_WIDTH_STORAGE_KEY_PCT, CHAT_WIDTH_DEFAULT_PX, CHAT_WIDTH_MIN_PX, defaultChatWidthPx, clampChatWidthPx } from '../../../hooks/panelWidth.js';
|
|
9
9
|
import useElemDrag from '../../../hooks/useDragElem.js';
|
|
10
10
|
import useEvent from '../../../hooks/useEvent.js';
|
|
11
11
|
import { useIsSidebarSheetLayout } from '../../../hooks/useIsSidebarSheetLayout.js';
|
|
@@ -80,7 +80,7 @@ function SidebarProvider({ defaultOpen = getCookie('isSidebarOpen') ?? true, cla
|
|
|
80
80
|
}, []);
|
|
81
81
|
const [sidebarWidthPx, _setSidebarWidthPx] = useState(() => readInitialSidebarWidthPx(sidebarLsKey));
|
|
82
82
|
const [chatWidthPx, _setChatWidthPx] = useState(readInitialChatWidthPx);
|
|
83
|
-
const [chatPanelOpen,
|
|
83
|
+
const [chatPanelOpen, _setChatPanelOpen] = useState(false);
|
|
84
84
|
const sidebarWidthRef = useRef(sidebarWidthPx);
|
|
85
85
|
const chatWidthRef = useRef(chatWidthPx);
|
|
86
86
|
sidebarWidthRef.current = sidebarWidthPx;
|
|
@@ -205,7 +205,46 @@ function SidebarProvider({ defaultOpen = getCookie('isSidebarOpen') ?? true, cla
|
|
|
205
205
|
fitPanelWidthsToShell,
|
|
206
206
|
]);
|
|
207
207
|
useShellWidthObserver(shellEl, handleShellResize);
|
|
208
|
+
const closeOppositeSidebarIfNoSpace = useCallback((openingMain, openingChat) => {
|
|
209
|
+
if (isSidebarSheetLayout)
|
|
210
|
+
return;
|
|
211
|
+
const shellW = getShellWidth();
|
|
212
|
+
const layoutAfterOpen = {
|
|
213
|
+
mainSidebarOpen: openingMain || isOpen,
|
|
214
|
+
chatPanelOpen: openingChat || chatPanelOpen,
|
|
215
|
+
sidebarWidthPx,
|
|
216
|
+
chatWidthPx,
|
|
217
|
+
};
|
|
218
|
+
if (shellFitsSidebarsLayout(shellW, layoutAfterOpen))
|
|
219
|
+
return;
|
|
220
|
+
if (openingMain && chatPanelOpen) {
|
|
221
|
+
_setChatPanelOpen(false);
|
|
222
|
+
}
|
|
223
|
+
else if (openingChat && isOpen) {
|
|
224
|
+
setIsOpen(false);
|
|
225
|
+
if (getCookiePreferences(userId)?.functional) {
|
|
226
|
+
setCookie('isSidebarOpen', 'false', 60 * 60 * 24 * 7);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}, [
|
|
230
|
+
isSidebarSheetLayout,
|
|
231
|
+
getShellWidth,
|
|
232
|
+
isOpen,
|
|
233
|
+
chatPanelOpen,
|
|
234
|
+
sidebarWidthPx,
|
|
235
|
+
chatWidthPx,
|
|
236
|
+
userId,
|
|
237
|
+
]);
|
|
238
|
+
const setChatPanelOpen = useCallback((open) => {
|
|
239
|
+
if (open) {
|
|
240
|
+
closeOppositeSidebarIfNoSpace(false, true);
|
|
241
|
+
}
|
|
242
|
+
_setChatPanelOpen(open);
|
|
243
|
+
}, [closeOppositeSidebarIfNoSpace]);
|
|
208
244
|
const setOpen = useCallback((value, options) => {
|
|
245
|
+
if (value) {
|
|
246
|
+
closeOppositeSidebarIfNoSpace(true, false);
|
|
247
|
+
}
|
|
209
248
|
const useViewTransition = options?.viewTransition !== false &&
|
|
210
249
|
!isSidebarSheetLayout &&
|
|
211
250
|
'startViewTransition' in document &&
|
|
@@ -221,7 +260,7 @@ function SidebarProvider({ defaultOpen = getCookie('isSidebarOpen') ?? true, cla
|
|
|
221
260
|
if (getCookiePreferences(userId)?.functional) {
|
|
222
261
|
setCookie('isSidebarOpen', value.toString(), 60 * 60 * 24 * 7);
|
|
223
262
|
}
|
|
224
|
-
}, [isSidebarSheetLayout, userId]);
|
|
263
|
+
}, [isSidebarSheetLayout, userId, closeOppositeSidebarIfNoSpace]);
|
|
225
264
|
const toggleSidebar = useCallback(() => setOpen(!isOpen), [isOpen, setOpen]);
|
|
226
265
|
useEffect(() => {
|
|
227
266
|
const shell = wrapperRef.current;
|
|
@@ -28,7 +28,7 @@ import { Table, TableHeader, TableRow, TableHead, TableCellValue, TableBody, Tab
|
|
|
28
28
|
import { TextShimmer } from '../../ui/TextShimmer/TextShimmer.js';
|
|
29
29
|
import { TIME_RANGES } from '../../ui/TimeRangeControls/TimeRangeControls.constants.js';
|
|
30
30
|
import S from './DriversComparisonChart.styl.js';
|
|
31
|
-
import { applyDriversComparisonViewToPayload, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, DRIVER_FORECAST_ID_BASE, getLagDisplayForView, resolveDriverLagLabel, INITIAL_VISIBLE_SERIES_COUNT, buildDriversComparisonChartData, formatSeriesImportance } from './driversComparisonChart.helpers.js';
|
|
31
|
+
import { applyDriversComparisonViewToPayload, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, computeDriversComparisonHistoricalWindowFloor, DRIVER_FORECAST_ID_BASE, getLagDisplayForView, resolveDriverLagLabel, INITIAL_VISIBLE_SERIES_COUNT, buildDriversComparisonChartData, formatSeriesImportance } from './driversComparisonChart.helpers.js';
|
|
32
32
|
|
|
33
33
|
const ALL_TIME_RANGE = TIME_RANGES[TIME_RANGES.length - 1];
|
|
34
34
|
function toForecastDataKey(id) {
|
|
@@ -82,6 +82,16 @@ function DriversComparisonChart({ payload, datasetHistorical = [], loading = fal
|
|
|
82
82
|
}, [payloadForView?.drivers]);
|
|
83
83
|
const mergedChartData = useMemo(() => mergeBacktestsChartData(payloadForView), [payloadForView]);
|
|
84
84
|
const mergedWithHistorical = useMemo(() => mergeDatasetHistoricalWithBacktestsChartData(datasetHistorical, mergedChartData), [datasetHistorical, mergedChartData]);
|
|
85
|
+
const historicalWindowFloor = useMemo(() => {
|
|
86
|
+
const overlappedMerged = mergeBacktestsChartData(payload);
|
|
87
|
+
const merged = mergeDatasetHistoricalWithBacktestsChartData(datasetHistorical, overlappedMerged);
|
|
88
|
+
const sortedDrivers = [...(payload?.drivers ?? [])]
|
|
89
|
+
.filter(d => d.normalized_series &&
|
|
90
|
+
Object.keys(d.normalized_series).some(key => d.normalized_series[key] != null))
|
|
91
|
+
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
92
|
+
const forecastIds = sortedDrivers.map((_, idx) => DRIVER_FORECAST_ID_BASE + idx);
|
|
93
|
+
return computeDriversComparisonHistoricalWindowFloor(merged, forecastIds);
|
|
94
|
+
}, [payload, datasetHistorical]);
|
|
85
95
|
const chartForecastData = useMemo(() => {
|
|
86
96
|
if (!payloadForView?.target?.normalized_series)
|
|
87
97
|
return [];
|
|
@@ -114,8 +124,7 @@ function DriversComparisonChart({ payload, datasetHistorical = [], loading = fal
|
|
|
114
124
|
};
|
|
115
125
|
});
|
|
116
126
|
}, [originalDriversById, sortedDriversWithData, viewTab]);
|
|
117
|
-
const seriesInitKeyResolved = seriesInitKey ??
|
|
118
|
-
`${tableSeriesRows.map(r => r.id).join(',') || 'none'}`;
|
|
127
|
+
const seriesInitKeyResolved = seriesInitKey ?? `${tableSeriesRows.map(r => r.id).join(',') || 'none'}`;
|
|
119
128
|
const backtestsSeriesInitKeyRef = useRef('');
|
|
120
129
|
useEffect(() => {
|
|
121
130
|
if (tableSeriesRows.length === 0)
|
|
@@ -135,7 +144,12 @@ function DriversComparisonChart({ payload, datasetHistorical = [], loading = fal
|
|
|
135
144
|
return next;
|
|
136
145
|
});
|
|
137
146
|
}, [seriesInitKeyResolved, tableSeriesRows]);
|
|
138
|
-
const driversComparisonChartData = useMemo(() => buildDriversComparisonChartData(mergedWithHistorical, datasetHistorical, chartForecastData.map(f => f.id)), [
|
|
147
|
+
const driversComparisonChartData = useMemo(() => buildDriversComparisonChartData(mergedWithHistorical, datasetHistorical, chartForecastData.map(f => f.id), historicalWindowFloor), [
|
|
148
|
+
chartForecastData,
|
|
149
|
+
datasetHistorical,
|
|
150
|
+
historicalWindowFloor,
|
|
151
|
+
mergedWithHistorical,
|
|
152
|
+
]);
|
|
139
153
|
const showEmptyOverlay = (runAnalysisHint || Boolean(statusHint)) && !loading;
|
|
140
154
|
return (jsxs("div", { className: cn(S.root, className), children: [jsxs("div", { className: cn(S.chartShell, loading && S.chartShellLoading), children: [jsx("div", { className: S.chartSlot, children: jsxs("div", { className: S.chartWithOverlay, children: [jsx("div", { className: cn(S.chartInteractiveLayer, showEmptyOverlay && S.chartInteractiveDimmed), children: jsx(ChartAreaInteractive, { chartRenderId: "drivers-comparison", disableLineDrawAnimation: true, disableHistoricalAnimation: true, disableTimeRangeSelector: true, enableTimeRangeBrush: true, chartContainerClassName: S.chartContainer, chartData: driversComparisonChartData, forecastData: chartForecastData, timeRange: timeRange, onTimeRangeChange: handleTimeRangeChange, pinMonth: undefined, onPinMonthChange: () => { }, isDarkTheme: isDarkTheme, loading: chartLoading, hasCombinedData: mergedWithHistorical.length > 0, forecastLineStyle: "solid", showLegend: false, hiddenSeries: hiddenSeries, toggleLegendSeries: toggleSeries, ensureAnalysisSeriesVisible: showSeries }) }), showEmptyOverlay && (jsx("div", { className: S.chartEmptyOverlay, role: "status", "aria-live": "polite", children: jsx("div", { className: S.chartEmptyBlurb, children: jsx(ChartEmptyState, { variant: "inline", hint: runAnalysisHint
|
|
141
155
|
? 'Run a completed analysis to load the drivers comparison chart for an analysis.'
|
package/dist/esm/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.js
CHANGED
|
@@ -231,17 +231,26 @@ function prependHistoricalLeadFromDataset(points, datasetHistorical, xMin) {
|
|
|
231
231
|
}
|
|
232
232
|
return [...lead, ...points];
|
|
233
233
|
}
|
|
234
|
-
|
|
234
|
+
/** Overlapped (unshifted) anchor minus lead months — stable xMin across lagged/overlapped tabs. */
|
|
235
|
+
function computeDriversComparisonHistoricalWindowFloor(mergedWithHistorical, forecastIds) {
|
|
236
|
+
if (mergedWithHistorical.length === 0 || forecastIds.length === 0)
|
|
237
|
+
return null;
|
|
238
|
+
const { anchor: anchorMonth } = getZoomAnchorMonthFromDriverMaterialStarts(mergedWithHistorical, forecastIds);
|
|
239
|
+
if (anchorMonth === null)
|
|
240
|
+
return null;
|
|
241
|
+
return subtractMonthsFromMonthStart(anchorMonth, DRIVER_COMPARISON_CHART_LEAD_MONTHS);
|
|
242
|
+
}
|
|
243
|
+
function buildDriversComparisonChartData(mergedWithHistorical, datasetHistorical, forecastIds, historicalWindowFloor) {
|
|
235
244
|
if (mergedWithHistorical.length === 0)
|
|
236
245
|
return mergedWithHistorical;
|
|
237
246
|
if (forecastIds.length === 0)
|
|
238
247
|
return mergedWithHistorical;
|
|
239
|
-
const
|
|
240
|
-
|
|
248
|
+
const xMin = historicalWindowFloor ??
|
|
249
|
+
computeDriversComparisonHistoricalWindowFloor(mergedWithHistorical, forecastIds);
|
|
250
|
+
if (xMin === null)
|
|
241
251
|
return mergedWithHistorical;
|
|
242
|
-
const xMin = subtractMonthsFromMonthStart(anchorMonth, DRIVER_COMPARISON_CHART_LEAD_MONTHS);
|
|
243
252
|
const withLead = prependHistoricalLeadFromDataset(mergedWithHistorical, datasetHistorical, xMin);
|
|
244
253
|
return withLead.filter(p => normalizeToMonthStart(p.date) >= xMin);
|
|
245
254
|
}
|
|
246
255
|
|
|
247
|
-
export { DRIVER_COMPARISON_CHART_LEAD_MONTHS, DRIVER_FORECAST_ID_BASE, INITIAL_VISIBLE_SERIES_COUNT, applyDriversComparisonViewToPayload, buildDriversComparisonChartData, formatLagMonthsLabel, formatSeriesImportance, getLagDisplayForView, getZoomAnchorMonthFromDriverMaterialStarts, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, parseLagMonthsFromLabel, prependHistoricalLeadFromDataset, resolveDriverLagLabel, shiftNormalizedSeriesForward, subtractMonthsFromMonthStart };
|
|
256
|
+
export { DRIVER_COMPARISON_CHART_LEAD_MONTHS, DRIVER_FORECAST_ID_BASE, INITIAL_VISIBLE_SERIES_COUNT, applyDriversComparisonViewToPayload, buildDriversComparisonChartData, computeDriversComparisonHistoricalWindowFloor, formatLagMonthsLabel, formatSeriesImportance, getLagDisplayForView, getZoomAnchorMonthFromDriverMaterialStarts, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, parseLagMonthsFromLabel, prependHistoricalLeadFromDataset, resolveDriverLagLabel, shiftNormalizedSeriesForward, subtractMonthsFromMonthStart };
|
|
@@ -46,5 +46,20 @@ function clampChatWidthPx(px, shellWidth, effectiveSidebarWidthPx) {
|
|
|
46
46
|
function defaultChatWidthPx(shellWidth) {
|
|
47
47
|
return clampChatWidthPx(CHAT_WIDTH_DEFAULT_PX, shellWidth, SIDEBAR_WIDTH_DEFAULT_PX);
|
|
48
48
|
}
|
|
49
|
+
/** Minimum shell width when nav and/or chat panels are open (main column at least `CENTRAL_AREA_MIN_PX`). */
|
|
50
|
+
function requiredShellWidthForSidebars(widths) {
|
|
51
|
+
const sidebarW = widths.mainSidebarOpen
|
|
52
|
+
? widths.sidebarWidthPx || SIDEBAR_WIDTH_MIN_PX
|
|
53
|
+
: 0;
|
|
54
|
+
const chatW = widths.chatPanelOpen
|
|
55
|
+
? widths.chatWidthPx || CHAT_WIDTH_MIN_PX
|
|
56
|
+
: 0;
|
|
57
|
+
return sidebarW + CENTRAL_AREA_MIN_PX + chatW;
|
|
58
|
+
}
|
|
59
|
+
function shellFitsSidebarsLayout(shellWidth, widths) {
|
|
60
|
+
if (shellWidth <= 0)
|
|
61
|
+
return true;
|
|
62
|
+
return shellWidth >= requiredShellWidthForSidebars(widths);
|
|
63
|
+
}
|
|
49
64
|
|
|
50
|
-
export { CENTRAL_AREA_MIN_PX, CHAT_WIDTH_ABS_MAX_PX, CHAT_WIDTH_DEFAULT_PX, CHAT_WIDTH_MIN_PX, CHAT_WIDTH_STORAGE_KEY, SHELL_MAX_FRACTION, SIDEBAR_SHEET_LAYOUT_MAX_WIDTH_PX, SIDEBAR_SHEET_SPLIT_MIN_VIEWPORT_PX, SIDEBAR_WIDTH_DEFAULT_PX, SIDEBAR_WIDTH_MIN_PX, SIDEBAR_WIDTH_STORAGE_KEY_PCT, SIDEBAR_WIDTH_STORAGE_KEY_PX, clampChatWidthPx, clampSidebarWidthPx, defaultChatWidthPx, maxChatWidthPx, maxSidebarWidthPx };
|
|
65
|
+
export { CENTRAL_AREA_MIN_PX, CHAT_WIDTH_ABS_MAX_PX, CHAT_WIDTH_DEFAULT_PX, CHAT_WIDTH_MIN_PX, CHAT_WIDTH_STORAGE_KEY, SHELL_MAX_FRACTION, SIDEBAR_SHEET_LAYOUT_MAX_WIDTH_PX, SIDEBAR_SHEET_SPLIT_MIN_VIEWPORT_PX, SIDEBAR_WIDTH_DEFAULT_PX, SIDEBAR_WIDTH_MIN_PX, SIDEBAR_WIDTH_STORAGE_KEY_PCT, SIDEBAR_WIDTH_STORAGE_KEY_PX, clampChatWidthPx, clampSidebarWidthPx, defaultChatWidthPx, maxChatWidthPx, maxSidebarWidthPx, requiredShellWidthForSidebars, shellFitsSidebarsLayout };
|
|
@@ -35,4 +35,6 @@ export declare function subtractMonthsFromMonthStart(date: string, count: number
|
|
|
35
35
|
* (scaled to the first normalized point) so the lead-in shows the target line alone.
|
|
36
36
|
*/
|
|
37
37
|
export declare function prependHistoricalLeadFromDataset(points: ChartDataPoint[], datasetHistorical: ChartDataPoint[], xMin: string): ChartDataPoint[];
|
|
38
|
-
|
|
38
|
+
/** Overlapped (unshifted) anchor minus lead months — stable xMin across lagged/overlapped tabs. */
|
|
39
|
+
export declare function computeDriversComparisonHistoricalWindowFloor(mergedWithHistorical: ChartDataPoint[], forecastIds: number[]): string | null;
|
|
40
|
+
export declare function buildDriversComparisonChartData(mergedWithHistorical: ChartDataPoint[], datasetHistorical: ChartDataPoint[], forecastIds: number[], historicalWindowFloor?: string | null): ChartDataPoint[];
|
|
@@ -22,3 +22,12 @@ export declare function clampSidebarWidthPx(px: number, shellWidth: number, opts
|
|
|
22
22
|
/** Width that reduces the main column when the nav sidebar is expanded; use 0 when collapsed. */
|
|
23
23
|
export declare function clampChatWidthPx(px: number, shellWidth: number, effectiveSidebarWidthPx: number): number;
|
|
24
24
|
export declare function defaultChatWidthPx(shellWidth: number): number;
|
|
25
|
+
export type SidebarsLayoutWidths = {
|
|
26
|
+
mainSidebarOpen: boolean;
|
|
27
|
+
chatPanelOpen: boolean;
|
|
28
|
+
sidebarWidthPx: number;
|
|
29
|
+
chatWidthPx: number;
|
|
30
|
+
};
|
|
31
|
+
/** Minimum shell width when nav and/or chat panels are open (main column at least `CENTRAL_AREA_MIN_PX`). */
|
|
32
|
+
export declare function requiredShellWidthForSidebars(widths: SidebarsLayoutWidths): number;
|
|
33
|
+
export declare function shellFitsSidebarsLayout(shellWidth: number, widths: SidebarsLayoutWidths): boolean;
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/package.json
CHANGED
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
useChatsForScopeId,
|
|
32
32
|
useSyncChatPanelBusy,
|
|
33
33
|
} from '#uilib/contexts/chat-context';
|
|
34
|
+
import { shellFitsSidebarsLayout } from '#uilib/hooks/panelWidth';
|
|
34
35
|
import useEvent from '#uilib/hooks/useEvent';
|
|
35
36
|
import { useIsMobile } from '#uilib/hooks/useIsMobile';
|
|
36
37
|
import { useQueryParams } from '#uilib/hooks/useQueryParams';
|
|
@@ -114,8 +115,6 @@ type PresetScriptState = {
|
|
|
114
115
|
|
|
115
116
|
const SCRIPT_STEP_DELAY_MS = 1200;
|
|
116
117
|
const CHAT_NEW_SHORTCUT_KEY = 'o';
|
|
117
|
-
/** When chat panel is open, collapse left nav below this width so both don't crowd the viewport. */
|
|
118
|
-
const CHAT_NAV_COLLAPSE_BREAKPOINT_PX = 1400;
|
|
119
118
|
|
|
120
119
|
const CHAT_QUERY_PARAM = 'chat';
|
|
121
120
|
const CHAT_OPEN_VALUE = 'open';
|
|
@@ -142,9 +141,11 @@ export function useChatPanelChromeModel({
|
|
|
142
141
|
chatPanelContainer,
|
|
143
142
|
isOpen: sidebarNavOpen,
|
|
144
143
|
setOpen: setSidebarNavOpen,
|
|
144
|
+
sidebarWidthPx,
|
|
145
145
|
chatWidthPx,
|
|
146
146
|
setChatWidthPx,
|
|
147
147
|
getShellWidth,
|
|
148
|
+
chatPanelOpen: shellChatPanelOpen,
|
|
148
149
|
setChatPanelOpen,
|
|
149
150
|
} = useSidebar();
|
|
150
151
|
const [localUiBusy, setLocalUiBusy] = useState(false);
|
|
@@ -859,6 +860,15 @@ export function useChatPanelChromeModel({
|
|
|
859
860
|
setChatPanelOpen(chatOpen);
|
|
860
861
|
}, [embedAsPage, chatOpen, setChatPanelOpen]);
|
|
861
862
|
|
|
863
|
+
/** Shell closed chat for space (e.g. nav opened); keep UI and URL in sync. */
|
|
864
|
+
useEffect(() => {
|
|
865
|
+
if (embedAsPage || shellChatPanelOpen || !isOpen) {
|
|
866
|
+
return;
|
|
867
|
+
}
|
|
868
|
+
setIsOpen(false);
|
|
869
|
+
removeSearchParams(CHAT_QUERY_PARAM);
|
|
870
|
+
}, [embedAsPage, shellChatPanelOpen, isOpen, removeSearchParams]);
|
|
871
|
+
|
|
862
872
|
/** When this instance unmounts (e.g. route hides ChatSheet), release shell layout — URL sync alone does not run. */
|
|
863
873
|
useEffect(() => {
|
|
864
874
|
return () => {
|
|
@@ -874,17 +884,33 @@ export function useChatPanelChromeModel({
|
|
|
874
884
|
}
|
|
875
885
|
if (!isOpen || !sidebarNavOpen) return;
|
|
876
886
|
|
|
877
|
-
const
|
|
878
|
-
|
|
887
|
+
const collapseNavIfNoSpace = () => {
|
|
888
|
+
const shellW = getShellWidth();
|
|
889
|
+
if (
|
|
890
|
+
!shellFitsSidebarsLayout(shellW, {
|
|
891
|
+
mainSidebarOpen: true,
|
|
892
|
+
chatPanelOpen: true,
|
|
893
|
+
sidebarWidthPx,
|
|
894
|
+
chatWidthPx,
|
|
895
|
+
})
|
|
896
|
+
) {
|
|
879
897
|
// Chat open uses `startViewTransition`; nested transition here aborts first → AbortError overlay.
|
|
880
898
|
setSidebarNavOpen(false, { viewTransition: false });
|
|
881
899
|
}
|
|
882
900
|
};
|
|
883
901
|
|
|
884
|
-
|
|
885
|
-
window.addEventListener('resize',
|
|
886
|
-
return () => window.removeEventListener('resize',
|
|
887
|
-
}, [
|
|
902
|
+
collapseNavIfNoSpace();
|
|
903
|
+
window.addEventListener('resize', collapseNavIfNoSpace);
|
|
904
|
+
return () => window.removeEventListener('resize', collapseNavIfNoSpace);
|
|
905
|
+
}, [
|
|
906
|
+
embedAsPage,
|
|
907
|
+
isOpen,
|
|
908
|
+
sidebarNavOpen,
|
|
909
|
+
setSidebarNavOpen,
|
|
910
|
+
getShellWidth,
|
|
911
|
+
sidebarWidthPx,
|
|
912
|
+
chatWidthPx,
|
|
913
|
+
]);
|
|
888
914
|
|
|
889
915
|
const renderPresets = (layout: ChatPresetsLayout = 'fixed') => {
|
|
890
916
|
if (!presetsWithFreeform?.length) return null;
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
clampChatWidthPx,
|
|
35
35
|
clampSidebarWidthPx,
|
|
36
36
|
defaultChatWidthPx,
|
|
37
|
+
shellFitsSidebarsLayout,
|
|
37
38
|
} from '#uilib/hooks/panelWidth';
|
|
38
39
|
import useElemDrag, { Delta } from '#uilib/hooks/useDragElem';
|
|
39
40
|
import useEvent from '#uilib/hooks/useEvent';
|
|
@@ -171,7 +172,7 @@ function SidebarProvider({
|
|
|
171
172
|
readInitialSidebarWidthPx(sidebarLsKey),
|
|
172
173
|
);
|
|
173
174
|
const [chatWidthPx, _setChatWidthPx] = useState(readInitialChatWidthPx);
|
|
174
|
-
const [chatPanelOpen,
|
|
175
|
+
const [chatPanelOpen, _setChatPanelOpen] = useState(false);
|
|
175
176
|
const sidebarWidthRef = useRef(sidebarWidthPx);
|
|
176
177
|
const chatWidthRef = useRef(chatWidthPx);
|
|
177
178
|
sidebarWidthRef.current = sidebarWidthPx;
|
|
@@ -311,8 +312,53 @@ function SidebarProvider({
|
|
|
311
312
|
|
|
312
313
|
useShellWidthObserver(shellEl, handleShellResize);
|
|
313
314
|
|
|
315
|
+
const closeOppositeSidebarIfNoSpace = useCallback(
|
|
316
|
+
(openingMain: boolean, openingChat: boolean) => {
|
|
317
|
+
if (isSidebarSheetLayout) return;
|
|
318
|
+
const shellW = getShellWidth();
|
|
319
|
+
const layoutAfterOpen = {
|
|
320
|
+
mainSidebarOpen: openingMain || isOpen,
|
|
321
|
+
chatPanelOpen: openingChat || chatPanelOpen,
|
|
322
|
+
sidebarWidthPx,
|
|
323
|
+
chatWidthPx,
|
|
324
|
+
};
|
|
325
|
+
if (shellFitsSidebarsLayout(shellW, layoutAfterOpen)) return;
|
|
326
|
+
if (openingMain && chatPanelOpen) {
|
|
327
|
+
_setChatPanelOpen(false);
|
|
328
|
+
} else if (openingChat && isOpen) {
|
|
329
|
+
setIsOpen(false);
|
|
330
|
+
if (getCookiePreferences(userId)?.functional) {
|
|
331
|
+
setCookie('isSidebarOpen', 'false', 60 * 60 * 24 * 7);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
},
|
|
335
|
+
[
|
|
336
|
+
isSidebarSheetLayout,
|
|
337
|
+
getShellWidth,
|
|
338
|
+
isOpen,
|
|
339
|
+
chatPanelOpen,
|
|
340
|
+
sidebarWidthPx,
|
|
341
|
+
chatWidthPx,
|
|
342
|
+
userId,
|
|
343
|
+
],
|
|
344
|
+
);
|
|
345
|
+
|
|
346
|
+
const setChatPanelOpen = useCallback(
|
|
347
|
+
(open: boolean) => {
|
|
348
|
+
if (open) {
|
|
349
|
+
closeOppositeSidebarIfNoSpace(false, true);
|
|
350
|
+
}
|
|
351
|
+
_setChatPanelOpen(open);
|
|
352
|
+
},
|
|
353
|
+
[closeOppositeSidebarIfNoSpace],
|
|
354
|
+
);
|
|
355
|
+
|
|
314
356
|
const setOpen = useCallback(
|
|
315
357
|
(value: boolean, options?: SetSidebarOpenOptions) => {
|
|
358
|
+
if (value) {
|
|
359
|
+
closeOppositeSidebarIfNoSpace(true, false);
|
|
360
|
+
}
|
|
361
|
+
|
|
316
362
|
const useViewTransition =
|
|
317
363
|
options?.viewTransition !== false &&
|
|
318
364
|
!isSidebarSheetLayout &&
|
|
@@ -331,7 +377,7 @@ function SidebarProvider({
|
|
|
331
377
|
setCookie('isSidebarOpen', value.toString(), 60 * 60 * 24 * 7);
|
|
332
378
|
}
|
|
333
379
|
},
|
|
334
|
-
[isSidebarSheetLayout, userId],
|
|
380
|
+
[isSidebarSheetLayout, userId, closeOppositeSidebarIfNoSpace],
|
|
335
381
|
);
|
|
336
382
|
|
|
337
383
|
const toggleSidebar = useCallback(() => setOpen(!isOpen), [isOpen, setOpen]);
|
|
@@ -20,4 +20,6 @@ Requires: `payload` — target + driver normalized_series; `loading` / `chartLoa
|
|
|
20
20
|
|
|
21
21
|
Lag column: **Overlapped** shows API `lag` string (may be a range). **Lagged** shows single `N month(s)` from `parseLagMonthsFromLabel` (range uses max month).
|
|
22
22
|
|
|
23
|
+
Historical window: lead-in is anchored to the **overlapped** view (`computeDriversComparisonHistoricalWindowFloor`); lagged tab shifts driver lines only — switching tabs does not change historical extent.
|
|
24
|
+
|
|
23
25
|
Empty/loading: loading props shimmer chart; null `payload` with `runAnalysisHint` prompts to run analysis.
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
INITIAL_VISIBLE_SERIES_COUNT,
|
|
36
36
|
applyDriversComparisonViewToPayload,
|
|
37
37
|
buildDriversComparisonChartData,
|
|
38
|
+
computeDriversComparisonHistoricalWindowFloor,
|
|
38
39
|
formatSeriesImportance,
|
|
39
40
|
getLagDisplayForView,
|
|
40
41
|
mergeBacktestsChartData,
|
|
@@ -158,6 +159,27 @@ export function DriversComparisonChart({
|
|
|
158
159
|
[datasetHistorical, mergedChartData],
|
|
159
160
|
);
|
|
160
161
|
|
|
162
|
+
const historicalWindowFloor = useMemo(() => {
|
|
163
|
+
const overlappedMerged = mergeBacktestsChartData(payload);
|
|
164
|
+
const merged = mergeDatasetHistoricalWithBacktestsChartData(
|
|
165
|
+
datasetHistorical,
|
|
166
|
+
overlappedMerged,
|
|
167
|
+
);
|
|
168
|
+
const sortedDrivers = [...(payload?.drivers ?? [])]
|
|
169
|
+
.filter(
|
|
170
|
+
d =>
|
|
171
|
+
d.normalized_series &&
|
|
172
|
+
Object.keys(d.normalized_series).some(
|
|
173
|
+
key => d.normalized_series![key] != null,
|
|
174
|
+
),
|
|
175
|
+
)
|
|
176
|
+
.sort((a, b) => String(a.id).localeCompare(String(b.id)));
|
|
177
|
+
const forecastIds = sortedDrivers.map(
|
|
178
|
+
(_, idx) => DRIVER_FORECAST_ID_BASE + idx,
|
|
179
|
+
);
|
|
180
|
+
return computeDriversComparisonHistoricalWindowFloor(merged, forecastIds);
|
|
181
|
+
}, [payload, datasetHistorical]);
|
|
182
|
+
|
|
161
183
|
const chartForecastData = useMemo((): ForecastItemData[] => {
|
|
162
184
|
if (!payloadForView?.target?.normalized_series) return [];
|
|
163
185
|
return sortedDriversWithData.map((driver, idx) => ({
|
|
@@ -193,8 +215,7 @@ export function DriversComparisonChart({
|
|
|
193
215
|
}, [originalDriversById, sortedDriversWithData, viewTab]);
|
|
194
216
|
|
|
195
217
|
const seriesInitKeyResolved =
|
|
196
|
-
seriesInitKey ??
|
|
197
|
-
`${tableSeriesRows.map(r => r.id).join(',') || 'none'}`;
|
|
218
|
+
seriesInitKey ?? `${tableSeriesRows.map(r => r.id).join(',') || 'none'}`;
|
|
198
219
|
|
|
199
220
|
const backtestsSeriesInitKeyRef = useRef<string>('');
|
|
200
221
|
|
|
@@ -222,8 +243,14 @@ export function DriversComparisonChart({
|
|
|
222
243
|
mergedWithHistorical,
|
|
223
244
|
datasetHistorical,
|
|
224
245
|
chartForecastData.map(f => f.id),
|
|
246
|
+
historicalWindowFloor,
|
|
225
247
|
),
|
|
226
|
-
[
|
|
248
|
+
[
|
|
249
|
+
chartForecastData,
|
|
250
|
+
datasetHistorical,
|
|
251
|
+
historicalWindowFloor,
|
|
252
|
+
mergedWithHistorical,
|
|
253
|
+
],
|
|
227
254
|
);
|
|
228
255
|
|
|
229
256
|
const showEmptyOverlay = (runAnalysisHint || Boolean(statusHint)) && !loading;
|
package/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.test.ts
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
1
|
+
import type { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
|
|
2
|
+
|
|
1
3
|
import {
|
|
4
|
+
DRIVER_COMPARISON_CHART_LEAD_MONTHS,
|
|
5
|
+
DRIVER_FORECAST_ID_BASE,
|
|
2
6
|
applyDriversComparisonViewToPayload,
|
|
7
|
+
buildDriversComparisonChartData,
|
|
8
|
+
computeDriversComparisonHistoricalWindowFloor,
|
|
3
9
|
formatLagMonthsLabel,
|
|
4
10
|
getLagDisplayForView,
|
|
11
|
+
mergeBacktestsChartData,
|
|
12
|
+
mergeDatasetHistoricalWithBacktestsChartData,
|
|
5
13
|
parseLagMonthsFromLabel,
|
|
6
14
|
resolveDriverLagLabel,
|
|
7
15
|
shiftNormalizedSeriesForward,
|
|
16
|
+
subtractMonthsFromMonthStart,
|
|
8
17
|
} from './driversComparisonChart.helpers';
|
|
9
18
|
|
|
10
19
|
describe('parseLagMonthsFromLabel', () => {
|
|
@@ -133,3 +142,173 @@ describe('applyDriversComparisonViewToPayload', () => {
|
|
|
133
142
|
expect(lagged?.drivers[0].normalized_series['2015-07-01']).toBe(0.8);
|
|
134
143
|
});
|
|
135
144
|
});
|
|
145
|
+
|
|
146
|
+
describe('computeDriversComparisonHistoricalWindowFloor', () => {
|
|
147
|
+
const laggedDriverPayload = {
|
|
148
|
+
target: {
|
|
149
|
+
id: 'target',
|
|
150
|
+
name: 'Target',
|
|
151
|
+
normalized_series: {
|
|
152
|
+
'2014-10-01': 1.0,
|
|
153
|
+
'2014-11-01': 1.01,
|
|
154
|
+
'2014-12-01': 1.02,
|
|
155
|
+
'2015-01-01': 1.03,
|
|
156
|
+
'2015-02-01': 1.04,
|
|
157
|
+
'2015-03-01': 1.05,
|
|
158
|
+
'2015-04-01': 1.06,
|
|
159
|
+
'2015-05-01': 1.07,
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
drivers: [
|
|
163
|
+
{
|
|
164
|
+
id: 'd1',
|
|
165
|
+
name: 'Driver',
|
|
166
|
+
lag: '3 month(s)',
|
|
167
|
+
normalized_series: {
|
|
168
|
+
'2015-04-01': 0.5,
|
|
169
|
+
'2015-05-01': 0.6,
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const datasetHistorical: ChartDataPoint[] = [
|
|
176
|
+
{ date: '2014-10-01', historical: 100 },
|
|
177
|
+
{ date: '2014-11-01', historical: 101 },
|
|
178
|
+
{ date: '2014-12-01', historical: 102 },
|
|
179
|
+
{ date: '2015-01-01', historical: 103 },
|
|
180
|
+
{ date: '2015-02-01', historical: 104 },
|
|
181
|
+
{ date: '2015-03-01', historical: 105 },
|
|
182
|
+
{ date: '2015-04-01', historical: 106 },
|
|
183
|
+
];
|
|
184
|
+
|
|
185
|
+
const forecastIds = [DRIVER_FORECAST_ID_BASE];
|
|
186
|
+
|
|
187
|
+
it('returns anchor minus lead months from overlapped merged data', () => {
|
|
188
|
+
const merged = mergeDatasetHistoricalWithBacktestsChartData(
|
|
189
|
+
datasetHistorical,
|
|
190
|
+
mergeBacktestsChartData(laggedDriverPayload),
|
|
191
|
+
);
|
|
192
|
+
const floor = computeDriversComparisonHistoricalWindowFloor(
|
|
193
|
+
merged,
|
|
194
|
+
forecastIds,
|
|
195
|
+
);
|
|
196
|
+
expect(floor).toBe(
|
|
197
|
+
subtractMonthsFromMonthStart(
|
|
198
|
+
'2015-04-01',
|
|
199
|
+
DRIVER_COMPARISON_CHART_LEAD_MONTHS,
|
|
200
|
+
),
|
|
201
|
+
);
|
|
202
|
+
expect(floor).toBe('2014-10-01');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('buildDriversComparisonChartData historical window floor', () => {
|
|
207
|
+
const laggedDriverPayload = {
|
|
208
|
+
target: {
|
|
209
|
+
id: 'target',
|
|
210
|
+
name: 'Target',
|
|
211
|
+
normalized_series: {
|
|
212
|
+
'2014-10-01': 1.0,
|
|
213
|
+
'2014-11-01': 1.01,
|
|
214
|
+
'2014-12-01': 1.02,
|
|
215
|
+
'2015-01-01': 1.03,
|
|
216
|
+
'2015-02-01': 1.04,
|
|
217
|
+
'2015-03-01': 1.05,
|
|
218
|
+
'2015-04-01': 1.06,
|
|
219
|
+
'2015-05-01': 1.07,
|
|
220
|
+
},
|
|
221
|
+
},
|
|
222
|
+
drivers: [
|
|
223
|
+
{
|
|
224
|
+
id: 'd1',
|
|
225
|
+
name: 'Driver',
|
|
226
|
+
lag: '3 month(s)',
|
|
227
|
+
normalized_series: {
|
|
228
|
+
'2015-04-01': 0.5,
|
|
229
|
+
'2015-05-01': 0.6,
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
],
|
|
233
|
+
};
|
|
234
|
+
|
|
235
|
+
const datasetHistorical: ChartDataPoint[] = [
|
|
236
|
+
{ date: '2014-10-01', historical: 100 },
|
|
237
|
+
{ date: '2014-11-01', historical: 101 },
|
|
238
|
+
{ date: '2014-12-01', historical: 102 },
|
|
239
|
+
{ date: '2015-01-01', historical: 103 },
|
|
240
|
+
{ date: '2015-02-01', historical: 104 },
|
|
241
|
+
{ date: '2015-03-01', historical: 105 },
|
|
242
|
+
{ date: '2015-04-01', historical: 106 },
|
|
243
|
+
];
|
|
244
|
+
|
|
245
|
+
const forecastIds = [DRIVER_FORECAST_ID_BASE];
|
|
246
|
+
|
|
247
|
+
it('keeps same xMin for lagged and overlapped when floor is pinned', () => {
|
|
248
|
+
const overlappedMerged = mergeDatasetHistoricalWithBacktestsChartData(
|
|
249
|
+
datasetHistorical,
|
|
250
|
+
mergeBacktestsChartData(laggedDriverPayload),
|
|
251
|
+
);
|
|
252
|
+
const laggedPayload = applyDriversComparisonViewToPayload(
|
|
253
|
+
laggedDriverPayload,
|
|
254
|
+
'lagged',
|
|
255
|
+
);
|
|
256
|
+
const laggedMerged = mergeDatasetHistoricalWithBacktestsChartData(
|
|
257
|
+
datasetHistorical,
|
|
258
|
+
mergeBacktestsChartData(laggedPayload),
|
|
259
|
+
);
|
|
260
|
+
const floor = computeDriversComparisonHistoricalWindowFloor(
|
|
261
|
+
overlappedMerged,
|
|
262
|
+
forecastIds,
|
|
263
|
+
)!;
|
|
264
|
+
|
|
265
|
+
const overlappedChart = buildDriversComparisonChartData(
|
|
266
|
+
overlappedMerged,
|
|
267
|
+
datasetHistorical,
|
|
268
|
+
forecastIds,
|
|
269
|
+
floor,
|
|
270
|
+
);
|
|
271
|
+
const laggedChart = buildDriversComparisonChartData(
|
|
272
|
+
laggedMerged,
|
|
273
|
+
datasetHistorical,
|
|
274
|
+
forecastIds,
|
|
275
|
+
floor,
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
expect(overlappedChart[0]?.date).toBe('2014-10-01');
|
|
279
|
+
expect(laggedChart[0]?.date).toBe('2014-10-01');
|
|
280
|
+
expect(laggedChart[0]?.date).toBe(overlappedChart[0]?.date);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('without floor override lagged chart starts later than overlapped', () => {
|
|
284
|
+
const overlappedMerged = mergeDatasetHistoricalWithBacktestsChartData(
|
|
285
|
+
datasetHistorical,
|
|
286
|
+
mergeBacktestsChartData(laggedDriverPayload),
|
|
287
|
+
);
|
|
288
|
+
const laggedPayload = applyDriversComparisonViewToPayload(
|
|
289
|
+
laggedDriverPayload,
|
|
290
|
+
'lagged',
|
|
291
|
+
);
|
|
292
|
+
const laggedMerged = mergeDatasetHistoricalWithBacktestsChartData(
|
|
293
|
+
datasetHistorical,
|
|
294
|
+
mergeBacktestsChartData(laggedPayload),
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
const overlappedChart = buildDriversComparisonChartData(
|
|
298
|
+
overlappedMerged,
|
|
299
|
+
datasetHistorical,
|
|
300
|
+
forecastIds,
|
|
301
|
+
);
|
|
302
|
+
const laggedChart = buildDriversComparisonChartData(
|
|
303
|
+
laggedMerged,
|
|
304
|
+
datasetHistorical,
|
|
305
|
+
forecastIds,
|
|
306
|
+
);
|
|
307
|
+
|
|
308
|
+
expect(overlappedChart[0]?.date).toBe('2014-10-01');
|
|
309
|
+
expect(laggedChart[0]?.date).toBe('2015-01-01');
|
|
310
|
+
expect(laggedChart[0]?.date!.localeCompare(overlappedChart[0]?.date!)).toBe(
|
|
311
|
+
1,
|
|
312
|
+
);
|
|
313
|
+
});
|
|
314
|
+
});
|
|
@@ -290,22 +290,41 @@ export function prependHistoricalLeadFromDataset(
|
|
|
290
290
|
return [...lead, ...points];
|
|
291
291
|
}
|
|
292
292
|
|
|
293
|
-
|
|
293
|
+
/** Overlapped (unshifted) anchor minus lead months — stable xMin across lagged/overlapped tabs. */
|
|
294
|
+
export function computeDriversComparisonHistoricalWindowFloor(
|
|
294
295
|
mergedWithHistorical: ChartDataPoint[],
|
|
295
|
-
datasetHistorical: ChartDataPoint[],
|
|
296
296
|
forecastIds: number[],
|
|
297
|
-
):
|
|
298
|
-
if (mergedWithHistorical.length === 0
|
|
299
|
-
|
|
297
|
+
): string | null {
|
|
298
|
+
if (mergedWithHistorical.length === 0 || forecastIds.length === 0)
|
|
299
|
+
return null;
|
|
300
300
|
const { anchor: anchorMonth } = getZoomAnchorMonthFromDriverMaterialStarts(
|
|
301
301
|
mergedWithHistorical,
|
|
302
302
|
forecastIds,
|
|
303
303
|
);
|
|
304
|
-
if (anchorMonth === null) return
|
|
305
|
-
|
|
304
|
+
if (anchorMonth === null) return null;
|
|
305
|
+
return subtractMonthsFromMonthStart(
|
|
306
306
|
anchorMonth,
|
|
307
307
|
DRIVER_COMPARISON_CHART_LEAD_MONTHS,
|
|
308
308
|
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
export function buildDriversComparisonChartData(
|
|
312
|
+
mergedWithHistorical: ChartDataPoint[],
|
|
313
|
+
datasetHistorical: ChartDataPoint[],
|
|
314
|
+
forecastIds: number[],
|
|
315
|
+
historicalWindowFloor?: string | null,
|
|
316
|
+
): ChartDataPoint[] {
|
|
317
|
+
if (mergedWithHistorical.length === 0) return mergedWithHistorical;
|
|
318
|
+
if (forecastIds.length === 0) return mergedWithHistorical;
|
|
319
|
+
|
|
320
|
+
const xMin =
|
|
321
|
+
historicalWindowFloor ??
|
|
322
|
+
computeDriversComparisonHistoricalWindowFloor(
|
|
323
|
+
mergedWithHistorical,
|
|
324
|
+
forecastIds,
|
|
325
|
+
);
|
|
326
|
+
if (xMin === null) return mergedWithHistorical;
|
|
327
|
+
|
|
309
328
|
const withLead = prependHistoricalLeadFromDataset(
|
|
310
329
|
mergedWithHistorical,
|
|
311
330
|
datasetHistorical,
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CENTRAL_AREA_MIN_PX,
|
|
3
|
+
CHAT_WIDTH_MIN_PX,
|
|
4
|
+
SIDEBAR_WIDTH_MIN_PX,
|
|
5
|
+
requiredShellWidthForSidebars,
|
|
6
|
+
shellFitsSidebarsLayout,
|
|
7
|
+
} from './panelWidth';
|
|
8
|
+
|
|
9
|
+
describe('requiredShellWidthForSidebars', () => {
|
|
10
|
+
it('sums only open panels plus central min', () => {
|
|
11
|
+
expect(
|
|
12
|
+
requiredShellWidthForSidebars({
|
|
13
|
+
mainSidebarOpen: true,
|
|
14
|
+
chatPanelOpen: true,
|
|
15
|
+
sidebarWidthPx: 320,
|
|
16
|
+
chatWidthPx: 600,
|
|
17
|
+
}),
|
|
18
|
+
).toBe(320 + CENTRAL_AREA_MIN_PX + 600);
|
|
19
|
+
|
|
20
|
+
expect(
|
|
21
|
+
requiredShellWidthForSidebars({
|
|
22
|
+
mainSidebarOpen: false,
|
|
23
|
+
chatPanelOpen: true,
|
|
24
|
+
sidebarWidthPx: 320,
|
|
25
|
+
chatWidthPx: 600,
|
|
26
|
+
}),
|
|
27
|
+
).toBe(CENTRAL_AREA_MIN_PX + 600);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('falls back to panel mins when width is zero', () => {
|
|
31
|
+
expect(
|
|
32
|
+
requiredShellWidthForSidebars({
|
|
33
|
+
mainSidebarOpen: true,
|
|
34
|
+
chatPanelOpen: true,
|
|
35
|
+
sidebarWidthPx: 0,
|
|
36
|
+
chatWidthPx: 0,
|
|
37
|
+
}),
|
|
38
|
+
).toBe(SIDEBAR_WIDTH_MIN_PX + CENTRAL_AREA_MIN_PX + CHAT_WIDTH_MIN_PX);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
describe('shellFitsSidebarsLayout', () => {
|
|
43
|
+
it('returns true when shell meets required width', () => {
|
|
44
|
+
expect(
|
|
45
|
+
shellFitsSidebarsLayout(1600, {
|
|
46
|
+
mainSidebarOpen: true,
|
|
47
|
+
chatPanelOpen: true,
|
|
48
|
+
sidebarWidthPx: 300,
|
|
49
|
+
chatWidthPx: 500,
|
|
50
|
+
}),
|
|
51
|
+
).toBe(true);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('returns false when shell is too narrow for both panels', () => {
|
|
55
|
+
expect(
|
|
56
|
+
shellFitsSidebarsLayout(1500, {
|
|
57
|
+
mainSidebarOpen: true,
|
|
58
|
+
chatPanelOpen: true,
|
|
59
|
+
sidebarWidthPx: 300,
|
|
60
|
+
chatWidthPx: 500,
|
|
61
|
+
}),
|
|
62
|
+
).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('returns true for unknown shell width', () => {
|
|
66
|
+
expect(
|
|
67
|
+
shellFitsSidebarsLayout(0, {
|
|
68
|
+
mainSidebarOpen: true,
|
|
69
|
+
chatPanelOpen: true,
|
|
70
|
+
sidebarWidthPx: 300,
|
|
71
|
+
chatWidthPx: 500,
|
|
72
|
+
}),
|
|
73
|
+
).toBe(true);
|
|
74
|
+
});
|
|
75
|
+
});
|
package/src/hooks/panelWidth.ts
CHANGED
|
@@ -78,3 +78,31 @@ export function defaultChatWidthPx(shellWidth: number): number {
|
|
|
78
78
|
SIDEBAR_WIDTH_DEFAULT_PX,
|
|
79
79
|
);
|
|
80
80
|
}
|
|
81
|
+
|
|
82
|
+
export type SidebarsLayoutWidths = {
|
|
83
|
+
mainSidebarOpen: boolean;
|
|
84
|
+
chatPanelOpen: boolean;
|
|
85
|
+
sidebarWidthPx: number;
|
|
86
|
+
chatWidthPx: number;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/** Minimum shell width when nav and/or chat panels are open (main column at least `CENTRAL_AREA_MIN_PX`). */
|
|
90
|
+
export function requiredShellWidthForSidebars(
|
|
91
|
+
widths: SidebarsLayoutWidths,
|
|
92
|
+
): number {
|
|
93
|
+
const sidebarW = widths.mainSidebarOpen
|
|
94
|
+
? widths.sidebarWidthPx || SIDEBAR_WIDTH_MIN_PX
|
|
95
|
+
: 0;
|
|
96
|
+
const chatW = widths.chatPanelOpen
|
|
97
|
+
? widths.chatWidthPx || CHAT_WIDTH_MIN_PX
|
|
98
|
+
: 0;
|
|
99
|
+
return sidebarW + CENTRAL_AREA_MIN_PX + chatW;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function shellFitsSidebarsLayout(
|
|
103
|
+
shellWidth: number,
|
|
104
|
+
widths: SidebarsLayoutWidths,
|
|
105
|
+
): boolean {
|
|
106
|
+
if (shellWidth <= 0) return true;
|
|
107
|
+
return shellWidth >= requiredShellWidthForSidebars(widths);
|
|
108
|
+
}
|