@sybilion/uilib 1.0.26 → 1.0.28

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 (25) hide show
  1. package/assets/mini-app-global.css +12 -12
  2. package/dist/esm/components/ui/Chart/Chart.styl.js +1 -1
  3. package/dist/esm/components/ui/Chart/components/BaseChartWrapper.js +117 -170
  4. package/dist/esm/components/ui/ChartAreaInteractive/ChartAreaInteractive.helpers.js +101 -1
  5. package/dist/esm/components/ui/ChartAreaInteractive/ChartAreaInteractive.js +12 -4
  6. package/dist/esm/index.js +1 -0
  7. package/dist/esm/mini-app/MiniAppRoot.js +9 -5
  8. package/dist/esm/mini-app/miniAppThemeConfig.js +40 -0
  9. package/dist/esm/types/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.helpers.d.ts +6 -1
  10. package/dist/esm/types/src/docs/contexts/theme-context.d.ts +1 -0
  11. package/dist/esm/types/src/mini-app/MiniAppRoot.d.ts +4 -1
  12. package/dist/esm/types/src/mini-app/index.d.ts +4 -2
  13. package/dist/esm/types/src/mini-app/miniAppThemeConfig.d.ts +3 -0
  14. package/docs/workspace-mini-apps.md +3 -1
  15. package/package.json +1 -1
  16. package/src/components/ui/Chart/Chart.styl +7 -4
  17. package/src/components/ui/Chart/components/BaseChartWrapper.tsx +156 -193
  18. package/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.helpers.ts +90 -40
  19. package/src/components/ui/ChartAreaInteractive/ChartAreaInteractive.tsx +15 -3
  20. package/src/docs/contexts/theme-context.tsx +9 -1
  21. package/src/docs/pages/ChartAreaInteractivePage.tsx +27 -1
  22. package/src/docs/pages/MiniAppRootPage.tsx +6 -1
  23. package/src/mini-app/MiniAppRoot.tsx +19 -1
  24. package/src/mini-app/index.ts +4 -8
  25. package/src/mini-app/miniAppThemeConfig.ts +45 -0
@@ -67,22 +67,99 @@ const timeRangeToMonths = {
67
67
 
68
68
  export type TimeRange = keyof typeof timeRangeToMonths;
69
69
 
70
+ function isPlottableNumber(value: unknown): value is number {
71
+ return typeof value === 'number' && Number.isFinite(value);
72
+ }
73
+
74
+ /** Any row the chart can draw a number for (excludes date-only or empty rows) */
75
+ function hasPlottableChartSeriesValue(item: ChartDataPoint): boolean {
76
+ for (const [key, v] of Object.entries(item)) {
77
+ if (key === 'date') continue;
78
+ if (isPlottableNumber(v)) return true;
79
+ if (Array.isArray(v) && v.some(x => isPlottableNumber(x))) {
80
+ return true;
81
+ }
82
+ }
83
+ return false;
84
+ }
85
+
86
+ /**
87
+ * Row counts toward the "end" of the window when anchoring the time range to a
88
+ * selected forecast (shared historical + that analysis line / quantiles).
89
+ */
90
+ function rowContributesToAnchoredTimeRange(
91
+ item: ChartDataPoint,
92
+ analysisId: number,
93
+ ): boolean {
94
+ if (isPlottableNumber(item.historical)) {
95
+ return true;
96
+ }
97
+ const forecastKey = `forecast_${analysisId}`;
98
+ if (isPlottableNumber(item[forecastKey])) {
99
+ return true;
100
+ }
101
+ for (const key of Object.keys(item)) {
102
+ if (key.startsWith('q') && key.endsWith(`_${analysisId}`)) {
103
+ if (isPlottableNumber(item[key])) {
104
+ return true;
105
+ }
106
+ }
107
+ }
108
+ return false;
109
+ }
110
+
111
+ function computeLatestPlottableDate(
112
+ data: ChartDataPoint[],
113
+ options?: { endDateAnchorAnalysisId?: number | null },
114
+ ): Date | null {
115
+ const anchorId = options?.endDateAnchorAnalysisId;
116
+
117
+ const pick = (item: ChartDataPoint, useAnchor: boolean): Date | null => {
118
+ if (!item.date) return null;
119
+ if (useAnchor && anchorId != null) {
120
+ if (!rowContributesToAnchoredTimeRange(item, anchorId)) {
121
+ return null;
122
+ }
123
+ } else if (!hasPlottableChartSeriesValue(item)) {
124
+ return null;
125
+ }
126
+ return new Date(item.date);
127
+ };
128
+
129
+ let latest: Date | null = null;
130
+ for (const item of data) {
131
+ const d = pick(item, true);
132
+ if (d && (!latest || d > latest)) {
133
+ latest = d;
134
+ }
135
+ }
136
+
137
+ if (latest == null && anchorId != null) {
138
+ for (const item of data) {
139
+ const d = pick(item, false);
140
+ if (d && (!latest || d > latest)) {
141
+ latest = d;
142
+ }
143
+ }
144
+ }
145
+
146
+ return latest;
147
+ }
148
+
149
+ export type FilterDataForTimeRangeOptions = {
150
+ /** When set (e.g. selected forecast on Forecast tab), the window ends at the
151
+ * latest point that has shared historical or that analysis — not at another run. */
152
+ endDateAnchorAnalysisId?: number | null;
153
+ };
154
+
70
155
  export const filterDataForTimeRange = (
71
156
  data: ChartDataPoint[],
72
157
  currentTimeRange: TimeRange,
73
- availableAnalyses: number[],
158
+ options?: FilterDataForTimeRangeOptions,
74
159
  ) => {
75
160
  if (currentTimeRange === 'All') return data;
76
161
 
77
- // Find the latest date in the data
78
- const latestDate = data.reduce(
79
- (latest, item) => {
80
- if (!item.date) return latest;
81
- const itemDate = new Date(item.date);
82
- return !latest || itemDate > latest ? itemDate : latest;
83
- },
84
- null as Date | null,
85
- );
162
+ const latestDate = computeLatestPlottableDate(data, options);
86
163
 
87
164
  // Pre-compute start date based on latest date in data
88
165
  let startDate: Date | null = null;
@@ -93,41 +170,14 @@ export const filterDataForTimeRange = (
93
170
  startDate.setMonth(startDate.getMonth() - monthsToSubtract);
94
171
  }
95
172
 
96
- // Create a Set for faster lookup when there are many analyses
97
- const forecastKeyPrefix = 'forecast_';
98
- const hasManyAnalyses = availableAnalyses.length > 10;
99
- const hasAnalyses = availableAnalyses.length > 0;
100
- const forecastKeysSet = hasManyAnalyses
101
- ? new Set(availableAnalyses.map(id => `${forecastKeyPrefix}${id}`))
102
- : null;
103
-
173
+ // Slice by date for every row. Rows with forecast_* keys must not bypass the
174
+ // window (e.g. spaghetti plots), or the X range stays stuck at full history.
104
175
  const filteredData = data.filter(item => {
105
- if (hasAnalyses) {
106
- // Optimize forecast data check for many analyses
107
- let hasForecastData = false;
108
- if (hasManyAnalyses && forecastKeysSet) {
109
- // Check if any forecast key exists in the item
110
- hasForecastData = Object.keys(item).some(key =>
111
- forecastKeysSet.has(key),
112
- );
113
- } else {
114
- // Original approach for small number of analyses
115
- hasForecastData = availableAnalyses.some(
116
- analysisId => item[`forecast_${analysisId}`] !== undefined,
117
- );
118
- }
119
-
120
- if (hasForecastData) {
121
- return true;
122
- }
123
- }
124
-
125
- // For historical data, apply time range filtering
176
+ if (!item.date) return false;
126
177
  if (startDate) {
127
178
  const date = new Date(item.date);
128
179
  return date >= startDate;
129
180
  }
130
-
131
181
  return true;
132
182
  });
133
183
  return filteredData;
@@ -11,6 +11,7 @@ import { TimeRangeControls } from '#uilib/components/ui/TimeRangeControls/TimeRa
11
11
  import { ensureChartForecastBridge } from '#uilib/utils/chartConnectionPoint';
12
12
 
13
13
  import {
14
+ filterDataForTimeRange,
14
15
  longDateFormatter,
15
16
  shortDateFormatter,
16
17
  } from './ChartAreaInteractive.helpers';
@@ -94,14 +95,25 @@ export function ChartAreaInteractive({
94
95
  }
95
96
  }, [selectedAnalysisId, ensureAnalysisSeriesVisible]);
96
97
 
98
+ const timeFilteredChartData = useMemo(() => {
99
+ const raw = selectedAnalysisId ?? selectedForecast?.id ?? null;
100
+ const anchorId =
101
+ raw == null ? null : typeof raw === 'number' ? raw : Number(raw);
102
+ const opts =
103
+ anchorId != null && Number.isFinite(anchorId)
104
+ ? { endDateAnchorAnalysisId: anchorId }
105
+ : undefined;
106
+ return filterDataForTimeRange(chartData, timeRange, opts);
107
+ }, [chartData, timeRange, selectedAnalysisId, selectedForecast?.id]);
108
+
97
109
  const bridgedChartData = useMemo(
98
110
  () =>
99
111
  disableForecastHistoricalBridge
100
- ? chartData
101
- : ensureChartForecastBridge(chartData, {
112
+ ? timeFilteredChartData
113
+ : ensureChartForecastBridge(timeFilteredChartData, {
102
114
  forecastSeriesIds: forecastData?.map(f => f.id),
103
115
  }),
104
- [chartData, disableForecastHistoricalBridge, forecastData],
116
+ [timeFilteredChartData, disableForecastHistoricalBridge, forecastData],
105
117
  );
106
118
 
107
119
  // Extract quantileBands from restProps
@@ -8,9 +8,11 @@ export type ThemeMode = 'light' | 'dark';
8
8
 
9
9
  const ThemeContext = createContext<{
10
10
  theme: ThemeMode;
11
+ isDarkMode: boolean;
11
12
  setTheme: (theme: ThemeMode) => void;
12
13
  }>({
13
14
  theme: 'light',
15
+ isDarkMode: false,
14
16
  setTheme: () => {},
15
17
  });
16
18
 
@@ -37,7 +39,13 @@ export function ThemeProvider({ children }: { children: React.ReactNode }) {
37
39
  }, [theme]);
38
40
 
39
41
  return (
40
- <ThemeContext.Provider value={{ theme, setTheme }}>
42
+ <ThemeContext.Provider
43
+ value={{
44
+ theme,
45
+ isDarkMode: theme === 'dark',
46
+ setTheme,
47
+ }}
48
+ >
41
49
  <ThemeRoot config={currThemeConfig} />
42
50
  {children}
43
51
  </ThemeContext.Provider>
@@ -9,6 +9,7 @@ import type {
9
9
  import { ForecastItemData } from '#uilib/components/ui/ChartAreaInteractive/ChartLines';
10
10
  import { PageContentSection } from '#uilib/components/ui/Page';
11
11
  import { Tabs, TabsList, TabsTrigger } from '#uilib/components/ui/Tabs';
12
+ import { useTheme } from '#uilib/docs/contexts/theme-context';
12
13
  import type { ForecastData } from '#uilib/types/forecast-data';
13
14
 
14
15
  import { AppPageHeader } from '../components/AppPageHeader/AppPageHeader';
@@ -48,6 +49,30 @@ const DEMO_DISCRETE_THRESHOLDS = (() => {
48
49
  })();
49
50
 
50
51
  const INITIAL_CHART: ChartDataPoint[] = [
52
+ { date: '2021-01-01', historical: 6 },
53
+ { date: '2021-02-01', historical: 8 },
54
+ { date: '2021-03-01', historical: 7 },
55
+ { date: '2021-04-01', historical: 10 },
56
+ { date: '2021-05-01', historical: 9 },
57
+ { date: '2021-06-01', historical: 10 },
58
+ { date: '2021-07-01', historical: 11 },
59
+ { date: '2021-08-01', historical: 10 },
60
+ { date: '2021-09-01', historical: 12 },
61
+ { date: '2021-10-01', historical: 11 },
62
+ { date: '2021-11-01', historical: 13 },
63
+ { date: '2021-12-01', historical: 12 },
64
+ { date: '2022-01-01', historical: 8 },
65
+ { date: '2022-02-01', historical: 10 },
66
+ { date: '2022-03-01', historical: 9 },
67
+ { date: '2022-04-01', historical: 12 },
68
+ { date: '2022-05-01', historical: 11 },
69
+ { date: '2022-06-01', historical: 12 },
70
+ { date: '2022-07-01', historical: 13 },
71
+ { date: '2022-08-01', historical: 12 },
72
+ { date: '2022-09-01', historical: 14 },
73
+ { date: '2022-10-01', historical: 13 },
74
+ { date: '2022-11-01', historical: 15 },
75
+ { date: '2022-12-01', historical: 14 },
51
76
  { date: '2023-01-01', historical: 10 },
52
77
  { date: '2023-02-01', historical: 12 },
53
78
  { date: '2023-03-01', historical: 11 },
@@ -65,6 +90,7 @@ const DEMO_FORECAST_ITEMS: ForecastItemData[] = [
65
90
  type DemoMode = 'none' | OverlayMode;
66
91
 
67
92
  export default function ChartAreaInteractivePage() {
93
+ const { isDarkMode } = useTheme();
68
94
  const [timeRange, setTimeRange] = useState<TimeRange>('1y');
69
95
  const [pinMonth, setPinMonth] = useState<string | undefined>(undefined);
70
96
  const [demoMode, setDemoMode] = useState<DemoMode>('none');
@@ -139,7 +165,7 @@ export default function ChartAreaInteractivePage() {
139
165
  chartData={chartData}
140
166
  forecastData={DEMO_FORECAST_ITEMS}
141
167
  loading={false}
142
- isDarkTheme={false}
168
+ isDarkTheme={isDarkMode}
143
169
  toggleLegendSeries={toggleLegendSeries}
144
170
  ensureAnalysisSeriesVisible={ensureAnalysisSeriesVisible}
145
171
  hiddenSeries={hidden}
@@ -1,4 +1,5 @@
1
1
  import { PageContentSection } from '#uilib/components/ui/Page';
2
+ import { getThemeConfig } from '#uilib/docs/lib/theme';
2
3
  import { MiniAppRoot, useMiniAppShellTheme } from '#uilib/mini-app';
3
4
 
4
5
  import { AppPageHeader } from '../components/AppPageHeader/AppPageHeader';
@@ -44,7 +45,11 @@ export default function MiniAppRootPage() {
44
45
  }
45
46
  `}</style>
46
47
  <PageContentSection style={{ maxHeight: '500px' }}>
47
- <MiniAppRoot appId="uilib-docs" className="mini-app-root-page">
48
+ <MiniAppRoot
49
+ appId="uilib-docs"
50
+ className="mini-app-root-page"
51
+ getThemeConfig={isDarkMode => getThemeConfig(isDarkMode)}
52
+ >
48
53
  <MiniAppThemeDemo />
49
54
  </MiniAppRoot>
50
55
  </PageContentSection>
@@ -10,7 +10,7 @@ import React, {
10
10
  useState,
11
11
  } from 'react';
12
12
 
13
- import { Scroll } from '@homecode/ui';
13
+ import { Scroll, Theme } from '@homecode/ui';
14
14
 
15
15
  import S from './MiniAppRoot.styl';
16
16
  import {
@@ -20,6 +20,10 @@ import {
20
20
  parseThemeSyncMessage,
21
21
  resolveParentOriginFromReferrer,
22
22
  } from './miniAppProtocol';
23
+ import {
24
+ type MiniAppThemeConfig,
25
+ getDefaultMiniAppThemeConfig,
26
+ } from './miniAppThemeConfig';
23
27
 
24
28
  const defaultTheme: ThemeSyncPayload = {
25
29
  mode: 'light',
@@ -76,6 +80,8 @@ export type MiniAppRootProps = {
76
80
  /** Included in READY payload when set. */
77
81
  appId?: string;
78
82
  onThemeChange?: (theme: ThemeSyncPayload) => void;
83
+ /** Overrides `@homecode/ui` `<Theme config>` builder (defaults to generic mini-app palette). */
84
+ getThemeConfig?: (isDarkMode: boolean) => MiniAppThemeConfig;
79
85
  };
80
86
 
81
87
  export function MiniAppRoot({
@@ -83,6 +89,7 @@ export function MiniAppRoot({
83
89
  className,
84
90
  appId,
85
91
  onThemeChange,
92
+ getThemeConfig,
86
93
  }: MiniAppRootProps): React.ReactElement {
87
94
  const [theme, setTheme] = useState<ThemeSyncPayload>(() =>
88
95
  isEmbeddedMiniApp() ? defaultTheme : themeFromDocument(),
@@ -90,6 +97,16 @@ export function MiniAppRoot({
90
97
  const onThemeChangeRef = useRef(onThemeChange);
91
98
  onThemeChangeRef.current = onThemeChange;
92
99
 
100
+ const getThemeConfigRef = useRef(
101
+ getThemeConfig ?? getDefaultMiniAppThemeConfig,
102
+ );
103
+ getThemeConfigRef.current = getThemeConfig ?? getDefaultMiniAppThemeConfig;
104
+
105
+ const currThemeConfig = useMemo(
106
+ () => getThemeConfigRef.current(theme.isDarkMode),
107
+ [theme.isDarkMode],
108
+ );
109
+
93
110
  const sendReady = useCallback(() => {
94
111
  if (!window.parent || window.parent === window) return;
95
112
  const payload = appId ? { appId } : {};
@@ -131,6 +148,7 @@ export function MiniAppRoot({
131
148
 
132
149
  return (
133
150
  <MiniAppShellContext.Provider value={ctx}>
151
+ <Theme config={currThemeConfig} />
134
152
  <Scroll
135
153
  y
136
154
  fadeSize="l"
@@ -11,11 +11,7 @@ export type {
11
11
  MiniAppMessageThemeSync,
12
12
  ThemeSyncPayload,
13
13
  } from './miniAppProtocol';
14
- export {
15
- MiniAppRoot,
16
- useMiniAppShellTheme,
17
- } from './MiniAppRoot';
18
- export type {
19
- MiniAppRootProps,
20
- MiniAppShellContextValue,
21
- } from './MiniAppRoot';
14
+ export { getDefaultMiniAppThemeConfig } from './miniAppThemeConfig';
15
+ export type { MiniAppThemeConfig } from './miniAppThemeConfig';
16
+ export { MiniAppRoot, useMiniAppShellTheme } from './MiniAppRoot';
17
+ export type { MiniAppRootProps, MiniAppShellContextValue } from './MiniAppRoot';
@@ -0,0 +1,45 @@
1
+ import { ThemeDefaults, ThemeHelpers } from '@homecode/ui';
2
+
3
+ const { colors, getColors, getConfig } = ThemeDefaults;
4
+
5
+ const defaultPalette = getColors();
6
+
7
+ const colorsConfig = {
8
+ light: {
9
+ ...ThemeHelpers.colorsConfigToVars({
10
+ ...getColors({
11
+ accent: colors.dark,
12
+ decent: colors.light,
13
+ }),
14
+ }),
15
+ },
16
+ dark: {
17
+ ...ThemeHelpers.colorsConfigToVars({
18
+ ...getColors({
19
+ accent: colors.light,
20
+ decent: colors.dark,
21
+ }),
22
+ }),
23
+ },
24
+ };
25
+
26
+ /** Homecode `<Theme config={...}>` shape for workspace mini-apps (generic palette). */
27
+ export function getDefaultMiniAppThemeConfig(isDarkMode: boolean) {
28
+ return {
29
+ ...getConfig(),
30
+ ...colorsConfig[isDarkMode ? 'dark' : 'light'],
31
+ ...ThemeHelpers.colorsConfigToVars({
32
+ active: {
33
+ color: '#00a9c7',
34
+ mods: {
35
+ // @ts-ignore — extend defaults so --active-color-alpha-* variants match Homecode
36
+ alpha: [0, 50, 100, 200, ...defaultPalette.active.mods.alpha],
37
+ },
38
+ },
39
+ }),
40
+ };
41
+ }
42
+
43
+ export type MiniAppThemeConfig = ReturnType<
44
+ typeof getDefaultMiniAppThemeConfig
45
+ >;