@columenlabs/lumen-ui 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +12 -0
- package/README.md +4 -2
- package/dist/add-column.d.ts +23 -0
- package/dist/add-column.js +62 -0
- package/dist/api-fetch.d.ts +9 -0
- package/dist/api-fetch.js +60 -0
- package/dist/app-switcher.d.ts +21 -0
- package/dist/app-switcher.js +12 -0
- package/dist/app-views.d.ts +18 -0
- package/dist/app-views.js +41 -0
- package/dist/assistant-context.d.ts +72 -0
- package/dist/assistant-context.js +30 -0
- package/dist/assistant-panel.d.ts +58 -0
- package/dist/assistant-panel.js +244 -0
- package/dist/assistant-screen.d.ts +124 -0
- package/dist/assistant-screen.js +252 -0
- package/dist/card-switch.d.ts +16 -0
- package/dist/card-switch.js +16 -0
- package/dist/color-dot.d.ts +14 -0
- package/dist/color-dot.js +19 -0
- package/dist/confirm.d.ts +18 -0
- package/dist/confirm.js +5 -0
- package/dist/connections.d.ts +45 -0
- package/dist/connections.js +275 -0
- package/dist/coverage-calendar-map.d.ts +27 -0
- package/dist/coverage-calendar-map.js +84 -0
- package/dist/coverage-cells.d.ts +55 -0
- package/dist/coverage-cells.js +85 -0
- package/dist/coverage-map.d.ts +112 -0
- package/dist/coverage-map.js +428 -0
- package/dist/details-table.d.ts +20 -0
- package/dist/details-table.js +13 -0
- package/dist/drawer-section.d.ts +27 -0
- package/dist/drawer-section.js +23 -0
- package/dist/floating-head.d.ts +2 -0
- package/dist/floating-head.js +57 -0
- package/dist/fold.d.ts +16 -0
- package/dist/fold.js +70 -0
- package/dist/form-issues.d.ts +32 -0
- package/dist/form-issues.js +60 -0
- package/dist/formula-input.d.ts +17 -0
- package/dist/formula-input.js +104 -0
- package/dist/frame-script.d.ts +7 -0
- package/dist/frame-script.js +103 -0
- package/dist/freshness.d.ts +24 -0
- package/dist/freshness.js +99 -0
- package/dist/group-button.d.ts +33 -0
- package/dist/group-button.js +67 -0
- package/dist/growth-chart.d.ts +77 -0
- package/dist/growth-chart.js +195 -0
- package/dist/hooks/use-keyboard-fit.d.ts +15 -0
- package/dist/hooks/use-keyboard-fit.js +29 -0
- package/dist/hooks/use-mobile.d.ts +1 -0
- package/dist/hooks/use-mobile.js +15 -0
- package/dist/icon-button.d.ts +21 -0
- package/dist/icon-button.js +22 -0
- package/dist/icon-picker.d.ts +14 -0
- package/dist/icon-picker.js +36 -0
- package/dist/index.d.ts +83 -0
- package/dist/index.js +90 -0
- package/dist/integrations.d.ts +57 -0
- package/dist/integrations.js +73 -0
- package/dist/joins.d.ts +128 -0
- package/dist/joins.js +767 -0
- package/dist/kit-config.d.ts +13 -0
- package/dist/kit-config.js +15 -0
- package/dist/lib/utils.d.ts +2 -0
- package/dist/lib/utils.js +5 -0
- package/dist/lumen-boundary.d.ts +7 -0
- package/dist/lumen-boundary.js +10 -0
- package/dist/lumen-code-editor-impl.d.ts +2 -0
- package/dist/lumen-code-editor-impl.js +16 -0
- package/dist/lumen-code-editor.d.ts +11 -0
- package/dist/lumen-code-editor.js +11 -0
- package/dist/lumen-code.d.ts +22 -0
- package/dist/lumen-code.js +75 -0
- package/dist/lumen-drawer.d.ts +30 -0
- package/dist/lumen-drawer.js +27 -0
- package/dist/lumen-echart.d.ts +58 -0
- package/dist/lumen-echart.js +129 -0
- package/dist/lumen-filter.d.ts +75 -0
- package/dist/lumen-filter.js +546 -0
- package/dist/lumen-finder.d.ts +62 -0
- package/dist/lumen-finder.js +275 -0
- package/dist/lumen-form.d.ts +34 -0
- package/dist/lumen-form.js +81 -0
- package/dist/lumen-geo-map.d.ts +9 -0
- package/dist/lumen-geo-map.js +135 -0
- package/dist/lumen-grid.d.ts +49 -0
- package/dist/lumen-grid.js +203 -0
- package/dist/lumen-layout.d.ts +150 -0
- package/dist/lumen-layout.js +661 -0
- package/dist/lumen-links.d.ts +96 -0
- package/dist/lumen-links.js +213 -0
- package/dist/lumen-page-state.d.ts +20 -0
- package/dist/lumen-page-state.js +15 -0
- package/dist/lumen-selector.d.ts +139 -0
- package/dist/lumen-selector.js +308 -0
- package/dist/lumen-source.d.ts +137 -0
- package/dist/lumen-source.js +327 -0
- package/dist/lumen-tab-drawer.d.ts +27 -0
- package/dist/lumen-tab-drawer.js +26 -0
- package/dist/lumen-tabs.d.ts +21 -0
- package/dist/lumen-tabs.js +6 -0
- package/dist/lumen-toast.d.ts +11 -0
- package/dist/lumen-toast.js +16 -0
- package/dist/lumen-trend.d.ts +18 -0
- package/dist/lumen-trend.js +157 -0
- package/dist/lumen-view-card.d.ts +35 -0
- package/dist/lumen-view-card.js +50 -0
- package/dist/lumen-view.d.ts +172 -0
- package/dist/lumen-view.js +645 -0
- package/dist/lumen-widget-charts.d.ts +57 -0
- package/dist/lumen-widget-charts.js +69 -0
- package/dist/lumen-widget.d.ts +720 -0
- package/dist/lumen-widget.js +609 -0
- package/dist/market/compare-chart.d.ts +34 -0
- package/dist/market/compare-chart.js +292 -0
- package/dist/market/indicator-picker.d.ts +29 -0
- package/dist/market/indicator-picker.js +125 -0
- package/dist/market/indicators.d.ts +63 -0
- package/dist/market/indicators.js +314 -0
- package/dist/market/live.d.ts +120 -0
- package/dist/market/live.js +444 -0
- package/dist/market/lumen-chart.d.ts +88 -0
- package/dist/market/lumen-chart.js +645 -0
- package/dist/market/market-widget.d.ts +45 -0
- package/dist/market/market-widget.js +346 -0
- package/dist/market/option-chain.d.ts +19 -0
- package/dist/market/option-chain.js +53 -0
- package/dist/market/price-chart.d.ts +128 -0
- package/dist/market/price-chart.js +619 -0
- package/dist/mcp-settings.d.ts +20 -0
- package/dist/mcp-settings.js +65 -0
- package/dist/nav.d.ts +77 -0
- package/dist/nav.js +73 -0
- package/dist/note-line.d.ts +13 -0
- package/dist/note-line.js +5 -0
- package/dist/notifications.d.ts +9 -0
- package/dist/notifications.js +166 -0
- package/dist/origin-legend.d.ts +14 -0
- package/dist/origin-legend.js +38 -0
- package/dist/overview.d.ts +84 -0
- package/dist/overview.js +105 -0
- package/dist/page-inputs.d.ts +14 -0
- package/dist/page-inputs.js +16 -0
- package/dist/page.d.ts +32 -0
- package/dist/page.js +45 -0
- package/dist/pager.d.ts +10 -0
- package/dist/pager.js +14 -0
- package/dist/prefs.d.ts +71 -0
- package/dist/prefs.js +126 -0
- package/dist/query-client.d.ts +7 -0
- package/dist/query-client.js +19 -0
- package/dist/refresh.d.ts +4 -0
- package/dist/refresh.js +19 -0
- package/dist/rename-field.d.ts +11 -0
- package/dist/rename-field.js +26 -0
- package/dist/reset-button.d.ts +9 -0
- package/dist/reset-button.js +10 -0
- package/dist/route-error.d.ts +42 -0
- package/dist/route-error.js +229 -0
- package/dist/schedules.d.ts +88 -0
- package/dist/schedules.js +221 -0
- package/dist/settings.d.ts +51 -0
- package/dist/settings.js +181 -0
- package/dist/shell.d.ts +99 -0
- package/dist/shell.js +137 -0
- package/dist/source-health.d.ts +12 -0
- package/dist/source-health.js +58 -0
- package/dist/sourced-card.d.ts +10 -0
- package/dist/sourced-card.js +69 -0
- package/dist/spotlight.d.ts +23 -0
- package/dist/spotlight.js +138 -0
- package/dist/status-health.d.ts +16 -0
- package/dist/status-health.js +41 -0
- package/dist/status.d.ts +9 -0
- package/dist/status.js +12 -0
- package/dist/summary-cards.d.ts +56 -0
- package/dist/summary-cards.js +270 -0
- package/dist/symbol-sheet-context.d.ts +4 -0
- package/dist/symbol-sheet-context.js +5 -0
- package/dist/symbol-sheet.d.ts +9 -0
- package/dist/symbol-sheet.js +24 -0
- package/dist/tasks.d.ts +29 -0
- package/dist/tasks.js +278 -0
- package/dist/theme.css +606 -0
- package/dist/tile.d.ts +37 -0
- package/dist/tile.js +23 -0
- package/dist/ui/alert-dialog.d.ts +18 -0
- package/dist/ui/alert-dialog.js +41 -0
- package/dist/ui/alert.d.ts +10 -0
- package/dist/ui/alert.js +27 -0
- package/dist/ui/avatar.d.ts +11 -0
- package/dist/ui/avatar.js +22 -0
- package/dist/ui/badge.d.ts +9 -0
- package/dist/ui/badge.js +27 -0
- package/dist/ui/breadcrumb.d.ts +11 -0
- package/dist/ui/breadcrumb.js +27 -0
- package/dist/ui/button-group.d.ts +11 -0
- package/dist/ui/button-group.js +27 -0
- package/dist/ui/button.d.ts +10 -0
- package/dist/ui/button.js +38 -0
- package/dist/ui/card.d.ts +11 -0
- package/dist/ui/card.js +24 -0
- package/dist/ui/chart.d.ts +44 -0
- package/dist/ui/chart.js +117 -0
- package/dist/ui/checkbox.d.ts +4 -0
- package/dist/ui/checkbox.js +8 -0
- package/dist/ui/collapsible.d.ts +5 -0
- package/dist/ui/collapsible.js +12 -0
- package/dist/ui/combobox.d.ts +24 -0
- package/dist/ui/combobox.js +60 -0
- package/dist/ui/command.d.ts +18 -0
- package/dist/ui/command.js +34 -0
- package/dist/ui/dialog.d.ts +23 -0
- package/dist/ui/dialog.js +50 -0
- package/dist/ui/dropdown-menu.d.ts +29 -0
- package/dist/ui/dropdown-menu.js +50 -0
- package/dist/ui/emoji-picker.d.ts +6 -0
- package/dist/ui/emoji-picker.js +28 -0
- package/dist/ui/empty.d.ts +11 -0
- package/dist/ui/empty.js +33 -0
- package/dist/ui/field.d.ts +24 -0
- package/dist/ui/field.js +65 -0
- package/dist/ui/hover-card.d.ts +6 -0
- package/dist/ui/hover-card.js +13 -0
- package/dist/ui/input-group.d.ts +16 -0
- package/dist/ui/input-group.js +56 -0
- package/dist/ui/input.d.ts +3 -0
- package/dist/ui/input.js +6 -0
- package/dist/ui/item.d.ts +23 -0
- package/dist/ui/item.js +67 -0
- package/dist/ui/kbd.d.ts +3 -0
- package/dist/ui/kbd.js +9 -0
- package/dist/ui/label.d.ts +4 -0
- package/dist/ui/label.js +7 -0
- package/dist/ui/popover.d.ts +10 -0
- package/dist/ui/popover.js +25 -0
- package/dist/ui/progress.d.ts +4 -0
- package/dist/ui/progress.js +9 -0
- package/dist/ui/resizable.d.ts +7 -0
- package/dist/ui/resizable.js +13 -0
- package/dist/ui/scroll-area.d.ts +5 -0
- package/dist/ui/scroll-area.js +10 -0
- package/dist/ui/select.d.ts +15 -0
- package/dist/ui/select.js +36 -0
- package/dist/ui/separator.d.ts +4 -0
- package/dist/ui/separator.js +7 -0
- package/dist/ui/sheet.d.ts +14 -0
- package/dist/ui/sheet.js +54 -0
- package/dist/ui/sidebar.d.ts +69 -0
- package/dist/ui/sidebar.js +206 -0
- package/dist/ui/skeleton.d.ts +2 -0
- package/dist/ui/skeleton.js +6 -0
- package/dist/ui/slider.d.ts +4 -0
- package/dist/ui/slider.js +9 -0
- package/dist/ui/sonner.d.ts +3 -0
- package/dist/ui/sonner.js +24 -0
- package/dist/ui/spinner.d.ts +2 -0
- package/dist/ui/spinner.js +7 -0
- package/dist/ui/switch.d.ts +6 -0
- package/dist/ui/switch.js +7 -0
- package/dist/ui/table.d.ts +10 -0
- package/dist/ui/table.js +31 -0
- package/dist/ui/tabs.d.ts +7 -0
- package/dist/ui/tabs.js +22 -0
- package/dist/ui/textarea.d.ts +3 -0
- package/dist/ui/textarea.js +6 -0
- package/dist/ui/toggle-group.d.ts +10 -0
- package/dist/ui/toggle-group.js +22 -0
- package/dist/ui/toggle.d.ts +9 -0
- package/dist/ui/toggle.js +25 -0
- package/dist/ui/tooltip.d.ts +7 -0
- package/dist/ui/tooltip.js +16 -0
- package/dist/view-edit-cell.d.ts +49 -0
- package/dist/view-edit-cell.js +198 -0
- package/dist/view-editor.d.ts +52 -0
- package/dist/view-editor.js +1081 -0
- package/dist/view-formulas.d.ts +61 -0
- package/dist/view-formulas.js +247 -0
- package/dist/view-icons.d.ts +20 -0
- package/dist/view-icons.js +70 -0
- package/dist/view-model.d.ts +390 -0
- package/dist/view-model.js +316 -0
- package/dist/whats-new.d.ts +3 -0
- package/dist/whats-new.js +53 -0
- package/dist/widget-controls.d.ts +9 -0
- package/dist/widget-controls.js +26 -0
- package/dist/widget-data.d.ts +4 -0
- package/dist/widget-data.js +32 -0
- package/dist/widget-settings.d.ts +516 -0
- package/dist/widget-settings.js +290 -0
- package/dist/widget-thumbs.d.ts +3 -0
- package/dist/widget-thumbs.js +29 -0
- package/dist/workspace-keys.d.ts +12 -0
- package/dist/workspace-keys.js +13 -0
- package/dist/workspaces.d.ts +57 -0
- package/dist/workspaces.js +473 -0
- package/package.json +82 -4
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { MARKET_SYMBOLS, isDailyMarketKey, marketWindows, } from '@columenlabs/lumen-ui-types/market';
|
|
3
|
+
import { queryOptions, useQueries, useQuery, useQueryClient } from '@tanstack/react-query';
|
|
4
|
+
import { XIcon } from 'lucide-react';
|
|
5
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react';
|
|
6
|
+
import { useInView } from 'react-intersection-observer';
|
|
7
|
+
import { useAssistantMoves } from '../assistant-screen.js';
|
|
8
|
+
import { IconButton } from '../icon-button.js';
|
|
9
|
+
import { cn } from '../lib/utils.js';
|
|
10
|
+
import { useLumenLoad } from '../lumen-source.js';
|
|
11
|
+
import { usePrefs } from '../prefs.js';
|
|
12
|
+
import { refreshHeaders } from '../refresh.js';
|
|
13
|
+
import { Badge } from '../ui/badge.js';
|
|
14
|
+
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '../ui/dialog.js';
|
|
15
|
+
import { Input } from '../ui/input.js';
|
|
16
|
+
import { Skeleton } from '../ui/skeleton.js';
|
|
17
|
+
import { ToggleGroup, ToggleGroupItem } from '../ui/toggle-group.js';
|
|
18
|
+
import { CompareChart } from './compare-chart.js';
|
|
19
|
+
import { ALL_SHOWN, IndicatorPicker } from './indicator-picker.js';
|
|
20
|
+
import { CHART_INDICATORS, computeIndicators, formulaIndicator, lineColor, resolveIndicator, } from './indicators.js';
|
|
21
|
+
import { extendedOf, todayOf, useMarket } from './live.js';
|
|
22
|
+
import { PriceChart, indexSpan, secOf } from './price-chart.js';
|
|
23
|
+
const ChartContext = createContext(null);
|
|
24
|
+
const BarsContext = createContext(null);
|
|
25
|
+
const IndicatorsContext = createContext(null);
|
|
26
|
+
const MineContext = createContext({ list: [] });
|
|
27
|
+
/** The person's saved formulas from `url` (`GET` lists, `POST` saves, `DELETE /:id`). */
|
|
28
|
+
function useMineIndicators(url) {
|
|
29
|
+
const client = useQueryClient();
|
|
30
|
+
const q = useQuery({
|
|
31
|
+
queryKey: ['chart-indicators', url],
|
|
32
|
+
enabled: !!url,
|
|
33
|
+
queryFn: async ({ signal }) => {
|
|
34
|
+
const r = await fetch(url, { credentials: 'same-origin', signal });
|
|
35
|
+
if (!r.ok)
|
|
36
|
+
throw new Error(`saved indicators answered ${r.status}`);
|
|
37
|
+
return (await r.json()).indicators;
|
|
38
|
+
},
|
|
39
|
+
});
|
|
40
|
+
const list = useMemo(() => (q.data ?? []).flatMap((m) => {
|
|
41
|
+
try {
|
|
42
|
+
return [formulaIndicator(m, m.id)];
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
}), [q.data]);
|
|
48
|
+
const send = useCallback(async (path, init) => {
|
|
49
|
+
const r = await fetch(`${url}${path}`, {
|
|
50
|
+
credentials: 'same-origin',
|
|
51
|
+
headers: { 'content-type': 'application/json' },
|
|
52
|
+
...init,
|
|
53
|
+
});
|
|
54
|
+
if (!r.ok) {
|
|
55
|
+
const e = (await r.json().catch(() => null));
|
|
56
|
+
throw new Error(e?.error ?? `saved indicators answered ${r.status}`);
|
|
57
|
+
}
|
|
58
|
+
await client.invalidateQueries({ queryKey: ['chart-indicators', url] });
|
|
59
|
+
}, [url, client]);
|
|
60
|
+
return useMemo(() => url
|
|
61
|
+
? {
|
|
62
|
+
list,
|
|
63
|
+
save: (f) => send('', { method: 'POST', body: JSON.stringify(f) }),
|
|
64
|
+
remove: (id) => send(`/${encodeURIComponent(id.replace(/^mine:/, ''))}`, { method: 'DELETE' }),
|
|
65
|
+
}
|
|
66
|
+
: { list }, [url, list, send]);
|
|
67
|
+
}
|
|
68
|
+
/** Opens the chart dialog; null where no app mounted the dialog. */
|
|
69
|
+
export const useSymbolChart = () => useContext(ChartContext);
|
|
70
|
+
/** The app's history route (`bars`) the chart reads; null where no app mounted the dialog. */
|
|
71
|
+
export const useBarsUrl = () => useContext(BarsContext);
|
|
72
|
+
/** Symbols one chart compares at most. */
|
|
73
|
+
export const MAX_COMPARE = 8;
|
|
74
|
+
/** Each compared symbol's colour, by its place in the list. */
|
|
75
|
+
const SYMBOL_COLORS = [
|
|
76
|
+
'var(--chart-1)',
|
|
77
|
+
'var(--chart-3)',
|
|
78
|
+
'var(--chart-6)',
|
|
79
|
+
'var(--chart-5)',
|
|
80
|
+
'var(--chart-2)',
|
|
81
|
+
'var(--chart-4)',
|
|
82
|
+
'var(--chart-9)',
|
|
83
|
+
'var(--chart-8)',
|
|
84
|
+
];
|
|
85
|
+
/** A compared symbol's colour. */
|
|
86
|
+
const colorIn = (symbols, s) => SYMBOL_COLORS[symbols.indexOf(s)] ?? 'var(--primary)';
|
|
87
|
+
const SYMBOL_RE = /^[A-Za-z0-9.^$=:/-]{1,24}$/;
|
|
88
|
+
const tidy = (list) => [...new Set(list.map((s) => s.trim().toUpperCase()).filter((s) => SYMBOL_RE.test(s)))].slice(0, MAX_COMPARE);
|
|
89
|
+
/**
|
|
90
|
+
* Mounts LumenChart once for an app, and its dialog: `bars` is the app's history route,
|
|
91
|
+
* `indicators` its sourced indicator route (P/E, IV, EPS estimate); left out, those indicators are
|
|
92
|
+
* not offered. `mine` is the route of the person's saved formulas; left out, there is no Mine.
|
|
93
|
+
*/
|
|
94
|
+
export function SymbolChartProvider({ bars, indicators, mine, children, }) {
|
|
95
|
+
const [open, setOpen] = useState(null);
|
|
96
|
+
const show = useCallback((s, i) => {
|
|
97
|
+
const list = tidy([s].flat());
|
|
98
|
+
setOpen(list.length ? { symbols: list, indicators: i?.length ? i : undefined } : null);
|
|
99
|
+
}, []);
|
|
100
|
+
const saved = useMineIndicators(mine);
|
|
101
|
+
// The assistant opens a chart the same way, one symbol or a list, with any indicators it names (built-ins
|
|
102
|
+
// with any window, saved ones, or formulas); Undo puts back what was open.
|
|
103
|
+
useAssistantMoves('chart', {
|
|
104
|
+
chart: show,
|
|
105
|
+
snapshot: () => open,
|
|
106
|
+
restore: (s) => setOpen(s ?? null),
|
|
107
|
+
});
|
|
108
|
+
const symbols = open?.symbols ?? null;
|
|
109
|
+
return (_jsx(ChartContext.Provider, { value: show, children: _jsx(BarsContext.Provider, { value: bars, children: _jsx(IndicatorsContext.Provider, { value: indicators ?? null, children: _jsxs(MineContext.Provider, { value: saved, children: [children, _jsx(Dialog, { open: symbols !== null, onOpenChange: (o) => !o && setOpen(null), children: _jsx(DialogContent, { showCloseButton: false, className: "flex max-h-svh flex-col gap-3 overflow-y-auto max-sm:h-svh max-sm:max-w-full max-sm:rounded-none sm:max-w-5xl", children: open && (_jsx(LumenChart
|
|
110
|
+
// A new list from outside (the assistant, a view) starts the chart over.
|
|
111
|
+
, { symbols: open.symbols, indicators: open.indicators, variant: "dialog", onClose: () => setOpen(null) }, `${open.symbols.join(',')}|${JSON.stringify(open.indicators ?? null)}`)) }) })] }) }) }) }));
|
|
112
|
+
}
|
|
113
|
+
const DEFAULT_PICKS = [
|
|
114
|
+
{ id: 'sma_20', for: ALL_SHOWN },
|
|
115
|
+
{ id: 'rsi_14', for: ALL_SHOWN },
|
|
116
|
+
];
|
|
117
|
+
/** Windows read at their own finer bars; every other window is a span of the daily history. */
|
|
118
|
+
const INTRADAY = ['1D', '5D', '1M'];
|
|
119
|
+
const DAY = 86_400;
|
|
120
|
+
const ALL_TIME = { from: Number.NEGATIVE_INFINITY, to: Number.POSITIVE_INFINITY };
|
|
121
|
+
/** The span a daily window shows, from today back; the chart snaps it to the bars it has. */
|
|
122
|
+
function spanOf(w) {
|
|
123
|
+
if (w === 'YTD')
|
|
124
|
+
return { ...ALL_TIME, from: Date.UTC(new Date().getUTCFullYear(), 0, 1) / 1000 };
|
|
125
|
+
const days = { '1M': 31, '6M': 183, '1Y': 366, '5Y': 5 * 366 }[w];
|
|
126
|
+
return days ? { ...ALL_TIME, from: Date.now() / 1000 - days * DAY } : ALL_TIME;
|
|
127
|
+
}
|
|
128
|
+
const TYPES = [
|
|
129
|
+
{ id: 'candles', label: 'Candles' },
|
|
130
|
+
{ id: 'line', label: 'Line' },
|
|
131
|
+
];
|
|
132
|
+
const MODES = [
|
|
133
|
+
{ id: 'pct', label: '%' },
|
|
134
|
+
{ id: 'price', label: 'Price' },
|
|
135
|
+
];
|
|
136
|
+
export const toChart = (b) => ({
|
|
137
|
+
time: b.t,
|
|
138
|
+
open: b.o,
|
|
139
|
+
high: b.h,
|
|
140
|
+
low: b.l,
|
|
141
|
+
close: b.c,
|
|
142
|
+
});
|
|
143
|
+
/** Daily bars sit at UTC midnight; the chart reads them as days. */
|
|
144
|
+
export const toDaily = (b) => ({
|
|
145
|
+
...toChart(b),
|
|
146
|
+
time: new Date(b.t * 1000).toISOString().slice(0, 10),
|
|
147
|
+
});
|
|
148
|
+
// One ChartBar array per source, so a re-render hands the chart the same bars and does not
|
|
149
|
+
// rebuild it. New arrays on every render rebuilt it in a loop, which flashed.
|
|
150
|
+
const chartBars = new WeakMap();
|
|
151
|
+
const once = (src, make) => {
|
|
152
|
+
let bars = chartBars.get(src);
|
|
153
|
+
if (!bars) {
|
|
154
|
+
bars = make();
|
|
155
|
+
chartBars.set(src, bars);
|
|
156
|
+
}
|
|
157
|
+
return bars;
|
|
158
|
+
};
|
|
159
|
+
const NO_BARS = [];
|
|
160
|
+
// History ends at the last nightly load; every other window ends on the live feed too, so its
|
|
161
|
+
// last price is the header's and the Symbol sheet's (one live source).
|
|
162
|
+
const tails = new WeakMap();
|
|
163
|
+
function liveTail(bars, today, date) {
|
|
164
|
+
if (!today?.bars.length || !bars.length || !date)
|
|
165
|
+
return bars;
|
|
166
|
+
let byToday = tails.get(bars);
|
|
167
|
+
if (!byToday) {
|
|
168
|
+
byToday = new WeakMap();
|
|
169
|
+
tails.set(bars, byToday);
|
|
170
|
+
}
|
|
171
|
+
const hit = byToday.get(today);
|
|
172
|
+
if (hit)
|
|
173
|
+
return hit;
|
|
174
|
+
const after = (extendedOf(today)?.bars ?? []).filter((x) => x.t > (today.bars.at(-1)?.t ?? 0));
|
|
175
|
+
const all = [...today.bars, ...after];
|
|
176
|
+
let out;
|
|
177
|
+
if (typeof bars[0]?.time === 'string') {
|
|
178
|
+
// Daily: today's bar from the session (and the trades since), replacing a stored one.
|
|
179
|
+
const day = {
|
|
180
|
+
time: date,
|
|
181
|
+
open: today.bars[0]?.o ?? 0,
|
|
182
|
+
high: Math.max(...all.map((b) => b.h)),
|
|
183
|
+
low: Math.min(...all.map((b) => b.l)),
|
|
184
|
+
close: all.at(-1)?.c ?? 0,
|
|
185
|
+
};
|
|
186
|
+
const last = bars.at(-1)?.time;
|
|
187
|
+
out = last > date ? bars : [...(last === date ? bars.slice(0, -1) : bars), day];
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
const end = secOf(bars.at(-1)?.time ?? 0);
|
|
191
|
+
out = [...bars, ...all.filter((b) => b.t > end).map(toChart)];
|
|
192
|
+
}
|
|
193
|
+
byToday.set(today, out);
|
|
194
|
+
return out;
|
|
195
|
+
}
|
|
196
|
+
const NO_VALUES = {};
|
|
197
|
+
const barsOf = (d) => d ? once(d, () => d.bars.map(d.resolution === '1d' ? toDaily : toChart)) : NO_BARS;
|
|
198
|
+
const NONE = { data: null, error: null };
|
|
199
|
+
const barsQuery = (url, w) => queryOptions({
|
|
200
|
+
queryKey: ['lumen-chart', url, w.symbol, w.window, w.warm],
|
|
201
|
+
queryFn: async ({ signal }) => {
|
|
202
|
+
const r = await fetch(`${url}?symbol=${encodeURIComponent(w.symbol)}&window=${w.window}${w.warm ? '&warmup=1' : ''}`, { credentials: 'same-origin', headers: refreshHeaders(), signal });
|
|
203
|
+
if (!r.ok)
|
|
204
|
+
throw new Error(`history answered ${r.status}`);
|
|
205
|
+
return (await r.json());
|
|
206
|
+
},
|
|
207
|
+
staleTime: 5 * 60_000,
|
|
208
|
+
});
|
|
209
|
+
/**
|
|
210
|
+
* History reads per symbol, window and whether they carry warm-up bars. A read with warm-up not
|
|
211
|
+
* back yet shows the same read without, so adding an indicator keeps the chart up while its bars
|
|
212
|
+
* load.
|
|
213
|
+
*/
|
|
214
|
+
function useReads(url, wants, enabled) {
|
|
215
|
+
const client = useQueryClient();
|
|
216
|
+
const queries = useQueries({
|
|
217
|
+
queries: wants.map((w) => ({
|
|
218
|
+
...barsQuery(url ?? '', w),
|
|
219
|
+
enabled: enabled && !!url,
|
|
220
|
+
placeholderData: w.warm
|
|
221
|
+
? () => client.getQueryData(barsQuery(url ?? '', { ...w, warm: false }).queryKey)
|
|
222
|
+
: undefined,
|
|
223
|
+
})),
|
|
224
|
+
});
|
|
225
|
+
const keyOf = (w) => `${w.symbol}|${w.window}|${w.warm ? 1 : 0}`;
|
|
226
|
+
const byKey = new Map(wants.map((w, i) => [keyOf(w), queries[i]]));
|
|
227
|
+
const read = (w) => {
|
|
228
|
+
const q = byKey.get(keyOf(w));
|
|
229
|
+
if (!q)
|
|
230
|
+
return NONE;
|
|
231
|
+
return { data: q.data ?? null, error: q.data ? null : (q.error?.message ?? null) };
|
|
232
|
+
};
|
|
233
|
+
return { read, queries };
|
|
234
|
+
}
|
|
235
|
+
/** Sourced lines (P/E, IV, EPS estimate) per symbol and indicator, each read once on add. */
|
|
236
|
+
function useSourced(url, wants) {
|
|
237
|
+
const [store, setStore] = useState({});
|
|
238
|
+
const asked = useRef(new Set());
|
|
239
|
+
const want = wants.map((w) => `${w.symbol}|${w.id}`).join(';');
|
|
240
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: `want` names every read in `wants`
|
|
241
|
+
useEffect(() => {
|
|
242
|
+
if (!url)
|
|
243
|
+
return;
|
|
244
|
+
const bySymbol = new Map();
|
|
245
|
+
for (const w of wants) {
|
|
246
|
+
const k = `${w.symbol}|${w.id}`;
|
|
247
|
+
if (asked.current.has(k))
|
|
248
|
+
continue;
|
|
249
|
+
asked.current.add(k);
|
|
250
|
+
bySymbol.set(w.symbol, [...(bySymbol.get(w.symbol) ?? []), w.id]);
|
|
251
|
+
}
|
|
252
|
+
for (const [symbol, ids] of bySymbol) {
|
|
253
|
+
setStore((s) => ({
|
|
254
|
+
...s,
|
|
255
|
+
...Object.fromEntries(ids.map((id) => [`${symbol}|${id}`, 'loading'])),
|
|
256
|
+
}));
|
|
257
|
+
fetch(`${url}?symbol=${encodeURIComponent(symbol)}&names=${ids.join(',')}`, {
|
|
258
|
+
credentials: 'same-origin',
|
|
259
|
+
})
|
|
260
|
+
.then(async (r) => {
|
|
261
|
+
if (!r.ok)
|
|
262
|
+
throw new Error(String(r.status));
|
|
263
|
+
const d = (await r.json());
|
|
264
|
+
setStore((s) => ({
|
|
265
|
+
...s,
|
|
266
|
+
...Object.fromEntries(ids.map((id) => [
|
|
267
|
+
`${symbol}|${id}`,
|
|
268
|
+
new Map((d.lines[id] ?? []).map(([t, v]) => [
|
|
269
|
+
new Date(t * 1000).toISOString().slice(0, 10),
|
|
270
|
+
v,
|
|
271
|
+
])),
|
|
272
|
+
])),
|
|
273
|
+
}));
|
|
274
|
+
})
|
|
275
|
+
.catch(() => {
|
|
276
|
+
for (const id of ids)
|
|
277
|
+
asked.current.delete(`${symbol}|${id}`);
|
|
278
|
+
setStore((s) => ({
|
|
279
|
+
...s,
|
|
280
|
+
...Object.fromEntries(ids.map((id) => [`${symbol}|${id}`, 'failed'])),
|
|
281
|
+
}));
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
}, [url, want]);
|
|
285
|
+
return store;
|
|
286
|
+
}
|
|
287
|
+
/** LumenChart as one symbol, or up to 8 compared, inside a card, a sheet or the chart dialog. */
|
|
288
|
+
export function LumenChart(props) {
|
|
289
|
+
const chart = useLumenChart(props);
|
|
290
|
+
return (_jsx("div", { ref: chart.ref, className: "flex flex-col gap-3", children: chart.element }));
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* LumenChart's state and body, for a card that shows its source: `load` (its ↻ and load time),
|
|
294
|
+
* `rows` (bars drawn), `ref` (for `lazy`) and `element`. `noDesk`: the app mounted no
|
|
295
|
+
* SymbolChartProvider, so there is nothing to read.
|
|
296
|
+
*/
|
|
297
|
+
export function useLumenChart({ symbols: initial, window: start, type: startType, indicators, variant = 'card', lazy = false, onClose, windows: showWindows = true, }) {
|
|
298
|
+
const barsUrl = useBarsUrl();
|
|
299
|
+
const indicatorsUrl = useContext(IndicatorsContext);
|
|
300
|
+
const mine = useContext(MineContext);
|
|
301
|
+
const { ref, inView: seen } = useInView({ triggerOnce: true, rootMargin: '200px', skip: !lazy });
|
|
302
|
+
const enabled = !lazy || seen;
|
|
303
|
+
const dialog = variant === 'dialog';
|
|
304
|
+
const { timeZone } = usePrefs();
|
|
305
|
+
const [symbols, setSymbols] = useState(() => tidy(initial));
|
|
306
|
+
const compare = symbols.length > 1;
|
|
307
|
+
// Breadth and macro series are daily: no intraday windows, and nothing live. One of them in a
|
|
308
|
+
// compare makes the whole chart daily (one time axis).
|
|
309
|
+
const daily = symbols.some(isDailyMarketKey);
|
|
310
|
+
const windows = daily ? marketWindows.filter((w) => w !== '1D' && w !== '5D') : marketWindows;
|
|
311
|
+
// What the chart reads and shows: a window button, or the span the timeline asked for outside an
|
|
312
|
+
// intraday window's bars. `pressed` is the button lit; panning or zooming clears it.
|
|
313
|
+
const [view, setView] = useState({
|
|
314
|
+
window: start ?? (daily ? '6M' : initial.length > 1 ? '1Y' : '1D'),
|
|
315
|
+
});
|
|
316
|
+
const [pressed, setPressed] = useState(view.window);
|
|
317
|
+
const [shown, setShown] = useState(null);
|
|
318
|
+
const window = view.window === '1D' || view.window === '5D' ? (daily ? '6M' : view.window) : view.window;
|
|
319
|
+
// Null until someone picks: candles, or a line where every bar is flat (a fund priced by NAV).
|
|
320
|
+
const [picked, setType] = useState(daily ? 'line' : (startType ?? null));
|
|
321
|
+
const [mode, setMode] = useState('pct');
|
|
322
|
+
// Indicators that are not in the catalog or Mine: built-ins with their own window and formulas.
|
|
323
|
+
const [made, setMade] = useState(() => (indicators ?? []).flatMap((spec) => {
|
|
324
|
+
const i = typeof spec === 'string' ? null : resolveIndicator(spec);
|
|
325
|
+
return i ? [i] : [];
|
|
326
|
+
}));
|
|
327
|
+
const [picks, setPicks] = useState(() => indicators
|
|
328
|
+
? indicators.map((spec) => ({
|
|
329
|
+
id: typeof spec === 'string' ? spec : (resolveIndicator(spec)?.id ?? ''),
|
|
330
|
+
for: ALL_SHOWN,
|
|
331
|
+
}))
|
|
332
|
+
: initial.length > 1
|
|
333
|
+
? []
|
|
334
|
+
: DEFAULT_PICKS);
|
|
335
|
+
const [target, setTarget] = useState(ALL_SHOWN);
|
|
336
|
+
const live = useMarket(daily ? [] : symbols);
|
|
337
|
+
const idsFor = useCallback((s) => new Set(picks.filter((p) => p.for === ALL_SHOWN || p.for === s).map((p) => p.id)), [picks]);
|
|
338
|
+
// What an id draws: the catalog, a saved formula, one made here, or a built-in with its window.
|
|
339
|
+
const resolve = useCallback((id) => made.find((i) => i.id === id) ?? resolveIndicator(id, mine.list), [made, mine.list]);
|
|
340
|
+
// The same array per symbol until the picks change, so the chart is not rebuilt each render.
|
|
341
|
+
const chosenFor = useMemo(() => {
|
|
342
|
+
const bySymbol = new Map();
|
|
343
|
+
return (s) => {
|
|
344
|
+
let got = bySymbol.get(s);
|
|
345
|
+
if (!got) {
|
|
346
|
+
got = [...idsFor(s)].flatMap((id) => {
|
|
347
|
+
const i = resolve(id);
|
|
348
|
+
return i ? [i] : [];
|
|
349
|
+
});
|
|
350
|
+
bySymbol.set(s, got);
|
|
351
|
+
}
|
|
352
|
+
return got;
|
|
353
|
+
};
|
|
354
|
+
}, [idsFor, resolve]);
|
|
355
|
+
const computedFor = (s) => chosenFor(s).some((i) => i.computed);
|
|
356
|
+
// Every symbol's whole history, read once: the timeline under the chart,
|
|
357
|
+
// and, when it is daily, the bars of every daily window, which the buttons, panning and zooming
|
|
358
|
+
// move through. The delayed source answers Max in months: then each window reads its own days.
|
|
359
|
+
const { read: reads, queries: fullQueries } = useReads(barsUrl, symbols.map((symbol) => ({ symbol, window: 'Max', warm: false })), enabled);
|
|
360
|
+
const coarse = (bars) => {
|
|
361
|
+
const gaps = bars.slice(1).map((b, i) => secOf(b.time) - secOf(bars[i]?.time ?? 0));
|
|
362
|
+
return (gaps.sort((x, y) => x - y)[gaps.length >> 1] ?? 0) > 5 * DAY;
|
|
363
|
+
};
|
|
364
|
+
const plan = symbols.map((symbol) => {
|
|
365
|
+
const full = reads({ symbol, window: 'Max', warm: false });
|
|
366
|
+
const timeline = barsOf(full.data);
|
|
367
|
+
const own = window !== null &&
|
|
368
|
+
((!daily && INTRADAY.includes(window)) || (coarse(timeline) && window !== 'Max'));
|
|
369
|
+
const today = live.series[symbol];
|
|
370
|
+
const useLive = window === '1D' && !!today?.bars.length;
|
|
371
|
+
const warm = computedFor(symbol);
|
|
372
|
+
return { symbol, full, timeline, own, useLive, warm, today };
|
|
373
|
+
});
|
|
374
|
+
const { read: partReads, queries: partQueries } = useReads(barsUrl, plan
|
|
375
|
+
.filter((p) => p.own && !(p.useLive && !p.warm) && window !== null)
|
|
376
|
+
.map((p) => ({ symbol: p.symbol, window: window, warm: p.warm })), enabled);
|
|
377
|
+
const load = useLumenLoad(...fullQueries, ...partQueries);
|
|
378
|
+
const sourced = useSourced(indicatorsUrl, symbols.flatMap((symbol) => chosenFor(symbol)
|
|
379
|
+
.filter((i) => i.sourced && !isDailyMarketKey(symbol))
|
|
380
|
+
.map((i) => ({ symbol, id: i.id }))));
|
|
381
|
+
const series = plan.map((p) => {
|
|
382
|
+
const part = p.own && window !== null ? partReads({ symbol: p.symbol, window, warm: p.warm }) : NONE;
|
|
383
|
+
const read = p.own ? part : p.full;
|
|
384
|
+
// 1D is the session's bars, then the trades since its close (pre-market, after hours,
|
|
385
|
+
// overnight), all from the one live feed, so the chart ends where the header's price is.
|
|
386
|
+
const bars = p.useLive
|
|
387
|
+
? p.today
|
|
388
|
+
? once(p.today, () => {
|
|
389
|
+
const s = p.today;
|
|
390
|
+
const after = (extendedOf(s)?.bars ?? []).filter((x) => x.t > (s.bars.at(-1)?.t ?? 0));
|
|
391
|
+
return [...s.bars, ...after].map(toChart);
|
|
392
|
+
})
|
|
393
|
+
: NO_BARS
|
|
394
|
+
: liveTail(p.own ? barsOf(part.data) : p.timeline, p.today, live.session?.date);
|
|
395
|
+
return {
|
|
396
|
+
symbol: p.symbol,
|
|
397
|
+
bars,
|
|
398
|
+
timeline: p.timeline,
|
|
399
|
+
values: {},
|
|
400
|
+
read,
|
|
401
|
+
live: p.useLive,
|
|
402
|
+
own: p.own,
|
|
403
|
+
};
|
|
404
|
+
});
|
|
405
|
+
// Indicator lines, worked out over warm-up + window so long lines (SMA 200) start on the first
|
|
406
|
+
// bar, then the sourced lines of daily bars.
|
|
407
|
+
const valueKey = series
|
|
408
|
+
.map((s) => `${s.symbol}:${s.bars.length}:${s.bars.at(-1)?.close}:${s.read.data?.warmup?.length}`)
|
|
409
|
+
.join('|');
|
|
410
|
+
const sourcedKey = Object.entries(sourced)
|
|
411
|
+
.map(([k, v]) => `${k}:${typeof v === 'string' ? v : v.size}`)
|
|
412
|
+
.join('|');
|
|
413
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed by what the lines read
|
|
414
|
+
const values = useMemo(() => {
|
|
415
|
+
const out = {};
|
|
416
|
+
for (const s of series) {
|
|
417
|
+
const d = s.read.data;
|
|
418
|
+
let warm = [];
|
|
419
|
+
if (s.live) {
|
|
420
|
+
const first = s.bars[0]?.time ?? Number.POSITIVE_INFINITY;
|
|
421
|
+
if (d?.resolution === '1m')
|
|
422
|
+
warm = [...(d.warmup ?? []), ...d.bars]
|
|
423
|
+
.filter((b) => b.t < first)
|
|
424
|
+
.slice(-250)
|
|
425
|
+
.map(toChart);
|
|
426
|
+
}
|
|
427
|
+
else if (d && s.own)
|
|
428
|
+
warm = (d.warmup ?? []).map(d.resolution === '1d' ? toDaily : toChart);
|
|
429
|
+
const chosen = chosenFor(s.symbol).filter((i) => i.computed && (!i.daily || typeof s.bars[0]?.time === 'string'));
|
|
430
|
+
const v = computeIndicators([...warm, ...s.bars], chosen);
|
|
431
|
+
if (typeof s.bars[0]?.time === 'string')
|
|
432
|
+
for (const i of chosenFor(s.symbol).filter((x) => x.sourced)) {
|
|
433
|
+
const got = sourced[`${s.symbol}|${i.id}`];
|
|
434
|
+
if (got instanceof Map)
|
|
435
|
+
v[i.id] = got;
|
|
436
|
+
}
|
|
437
|
+
out[s.symbol] = v;
|
|
438
|
+
}
|
|
439
|
+
return out;
|
|
440
|
+
}, [valueKey, sourcedKey, chosenFor]);
|
|
441
|
+
// Which indicators the current window can draw, and why not.
|
|
442
|
+
const dailyBars = !(window && INTRADAY.includes(window)) || daily;
|
|
443
|
+
const reasons = useMemo(() => {
|
|
444
|
+
const m = new Map();
|
|
445
|
+
for (const i of CHART_INDICATORS) {
|
|
446
|
+
if (i.sourced) {
|
|
447
|
+
if (!indicatorsUrl)
|
|
448
|
+
m.set(i.id, 'not on this desk');
|
|
449
|
+
else if (!dailyBars)
|
|
450
|
+
m.set(i.id, 'daily windows');
|
|
451
|
+
else if (symbols.every(isDailyMarketKey))
|
|
452
|
+
m.set(i.id, 'stocks and ETFs');
|
|
453
|
+
}
|
|
454
|
+
else if (!i.computed)
|
|
455
|
+
m.set(i.id, 'needs volume');
|
|
456
|
+
else if (i.daily && !dailyBars)
|
|
457
|
+
m.set(i.id, 'daily windows');
|
|
458
|
+
}
|
|
459
|
+
return m;
|
|
460
|
+
}, [indicatorsUrl, dailyBars, symbols]);
|
|
461
|
+
const available = useMemo(() => new Set(CHART_INDICATORS.filter((i) => !reasons.has(i.id)).map((i) => i.id)), [reasons]);
|
|
462
|
+
const pickTarget = compare ? target : ALL_SHOWN;
|
|
463
|
+
const selected = picks.filter((p) => p.for === pickTarget).map((p) => p.id);
|
|
464
|
+
const toggle = (id) => setPicks(selected.includes(id)
|
|
465
|
+
? picks.filter((p) => !(p.id === id && p.for === pickTarget))
|
|
466
|
+
: [...picks, { id, for: pickTarget }]);
|
|
467
|
+
// A built-in with its own window or a formula from the picker: added, and kept to resolve.
|
|
468
|
+
const add = (i) => {
|
|
469
|
+
if (!resolve(i.id))
|
|
470
|
+
setMade([...made, i]);
|
|
471
|
+
if (!selected.includes(i.id))
|
|
472
|
+
setPicks([...picks, { id: i.id, for: pickTarget }]);
|
|
473
|
+
};
|
|
474
|
+
const range = useMemo(() => view.span ?? (window && INTRADAY.includes(window) && !daily ? ALL_TIME : spanOf(window)), [view, window, daily]);
|
|
475
|
+
const pick = (w) => {
|
|
476
|
+
setView({ window: w });
|
|
477
|
+
setPressed(w);
|
|
478
|
+
};
|
|
479
|
+
const addSymbol = (raw) => {
|
|
480
|
+
const next = tidy([...symbols, ...raw.split(/[\s,]+/)]);
|
|
481
|
+
if (next.length !== symbols.length)
|
|
482
|
+
setSymbols(next);
|
|
483
|
+
};
|
|
484
|
+
const removeSymbol = (s) => {
|
|
485
|
+
setSymbols(symbols.filter((x) => x !== s));
|
|
486
|
+
setPicks(picks.filter((p) => p.for !== s));
|
|
487
|
+
if (target === s)
|
|
488
|
+
setTarget(ALL_SHOWN);
|
|
489
|
+
};
|
|
490
|
+
const windowButtons = (_jsx(ToggleGroup, { type: "single", size: "sm", variant: "outline", spacing: 0, value: pressed ?? '', onValueChange: (v) => v && pick(v), "aria-label": "Window", className: "flex-wrap", children: windows.map((w) => (_jsx(ToggleGroupItem, { value: w, className: "px-2.5",
|
|
491
|
+
// A lit button pressed again goes back to its window after panning.
|
|
492
|
+
onClick: () => w === pressed && pick(w), children: w }, w))) }));
|
|
493
|
+
const pickChips = picks.map((p) => {
|
|
494
|
+
const i = resolve(p.id);
|
|
495
|
+
if (!i)
|
|
496
|
+
return null;
|
|
497
|
+
const loading = i.sourced &&
|
|
498
|
+
symbols.some((s) => sourced[`${s}|${i.id}`] === 'loading' && idsFor(s).has(i.id));
|
|
499
|
+
const color = compare
|
|
500
|
+
? p.for === ALL_SHOWN
|
|
501
|
+
? 'var(--muted-foreground)'
|
|
502
|
+
: (SYMBOL_COLORS[symbols.indexOf(p.for)] ?? 'var(--primary)')
|
|
503
|
+
: lineColor(i.lines[0]?.key ?? '');
|
|
504
|
+
const label = i.formula ? `ƒ ${i.label}` : i.label;
|
|
505
|
+
const name = compare ? `${label} · ${p.for === ALL_SHOWN ? 'All' : p.for}` : label;
|
|
506
|
+
return (_jsxs(Badge, { variant: "secondary", className: "h-7 gap-1.5 pr-1", children: [_jsx("span", { "aria-hidden": true, className: "h-0.5 w-2.5 rounded-full", style: { background: color } }), name, loading && _jsx("span", { className: "text-muted-foreground", children: "loading\u2026" }), _jsx(IconButton, { icon: XIcon, size: "icon-xs", label: `Remove ${name}`, onClick: () => setPicks(picks.filter((x) => x !== p)) })] }, `${p.id}|${p.for}`));
|
|
507
|
+
});
|
|
508
|
+
const picker = (_jsx(IndicatorPicker, { selected: selected, available: available, reasons: reasons, mine: mine, onAdd: add, targets: compare ? symbols : undefined, target: pickTarget, onTarget: setTarget, onToggle: toggle }));
|
|
509
|
+
const adder = symbols.length < MAX_COMPARE ? (_jsx(Input, { "aria-label": "Add a symbol to compare", placeholder: "\uFF0B Compare", className: "h-7 w-28 font-mono uppercase placeholder:normal-case", onKeyDown: (e) => {
|
|
510
|
+
if (e.key !== 'Enter')
|
|
511
|
+
return;
|
|
512
|
+
addSymbol(e.currentTarget.value);
|
|
513
|
+
e.currentTarget.value = '';
|
|
514
|
+
} })) : null;
|
|
515
|
+
const footer = (_jsxs("p", { className: "flex flex-wrap justify-between gap-2 text-xs text-muted-foreground", children: [_jsx("span", { children: compare && mode === 'pct'
|
|
516
|
+
? '% from the left edge of the chart · drag the timeline to move the baseline'
|
|
517
|
+
: compare
|
|
518
|
+
? 'Each symbol on its own scale · hover to read the day'
|
|
519
|
+
: 'Hover to read a bar · drag to pan, scroll to zoom · drag the timeline window or its edges' }), !series[0]?.live && series[0]?.read.data && (_jsxs("span", { children: [series.some((s) => s.read.data?.source === 'delayed') ? 'Delayed source' : 'History', " \u00B7", ' ', series[0].read.data.resolution, " bars"] }))] }));
|
|
520
|
+
const result = (element) => ({
|
|
521
|
+
load,
|
|
522
|
+
rows: series[0]?.bars.length ?? 0,
|
|
523
|
+
ref,
|
|
524
|
+
element,
|
|
525
|
+
noDesk: !barsUrl,
|
|
526
|
+
});
|
|
527
|
+
if (!barsUrl)
|
|
528
|
+
return result(_jsx("p", { className: "text-sm text-muted-foreground", children: "Charts are not on this desk." }));
|
|
529
|
+
if (compare)
|
|
530
|
+
return result(_jsx(Compare, { dialog: dialog, symbols: symbols, series: series, values: values, chosenFor: chosenFor, mode: mode, timeZone: timeZone, range: range, onClose: onClose, onRemove: removeSymbol, header: _jsxs("div", { className: "flex flex-wrap items-center justify-between gap-2", children: [windowButtons, _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [pickChips, picker, _jsx(ToggleGroup, { type: "single", size: "sm", variant: "outline", spacing: 0, value: mode, onValueChange: (v) => v && setMode(v), "aria-label": "Scale", children: MODES.map((m) => (_jsx(ToggleGroupItem, { value: m.id, className: "px-2.5", children: m.label }, m.id))) })] })] }), adder: adder, footer: footer, onRange: (span, byPerson) => {
|
|
531
|
+
setShown(span);
|
|
532
|
+
if (byPerson)
|
|
533
|
+
setPressed(null);
|
|
534
|
+
}, onOutside: (span) => {
|
|
535
|
+
setView({ window: null, span });
|
|
536
|
+
setPressed(null);
|
|
537
|
+
} }));
|
|
538
|
+
const one = series[0];
|
|
539
|
+
const symbol = one.symbol;
|
|
540
|
+
const today = live.series[symbol];
|
|
541
|
+
const meta = MARKET_SYMBOLS[symbol];
|
|
542
|
+
const fmt = (v) => `${today?.prefix ?? meta?.prefix ?? ''}${v.toLocaleString('en-US', {
|
|
543
|
+
minimumFractionDigits: today?.decimals ?? meta?.decimals ?? 2,
|
|
544
|
+
maximumFractionDigits: today?.decimals ?? meta?.decimals ?? 2,
|
|
545
|
+
})}${today?.suffix ?? meta?.suffix ?? ''}`;
|
|
546
|
+
const bars = one.bars;
|
|
547
|
+
// The header reads the bars the chart shows, so its price, change, high and low match them.
|
|
548
|
+
const secs = bars.map((b) => secOf(b.time));
|
|
549
|
+
const [a, b] = shown && secs.length ? indexSpan(secs, shown) : [0, bars.length - 1];
|
|
550
|
+
const inView = bars.slice(a, b + 1);
|
|
551
|
+
const atEnd = b >= bars.length - 1;
|
|
552
|
+
const last = inView.at(-1)?.close ?? null;
|
|
553
|
+
// Live at the end: up or down today, from the same base the Outlook and Holdings use (the prior
|
|
554
|
+
// close, or the session's close once the overnight session starts a new day).
|
|
555
|
+
const day = one.live && today ? todayOf(today) : null;
|
|
556
|
+
const base = day?.base ?? today?.prevClose ?? null;
|
|
557
|
+
const prev = one.live && atEnd ? base : (inView[0]?.open ?? null);
|
|
558
|
+
// Breadth and rates move in points (a % above the 50-day from 49 to 36 is −13 pts, not −26%).
|
|
559
|
+
const points = !!meta?.daily?.points;
|
|
560
|
+
const change = last !== null && prev ? (points ? last - prev : (last / prev - 1) * 100) : null;
|
|
561
|
+
const delayed = one.live ? today?.delayed : one.read.data?.source === 'delayed';
|
|
562
|
+
const hi = inView.length ? Math.max(...inView.map((x) => x.high)) : null;
|
|
563
|
+
const lo = inView.length ? Math.min(...inView.map((x) => x.low)) : null;
|
|
564
|
+
const endDay = inView.at(-1)?.time;
|
|
565
|
+
const loading = !one.live && !one.read.data && !one.read.error;
|
|
566
|
+
const chosen = chosenFor(symbol);
|
|
567
|
+
const judged = one.timeline.length ? one.timeline : bars;
|
|
568
|
+
const flat = judged.length > 1 &&
|
|
569
|
+
judged.every((x) => x.open === x.high && x.high === x.low && x.low === x.close);
|
|
570
|
+
const type = picked ?? (flat ? 'line' : 'candles');
|
|
571
|
+
return result(_jsxs(_Fragment, { children: [_jsxs(ChartHeader, { dialog: dialog, children: [_jsxs("div", { className: "flex min-w-0 flex-col gap-1", children: [_jsxs(ChartTitle, { dialog: dialog, className: "flex flex-wrap items-baseline gap-2", children: [_jsx("span", { className: "text-lg font-bold", children: today?.label ?? meta?.label ?? symbol }), _jsxs("span", { className: "text-sm font-normal text-muted-foreground", children: [today?.name ?? meta?.name ?? '', today?.via ? ` via ${today.via}` : ''] }), delayed ? (_jsx(Badge, { variant: "outline", className: "border-warning/50 bg-warning/10 text-warning", children: "Delayed" })) : one.live && live.status === 'live' ? (_jsxs(Badge, { variant: "outline", className: "gap-1.5 border-gain/40 text-gain", children: [_jsx("span", { className: "size-1.5 rounded-full bg-gain" }), "Live"] })) : null] }), _jsx(ChartDescription, { dialog: dialog, children: _jsxs("div", { className: "num flex flex-wrap items-baseline gap-x-3 gap-y-1 text-foreground", children: [last !== null && _jsx("span", { className: "text-xl", children: fmt(last) }), !atEnd && endDay !== undefined && (_jsxs("span", { className: "text-xs text-muted-foreground", children: ["on", ' ', typeof endDay === 'string'
|
|
572
|
+
? endDay
|
|
573
|
+
: new Date(endDay * 1000).toLocaleString('en-US', {
|
|
574
|
+
timeZone,
|
|
575
|
+
month: 'short',
|
|
576
|
+
day: 'numeric',
|
|
577
|
+
hour: 'numeric',
|
|
578
|
+
minute: '2-digit',
|
|
579
|
+
})] })), one.live && atEnd && day?.label && (_jsx("span", { className: "text-xs text-muted-foreground", children: day.label })), change !== null && (_jsxs("span", { className: change >= 0 ? 'text-gain' : 'text-loss', children: [change >= 0 ? '+' : '−', points
|
|
580
|
+
? `${Math.abs(change).toFixed(meta?.decimals ?? 2)}${meta?.suffix === '%' ? ' pts' : ''}`
|
|
581
|
+
: `${Math.abs(change).toFixed(2)}%`, ' ', change >= 0 ? '▲' : '▼'] })), hi !== null && lo !== null && (_jsxs("span", { className: "text-xs text-muted-foreground", children: ["H ", fmt(hi), " \u00B7 L ", fmt(lo), prev
|
|
582
|
+
? ` · ${one.live && atEnd ? (base === today?.prevClose ? 'Prev' : 'Close') : 'Start'} ${fmt(prev)}`
|
|
583
|
+
: ''] }))] }) })] }), _jsxs("div", { className: "flex items-center gap-2", children: [adder, onClose && _jsx(IconButton, { icon: XIcon, label: "Close", variant: "outline", onClick: onClose })] })] }), _jsxs("div", { className: "flex flex-wrap items-center justify-between gap-2", children: [showWindows ? windowButtons : _jsx("span", {}), _jsxs("div", { className: "flex flex-wrap items-center gap-2", children: [pickChips, picker, _jsx(ToggleGroup, { type: "single", size: "sm", variant: "outline", spacing: 0, value: type, onValueChange: (v) => v && setType(v), "aria-label": "Chart type", children: TYPES.map((t) => (_jsx(ToggleGroupItem, { value: t.id, className: "px-2.5", children: t.label }, t.id))) })] })] }), loading ? (_jsx(Skeleton, { className: "h-[420px] w-full" })) : !bars.length ? (_jsx("p", { className: "py-16 text-center text-sm text-muted-foreground", children: one.read.error ?? today?.error ?? `No bars for ${symbol} in this window.` })) : (_jsx(PriceChart, { bars: bars, type: type, chosen: chosen, values: values[symbol] ?? NO_VALUES, timeZone: timeZone, zoom: true, navigator: true, timeline: one.timeline, range: range, onRange: (span, byPerson) => {
|
|
584
|
+
setShown(span);
|
|
585
|
+
if (byPerson)
|
|
586
|
+
setPressed(null);
|
|
587
|
+
}, onOutside: (span) => {
|
|
588
|
+
setView({ window: null, span });
|
|
589
|
+
setPressed(null);
|
|
590
|
+
}, baseline: one.live ? base : null })), footer] }));
|
|
591
|
+
}
|
|
592
|
+
/** The chart's title row: the dialog's own header, title and description inside the dialog. */
|
|
593
|
+
function ChartHeader({ dialog, children }) {
|
|
594
|
+
const className = 'flex-row items-start justify-between gap-3 space-y-0';
|
|
595
|
+
return dialog ? (_jsx(DialogHeader, { className: className, children: children })) : (_jsx("div", { className: cn('flex', className), children: children }));
|
|
596
|
+
}
|
|
597
|
+
function ChartTitle({ dialog, className, children, }) {
|
|
598
|
+
return dialog ? (_jsx(DialogTitle, { className: className, children: children })) : (_jsx("div", { className: className, children: children }));
|
|
599
|
+
}
|
|
600
|
+
function ChartDescription({ dialog, children }) {
|
|
601
|
+
return dialog ? _jsx(DialogDescription, { asChild: true, children: children }) : children;
|
|
602
|
+
}
|
|
603
|
+
/** Two or more symbols: their chips, then the compare chart with each pick's lines. */
|
|
604
|
+
function Compare({ dialog, symbols, series, values, chosenFor, mode, timeZone, range, header, adder, footer, onClose, onRemove, onRange, onOutside, }) {
|
|
605
|
+
const barsKey = series
|
|
606
|
+
.map((s) => `${s.symbol}:${s.bars.length}:${s.bars.at(-1)?.close}`)
|
|
607
|
+
.join('|');
|
|
608
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: keyed by the bars drawn
|
|
609
|
+
const lines = useMemo(() => series
|
|
610
|
+
.filter((s) => s.bars.length)
|
|
611
|
+
.map((s) => ({ symbol: s.symbol, color: colorIn(symbols, s.symbol), bars: s.bars })), [barsKey]);
|
|
612
|
+
const { overlays, panes } = useMemo(() => {
|
|
613
|
+
const overlays = [];
|
|
614
|
+
const panes = [];
|
|
615
|
+
const every = new Map(symbols.flatMap(chosenFor).map((i) => [i.id, i]));
|
|
616
|
+
for (const i of every.values()) {
|
|
617
|
+
const on = symbols.filter((s) => chosenFor(s).some((c) => c.id === i.id));
|
|
618
|
+
if (!on.length)
|
|
619
|
+
continue;
|
|
620
|
+
const extras = on.flatMap((s) => i.lines
|
|
621
|
+
.filter((l) => !l.bar)
|
|
622
|
+
.map((l) => ({
|
|
623
|
+
key: `${s}:${l.key}`,
|
|
624
|
+
label: i.lines.length === 1 ? s : `${s} ${l.label}`,
|
|
625
|
+
symbol: s,
|
|
626
|
+
color: colorIn(symbols, s),
|
|
627
|
+
values: values[s]?.[l.key],
|
|
628
|
+
})));
|
|
629
|
+
if (i.where === 'overlay')
|
|
630
|
+
overlays.push(...extras);
|
|
631
|
+
else
|
|
632
|
+
panes.push({
|
|
633
|
+
id: i.id,
|
|
634
|
+
label: `${i.label} · ${on.length === symbols.length ? 'all symbols' : on.join(', ')}`,
|
|
635
|
+
domain: i.domain,
|
|
636
|
+
refs: i.refs,
|
|
637
|
+
lines: extras,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
return { overlays, panes };
|
|
641
|
+
}, [symbols, chosenFor, values]);
|
|
642
|
+
const loading = series.some((s) => !s.live && !s.read.data && !s.read.error);
|
|
643
|
+
const failed = series.filter((s) => s.read.error || (!s.bars.length && s.read.data));
|
|
644
|
+
return (_jsxs(_Fragment, { children: [_jsxs(ChartHeader, { dialog: dialog, children: [_jsxs("div", { className: "flex min-w-0 flex-col gap-1.5", children: [_jsx(ChartTitle, { dialog: dialog, className: "text-lg font-bold", children: "Compare" }), _jsx(ChartDescription, { dialog: dialog, children: _jsxs("div", { className: "flex flex-wrap items-center gap-1.5", children: [symbols.map((s) => (_jsxs(Badge, { variant: "outline", className: "num h-7 gap-1.5 pr-1 font-mono", children: [_jsx("span", { "aria-hidden": true, className: "size-2 rounded-full", style: { background: colorIn(symbols, s) } }), s, _jsx(IconButton, { icon: XIcon, size: "icon-xs", label: `Remove ${s}`, onClick: () => onRemove(s) })] }, s))), adder] }) })] }), onClose && _jsx(IconButton, { icon: XIcon, label: "Close", variant: "outline", onClick: onClose })] }), header, loading && !lines.length ? (_jsx(Skeleton, { className: "h-[420px] w-full" })) : !lines.length ? (_jsx("p", { className: "py-16 text-center text-sm text-muted-foreground", children: "No bars in this window." })) : (_jsx(CompareChart, { lines: lines, mode: mode, overlays: overlays, panes: panes, timeZone: timeZone, timeline: series.find((s) => s.timeline.length)?.timeline ?? [], range: range, onRange: onRange, onOutside: onOutside })), failed.length > 0 && (_jsxs("p", { className: "text-xs text-muted-foreground", children: ["No bars for ", failed.map((s) => s.symbol).join(', '), " in this window."] })), footer] }));
|
|
645
|
+
}
|