@sybilion/uilib 1.3.21 → 1.3.23

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 (26) hide show
  1. package/dist/esm/components/ui/Chat/ChatSheet/useChatPanelChromeModel.js +21 -4
  2. package/dist/esm/components/widgets/DriversComparisonChart/DriversComparisonChart.js +139 -0
  3. package/dist/esm/components/widgets/DriversComparisonChart/DriversComparisonChart.styl.js +7 -0
  4. package/dist/esm/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.js +159 -0
  5. package/dist/esm/index.js +2 -0
  6. package/dist/esm/types/src/components/ui/Chat/ChatEmptyState/ChatEmptyState.types.d.ts +9 -1
  7. package/dist/esm/types/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.d.ts +2 -2
  8. package/dist/esm/types/src/components/ui/Chat/index.d.ts +1 -0
  9. package/dist/esm/types/src/components/widgets/DriversComparisonChart/DriversComparisonChart.d.ts +18 -0
  10. package/dist/esm/types/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.d.ts +26 -0
  11. package/dist/esm/types/src/components/widgets/DriversComparisonChart/index.d.ts +2 -0
  12. package/dist/esm/types/src/docs/pages/DriversComparisonChartPage.d.ts +1 -0
  13. package/dist/esm/types/src/index.d.ts +1 -0
  14. package/dist/esm/utils/chartConnectionPoint.js +9 -1
  15. package/package.json +1 -1
  16. package/src/components/ui/Chat/ChatEmptyState/ChatEmptyState.types.ts +17 -1
  17. package/src/components/ui/Chat/ChatSheet/useChatPanelChromeModel.tsx +23 -4
  18. package/src/components/ui/Chat/index.ts +5 -0
  19. package/src/components/widgets/DriversComparisonChart/DriversComparisonChart.styl +145 -0
  20. package/src/components/widgets/DriversComparisonChart/DriversComparisonChart.styl.d.ts +29 -0
  21. package/src/components/widgets/DriversComparisonChart/DriversComparisonChart.tsx +325 -0
  22. package/src/components/widgets/DriversComparisonChart/driversComparisonChart.helpers.ts +206 -0
  23. package/src/components/widgets/DriversComparisonChart/index.ts +13 -0
  24. package/src/docs/pages/DriversComparisonChartPage.tsx +174 -0
  25. package/src/docs/registry.ts +6 -0
  26. package/src/index.ts +1 -0
@@ -1,7 +1,7 @@
1
1
  import { jsx } from 'react/jsx-runtime';
2
2
  import { useState, useRef, useEffect, useMemo, useCallback } from 'react';
3
3
  import { MessageRole, GENERATING_DASHBOARD_SYSTEM_TEXT } from '../Chat.types.js';
4
- import { isGraphIntakeAssistantStepComplete, matchUserTextToQuickReply, parseScriptLine, textHasQuickReplyMarkers, branchKeysUsedFromChatHistory, branchKeysUsedByUserMessages, extractQuickReplyLabelKeyPairsFromText, entryBranchKeyBeforeLastAssistant, isPresetScriptGraph, branchesFromPresetScriptGraph } from '../ChatMessage/presetScript.js';
4
+ import { isGraphIntakeAssistantStepComplete, matchUserTextToQuickReply, isPresetScriptGraph, branchesFromPresetScriptGraph, parseScriptLine, textHasQuickReplyMarkers, branchKeysUsedFromChatHistory, branchKeysUsedByUserMessages, extractQuickReplyLabelKeyPairsFromText, entryBranchKeyBeforeLastAssistant } from '../ChatMessage/presetScript.js';
5
5
  import { buildChatSendMessagePayload, displayTextFromSendPayload } from '../buildChatSendMessagePayload.js';
6
6
  import { usedPresetIdsFromMessages, formatChatTranscript } from '../chat-preset-utils.js';
7
7
  import { useChatsForScopeId, useChat, useChatOutboundPending, useSyncChatPanelBusy, isChatEmpty } from '../../../../contexts/chat-context.js';
@@ -425,7 +425,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
425
425
  onMessage,
426
426
  onScriptComplete,
427
427
  ]);
428
- const submitPreset = async (preset) => {
428
+ const submitPreset = useCallback(async (preset) => {
429
429
  const script = preset.script;
430
430
  const scriptGraph = isPresetScriptGraph(script);
431
431
  const hasLinearScript = Array.isArray(script) && script.length > 0;
@@ -518,7 +518,24 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
518
518
  finally {
519
519
  setLocalUiBusy(false);
520
520
  }
521
- };
521
+ }, [
522
+ currentChatId,
523
+ endLocalDemoFlow,
524
+ handlePromptSubmit,
525
+ addMessage,
526
+ presetsWithFreeform,
527
+ ]);
528
+ const resolvedEmptyState = useMemo(() => {
529
+ if (!emptyState)
530
+ return undefined;
531
+ const { additionalContent, ...rest } = emptyState;
532
+ return {
533
+ ...rest,
534
+ additionalContent: typeof additionalContent === 'function'
535
+ ? additionalContent({ submitPreset })
536
+ : additionalContent,
537
+ };
538
+ }, [emptyState, submitPreset]);
522
539
  const activeScript = currentChatId
523
540
  ? scriptByChatId[currentChatId]
524
541
  : undefined;
@@ -765,7 +782,7 @@ function useChatPanelChromeModel({ embedAsPage, presets, scopeId, onMessage, onS
765
782
  onPromptSubmit: handlePromptSubmit,
766
783
  onChatDeleted: endLocalDemoFlow,
767
784
  promptPrefill: promptLinkPrefill,
768
- emptyState,
785
+ emptyState: resolvedEmptyState,
769
786
  allowedAttachments,
770
787
  allowPdfAttachments,
771
788
  onAttachmentsDropped,
@@ -0,0 +1,139 @@
1
+ import { jsxs, jsx } from 'react/jsx-runtime';
2
+ import cn from 'classnames';
3
+ import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
4
+ import { ChartEmptyState } from '../../ui/Chart/components/ChartEmptyState/ChartEmptyState.js';
5
+ import { ChartAreaInteractive } from '../../ui/ChartAreaInteractive/ChartAreaInteractive.js';
6
+ import { getForecastColor } from '../../ui/ChartAreaInteractive/ChartLines.js';
7
+ import '../../ui/Page/AppShell/AppShell.js';
8
+ import 'react-router-dom';
9
+ import '../../ui/Sidebar/Sidebar.js';
10
+ import 'lucide-react';
11
+ import '../../ui/Page/Breadcrumbs/Breadcrumbs.styl.js';
12
+ import '../../ui/Page/pageContext.js';
13
+ import '@radix-ui/react-tooltip';
14
+ import '../../ui/Tooltip/Tooltip.styl.js';
15
+ import '../../ui/Page/PageHeader/PageHeader.styl.js';
16
+ import '../../ui/Page/PageEmptyCanvas/PageEmptyCanvas.styl.js';
17
+ import '../../ui/Page/PageContent/PageContent.styl.js';
18
+ import '@homecode/ui';
19
+ import '../../ui/Page/PageScroll/PageScroll.styl.js';
20
+ import { PageXScroll } from '../../ui/Page/PageXScroll/PageXScroll.js';
21
+ import '../../ui/Page/PageFooter/PageFooter.styl.js';
22
+ import '@radix-ui/react-tabs';
23
+ import '../../ui/Tabs/Tabs.styl.js';
24
+ import '../../ui/Page/PageTabs/PageTabs.styl.js';
25
+ import '../../ui/Page/PageColumns/PageColumns.styl.js';
26
+ import '../../ui/Page/SectionHeader/SectionHeader.styl.js';
27
+ import { Table, TableHeader, TableRow, TableHead, TableBody, TableCell } from '../../ui/Table/Table.js';
28
+ import { TextShimmer } from '../../ui/TextShimmer/TextShimmer.js';
29
+ import { TIME_RANGES } from '../../ui/TimeRangeControls/TimeRangeControls.constants.js';
30
+ import S from './DriversComparisonChart.styl.js';
31
+ import { mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, DRIVER_FORECAST_ID_BASE, INITIAL_VISIBLE_SERIES_COUNT, buildDriversComparisonChartData, formatSeriesImportance } from './driversComparisonChart.helpers.js';
32
+
33
+ const ALL_TIME_RANGE = TIME_RANGES[TIME_RANGES.length - 1];
34
+ function toForecastDataKey(id) {
35
+ const s = String(id);
36
+ return s.startsWith('forecast_') ? s : `forecast_${id}`;
37
+ }
38
+ function DriversComparisonChart({ payload, datasetHistorical = [], loading = false, chartLoading = false, statusHint = null, statusTone = 'muted', runAnalysisHint = false, timeRange: timeRangeProp, onTimeRangeChange, isDarkTheme = false, className, seriesInitKey, }) {
39
+ const [internalTimeRange, setInternalTimeRange] = useState(ALL_TIME_RANGE);
40
+ const [hiddenSeries, setHiddenSeries] = useState(new Set());
41
+ const timeRange = timeRangeProp ?? internalTimeRange;
42
+ const handleTimeRangeChange = useCallback((val) => {
43
+ if (!val)
44
+ return;
45
+ onTimeRangeChange?.(val);
46
+ if (timeRangeProp === undefined) {
47
+ setInternalTimeRange(val);
48
+ }
49
+ }, [onTimeRangeChange, timeRangeProp]);
50
+ const toggleSeries = useCallback((id) => {
51
+ const key = toForecastDataKey(id);
52
+ setHiddenSeries(prev => {
53
+ const next = new Set(prev);
54
+ if (next.has(key))
55
+ next.delete(key);
56
+ else
57
+ next.add(key);
58
+ return next;
59
+ });
60
+ }, []);
61
+ const showSeries = useCallback((id) => {
62
+ const key = toForecastDataKey(id);
63
+ setHiddenSeries(prev => {
64
+ if (!prev.has(key))
65
+ return prev;
66
+ const next = new Set(prev);
67
+ next.delete(key);
68
+ return next;
69
+ });
70
+ }, []);
71
+ const sortedDriversWithData = useMemo(() => {
72
+ const driversList = payload?.drivers ?? [];
73
+ if (!driversList.length)
74
+ return [];
75
+ return [...driversList]
76
+ .filter(d => d.normalized_series &&
77
+ Object.keys(d.normalized_series).some(key => d.normalized_series[key] != null))
78
+ .sort((a, b) => String(a.id).localeCompare(String(b.id)));
79
+ }, [payload?.drivers]);
80
+ const mergedChartData = useMemo(() => mergeBacktestsChartData(payload), [payload]);
81
+ const mergedWithHistorical = useMemo(() => mergeDatasetHistoricalWithBacktestsChartData(datasetHistorical, mergedChartData), [datasetHistorical, mergedChartData]);
82
+ const chartForecastData = useMemo(() => {
83
+ if (!payload?.target?.normalized_series)
84
+ return [];
85
+ return sortedDriversWithData.map((driver, idx) => ({
86
+ id: DRIVER_FORECAST_ID_BASE + idx,
87
+ name: driver.name || String(driver.id),
88
+ color: getForecastColor(idx + 1),
89
+ }));
90
+ }, [payload?.target?.normalized_series, sortedDriversWithData]);
91
+ const tableSeriesRows = useMemo(() => {
92
+ if (sortedDriversWithData.length === 0)
93
+ return [];
94
+ return sortedDriversWithData.map((driver, idx) => {
95
+ const raw = driver.importance;
96
+ const importance = typeof raw === 'number' && Number.isFinite(raw) ? raw : null;
97
+ return {
98
+ id: DRIVER_FORECAST_ID_BASE + idx,
99
+ name: driver.name || String(driver.id),
100
+ color: getForecastColor(idx + 1),
101
+ importance,
102
+ lag: driver.lag,
103
+ };
104
+ });
105
+ }, [sortedDriversWithData]);
106
+ const seriesInitKeyResolved = seriesInitKey ?? `${tableSeriesRows.map(r => r.id).join(',') || 'none'}`;
107
+ const backtestsSeriesInitKeyRef = useRef('');
108
+ useEffect(() => {
109
+ if (tableSeriesRows.length === 0)
110
+ return;
111
+ const initKey = seriesInitKeyResolved;
112
+ if (backtestsSeriesInitKeyRef.current === initKey)
113
+ return;
114
+ backtestsSeriesInitKeyRef.current = initKey;
115
+ setHiddenSeries(() => {
116
+ const next = new Set();
117
+ tableSeriesRows.forEach((row, idx) => {
118
+ const key = toForecastDataKey(row.id);
119
+ if (idx >= INITIAL_VISIBLE_SERIES_COUNT) {
120
+ next.add(key);
121
+ }
122
+ });
123
+ return next;
124
+ });
125
+ }, [seriesInitKeyResolved, tableSeriesRows]);
126
+ const driversComparisonChartData = useMemo(() => buildDriversComparisonChartData(mergedWithHistorical, datasetHistorical, chartForecastData.map(f => f.id)), [chartForecastData, datasetHistorical, mergedWithHistorical]);
127
+ const showEmptyOverlay = (runAnalysisHint || Boolean(statusHint)) && !loading;
128
+ 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, { 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
129
+ ? 'Run a completed analysis to load the drivers comparison chart for an analysis.'
130
+ : undefined, status: statusHint ?? undefined, statusTone: statusTone }) }) }))] }) }), loading && (jsx("div", { className: S.loadingLayer, "aria-busy": "true", "aria-live": "polite", children: jsx("div", { className: S.loadingMessage, children: jsx(TextShimmer, { as: "span", className: S.loadingText, children: "Loading drivers comparison\u2026" }) }) }))] }), jsx("div", { className: S.seriesSection, children: tableSeriesRows.length === 0 ? (jsx("div", { className: S.seriesEmptyWrap, children: jsx("div", { className: S.chartEmptyBlurb, children: jsx(ChartEmptyState, { variant: "inline", align: "center", status: "No series" }) }) })) : (jsx("div", { className: S.seriesTableWrapper, children: jsx(PageXScroll, { size: "md", fullWidth: true, innerClassName: S.seriesTableContainer, scrollbarClassName: S.seriesScrollbar, children: jsxs(Table, { withBackground: true, withPaddings: true, className: S.seriesTable, children: [jsx(TableHeader, { children: jsxs(TableRow, { children: [jsx(TableHead, { className: S.seriesColSeries, children: "Driver name" }), jsx(TableHead, { children: "Importance" }), jsx(TableHead, { children: "Lag" })] }) }), jsx(TableBody, { children: tableSeriesRows.map(row => {
131
+ const dataKey = toForecastDataKey(row.id);
132
+ const hidden = hiddenSeries.has(dataKey);
133
+ return (jsxs(TableRow, { className: cn(hidden && S.rowHidden), onClick: () => toggleSeries(row.id), children: [jsx(TableCell, { children: jsxs("span", { className: S.seriesLabel, children: [jsx("span", { className: S.colorSwatch, style: {
134
+ backgroundColor: row.color,
135
+ } }), row.name ?? String(row.id)] }) }), jsx(TableCell, { children: formatSeriesImportance(row.importance) }), jsx(TableCell, { children: row.lag ?? '—' })] }, row.id));
136
+ }) })] }) }) })) })] }));
137
+ }
138
+
139
+ export { DriversComparisonChart };
@@ -0,0 +1,7 @@
1
+ import styleInject from 'style-inject';
2
+
3
+ var css_248z = "@media (max-width:768px){:root{--page-x-padding:var(--p-6);--page-y-padding:var(--p-6)}}.DriversComparisonChart_root__GLnlF{display:flex;flex-direction:column;gap:var(--p-4);width:100%}.DriversComparisonChart_chartShell__0AzAz{position:relative;width:100%}.DriversComparisonChart_chartShellLoading__wTLod .DriversComparisonChart_chartSlot__WKe6N{opacity:.3;pointer-events:none}.DriversComparisonChart_chartShellLoading__wTLod .recharts-line-dots{display:none}.DriversComparisonChart_chartSlot__WKe6N{width:100%}.DriversComparisonChart_loadingLayer__HlEmA{inset:0;pointer-events:none;position:absolute;z-index:10}.DriversComparisonChart_loadingMessage__Li3S7{color:var(--foreground);display:block;left:0;position:absolute;text-align:center;top:calc(50% - 4em);width:100%;z-index:1}.DriversComparisonChart_loadingText__0y3Ms{backdrop-filter:blur(10px);border-radius:var(--p-4);box-shadow:0 0 0 2px var(--page-color);color:var(--foreground);font-size:16px;font-weight:500;padding:0 var(--p-3)}.DriversComparisonChart_chartWithOverlay__SMToK{position:relative;width:100%}.DriversComparisonChart_chartInteractiveLayer__0aquh{transition:opacity .2s ease-out;width:100%}.DriversComparisonChart_chartInteractiveDimmed__us1dv{opacity:.3;pointer-events:none}.DriversComparisonChart_chartInteractiveDimmed__us1dv .recharts-line-dots{display:none}.DriversComparisonChart_chartEmptyOverlay__eXqzk{align-items:center;display:flex;inset:0;justify-content:center;padding:var(--p-4);pointer-events:none;position:absolute;z-index:6}.DriversComparisonChart_chartEmptyBlurb__Tjy9E{max-width:42rem;padding:0 var(--p-3)}.DriversComparisonChart_seriesEmptyWrap__RyRs5{display:flex;justify-content:center;padding:var(--p-8) var(--p-4)}.DriversComparisonChart_seriesSection__yrf4I{width:100%}.DriversComparisonChart_seriesTableWrapper__oredC{margin:0 calc(var(--page-x-padding)*-1);position:relative}.DriversComparisonChart_seriesTableContainer__dhfXK{overflow-x:auto;position:relative;width:100%}.DriversComparisonChart_seriesScrollbar__2jgk6{bottom:calc(var(--p-7)*-1 + 2px)!important}.DriversComparisonChart_seriesTable__NybPR{max-width:100%;table-layout:auto;width:100%}.DriversComparisonChart_seriesTable__NybPR tr{cursor:pointer;position:relative}.DriversComparisonChart_seriesTable__NybPR td:not(:first-child),.DriversComparisonChart_seriesTable__NybPR th:not(:first-child){text-align:right}.DriversComparisonChart_seriesColSeries__--0mj{text-align:left;vertical-align:middle}.DriversComparisonChart_rowHidden__Fwhna{opacity:.4}.DriversComparisonChart_colorSwatch__o3ZVr{border-radius:2px;display:inline-flex;flex-shrink:0;height:10px;margin-right:var(--p-2);width:10px}.DriversComparisonChart_seriesLabel__Aidof{display:-webkit-box;-webkit-line-clamp:3;max-width:60vw;min-width:0;padding:var(--p-2) 0;-webkit-box-orient:vertical;white-space:break-spaces}.DriversComparisonChart_chartContainer__1eM-O{margin-left:calc(var(--page-x-padding)*-1 + 26px);max-width:calc(100% + 60px);transition:opacity .3s ease-out;width:calc(100% + 60px)}.DriversComparisonChart_chartContainer__1eM-O .recharts-yAxis-tick-labels{display:none}@media (max-width:768px){.DriversComparisonChart_chartContainer__1eM-O{margin-left:calc(var(--page-x-padding)*-1 + 10px);width:calc(100% + 30px)}}";
4
+ var S = {"root":"DriversComparisonChart_root__GLnlF","chartShell":"DriversComparisonChart_chartShell__0AzAz","chartShellLoading":"DriversComparisonChart_chartShellLoading__wTLod","chartSlot":"DriversComparisonChart_chartSlot__WKe6N","loadingLayer":"DriversComparisonChart_loadingLayer__HlEmA","loadingMessage":"DriversComparisonChart_loadingMessage__Li3S7","loadingText":"DriversComparisonChart_loadingText__0y3Ms","chartWithOverlay":"DriversComparisonChart_chartWithOverlay__SMToK","chartInteractiveLayer":"DriversComparisonChart_chartInteractiveLayer__0aquh","chartInteractiveDimmed":"DriversComparisonChart_chartInteractiveDimmed__us1dv","chartEmptyOverlay":"DriversComparisonChart_chartEmptyOverlay__eXqzk","chartEmptyBlurb":"DriversComparisonChart_chartEmptyBlurb__Tjy9E","seriesEmptyWrap":"DriversComparisonChart_seriesEmptyWrap__RyRs5","seriesSection":"DriversComparisonChart_seriesSection__yrf4I","seriesTableWrapper":"DriversComparisonChart_seriesTableWrapper__oredC","seriesTableContainer":"DriversComparisonChart_seriesTableContainer__dhfXK","seriesScrollbar":"DriversComparisonChart_seriesScrollbar__2jgk6","seriesTable":"DriversComparisonChart_seriesTable__NybPR","seriesColSeries":"DriversComparisonChart_seriesColSeries__--0mj","rowHidden":"DriversComparisonChart_rowHidden__Fwhna","colorSwatch":"DriversComparisonChart_colorSwatch__o3ZVr","seriesLabel":"DriversComparisonChart_seriesLabel__Aidof","chartContainer":"DriversComparisonChart_chartContainer__1eM-O"};
5
+ styleInject(css_248z);
6
+
7
+ export { S as default };
@@ -0,0 +1,159 @@
1
+ import { normalizeToMonthStart, getPreviousMonth, getNextMonth } from '../../../utils/chartConnectionPoint.js';
2
+
3
+ const DRIVER_FORECAST_ID_BASE = 8_000_000;
4
+ const INITIAL_VISIBLE_SERIES_COUNT = 3;
5
+ /** Months of historical context before the earliest forecast month (inclusive). */
6
+ const DRIVER_COMPARISON_CHART_LEAD_MONTHS = 6;
7
+ /** Ignore leading months where drivers are numerically zero (API often fills 0; lines look empty until real signal). */
8
+ const DRIVER_FORECAST_NONZERO_EPS = 1e-9;
9
+ function formatSeriesImportance(value) {
10
+ if (value === null)
11
+ return '—';
12
+ return `${value.toFixed(1)}%`;
13
+ }
14
+ function mergeBacktestsChartData(payload) {
15
+ if (!payload?.target?.normalized_series)
16
+ return [];
17
+ const norm = (d) => normalizeToMonthStart(d);
18
+ const map = new Map();
19
+ const targetSeries = payload.target.normalized_series;
20
+ Object.entries(targetSeries).forEach(([dateStr, val]) => {
21
+ if (val === null || val === undefined)
22
+ return;
23
+ const k = norm(dateStr);
24
+ const existing = map.get(k) ?? { date: k };
25
+ existing.historical = val;
26
+ map.set(k, existing);
27
+ });
28
+ if (payload.drivers?.length) {
29
+ const sorted = [...payload.drivers]
30
+ .filter(d => d.normalized_series &&
31
+ Object.keys(d.normalized_series).some(key => d.normalized_series[key] != null))
32
+ .sort((a, b) => String(a.id).localeCompare(String(b.id)));
33
+ sorted.forEach((driver, idx) => {
34
+ const fid = DRIVER_FORECAST_ID_BASE + idx;
35
+ const series = driver.normalized_series;
36
+ Object.entries(series).forEach(([dateStr, val]) => {
37
+ if (val === null || val === undefined)
38
+ return;
39
+ const k = norm(dateStr);
40
+ const existing = map.get(k) ?? { date: k };
41
+ existing[`forecast_${fid}`] = val;
42
+ map.set(k, existing);
43
+ });
44
+ });
45
+ }
46
+ return Array.from(map.values()).sort((a, b) => a.date.localeCompare(b.date));
47
+ }
48
+ /** While drivers comparison is not loaded: dataset historical only. After load: normalized historical from target.normalized_series (see mergeBacktestsChartData) replaces it. */
49
+ function mergeDatasetHistoricalWithBacktestsChartData(datasetHistorical, backtestsMerged) {
50
+ if (backtestsMerged.length === 0) {
51
+ if (datasetHistorical.length === 0)
52
+ return [];
53
+ return datasetHistorical
54
+ .map(p => {
55
+ const k = normalizeToMonthStart(p.date);
56
+ const histVal = p.historical;
57
+ return {
58
+ date: k,
59
+ ...(typeof histVal === 'number' ? { historical: histVal } : {}),
60
+ };
61
+ })
62
+ .sort((a, b) => a.date.localeCompare(b.date));
63
+ }
64
+ return backtestsMerged;
65
+ }
66
+ /** Lower-median of ISO month strings (YYYY-MM-01 sorts lexicographically). */
67
+ function medianSortedIsoMonth(months) {
68
+ const s = [...months].sort((a, b) => a.localeCompare(b));
69
+ return s[Math.floor((s.length - 1) / 2)];
70
+ }
71
+ /**
72
+ * For each driver, first month with materially non-zero value; anchor = median of those months.
73
+ * (Min is too early with outliers; max matched logs where one driver started 2017-12 while most started 2015-01.)
74
+ */
75
+ function getZoomAnchorMonthFromDriverMaterialStarts(points, forecastIds) {
76
+ const perDriverFirstMaterial = [];
77
+ for (const id of forecastIds) {
78
+ let minForId = null;
79
+ for (const p of points) {
80
+ const v = p[`forecast_${id}`];
81
+ if (typeof v !== 'number' ||
82
+ !Number.isFinite(v) ||
83
+ Math.abs(v) <= DRIVER_FORECAST_NONZERO_EPS) {
84
+ continue;
85
+ }
86
+ const d = normalizeToMonthStart(p.date);
87
+ if (minForId === null || d.localeCompare(minForId) < 0)
88
+ minForId = d;
89
+ }
90
+ if (minForId === null) {
91
+ return { anchor: null, perDriverFirstMaterial };
92
+ }
93
+ perDriverFirstMaterial.push(minForId);
94
+ }
95
+ const anchor = medianSortedIsoMonth(perDriverFirstMaterial);
96
+ return { anchor, perDriverFirstMaterial };
97
+ }
98
+ function subtractMonthsFromMonthStart(date, count) {
99
+ let result = date;
100
+ for (let i = 0; i < count; i++) {
101
+ result = getPreviousMonth(result);
102
+ }
103
+ return result;
104
+ }
105
+ /**
106
+ * Backtests payload often starts at the same month for target + drivers, so xMin falls
107
+ * in a range with no rows. Prepend historical-only months from raw dataset series
108
+ * (scaled to the first normalized point) so the lead-in shows the target line alone.
109
+ */
110
+ function prependHistoricalLeadFromDataset(points, datasetHistorical, xMin) {
111
+ if (points.length === 0)
112
+ return points;
113
+ const firstMonth = normalizeToMonthStart(points[0].date);
114
+ if (xMin.localeCompare(firstMonth) >= 0)
115
+ return points;
116
+ const rawByMonth = new Map();
117
+ for (const p of datasetHistorical) {
118
+ const k = normalizeToMonthStart(p.date);
119
+ const v = p.historical;
120
+ if (typeof v === 'number' && Number.isFinite(v))
121
+ rawByMonth.set(k, v);
122
+ }
123
+ const histFirst = points[0].historical;
124
+ const rawFirst = rawByMonth.get(firstMonth);
125
+ if (typeof histFirst !== 'number' ||
126
+ !Number.isFinite(histFirst) ||
127
+ typeof rawFirst !== 'number' ||
128
+ !Number.isFinite(rawFirst) ||
129
+ Math.abs(rawFirst) <= 1e-15) {
130
+ return points;
131
+ }
132
+ const lead = [];
133
+ let m = xMin;
134
+ while (m.localeCompare(firstMonth) < 0) {
135
+ const rawM = rawByMonth.get(m);
136
+ if (typeof rawM === 'number' && Number.isFinite(rawM)) {
137
+ lead.push({
138
+ date: m,
139
+ historical: histFirst * (rawM / rawFirst),
140
+ });
141
+ }
142
+ m = getNextMonth(m);
143
+ }
144
+ return [...lead, ...points];
145
+ }
146
+ function buildDriversComparisonChartData(mergedWithHistorical, datasetHistorical, forecastIds) {
147
+ if (mergedWithHistorical.length === 0)
148
+ return mergedWithHistorical;
149
+ if (forecastIds.length === 0)
150
+ return mergedWithHistorical;
151
+ const { anchor: anchorMonth } = getZoomAnchorMonthFromDriverMaterialStarts(mergedWithHistorical, forecastIds);
152
+ if (anchorMonth === null)
153
+ return mergedWithHistorical;
154
+ const xMin = subtractMonthsFromMonthStart(anchorMonth, DRIVER_COMPARISON_CHART_LEAD_MONTHS);
155
+ const withLead = prependHistoricalLeadFromDataset(mergedWithHistorical, datasetHistorical, xMin);
156
+ return withLead.filter(p => normalizeToMonthStart(p.date) >= xMin);
157
+ }
158
+
159
+ export { DRIVER_COMPARISON_CHART_LEAD_MONTHS, DRIVER_FORECAST_ID_BASE, INITIAL_VISIBLE_SERIES_COUNT, buildDriversComparisonChartData, formatSeriesImportance, getZoomAnchorMonthFromDriverMaterialStarts, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, prependHistoricalLeadFromDataset, subtractMonthsFromMonthStart };
package/dist/esm/index.js CHANGED
@@ -104,6 +104,8 @@ export { DriverMap } from './components/widgets/DriverMap/DriverMap.js';
104
104
  export { getCategoryIcon } from './components/widgets/DriverMap/driverCategoryIcon.js';
105
105
  export { getDriverImportance, getHighestImportanceDriver } from './components/widgets/DriverMap/driverMapSelection.js';
106
106
  export { geographicCoordinates, geographicToSVG, getContinentFromRegion, getPreciseCoordinates, getResponsiveCoordinates, svgToPercentage } from './components/widgets/DriverMap/driverMapGeography.js';
107
+ export { DriversComparisonChart } from './components/widgets/DriversComparisonChart/DriversComparisonChart.js';
108
+ export { DRIVER_COMPARISON_CHART_LEAD_MONTHS, DRIVER_FORECAST_ID_BASE, INITIAL_VISIBLE_SERIES_COUNT, buildDriversComparisonChartData, formatSeriesImportance, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData } from './components/widgets/DriversComparisonChart/driversComparisonChart.helpers.js';
107
109
  export { SybilionAppHeader } from './components/widgets/SybilionAppHeader/SybilionAppHeader.js';
108
110
  export { SybilionAuthLayout } from './components/widgets/SybilionAuthLayout/SybilionAuthLayout.js';
109
111
  export { SybilionAuthHeadline } from './components/widgets/SybilionAuthLayout/SybilionAuthHeadline.js';
@@ -1,7 +1,15 @@
1
+ import type { ChatPreset } from '#uilib/components/ui/Chat/Chat.types';
2
+ export type ChatEmptyStateContext = {
3
+ submitPreset: (preset: ChatPreset) => void | Promise<void>;
4
+ };
1
5
  export interface ChatEmptyStateProps {
2
6
  icon?: React.ReactNode;
3
7
  title?: string;
4
8
  description?: string;
5
- /** Extra block below description (works when passed via `ChatChrome` / `ChatSheet` `emptyState`). */
9
+ /** Extra block below description (resolved before render in `ChatEmptyState`). */
6
10
  additionalContent?: React.ReactNode;
7
11
  }
12
+ /** Passed to `ChatSheet` / `useChatPanelChromeModel`; function form resolved before render. */
13
+ export interface ChatEmptyStateConfig extends Omit<ChatEmptyStateProps, 'additionalContent'> {
14
+ additionalContent?: React.ReactNode | ((ctx: ChatEmptyStateContext) => React.ReactNode);
15
+ }
@@ -1,7 +1,7 @@
1
1
  import { ChatPreset, type ScriptCompletePayload } from '#uilib/components/ui/Chat/Chat.types';
2
2
  import type { ChatChromeProps } from '../ChatChrome';
3
3
  import type { ChatAttachmentDropItem } from '../ChatChrome/ChatChrome.types';
4
- import type { ChatEmptyStateProps } from '../ChatEmptyState/ChatEmptyState.types';
4
+ import type { ChatEmptyStateConfig } from '../ChatEmptyState/ChatEmptyState.types';
5
5
  export type UseChatPanelChromeModelInput = {
6
6
  /** When true, skip sidebar chat slot, URL `?chat=`, and portal behavior (e.g. page main content). */
7
7
  embedAsPage: boolean;
@@ -16,7 +16,7 @@ export type UseChatPanelChromeModelInput = {
16
16
  /** Renders `[CHART]` tokens in assistant messages. */
17
17
  renderMessageChart?: () => React.ReactNode;
18
18
  /** Forwarded to `ChatChrome` when the thread is empty. */
19
- emptyState?: ChatEmptyStateProps;
19
+ emptyState?: ChatEmptyStateConfig;
20
20
  /** MIME types / extensions for text-only chat attachments (filtered by uilib allowlist). */
21
21
  allowedAttachments?: readonly string[];
22
22
  /** When true, PDF drops are accepted and parsed to plain text. */
@@ -12,6 +12,7 @@ export type { UseChatPanelChromeModelInput, UseChatPanelChromeModelResult, } fro
12
12
  export { ChatMessage } from './ChatMessage';
13
13
  export { ChatPrompt } from './ChatPrompt';
14
14
  export { ChatPresets } from './ChatPresets';
15
+ export type { ChatEmptyStateConfig, ChatEmptyStateContext, ChatEmptyStateProps, } from './ChatEmptyState/ChatEmptyState.types';
15
16
  export type { Chat as ChatType, ChatAttachmentDropItem, ChatSendMessagePayload, ChatProps, ChatPreset as ChatPresetType, Message, UserTextFileAttachment, } from './Chat.types';
16
17
  export { MessageRole } from './Chat.types';
17
18
  export { CsvIcon } from '../../icons/CsvIcon/CsvIcon';
@@ -0,0 +1,18 @@
1
+ import type { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
2
+ import type { BacktestsComponentPayload } from '@sybilion/platform-sdk';
3
+ export type DriversComparisonChartProps = {
4
+ payload: BacktestsComponentPayload | null;
5
+ datasetHistorical?: ChartDataPoint[];
6
+ loading?: boolean;
7
+ chartLoading?: boolean;
8
+ statusHint?: string | null;
9
+ statusTone?: 'destructive' | 'muted';
10
+ runAnalysisHint?: boolean;
11
+ timeRange?: string;
12
+ onTimeRangeChange?: (range: string) => void;
13
+ isDarkTheme?: boolean;
14
+ className?: string;
15
+ /** Resets visible series when this key changes (e.g. selected analysis id). */
16
+ seriesInitKey?: string;
17
+ };
18
+ export declare function DriversComparisonChart({ payload, datasetHistorical, loading, chartLoading, statusHint, statusTone, runAnalysisHint, timeRange: timeRangeProp, onTimeRangeChange, isDarkTheme, className, seriesInitKey, }: DriversComparisonChartProps): import("react/jsx-runtime").JSX.Element;
@@ -0,0 +1,26 @@
1
+ import type { ChartDataPoint } from '#uilib/components/ui/ChartAreaInteractive/ChartAreaInteractive.types';
2
+ import type { BacktestsComponentPayload } from '@sybilion/platform-sdk';
3
+ export declare const DRIVER_FORECAST_ID_BASE = 8000000;
4
+ export declare const INITIAL_VISIBLE_SERIES_COUNT = 3;
5
+ /** Months of historical context before the earliest forecast month (inclusive). */
6
+ export declare const DRIVER_COMPARISON_CHART_LEAD_MONTHS = 6;
7
+ export declare function formatSeriesImportance(value: number | null): string;
8
+ export declare function mergeBacktestsChartData(payload: BacktestsComponentPayload | null): ChartDataPoint[];
9
+ /** While drivers comparison is not loaded: dataset historical only. After load: normalized historical from target.normalized_series (see mergeBacktestsChartData) replaces it. */
10
+ export declare function mergeDatasetHistoricalWithBacktestsChartData(datasetHistorical: ChartDataPoint[], backtestsMerged: ChartDataPoint[]): ChartDataPoint[];
11
+ /**
12
+ * For each driver, first month with materially non-zero value; anchor = median of those months.
13
+ * (Min is too early with outliers; max matched logs where one driver started 2017-12 while most started 2015-01.)
14
+ */
15
+ export declare function getZoomAnchorMonthFromDriverMaterialStarts(points: ChartDataPoint[], forecastIds: number[]): {
16
+ anchor: string | null;
17
+ perDriverFirstMaterial: string[];
18
+ };
19
+ export declare function subtractMonthsFromMonthStart(date: string, count: number): string;
20
+ /**
21
+ * Backtests payload often starts at the same month for target + drivers, so xMin falls
22
+ * in a range with no rows. Prepend historical-only months from raw dataset series
23
+ * (scaled to the first normalized point) so the lead-in shows the target line alone.
24
+ */
25
+ export declare function prependHistoricalLeadFromDataset(points: ChartDataPoint[], datasetHistorical: ChartDataPoint[], xMin: string): ChartDataPoint[];
26
+ export declare function buildDriversComparisonChartData(mergedWithHistorical: ChartDataPoint[], datasetHistorical: ChartDataPoint[], forecastIds: number[]): ChartDataPoint[];
@@ -0,0 +1,2 @@
1
+ export { DriversComparisonChart, type DriversComparisonChartProps, } from './DriversComparisonChart';
2
+ export { buildDriversComparisonChartData, DRIVER_COMPARISON_CHART_LEAD_MONTHS, DRIVER_FORECAST_ID_BASE, formatSeriesImportance, INITIAL_VISIBLE_SERIES_COUNT, mergeBacktestsChartData, mergeDatasetHistoricalWithBacktestsChartData, } from './driversComparisonChart.helpers';
@@ -0,0 +1 @@
1
+ export default function DriversComparisonChartPage(): import("react/jsx-runtime").JSX.Element;
@@ -68,6 +68,7 @@ export * from './components/ui/WorkspaceAppSwitcher';
68
68
  export * from './components/widgets/SidebarDatasetsItemsGrouped';
69
69
  export * from './components/widgets/DriverCard';
70
70
  export * from './components/widgets/DriverMap';
71
+ export * from './components/widgets/DriversComparisonChart';
71
72
  export * from './components/widgets/SybilionAppHeader';
72
73
  export * from './components/widgets/SybilionAuthLayout';
73
74
  export * from './components/widgets/SybilionSignInPanel';
@@ -101,5 +101,13 @@ function getPreviousMonth(date) {
101
101
  d.setMonth(d.getMonth() - 1);
102
102
  return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`;
103
103
  }
104
+ /**
105
+ * Get the next month date (first day of next month)
106
+ */
107
+ function getNextMonth(date) {
108
+ const d = new Date(date);
109
+ d.setMonth(d.getMonth() + 1);
110
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-01`;
111
+ }
104
112
 
105
- export { ensureChartForecastBridge, findLastValidHistoricalChartPoint, getPreviousMonth, normalizeToMonthStart };
113
+ export { ensureChartForecastBridge, findLastValidHistoricalChartPoint, getNextMonth, getPreviousMonth, normalizeToMonthStart };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sybilion/uilib",
3
- "version": "1.3.21",
3
+ "version": "1.3.23",
4
4
  "description": "Sybilion Design System — React UI components (Webpack + Stylus)",
5
5
  "publishConfig": {
6
6
  "access": "public",
@@ -1,7 +1,23 @@
1
+ import type { ChatPreset } from '#uilib/components/ui/Chat/Chat.types';
2
+
3
+ export type ChatEmptyStateContext = {
4
+ submitPreset: (preset: ChatPreset) => void | Promise<void>;
5
+ };
6
+
1
7
  export interface ChatEmptyStateProps {
2
8
  icon?: React.ReactNode;
3
9
  title?: string;
4
10
  description?: string;
5
- /** Extra block below description (works when passed via `ChatChrome` / `ChatSheet` `emptyState`). */
11
+ /** Extra block below description (resolved before render in `ChatEmptyState`). */
6
12
  additionalContent?: React.ReactNode;
7
13
  }
14
+
15
+ /** Passed to `ChatSheet` / `useChatPanelChromeModel`; function form resolved before render. */
16
+ export interface ChatEmptyStateConfig extends Omit<
17
+ ChatEmptyStateProps,
18
+ 'additionalContent'
19
+ > {
20
+ additionalContent?:
21
+ | React.ReactNode
22
+ | ((ctx: ChatEmptyStateContext) => React.ReactNode);
23
+ }
@@ -45,6 +45,7 @@ import { useSidebar } from '../../Sidebar/Sidebar';
45
45
  import { Chat } from '../Chat';
46
46
  import type { ChatChromeProps } from '../ChatChrome';
47
47
  import type { ChatAttachmentDropItem } from '../ChatChrome/ChatChrome.types';
48
+ import type { ChatEmptyStateConfig } from '../ChatEmptyState/ChatEmptyState.types';
48
49
  import type { ChatEmptyStateProps } from '../ChatEmptyState/ChatEmptyState.types';
49
50
 
50
51
  export type UseChatPanelChromeModelInput = {
@@ -61,7 +62,7 @@ export type UseChatPanelChromeModelInput = {
61
62
  /** Renders `[CHART]` tokens in assistant messages. */
62
63
  renderMessageChart?: () => React.ReactNode;
63
64
  /** Forwarded to `ChatChrome` when the thread is empty. */
64
- emptyState?: ChatEmptyStateProps;
65
+ emptyState?: ChatEmptyStateConfig;
65
66
  /** MIME types / extensions for text-only chat attachments (filtered by uilib allowlist). */
66
67
  allowedAttachments?: readonly string[];
67
68
  /** When true, PDF drops are accepted and parsed to plain text. */
@@ -615,7 +616,7 @@ export function useChatPanelChromeModel({
615
616
  ],
616
617
  );
617
618
 
618
- const submitPreset = async (preset: ChatPreset) => {
619
+ const submitPreset = useCallback(async (preset: ChatPreset) => {
619
620
  const script = preset.script;
620
621
  const scriptGraph = isPresetScriptGraph(script);
621
622
  const hasLinearScript = Array.isArray(script) && script.length > 0;
@@ -705,7 +706,25 @@ export function useChatPanelChromeModel({
705
706
  } finally {
706
707
  setLocalUiBusy(false);
707
708
  }
708
- };
709
+ }, [
710
+ currentChatId,
711
+ endLocalDemoFlow,
712
+ handlePromptSubmit,
713
+ addMessage,
714
+ presetsWithFreeform,
715
+ ]);
716
+
717
+ const resolvedEmptyState = useMemo((): ChatEmptyStateProps | undefined => {
718
+ if (!emptyState) return undefined;
719
+ const { additionalContent, ...rest } = emptyState;
720
+ return {
721
+ ...rest,
722
+ additionalContent:
723
+ typeof additionalContent === 'function'
724
+ ? additionalContent({ submitPreset })
725
+ : additionalContent,
726
+ };
727
+ }, [emptyState, submitPreset]);
709
728
 
710
729
  const activeScript = currentChatId
711
730
  ? scriptByChatId[currentChatId]
@@ -1017,7 +1036,7 @@ export function useChatPanelChromeModel({
1017
1036
  onPromptSubmit: handlePromptSubmit,
1018
1037
  onChatDeleted: endLocalDemoFlow,
1019
1038
  promptPrefill: promptLinkPrefill,
1020
- emptyState,
1039
+ emptyState: resolvedEmptyState,
1021
1040
  allowedAttachments,
1022
1041
  allowPdfAttachments,
1023
1042
  onAttachmentsDropped,
@@ -25,6 +25,11 @@ export type {
25
25
  export { ChatMessage } from './ChatMessage';
26
26
  export { ChatPrompt } from './ChatPrompt';
27
27
  export { ChatPresets } from './ChatPresets';
28
+ export type {
29
+ ChatEmptyStateConfig,
30
+ ChatEmptyStateContext,
31
+ ChatEmptyStateProps,
32
+ } from './ChatEmptyState/ChatEmptyState.types';
28
33
  export type {
29
34
  Chat as ChatType,
30
35
  ChatAttachmentDropItem,