gloomberb 0.7.3 → 0.7.5
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/package.json +1 -1
- package/src/app.tsx +59 -30
- package/src/components/chart/chart-renderer.ts +109 -32
- package/src/components/chart/chart-types.ts +18 -0
- package/src/components/chart/chart.test.ts +152 -5
- package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
- package/src/components/chart/comparison-chart-renderer.ts +50 -11
- package/src/components/chart/comparison-stock-chart.tsx +45 -8
- package/src/components/chart/market-session.ts +187 -0
- package/src/components/chart/native/chart-rasterizer.ts +65 -2
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +96 -0
- package/src/components/chart/stock-chart.tsx +36 -8
- package/src/components/chart/time-axis-label.tsx +56 -2
- package/src/components/command-bar/command-bar.test.tsx +1 -1
- package/src/components/command-bar/command-bar.tsx +26 -2
- package/src/components/command-bar/list-model.ts +8 -4
- package/src/components/command-bar/view-model.test.ts +20 -0
- package/src/components/command-bar/view-model.ts +15 -3
- package/src/components/layout/shell.test.tsx +26 -0
- package/src/components/layout/shell.tsx +69 -21
- package/src/components/ticker-list-table.tsx +4 -15
- package/src/core/state/session-persistence.ts +25 -0
- package/src/market-data/coordinator-subscriptions.test.ts +102 -18
- package/src/market-data/coordinator.test.ts +122 -2
- package/src/market-data/coordinator.ts +192 -55
- package/src/market-data/hooks.test.tsx +31 -0
- package/src/market-data/hooks.tsx +1 -0
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
- package/src/plugins/builtin/chat-controller.ts +8 -7
- package/src/plugins/builtin/chat.test.tsx +55 -0
- package/src/plugins/builtin/chat.tsx +20 -6
- package/src/plugins/builtin/holders/index.test.ts +30 -1
- package/src/plugins/builtin/holders/index.tsx +7 -3
- package/src/plugins/builtin/market-movers/index.tsx +72 -40
- package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
- package/src/plugins/builtin/news-wire/index.tsx +0 -2
- package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
- package/src/plugins/builtin/portfolio-list/pane.tsx +26 -9
- package/src/plugins/builtin/portfolio-list.test.tsx +29 -7
- package/src/plugins/builtin/sectors/index.tsx +30 -9
- package/src/plugins/builtin/ticker-detail/pane.tsx +18 -1
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +4 -1
- package/src/plugins/builtin/world-indices/index.tsx +58 -19
- package/src/plugins/pane-manager.test.ts +31 -0
- package/src/plugins/pane-manager.ts +32 -6
- package/src/renderers/electrobun/bun/index.ts +83 -3
- package/src/renderers/electrobun/shared/protocol.ts +8 -1
- package/src/renderers/electrobun/view/backend-rpc.ts +5 -0
- package/src/renderers/electrobun/view/fatal-screen.tsx +118 -0
- package/src/renderers/electrobun/view/main.tsx +38 -24
- package/src/renderers/electrobun/view/styles.css +48 -4
- package/src/sources/gloomberb-cloud.test.ts +55 -2
- package/src/sources/gloomberb-cloud.ts +180 -29
- package/src/sources/provider-router.test.ts +567 -10
- package/src/sources/provider-router.ts +323 -93
- package/src/sources/yahoo-finance.test.ts +37 -0
- package/src/sources/yahoo-finance.ts +48 -2
- package/src/state/app-bootstrap.test.ts +50 -23
- package/src/state/app-bootstrap.ts +26 -10
- package/src/state/session-persistence.test.ts +5 -3
- package/src/state/use-inline-tickers.ts +3 -0
- package/src/state/use-quote-streaming.ts +11 -0
- package/src/types/data-provider.ts +5 -1
- package/src/types/financials.ts +1 -0
- package/src/utils/api-client.test.ts +120 -0
- package/src/utils/api-client.ts +66 -19
- package/src/utils/exchanges.test.ts +16 -0
- package/src/utils/exchanges.ts +33 -0
- package/src/utils/options.ts +1 -1
- package/src/utils/price-history.test.ts +28 -1
- package/src/utils/price-history.ts +16 -0
- package/src/utils/quote-freshness.test.ts +150 -0
- package/src/utils/quote-freshness.ts +42 -1
- package/src/utils/quote-resolution.ts +2 -0
- package/src/utils/quote-time.test.ts +34 -0
- package/src/utils/quote-time.ts +18 -5
- package/src/utils/ticker-search.ts +72 -1
- package/src/version.ts +1 -1
- package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
package/package.json
CHANGED
package/src/app.tsx
CHANGED
|
@@ -40,7 +40,7 @@ import {
|
|
|
40
40
|
type PaneBinding,
|
|
41
41
|
type PaneInstanceConfig,
|
|
42
42
|
} from "./types/config";
|
|
43
|
-
import type { CliLaunchRequest, PaneTemplateCreateOptions, PaneTemplateInstanceConfig, WizardStep } from "./types/plugin";
|
|
43
|
+
import type { CliLaunchRequest, PaneTemplateCreateOptions, PaneTemplateInstanceConfig, PinTickerOptions, WizardStep } from "./types/plugin";
|
|
44
44
|
import type { PaneSettingField } from "./types/plugin";
|
|
45
45
|
import type { TickerRecord } from "./types/ticker";
|
|
46
46
|
import type { DataProvider } from "./types/data-provider";
|
|
@@ -48,7 +48,7 @@ import type { TickerFinancials } from "./types/financials";
|
|
|
48
48
|
import type { BrokerAccount } from "./types/trading";
|
|
49
49
|
import type { DesktopDockPreviewState, DesktopSharedStateSnapshot, DesktopThemePreviewState, DesktopWindowBridge } from "./types/desktop-window";
|
|
50
50
|
import type { DesktopApplicationMenuBridge } from "./types/desktop-menu";
|
|
51
|
-
import {
|
|
51
|
+
import { resolveTickerOpenTarget, type TickerOpenTarget } from "./utils/ticker-search";
|
|
52
52
|
|
|
53
53
|
import {
|
|
54
54
|
clearPersistedBrokerAccounts,
|
|
@@ -70,6 +70,7 @@ import {
|
|
|
70
70
|
gridlockAllPanes,
|
|
71
71
|
isPaneInLayout,
|
|
72
72
|
removePane,
|
|
73
|
+
type LayoutBounds,
|
|
73
74
|
} from "./plugins/pane-manager";
|
|
74
75
|
import { notifyGridlockComplete } from "./plugins/gridlock-notification";
|
|
75
76
|
import {
|
|
@@ -206,6 +207,7 @@ function AppInner({
|
|
|
206
207
|
const isDetachedWindow = desktopWindowBridge?.kind === "detached";
|
|
207
208
|
const detachedPaneId = isDetachedWindow ? desktopWindowBridge.paneId ?? null : null;
|
|
208
209
|
const [desktopDockPreview, setDesktopDockPreview] = useState<DesktopDockPreviewState | null>(null);
|
|
210
|
+
const [commandBarNativeOccluder, setCommandBarNativeOccluder] = useState<LayoutBounds | null>(null);
|
|
209
211
|
appActiveRef.current = appActive;
|
|
210
212
|
const appNotifier = useMemo(() => createAppNotifier({
|
|
211
213
|
isAppActive: () => appActiveRef.current,
|
|
@@ -1359,14 +1361,45 @@ function AppInner({
|
|
|
1359
1361
|
|
|
1360
1362
|
focusVisiblePane(instanceId);
|
|
1361
1363
|
};
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
+
const resolveOpenTickerTarget = async (rawSymbol: string): Promise<TickerOpenTarget | null> => {
|
|
1365
|
+
try {
|
|
1366
|
+
const target = await resolveTickerOpenTarget({
|
|
1367
|
+
query: rawSymbol,
|
|
1368
|
+
tickers: stateRef.current.tickers,
|
|
1369
|
+
dataProvider,
|
|
1370
|
+
tickerRepository,
|
|
1371
|
+
});
|
|
1372
|
+
if (!target) {
|
|
1373
|
+
pluginRegistry.notify({ body: `Could not open ${rawSymbol}.`, type: "error" });
|
|
1374
|
+
}
|
|
1375
|
+
return target;
|
|
1376
|
+
} catch (err) {
|
|
1377
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1378
|
+
pluginRegistry.notify({ body: `Failed to open ${rawSymbol}: ${message}`, type: "error" });
|
|
1379
|
+
return null;
|
|
1380
|
+
}
|
|
1381
|
+
};
|
|
1382
|
+
const publishTickerOpenTarget = (target: TickerOpenTarget) => {
|
|
1383
|
+
const currentTicker = stateRef.current.tickers.get(target.symbol);
|
|
1384
|
+
if (currentTicker !== target.ticker) {
|
|
1385
|
+
dispatch({ type: "UPDATE_TICKER", ticker: target.ticker });
|
|
1386
|
+
}
|
|
1387
|
+
if (target.created) {
|
|
1388
|
+
pluginRegistry.events.emit("ticker:added", { symbol: target.symbol, ticker: target.ticker });
|
|
1389
|
+
}
|
|
1390
|
+
};
|
|
1391
|
+
const placePinnedTickerTarget = (target: TickerOpenTarget, options?: PinTickerOptions) => {
|
|
1364
1392
|
const paneType = options?.paneType ?? "ticker-detail";
|
|
1365
1393
|
const paneDef = pluginRegistry.panes.get(paneType);
|
|
1366
1394
|
if (!paneDef) return;
|
|
1395
|
+
|
|
1396
|
+
publishTickerOpenTarget(target);
|
|
1397
|
+
const symbol = target.symbol;
|
|
1398
|
+
const currentState = stateRef.current;
|
|
1399
|
+
const currentLayout = currentState.config.layout;
|
|
1367
1400
|
const existing = options?.forceNewPane
|
|
1368
1401
|
? null
|
|
1369
|
-
: findFixedTickerPaneForSymbol(
|
|
1402
|
+
: findFixedTickerPaneForSymbol(currentLayout, paneType, symbol);
|
|
1370
1403
|
if (existing) {
|
|
1371
1404
|
focusVisiblePane(existing.instanceId);
|
|
1372
1405
|
return;
|
|
@@ -1379,45 +1412,38 @@ function AppInner({
|
|
|
1379
1412
|
const { width, height } = pluginRegistry.getTermSizeFn();
|
|
1380
1413
|
const shouldFloat = options?.floating ?? true;
|
|
1381
1414
|
const nextLayout = shouldFloat
|
|
1382
|
-
? addPaneFloating(
|
|
1415
|
+
? addPaneFloating(currentLayout, instance, width, height, paneDef)
|
|
1383
1416
|
: addPaneToLayout(
|
|
1384
|
-
|
|
1417
|
+
currentLayout,
|
|
1385
1418
|
instance,
|
|
1386
1419
|
{
|
|
1387
|
-
relativeTo:
|
|
1388
|
-
?
|
|
1389
|
-
: (getDockedPaneIds(
|
|
1420
|
+
relativeTo: currentState.focusedPaneId && isPaneInLayout(currentLayout, currentState.focusedPaneId)
|
|
1421
|
+
? currentState.focusedPaneId
|
|
1422
|
+
: (getDockedPaneIds(currentLayout).at(-1) ?? instance.instanceId),
|
|
1390
1423
|
position: "right",
|
|
1391
1424
|
},
|
|
1392
1425
|
);
|
|
1393
1426
|
persistLayout(nextLayout);
|
|
1394
1427
|
activatePane(instance.instanceId, nextLayout);
|
|
1395
1428
|
};
|
|
1429
|
+
const openPinnedTicker = async (rawSymbol: string, options?: PinTickerOptions) => {
|
|
1430
|
+
const target = await resolveOpenTickerTarget(rawSymbol);
|
|
1431
|
+
if (!target) return;
|
|
1432
|
+
placePinnedTickerTarget(target, options);
|
|
1433
|
+
};
|
|
1434
|
+
pluginRegistry.pinTickerFn = (symbol, options) => {
|
|
1435
|
+
if (isDetachedWindow) return;
|
|
1436
|
+
void openPinnedTicker(symbol, options);
|
|
1437
|
+
};
|
|
1396
1438
|
|
|
1397
1439
|
pluginRegistry.navigateTickerFn = (rawSymbol, options) => {
|
|
1398
1440
|
if (isDetachedWindow) return;
|
|
1399
1441
|
const sourcePaneId = options?.sourcePaneId ?? stateRef.current.focusedPaneId;
|
|
1400
1442
|
(async () => {
|
|
1401
1443
|
try {
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
activeTicker: null,
|
|
1406
|
-
tickers: stateRef.current.tickers,
|
|
1407
|
-
dataProvider,
|
|
1408
|
-
});
|
|
1409
|
-
|
|
1410
|
-
let symbol = rawSymbol;
|
|
1411
|
-
if (resolved?.kind === "local") {
|
|
1412
|
-
symbol = resolved.symbol;
|
|
1413
|
-
} else if (resolved?.kind === "provider" && resolved.result) {
|
|
1414
|
-
const { ticker, created } = await upsertTickerFromSearchResult(tickerRepository, resolved.result);
|
|
1415
|
-
symbol = ticker.metadata.ticker;
|
|
1416
|
-
dispatch({ type: "UPDATE_TICKER", ticker });
|
|
1417
|
-
if (created) {
|
|
1418
|
-
pluginRegistry.events.emit("ticker:added", { symbol, ticker });
|
|
1419
|
-
}
|
|
1420
|
-
}
|
|
1444
|
+
const target = await resolveOpenTickerTarget(rawSymbol);
|
|
1445
|
+
if (!target) return;
|
|
1446
|
+
const symbol = target.symbol;
|
|
1421
1447
|
|
|
1422
1448
|
// Only actions that originate from a ticker detail replace that exact pane.
|
|
1423
1449
|
// Everything else opens or focuses a fixed ticker detail for the symbol.
|
|
@@ -1436,6 +1462,7 @@ function AppInner({
|
|
|
1436
1462
|
};
|
|
1437
1463
|
|
|
1438
1464
|
if (detailPane) {
|
|
1465
|
+
publishTickerOpenTarget(target);
|
|
1439
1466
|
const nextLayout = {
|
|
1440
1467
|
...currentLayout,
|
|
1441
1468
|
instances: currentLayout.instances.map((instance) => (
|
|
@@ -1451,7 +1478,7 @@ function AppInner({
|
|
|
1451
1478
|
currentFocusedPaneId: stateRef.current.focusedPaneId,
|
|
1452
1479
|
targetPaneId: null,
|
|
1453
1480
|
})) {
|
|
1454
|
-
|
|
1481
|
+
placePinnedTickerTarget(target, { floating: false });
|
|
1455
1482
|
}
|
|
1456
1483
|
} catch (err) {
|
|
1457
1484
|
const message = err instanceof Error ? err.message : String(err);
|
|
@@ -1618,6 +1645,7 @@ function AppInner({
|
|
|
1618
1645
|
pluginRegistry={pluginRegistry}
|
|
1619
1646
|
desktopWindowBridge={desktopWindowBridge}
|
|
1620
1647
|
desktopDockPreview={desktopDockPreview}
|
|
1648
|
+
commandBarNativeOccluder={commandBarNativeOccluder}
|
|
1621
1649
|
/>
|
|
1622
1650
|
<StatusBar />
|
|
1623
1651
|
{state.commandBarOpen && (
|
|
@@ -1627,6 +1655,7 @@ function AppInner({
|
|
|
1627
1655
|
pluginRegistry={pluginRegistry}
|
|
1628
1656
|
quitApp={() => rendererHost.requestExit()}
|
|
1629
1657
|
onCheckForUpdates={() => runUpdateCheck(true)}
|
|
1658
|
+
onNativeOccluderChange={setCommandBarNativeOccluder}
|
|
1630
1659
|
/>
|
|
1631
1660
|
)}
|
|
1632
1661
|
<ToastViewport position="bottom-right" />
|
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
import { RGBA } from "../../ui";
|
|
2
|
-
import type {
|
|
2
|
+
import type {
|
|
3
|
+
ChartAxisMode,
|
|
4
|
+
ChartColors,
|
|
5
|
+
ChartIndicatorOverlays,
|
|
6
|
+
ChartMarketSession,
|
|
7
|
+
ChartSessionBackgroundSpan,
|
|
8
|
+
Pixel,
|
|
9
|
+
PixelBuffer,
|
|
10
|
+
ChartRenderMode,
|
|
11
|
+
} from "./chart-types";
|
|
3
12
|
import type { ProjectedChartPoint } from "./chart-data";
|
|
4
13
|
import { formatCompactMarketPriceWithCurrency, formatMarketPrice, resolveAssetDisplayKind } from "../../utils/market-format";
|
|
14
|
+
import { resolveExtendedHoursBackgroundSpans } from "./market-session";
|
|
5
15
|
|
|
6
16
|
// ---------------------------------------------------------------------------
|
|
7
17
|
// Styled output types (unchanged public API)
|
|
@@ -26,7 +36,7 @@ export interface StyledContent {
|
|
|
26
36
|
const LAYER_GRID = 0;
|
|
27
37
|
const LAYER_FILL = 1;
|
|
28
38
|
const LAYER_DATA = 2;
|
|
29
|
-
const LAYER_OVERLAY =
|
|
39
|
+
const LAYER_OVERLAY = 1;
|
|
30
40
|
const LAYER_CROSSHAIR = 3;
|
|
31
41
|
|
|
32
42
|
function normalizeCount(value: number, min: number): number {
|
|
@@ -135,6 +145,8 @@ export function resolveChartPalette(
|
|
|
135
145
|
axisColor: baseColors.textDim,
|
|
136
146
|
activeRangeColor: baseColors.text,
|
|
137
147
|
inactiveRangeColor: blendHex(baseColors.bg, baseColors.textDim, 0.75),
|
|
148
|
+
preMarketBgColor: blendHex(baseColors.bg, "#8a641f", 0.28),
|
|
149
|
+
postMarketBgColor: blendHex(baseColors.bg, "#7a4624", 0.28),
|
|
138
150
|
candleUp: baseColors.positive,
|
|
139
151
|
candleDown: baseColors.negative,
|
|
140
152
|
wickUp: blendHex(baseColors.positive, baseColors.text, 0.35),
|
|
@@ -150,10 +162,12 @@ export function createPixelBuffer(width: number, heightPixels: number): PixelBuf
|
|
|
150
162
|
const bufferWidth = normalizeCount(width, 0);
|
|
151
163
|
const bufferHeight = normalizeCount(heightPixels, 0);
|
|
152
164
|
const pixels: (Pixel | null)[][] = [];
|
|
165
|
+
const backgrounds: (string | null)[][] = [];
|
|
153
166
|
for (let y = 0; y < bufferHeight; y++) {
|
|
154
167
|
pixels.push(new Array(bufferWidth).fill(null));
|
|
168
|
+
backgrounds.push(new Array(bufferWidth).fill(null));
|
|
155
169
|
}
|
|
156
|
-
return { width: bufferWidth, height: bufferHeight, pixels };
|
|
170
|
+
return { width: bufferWidth, height: bufferHeight, pixels, backgrounds };
|
|
157
171
|
}
|
|
158
172
|
|
|
159
173
|
function setPixel(buf: PixelBuffer, x: number, y: number, color: string, layer: number) {
|
|
@@ -224,6 +238,27 @@ function fillRect(buf: PixelBuffer, x0: number, y0: number, x1: number, y1: numb
|
|
|
224
238
|
}
|
|
225
239
|
}
|
|
226
240
|
|
|
241
|
+
function setBackgroundPixel(buf: PixelBuffer, x: number, y: number, color: string) {
|
|
242
|
+
const px = Math.round(x);
|
|
243
|
+
const py = Math.round(y);
|
|
244
|
+
if (px < 0 || px >= buf.width || py < 0 || py >= buf.height) return;
|
|
245
|
+
const row = buf.backgrounds[py];
|
|
246
|
+
if (!row) return;
|
|
247
|
+
row[px] = color;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function fillBackgroundRect(buf: PixelBuffer, x0: number, y0: number, x1: number, y1: number, color: string) {
|
|
251
|
+
const left = Math.max(Math.min(Math.round(x0), Math.round(x1)), 0);
|
|
252
|
+
const right = Math.min(Math.max(Math.round(x0), Math.round(x1)), Math.max(buf.width - 1, 0));
|
|
253
|
+
const top = Math.max(Math.min(Math.round(y0), Math.round(y1)), 0);
|
|
254
|
+
const bottom = Math.min(Math.max(Math.round(y0), Math.round(y1)), Math.max(buf.height - 1, 0));
|
|
255
|
+
for (let y = top; y <= bottom; y += 1) {
|
|
256
|
+
for (let x = left; x <= right; x += 1) {
|
|
257
|
+
setBackgroundPixel(buf, x, y, color);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
227
262
|
function drawWickOutsideBody(
|
|
228
263
|
buf: PixelBuffer,
|
|
229
264
|
x: number,
|
|
@@ -329,6 +364,38 @@ export function getPointTerminalColumn(index: number, pointCount: number, width:
|
|
|
329
364
|
return Math.min(Math.max(Math.floor(getPointDotX(index, pointCount, width, mode) / 2), 0), width - 1);
|
|
330
365
|
}
|
|
331
366
|
|
|
367
|
+
function getTimePointBand(index: number, pointCount: number, width: number): { left: number; right: number } {
|
|
368
|
+
const currentX = getSeriesPosition(index, pointCount, width);
|
|
369
|
+
const previousX = index > 0 ? getSeriesPosition(index - 1, pointCount, width) : currentX;
|
|
370
|
+
const nextX = index < pointCount - 1 ? getSeriesPosition(index + 1, pointCount, width) : currentX;
|
|
371
|
+
return {
|
|
372
|
+
left: index === 0 ? 0 : Math.floor((previousX + currentX) / 2) + 1,
|
|
373
|
+
right: index === pointCount - 1 ? Math.max(width - 1, 0) : Math.floor((currentX + nextX) / 2),
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function getSessionBackgroundColor(span: ChartSessionBackgroundSpan, colors: ChartColors): string {
|
|
378
|
+
return span.kind === "pre" ? colors.preMarketBgColor : colors.postMarketBgColor;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function drawSessionBackgrounds(
|
|
382
|
+
buf: PixelBuffer,
|
|
383
|
+
points: ProjectedChartPoint[],
|
|
384
|
+
spans: readonly ChartSessionBackgroundSpan[],
|
|
385
|
+
yTop: number,
|
|
386
|
+
yBottom: number,
|
|
387
|
+
colors: ChartColors,
|
|
388
|
+
) {
|
|
389
|
+
if (spans.length === 0 || points.length === 0) return;
|
|
390
|
+
for (const span of spans) {
|
|
391
|
+
const startIndex = Math.max(Math.min(span.startIndex, points.length - 1), 0);
|
|
392
|
+
const endIndex = Math.max(Math.min(span.endIndex, points.length - 1), startIndex);
|
|
393
|
+
const startBand = getTimePointBand(startIndex, points.length, buf.width);
|
|
394
|
+
const endBand = getTimePointBand(endIndex, points.length, buf.width);
|
|
395
|
+
fillBackgroundRect(buf, startBand.left, yTop, endBand.right, yBottom, getSessionBackgroundColor(span, colors));
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
332
399
|
// ---------------------------------------------------------------------------
|
|
333
400
|
// Chart mode renderers
|
|
334
401
|
// ---------------------------------------------------------------------------
|
|
@@ -573,14 +640,19 @@ export function bufferToBrailleLines(buf: PixelBuffer): StyledContent[] {
|
|
|
573
640
|
let topLayer = -1;
|
|
574
641
|
|
|
575
642
|
// Track dots per layer so we can render only the top layer's dots
|
|
576
|
-
const dotsByLayer: Map<number, number> = new Map(); // layer
|
|
643
|
+
const dotsByLayer: Map<number, number> = new Map(); // layer -> braille bits
|
|
577
644
|
const colorByLayer: Map<number, Map<string, number>> = new Map();
|
|
645
|
+
const backgroundCounts: Map<string, number> = new Map();
|
|
578
646
|
|
|
579
647
|
for (let dy = 0; dy < 4; dy++) {
|
|
580
648
|
for (let dx = 0; dx < 2; dx++) {
|
|
581
649
|
const px = col * 2 + dx;
|
|
582
650
|
const py = row * 4 + dy;
|
|
583
651
|
if (px >= buf.width || py >= buf.height) continue;
|
|
652
|
+
const bg = buf.backgrounds[py]?.[px] ?? null;
|
|
653
|
+
if (bg) {
|
|
654
|
+
backgroundCounts.set(bg, (backgroundCounts.get(bg) || 0) + 1);
|
|
655
|
+
}
|
|
584
656
|
const pixel = buf.pixels[py]?.[px] ?? null;
|
|
585
657
|
if (pixel) {
|
|
586
658
|
const bit = BRAILLE_DOT[dy]![dx]!;
|
|
@@ -598,14 +670,19 @@ export function bufferToBrailleLines(buf: PixelBuffer): StyledContent[] {
|
|
|
598
670
|
let char: string;
|
|
599
671
|
let cellFg: string | undefined;
|
|
600
672
|
let cellBg: string | undefined;
|
|
673
|
+
let bestBackgroundCount = 0;
|
|
674
|
+
for (const [bg, count] of backgroundCounts) {
|
|
675
|
+
if (count > bestBackgroundCount) {
|
|
676
|
+
bestBackgroundCount = count;
|
|
677
|
+
cellBg = bg;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
601
680
|
|
|
602
681
|
if (topLayer < 0) {
|
|
603
682
|
char = " ";
|
|
604
683
|
cellFg = undefined;
|
|
605
|
-
cellBg = undefined;
|
|
606
684
|
} else {
|
|
607
|
-
// Only show dots from the highest layer
|
|
608
|
-
// cells (e.g. area chart where line and fill overlap).
|
|
685
|
+
// Only show dots from the highest layer so lines stay thin in mixed cells.
|
|
609
686
|
const pattern = dotsByLayer.get(topLayer) || 0;
|
|
610
687
|
char = String.fromCharCode(BRAILLE_BASE + pattern);
|
|
611
688
|
|
|
@@ -617,7 +694,6 @@ export function bufferToBrailleLines(buf: PixelBuffer): StyledContent[] {
|
|
|
617
694
|
}
|
|
618
695
|
|
|
619
696
|
cellFg = topColor;
|
|
620
|
-
cellBg = undefined;
|
|
621
697
|
}
|
|
622
698
|
|
|
623
699
|
if (char === runChar && cellFg === runFg && cellBg === runBg) {
|
|
@@ -823,6 +899,10 @@ function formatClockTime(date: Date, unit: AxisLabelUnit): string {
|
|
|
823
899
|
}
|
|
824
900
|
}
|
|
825
901
|
|
|
902
|
+
function formatFullCalendarDate(date: Date): string {
|
|
903
|
+
return `${MONTHS[date.getMonth()]} ${date.getDate()} ${date.getFullYear()}`;
|
|
904
|
+
}
|
|
905
|
+
|
|
826
906
|
function isSameCalendarDay(a: Date, b: Date): boolean {
|
|
827
907
|
return a.getFullYear() === b.getFullYear()
|
|
828
908
|
&& a.getMonth() === b.getMonth()
|
|
@@ -985,6 +1065,10 @@ function formatCursorTimeAxisValue(
|
|
|
985
1065
|
const minGapMs = getMinPositiveGapMs(validDates);
|
|
986
1066
|
const effectiveStepMs = Math.max(spanMs / Math.max(roughLabelCount - 1, 1), minGapMs || 0);
|
|
987
1067
|
const unit = resolveAxisLabelUnit(effectiveStepMs);
|
|
1068
|
+
if (unit === "year" || unit === "month") {
|
|
1069
|
+
return formatFullCalendarDate(date);
|
|
1070
|
+
}
|
|
1071
|
+
|
|
988
1072
|
const counterpart = isSameCalendarDay(first, last)
|
|
989
1073
|
? first
|
|
990
1074
|
: isSameCalendarDay(date, first)
|
|
@@ -1152,6 +1236,7 @@ export interface RenderChartOptions {
|
|
|
1152
1236
|
colors: ResolvedChartPalette;
|
|
1153
1237
|
timeAxisDates?: Array<Date | string | number>;
|
|
1154
1238
|
indicators?: ChartIndicatorOverlays | null;
|
|
1239
|
+
marketSession?: ChartMarketSession | null;
|
|
1155
1240
|
}
|
|
1156
1241
|
|
|
1157
1242
|
export interface ChartScene {
|
|
@@ -1164,6 +1249,7 @@ export interface ChartScene {
|
|
|
1164
1249
|
mode: ChartRenderMode;
|
|
1165
1250
|
colors: ResolvedChartPalette;
|
|
1166
1251
|
indicators: ChartIndicatorOverlays | null;
|
|
1252
|
+
sessionBackgroundSpans: ChartSessionBackgroundSpan[];
|
|
1167
1253
|
min: number;
|
|
1168
1254
|
max: number;
|
|
1169
1255
|
activeIdx: number;
|
|
@@ -1225,27 +1311,6 @@ export function getActivePointIndex(
|
|
|
1225
1311
|
return bestIndex;
|
|
1226
1312
|
}
|
|
1227
1313
|
|
|
1228
|
-
function getIndicatorOverlayValues(indicators: ChartIndicatorOverlays | null | undefined): number[] {
|
|
1229
|
-
if (!indicators) return [];
|
|
1230
|
-
|
|
1231
|
-
const values: number[] = [];
|
|
1232
|
-
for (const line of indicators.smaLines) {
|
|
1233
|
-
values.push(...line.points.map((point) => point.value));
|
|
1234
|
-
}
|
|
1235
|
-
for (const line of indicators.emaLines) {
|
|
1236
|
-
values.push(...line.points.map((point) => point.value));
|
|
1237
|
-
}
|
|
1238
|
-
if (indicators.bollinger) {
|
|
1239
|
-
values.push(
|
|
1240
|
-
...indicators.bollinger.upper.map((point) => point.value),
|
|
1241
|
-
...indicators.bollinger.middle.map((point) => point.value),
|
|
1242
|
-
...indicators.bollinger.lower.map((point) => point.value),
|
|
1243
|
-
);
|
|
1244
|
-
}
|
|
1245
|
-
|
|
1246
|
-
return values.filter((value) => Number.isFinite(value));
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
1314
|
export function buildChartScene(
|
|
1250
1315
|
points: ProjectedChartPoint[],
|
|
1251
1316
|
opts: RenderChartOptions,
|
|
@@ -1259,9 +1324,8 @@ export function buildChartScene(
|
|
|
1259
1324
|
const dataMax = isHighLowMode(opts.mode)
|
|
1260
1325
|
? Math.max(...points.map((point) => point.high))
|
|
1261
1326
|
: Math.max(...points.map((point) => point.close));
|
|
1262
|
-
const
|
|
1263
|
-
const
|
|
1264
|
-
const max = indicatorValues.length > 0 ? Math.max(dataMax, ...indicatorValues) : dataMax;
|
|
1327
|
+
const min = dataMin;
|
|
1328
|
+
const max = dataMax;
|
|
1265
1329
|
const activeIdx = getActivePointIndex(points.length, dimensions.width, opts.cursorX, opts.mode);
|
|
1266
1330
|
const activePoint = points[activeIdx]!;
|
|
1267
1331
|
const range = max - min || 1;
|
|
@@ -1290,6 +1354,10 @@ export function buildChartScene(
|
|
|
1290
1354
|
: max - (cursorY / Math.max(dimensions.chartRows - 1, 1)) * range;
|
|
1291
1355
|
|
|
1292
1356
|
const timeAxisDates = opts.timeAxisDates ?? points.map((point) => point.date);
|
|
1357
|
+
const sessionBackgroundSpans = resolveExtendedHoursBackgroundSpans(
|
|
1358
|
+
points.map((point) => point.date),
|
|
1359
|
+
opts.marketSession,
|
|
1360
|
+
);
|
|
1293
1361
|
|
|
1294
1362
|
return {
|
|
1295
1363
|
points,
|
|
@@ -1301,6 +1369,7 @@ export function buildChartScene(
|
|
|
1301
1369
|
mode: opts.mode,
|
|
1302
1370
|
colors: opts.colors,
|
|
1303
1371
|
indicators: opts.indicators ?? null,
|
|
1372
|
+
sessionBackgroundSpans,
|
|
1304
1373
|
min,
|
|
1305
1374
|
max,
|
|
1306
1375
|
activeIdx,
|
|
@@ -1412,6 +1481,14 @@ export function renderChart(
|
|
|
1412
1481
|
getAxisFractionDigitFloor(assetCategory, priceRange),
|
|
1413
1482
|
)
|
|
1414
1483
|
: null;
|
|
1484
|
+
drawSessionBackgrounds(
|
|
1485
|
+
buf,
|
|
1486
|
+
points,
|
|
1487
|
+
scene.sessionBackgroundSpans,
|
|
1488
|
+
0,
|
|
1489
|
+
showVolume ? volDotBottom : chartDotBottom,
|
|
1490
|
+
palette,
|
|
1491
|
+
);
|
|
1415
1492
|
drawGridLines(buf, gridLines.map((line) => line.y), palette.gridColor);
|
|
1416
1493
|
|
|
1417
1494
|
switch (mode) {
|
|
@@ -7,6 +7,7 @@ export type ChartAxisMode = "price" | "percent";
|
|
|
7
7
|
export type ChartRendererPreference = "auto" | "kitty" | "braille";
|
|
8
8
|
export type ResolvedChartRenderer = "kitty" | "braille";
|
|
9
9
|
export type ComparisonChartRenderMode = "area" | "line";
|
|
10
|
+
export type ChartSessionBackgroundKind = "pre" | "post";
|
|
10
11
|
|
|
11
12
|
export const TIME_RANGES: TimeRange[] = ["1D", "1W", "1M", "3M", "6M", "1Y", "5Y", "ALL"];
|
|
12
13
|
export const CHART_RENDER_MODES: ChartRenderMode[] = ["area", "line", "candles", "ohlc", "hlc"];
|
|
@@ -46,6 +47,7 @@ export interface PixelBuffer {
|
|
|
46
47
|
width: number;
|
|
47
48
|
height: number; // virtual pixel rows (4x terminal rows for braille rendering)
|
|
48
49
|
pixels: (Pixel | null)[][]; // [y][x] = pixel with color and layer, or null
|
|
50
|
+
backgrounds: (string | null)[][];
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
export interface ChartColors {
|
|
@@ -59,6 +61,22 @@ export interface ChartColors {
|
|
|
59
61
|
axisColor: string;
|
|
60
62
|
activeRangeColor: string;
|
|
61
63
|
inactiveRangeColor: string;
|
|
64
|
+
preMarketBgColor: string;
|
|
65
|
+
postMarketBgColor: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ChartMarketSession {
|
|
69
|
+
timeZone: string;
|
|
70
|
+
regularStartMinutes: number;
|
|
71
|
+
regularEndMinutes: number;
|
|
72
|
+
preMarketStartMinutes: number;
|
|
73
|
+
postMarketEndMinutes: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ChartSessionBackgroundSpan {
|
|
77
|
+
kind: ChartSessionBackgroundKind;
|
|
78
|
+
startIndex: number;
|
|
79
|
+
endIndex: number;
|
|
62
80
|
}
|
|
63
81
|
|
|
64
82
|
export interface VisibleWindow {
|