gloomberb 0.1.0 → 0.3.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 +25 -0
- package/README.md +68 -121
- package/bin/gloomberb +3 -0
- package/package.json +13 -4
- package/patches/@opentui%2Fcore@0.1.90.patch +356 -0
- package/src/app.tsx +1330 -0
- package/src/brokers/sync-broker-instance.test.ts +100 -0
- package/src/brokers/sync-broker-instance.ts +341 -0
- package/src/cli/commands/plugins.ts +173 -0
- package/src/cli/commands/portfolio.ts +210 -0
- package/src/cli/commands/search.ts +120 -0
- package/src/cli/commands/ticker.ts +375 -0
- package/src/cli/commands/watchlist.ts +210 -0
- package/src/cli/context.ts +62 -0
- package/src/cli/errors.ts +13 -0
- package/src/cli/helpers.ts +85 -0
- package/src/cli/index.ts +143 -0
- package/src/cli/ticker-resolution.ts +39 -0
- package/src/cli/types.ts +15 -0
- package/src/cli.test.ts +361 -0
- package/src/components/chart/chart-data.ts +239 -0
- package/src/components/chart/chart-renderer.ts +1199 -0
- package/src/components/chart/chart-types.ts +82 -0
- package/src/components/chart/chart-viewport.ts +21 -0
- package/src/components/chart/chart.test.ts +439 -0
- package/src/components/chart/comparison-chart-data.test.ts +109 -0
- package/src/components/chart/comparison-chart-data.ts +316 -0
- package/src/components/chart/comparison-chart-renderer.test.ts +84 -0
- package/src/components/chart/comparison-chart-renderer.ts +354 -0
- package/src/components/chart/comparison-stock-chart.tsx +1486 -0
- package/src/components/chart/cursor-motion.ts +48 -0
- package/src/components/chart/native/chart-rasterizer.ts +732 -0
- package/src/components/chart/native/kitty-adapter.ts +12 -0
- package/src/components/chart/native/kitty-manager.ts +142 -0
- package/src/components/chart/native/kitty-protocol.ts +110 -0
- package/src/components/chart/native/kitty-support.ts +71 -0
- package/src/components/chart/native/kitty.test.ts +620 -0
- package/src/components/chart/native/renderer-selection.ts +24 -0
- package/src/components/chart/native/surface-manager.ts +225 -0
- package/src/components/chart/native/surface-sync.ts +34 -0
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +165 -0
- package/src/components/chart/stock-chart.test.ts +107 -0
- package/src/components/chart/stock-chart.tsx +1556 -0
- package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +137 -0
- package/src/components/command-bar/command-bar.test.tsx +1524 -0
- package/src/components/command-bar/command-bar.tsx +4823 -0
- package/src/components/command-bar/command-registry.ts +261 -0
- package/src/components/command-bar/helpers.test.ts +224 -0
- package/src/components/command-bar/helpers.ts +371 -0
- package/src/components/command-bar/root-shortcuts.ts +158 -0
- package/src/components/command-bar/view-model.test.ts +152 -0
- package/src/components/command-bar/view-model.ts +185 -0
- package/src/components/command-bar/workflow-ops.test.ts +70 -0
- package/src/components/command-bar/workflow-ops.ts +433 -0
- package/src/components/command-bar/workflow-types.ts +127 -0
- package/src/components/detail-feed-view.test.tsx +105 -0
- package/src/components/detail-feed-view.tsx +295 -0
- package/src/components/index.ts +32 -0
- package/src/components/layout/floating-pane.tsx +49 -0
- package/src/components/layout/header.tsx +129 -0
- package/src/components/layout/pane-header.tsx +44 -0
- package/src/components/layout/pane.tsx +49 -0
- package/src/components/layout/pixel-mouse-mode.test.ts +122 -0
- package/src/components/layout/pixel-mouse.test.ts +40 -0
- package/src/components/layout/shell.test.tsx +625 -0
- package/src/components/layout/shell.tsx +1200 -0
- package/src/components/layout/status-bar.test.tsx +173 -0
- package/src/components/layout/status-bar.tsx +138 -0
- package/src/components/markdown-editor.tsx +134 -0
- package/src/components/onboarding/onboarding-wizard.test.tsx +184 -0
- package/src/components/onboarding/onboarding-wizard.tsx +1365 -0
- package/src/components/pane-settings-dialog.tsx +468 -0
- package/src/components/pane-template-wizard.test.tsx +52 -0
- package/src/components/pane-template-wizard.tsx +199 -0
- package/src/components/price-selector-dialog.tsx +195 -0
- package/src/components/spinner.tsx +2 -0
- package/src/components/tab-bar.tsx +17 -0
- package/src/components/ticker-badge-text.test.tsx +160 -0
- package/src/components/ticker-badge-text.tsx +58 -0
- package/src/components/ticker-badge.tsx +88 -0
- package/src/components/ticker-list-table.tsx +188 -0
- package/src/components/toggle-list.tsx +74 -0
- package/src/components/ui/button.tsx +77 -0
- package/src/components/ui/data-table.tsx +210 -0
- package/src/components/ui/external-link.tsx +22 -0
- package/src/components/ui/fields.tsx +187 -0
- package/src/components/ui/frame.tsx +73 -0
- package/src/components/ui/index.ts +53 -0
- package/src/components/ui/list-view.tsx +166 -0
- package/src/components/ui/loading.tsx +70 -0
- package/src/components/ui/status.tsx +83 -0
- package/src/components/ui/tabs.tsx +47 -0
- package/src/components/ui/toggle.tsx +164 -0
- package/src/components/ui/ui.test.tsx +134 -0
- package/src/data/app-persistence.ts +25 -0
- package/src/data/config-store.test.ts +310 -0
- package/src/data/config-store.ts +469 -0
- package/src/data/plugin-state-store.ts +102 -0
- package/src/data/resource-store.ts +323 -0
- package/src/data/session-store.ts +52 -0
- package/src/data/sqlite-cache.test.ts +103 -0
- package/src/data/sqlite-database.ts +62 -0
- package/src/data/sqlite-json.ts +19 -0
- package/src/data/ticker-repository.ts +45 -0
- package/src/data/ticker-store.ts +46 -0
- package/src/index.tsx +67 -0
- package/src/market-data/coordinator.test.ts +369 -0
- package/src/market-data/coordinator.ts +580 -0
- package/src/market-data/hooks.tsx +234 -0
- package/src/market-data/query-store.ts +28 -0
- package/src/market-data/request-types.test.ts +64 -0
- package/src/market-data/request-types.ts +70 -0
- package/src/market-data/result-types.ts +56 -0
- package/src/market-data/selectors.ts +166 -0
- package/src/market-data/trace.ts +7 -0
- package/src/pane-settings.test.ts +71 -0
- package/src/pane-settings.ts +73 -0
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +338 -0
- package/src/plugins/builtin/ai/gloomberb-cli.ts +70 -0
- package/src/plugins/builtin/ai/index.test.tsx +139 -0
- package/src/plugins/builtin/ai/index.tsx +86 -0
- package/src/plugins/builtin/ai/providers.ts +99 -0
- package/src/plugins/builtin/ai/runner.ts +82 -0
- package/src/plugins/builtin/ai/screener-contract.test.ts +117 -0
- package/src/plugins/builtin/ai/screener-contract.ts +174 -0
- package/src/plugins/builtin/ai/screener-pane.test.tsx +365 -0
- package/src/plugins/builtin/ai/screener-pane.tsx +1161 -0
- package/src/plugins/builtin/ai/settings.ts +79 -0
- package/src/plugins/builtin/ai/ticker-context.ts +64 -0
- package/src/plugins/builtin/ai/utils.ts +71 -0
- package/src/plugins/builtin/ask-ai.test.tsx +258 -0
- package/src/plugins/builtin/ask-ai.tsx +9 -0
- package/src/plugins/builtin/chat-controller.test.ts +217 -0
- package/src/plugins/builtin/chat-controller.ts +340 -0
- package/src/plugins/builtin/chat.test.tsx +451 -0
- package/src/plugins/builtin/chat.tsx +620 -0
- package/src/plugins/builtin/comparison-chart.test.tsx +277 -0
- package/src/plugins/builtin/comparison-chart.tsx +169 -0
- package/src/plugins/builtin/data-providers.ts +16 -0
- package/src/plugins/builtin/debug.tsx +351 -0
- package/src/plugins/builtin/help.test.tsx +110 -0
- package/src/plugins/builtin/help.tsx +281 -0
- package/src/plugins/builtin/layout-manager.tsx +264 -0
- package/src/plugins/builtin/manual-entry.tsx +25 -0
- package/src/plugins/builtin/news.tsx +143 -0
- package/src/plugins/builtin/notes-files.ts +58 -0
- package/src/plugins/builtin/notes.tsx +396 -0
- package/src/plugins/builtin/options-availability.test.tsx +179 -0
- package/src/plugins/builtin/options-availability.ts +158 -0
- package/src/plugins/builtin/options.tsx +364 -0
- package/src/plugins/builtin/portfolio-list/header.tsx +227 -0
- package/src/plugins/builtin/portfolio-list/index.tsx +99 -0
- package/src/plugins/builtin/portfolio-list/metrics.ts +436 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +533 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +280 -0
- package/src/plugins/builtin/portfolio-list/summary.tsx +270 -0
- package/src/plugins/builtin/portfolio-list/table.tsx +64 -0
- package/src/plugins/builtin/portfolio-list.test.ts +74 -0
- package/src/plugins/builtin/portfolio-list.test.tsx +1164 -0
- package/src/plugins/builtin/portfolio-metrics.test.ts +153 -0
- package/src/plugins/builtin/quote-monitor.test.tsx +114 -0
- package/src/plugins/builtin/sec.tsx +250 -0
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +43 -0
- package/src/plugins/builtin/ticker-detail/financials-tab.tsx +390 -0
- package/src/plugins/builtin/ticker-detail/index.tsx +80 -0
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +242 -0
- package/src/plugins/builtin/ticker-detail/pane.tsx +183 -0
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +83 -0
- package/src/plugins/builtin/ticker-detail/settings.ts +132 -0
- package/src/plugins/builtin/ticker-detail-chart-tab-switch.test.tsx +193 -0
- package/src/plugins/builtin/ticker-detail.test.tsx +578 -0
- package/src/plugins/builtin/yahoo.ts +18 -0
- package/src/plugins/event-bus.ts +37 -0
- package/src/plugins/ibkr/__snapshots__/trade-tab.test.tsx.snap +35 -0
- package/src/plugins/ibkr/account-cache.test.ts +101 -0
- package/src/plugins/ibkr/account-cache.ts +137 -0
- package/src/plugins/ibkr/broker-adapter.ts +168 -0
- package/src/plugins/ibkr/config.test.ts +68 -0
- package/src/plugins/ibkr/config.ts +262 -0
- package/src/plugins/ibkr/dialogs.tsx +83 -0
- package/src/plugins/ibkr/flex.test.ts +107 -0
- package/src/plugins/ibkr/flex.ts +267 -0
- package/src/plugins/ibkr/fundamental-parser.ts +149 -0
- package/src/plugins/ibkr/gateway-helpers.ts +28 -0
- package/src/plugins/ibkr/gateway-selection.ts +51 -0
- package/src/plugins/ibkr/gateway-service.test.ts +450 -0
- package/src/plugins/ibkr/gateway-service.ts +2228 -0
- package/src/plugins/ibkr/index.test.tsx +183 -0
- package/src/plugins/ibkr/index.tsx +153 -0
- package/src/plugins/ibkr/instance-selection.test.ts +121 -0
- package/src/plugins/ibkr/instance-selection.ts +36 -0
- package/src/plugins/ibkr/price-normalization.test.ts +38 -0
- package/src/plugins/ibkr/price-normalization.ts +59 -0
- package/src/plugins/ibkr/trade-badge.tsx +27 -0
- package/src/plugins/ibkr/trade-tab.test.tsx +225 -0
- package/src/plugins/ibkr/trade-tab.tsx +1050 -0
- package/src/plugins/ibkr/trade-utils.test.ts +175 -0
- package/src/plugins/ibkr/trade-utils.ts +149 -0
- package/src/plugins/ibkr/trading-pane.tsx +438 -0
- package/src/plugins/ibkr/trading-state.test.ts +109 -0
- package/src/plugins/ibkr/trading-state.ts +339 -0
- package/src/plugins/loader.ts +71 -0
- package/src/plugins/pane-manager.test.ts +103 -0
- package/src/plugins/pane-manager.ts +974 -0
- package/src/plugins/plugin-persistence.ts +85 -0
- package/src/plugins/plugin-runtime.test.tsx +185 -0
- package/src/plugins/plugin-runtime.tsx +175 -0
- package/src/plugins/prediction-markets/cache.ts +114 -0
- package/src/plugins/prediction-markets/categories.ts +223 -0
- package/src/plugins/prediction-markets/chart.tsx +512 -0
- package/src/plugins/prediction-markets/columns.ts +133 -0
- package/src/plugins/prediction-markets/controller-data.ts +614 -0
- package/src/plugins/prediction-markets/controller.ts +725 -0
- package/src/plugins/prediction-markets/detail/book.tsx +118 -0
- package/src/plugins/prediction-markets/detail/outcomes.tsx +102 -0
- package/src/plugins/prediction-markets/detail/overview.tsx +73 -0
- package/src/plugins/prediction-markets/detail/pane.tsx +308 -0
- package/src/plugins/prediction-markets/detail/rules.tsx +16 -0
- package/src/plugins/prediction-markets/detail/shared.tsx +29 -0
- package/src/plugins/prediction-markets/detail/trades.tsx +30 -0
- package/src/plugins/prediction-markets/detail.test.tsx +47 -0
- package/src/plugins/prediction-markets/index.tsx +102 -0
- package/src/plugins/prediction-markets/keyboard.ts +64 -0
- package/src/plugins/prediction-markets/launch.test.ts +126 -0
- package/src/plugins/prediction-markets/launch.ts +240 -0
- package/src/plugins/prediction-markets/metrics.ts +311 -0
- package/src/plugins/prediction-markets/navigation.ts +90 -0
- package/src/plugins/prediction-markets/outcome-order.ts +19 -0
- package/src/plugins/prediction-markets/pane.test.tsx +425 -0
- package/src/plugins/prediction-markets/pane.tsx +294 -0
- package/src/plugins/prediction-markets/plugin.test.tsx +570 -0
- package/src/plugins/prediction-markets/rows.ts +229 -0
- package/src/plugins/prediction-markets/services/fetch.ts +97 -0
- package/src/plugins/prediction-markets/services/kalshi-adapter.ts +555 -0
- package/src/plugins/prediction-markets/services/polymarket-adapter.ts +660 -0
- package/src/plugins/prediction-markets/services/polymarket-ws.ts +108 -0
- package/src/plugins/prediction-markets/settings.ts +164 -0
- package/src/plugins/prediction-markets/status.ts +24 -0
- package/src/plugins/prediction-markets/table.tsx +84 -0
- package/src/plugins/prediction-markets/test-helpers.tsx +636 -0
- package/src/plugins/prediction-markets/types.ts +186 -0
- package/src/plugins/registry.ts +633 -0
- package/src/sources/exchange-rates.ts +18 -0
- package/src/sources/gloomberb-cloud.test.ts +199 -0
- package/src/sources/gloomberb-cloud.ts +326 -0
- package/src/sources/provider-errors.ts +19 -0
- package/src/sources/provider-router.test.ts +1476 -0
- package/src/sources/provider-router.ts +1405 -0
- package/src/sources/sec-edgar.test.ts +286 -0
- package/src/sources/sec-edgar.ts +526 -0
- package/src/sources/yahoo-finance.test.ts +14 -0
- package/src/sources/yahoo-finance.ts +1017 -0
- package/src/state/app-activity.test.ts +41 -0
- package/src/state/app-activity.ts +106 -0
- package/src/state/app-bootstrap.test.ts +471 -0
- package/src/state/app-bootstrap.ts +308 -0
- package/src/state/app-context.test.ts +406 -0
- package/src/state/app-context.test.tsx +132 -0
- package/src/state/app-context.tsx +969 -0
- package/src/state/selectors.ts +67 -0
- package/src/state/session-persistence.test.ts +128 -0
- package/src/state/session-persistence.ts +161 -0
- package/src/state/ticker-refresh-queue.test.ts +46 -0
- package/src/state/ticker-refresh-queue.ts +40 -0
- package/src/state/use-inline-tickers.ts +174 -0
- package/src/state/use-quote-streaming.test.ts +37 -0
- package/src/state/use-quote-streaming.test.tsx +70 -0
- package/src/state/use-quote-streaming.ts +80 -0
- package/src/state/use-session-snapshot.ts +84 -0
- package/src/test-support/data-provider.ts +22 -0
- package/src/theme/color-utils.ts +67 -0
- package/src/theme/colors.ts +128 -0
- package/src/theme/themes.test.ts +45 -0
- package/src/theme/themes.ts +491 -0
- package/src/types/broker.ts +105 -0
- package/src/types/config.ts +434 -0
- package/src/types/data-provider.ts +81 -0
- package/src/types/financials.ts +142 -0
- package/src/types/instrument.ts +29 -0
- package/src/types/persistence.ts +21 -0
- package/src/types/plugin.ts +354 -0
- package/src/types/ticker.ts +58 -0
- package/src/types/trading.ts +85 -0
- package/src/updater.test.ts +162 -0
- package/src/updater.ts +247 -0
- package/src/utils/api-client.ts +666 -0
- package/src/utils/ascii-chart.ts +172 -0
- package/src/utils/broker-instances.ts +47 -0
- package/src/utils/cli-output.ts +83 -0
- package/src/utils/currency-units.test.ts +49 -0
- package/src/utils/currency-units.ts +82 -0
- package/src/utils/debug-log.ts +133 -0
- package/src/utils/exchange-rate-map.ts +8 -0
- package/src/utils/format.ts +158 -0
- package/src/utils/fuzzy-search.ts +34 -0
- package/src/utils/market-status.ts +95 -0
- package/src/utils/options.ts +86 -0
- package/src/utils/price-history.ts +39 -0
- package/src/utils/quote-time.test.ts +22 -0
- package/src/utils/quote-time.ts +50 -0
- package/src/utils/sec.test.ts +55 -0
- package/src/utils/sec.ts +45 -0
- package/src/utils/ticker-list.test.ts +22 -0
- package/src/utils/ticker-list.ts +35 -0
- package/src/utils/ticker-metadata.ts +51 -0
- package/src/utils/ticker-search.test.ts +227 -0
- package/src/utils/ticker-search.ts +764 -0
- package/src/utils/ticker-tokenizer.test.ts +48 -0
- package/src/utils/ticker-tokenizer.ts +72 -0
- package/src/version.ts +1 -0
- package/install.mjs +0 -69
|
@@ -0,0 +1,1199 @@
|
|
|
1
|
+
import { RGBA } from "@opentui/core";
|
|
2
|
+
import type { ChartAxisMode, ChartColors, Pixel, PixelBuffer, ChartRenderMode } from "./chart-types";
|
|
3
|
+
import type { ProjectedChartPoint } from "./chart-data";
|
|
4
|
+
import { formatCurrency } from "../../utils/format";
|
|
5
|
+
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
// Styled output types (unchanged public API)
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
|
|
10
|
+
interface StyledChunk {
|
|
11
|
+
__isChunk: true;
|
|
12
|
+
text: string;
|
|
13
|
+
fg?: RGBA;
|
|
14
|
+
bg?: RGBA;
|
|
15
|
+
attributes: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface StyledContent {
|
|
19
|
+
chunks: StyledChunk[];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
// Layer constants — higher values win when pixels overlap
|
|
24
|
+
// ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
const LAYER_GRID = 0;
|
|
27
|
+
const LAYER_FILL = 1;
|
|
28
|
+
const LAYER_DATA = 2;
|
|
29
|
+
const LAYER_CROSSHAIR = 3;
|
|
30
|
+
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
// Braille character mapping
|
|
33
|
+
// Each terminal cell maps to a 2×4 dot grid. Unicode braille = U+2800 + bits.
|
|
34
|
+
// Col 0 Col 1
|
|
35
|
+
// bit 0 bit 3 (row 0, top)
|
|
36
|
+
// bit 1 bit 4 (row 1)
|
|
37
|
+
// bit 2 bit 5 (row 2)
|
|
38
|
+
// bit 6 bit 7 (row 3, bottom)
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
const BRAILLE_BASE = 0x2800;
|
|
42
|
+
const BRAILLE_DOT: number[][] = [
|
|
43
|
+
[0x01, 0x08], // row 0
|
|
44
|
+
[0x02, 0x10], // row 1
|
|
45
|
+
[0x04, 0x20], // row 2
|
|
46
|
+
[0x40, 0x80], // row 3
|
|
47
|
+
];
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Palette resolution
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
interface ChartPaletteInput {
|
|
54
|
+
bg: string;
|
|
55
|
+
border: string;
|
|
56
|
+
borderFocused: string;
|
|
57
|
+
text: string;
|
|
58
|
+
textDim: string;
|
|
59
|
+
positive: string;
|
|
60
|
+
negative: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface ResolvedChartPalette extends ChartColors {
|
|
64
|
+
candleUp: string;
|
|
65
|
+
candleDown: string;
|
|
66
|
+
wickUp: string;
|
|
67
|
+
wickDown: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type VolumeTrendMode = "previousClose" | "openClose";
|
|
71
|
+
|
|
72
|
+
function makeChunk(text: string, fgColor: string, bgColor: string): StyledChunk {
|
|
73
|
+
return {
|
|
74
|
+
__isChunk: true,
|
|
75
|
+
text,
|
|
76
|
+
fg: RGBA.fromHex(fgColor),
|
|
77
|
+
bg: RGBA.fromHex(bgColor),
|
|
78
|
+
attributes: 0,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function blendHex(a: string, b: string, ratio: number): string {
|
|
83
|
+
const parse = (hex: string) => {
|
|
84
|
+
const h = hex.replace("#", "");
|
|
85
|
+
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)] as const;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const [ar, ag, ab] = parse(a);
|
|
89
|
+
const [br, bg, bb] = parse(b);
|
|
90
|
+
const mix = (x: number, y: number) => Math.round(x + (y - x) * ratio).toString(16).padStart(2, "0");
|
|
91
|
+
return `#${mix(ar, br)}${mix(ag, bg)}${mix(ab, bb)}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function resolveChartPalette(
|
|
95
|
+
baseColors: ChartPaletteInput,
|
|
96
|
+
trend: "positive" | "negative" | "neutral" = "positive",
|
|
97
|
+
): ResolvedChartPalette {
|
|
98
|
+
const lineColor = trend === "negative"
|
|
99
|
+
? baseColors.negative
|
|
100
|
+
: trend === "neutral"
|
|
101
|
+
? baseColors.text
|
|
102
|
+
: baseColors.positive;
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
lineColor,
|
|
106
|
+
fillColor: blendHex(baseColors.bg, lineColor, 0.22),
|
|
107
|
+
volumeUp: blendHex(baseColors.bg, baseColors.positive, 0.35),
|
|
108
|
+
volumeDown: blendHex(baseColors.bg, baseColors.negative, 0.35),
|
|
109
|
+
gridColor: blendHex(baseColors.bg, baseColors.border, 0.55),
|
|
110
|
+
crosshairColor: baseColors.borderFocused,
|
|
111
|
+
bgColor: baseColors.bg,
|
|
112
|
+
axisColor: baseColors.textDim,
|
|
113
|
+
activeRangeColor: baseColors.text,
|
|
114
|
+
inactiveRangeColor: blendHex(baseColors.bg, baseColors.textDim, 0.75),
|
|
115
|
+
candleUp: baseColors.positive,
|
|
116
|
+
candleDown: baseColors.negative,
|
|
117
|
+
wickUp: blendHex(baseColors.positive, baseColors.text, 0.35),
|
|
118
|
+
wickDown: blendHex(baseColors.negative, baseColors.text, 0.35),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
// Pixel buffer — now at braille resolution (2× wide, 4× tall)
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
export function createPixelBuffer(width: number, heightPixels: number): PixelBuffer {
|
|
127
|
+
const pixels: (Pixel | null)[][] = [];
|
|
128
|
+
for (let y = 0; y < heightPixels; y++) {
|
|
129
|
+
pixels.push(new Array(width).fill(null));
|
|
130
|
+
}
|
|
131
|
+
return { width, height: heightPixels, pixels };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function setPixel(buf: PixelBuffer, x: number, y: number, color: string, layer: number) {
|
|
135
|
+
if (x >= 0 && x < buf.width && y >= 0 && y < buf.height) {
|
|
136
|
+
const existing = buf.pixels[y]![x];
|
|
137
|
+
if (!existing || layer >= existing.layer) {
|
|
138
|
+
buf.pixels[y]![x] = { color, layer };
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Drawing primitives
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
export function drawLine(
|
|
148
|
+
buf: PixelBuffer,
|
|
149
|
+
x0: number,
|
|
150
|
+
y0: number,
|
|
151
|
+
x1: number,
|
|
152
|
+
y1: number,
|
|
153
|
+
color: string,
|
|
154
|
+
layer: number = LAYER_DATA,
|
|
155
|
+
) {
|
|
156
|
+
let dx = Math.abs(x1 - x0);
|
|
157
|
+
let dy = Math.abs(y1 - y0);
|
|
158
|
+
const sx = x0 < x1 ? 1 : -1;
|
|
159
|
+
const sy = y0 < y1 ? 1 : -1;
|
|
160
|
+
let err = dx - dy;
|
|
161
|
+
let x = x0;
|
|
162
|
+
let y = y0;
|
|
163
|
+
|
|
164
|
+
while (true) {
|
|
165
|
+
setPixel(buf, x, y, color, layer);
|
|
166
|
+
if (x === x1 && y === y1) break;
|
|
167
|
+
const e2 = 2 * err;
|
|
168
|
+
if (e2 > -dy) {
|
|
169
|
+
err -= dy;
|
|
170
|
+
x += sx;
|
|
171
|
+
}
|
|
172
|
+
if (e2 < dx) {
|
|
173
|
+
err += dx;
|
|
174
|
+
y += sy;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function fillColumn(buf: PixelBuffer, x: number, y0: number, y1: number, color: string, layer: number) {
|
|
180
|
+
const start = Math.min(y0, y1);
|
|
181
|
+
const end = Math.max(y0, y1);
|
|
182
|
+
for (let y = start; y <= end; y++) {
|
|
183
|
+
setPixel(buf, x, y, color, layer);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function fillRect(buf: PixelBuffer, x0: number, y0: number, x1: number, y1: number, color: string, layer: number) {
|
|
188
|
+
for (let x = Math.min(x0, x1); x <= Math.max(x0, x1); x++) {
|
|
189
|
+
fillColumn(buf, x, y0, y1, color, layer);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function drawWickOutsideBody(
|
|
194
|
+
buf: PixelBuffer,
|
|
195
|
+
x: number,
|
|
196
|
+
highY: number,
|
|
197
|
+
lowY: number,
|
|
198
|
+
bodyTop: number,
|
|
199
|
+
bodyBottom: number,
|
|
200
|
+
color: string,
|
|
201
|
+
) {
|
|
202
|
+
if (highY < bodyTop) {
|
|
203
|
+
drawLine(buf, x, highY, x, bodyTop, color, LAYER_DATA);
|
|
204
|
+
}
|
|
205
|
+
if (bodyBottom < lowY) {
|
|
206
|
+
drawLine(buf, x, bodyBottom, x, lowY, color, LAYER_DATA);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function getSeriesPosition(
|
|
211
|
+
index: number,
|
|
212
|
+
pointCount: number,
|
|
213
|
+
width: number,
|
|
214
|
+
startPadding = 0,
|
|
215
|
+
endPadding = startPadding,
|
|
216
|
+
): number {
|
|
217
|
+
const start = Math.max(startPadding, 0);
|
|
218
|
+
const end = Math.max(start, width - 1 - Math.max(endPadding, 0));
|
|
219
|
+
if (pointCount <= 1) return Math.round((start + end) / 2);
|
|
220
|
+
return Math.round(start + (index / (pointCount - 1)) * (end - start));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function getScaledY(value: number, min: number, max: number, chartTop: number, chartBottom: number): number {
|
|
224
|
+
const range = max - min || 1;
|
|
225
|
+
const chartH = chartBottom - chartTop;
|
|
226
|
+
return chartTop + Math.round((1 - (value - min) / range) * chartH);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// Proportional element widths for braille resolution
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
|
|
233
|
+
function getCandleHalfWidth(pointCount: number, bufWidth: number): number {
|
|
234
|
+
if (pointCount <= 1) return 2;
|
|
235
|
+
const spacing = bufWidth / pointCount;
|
|
236
|
+
const bodyW = Math.min(Math.max(Math.round(spacing * 0.55), 2), 10);
|
|
237
|
+
return Math.floor(bodyW / 2);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function getBarHalfWidth(pointCount: number, bufWidth: number): number {
|
|
241
|
+
if (pointCount <= 1) return 2;
|
|
242
|
+
const spacing = bufWidth / pointCount;
|
|
243
|
+
const barW = Math.min(Math.max(Math.round(spacing * 0.45), 1), 10);
|
|
244
|
+
return Math.floor(barW / 2);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function getDotX(index: number, pointCount: number, width: number, mode: ChartRenderMode): number {
|
|
248
|
+
switch (mode) {
|
|
249
|
+
case "candles": {
|
|
250
|
+
const pad = getCandleHalfWidth(pointCount, width);
|
|
251
|
+
return getSeriesPosition(index, pointCount, width, pad, pad);
|
|
252
|
+
}
|
|
253
|
+
case "ohlc": {
|
|
254
|
+
const tickLen = Math.max(getCandleHalfWidth(pointCount, width), 2);
|
|
255
|
+
const pad = Math.max(tickLen - 1, 0);
|
|
256
|
+
return getSeriesPosition(index, pointCount, width, pad, pad);
|
|
257
|
+
}
|
|
258
|
+
default:
|
|
259
|
+
return getSeriesPosition(index, pointCount, width);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function getPointDotX(index: number, pointCount: number, width: number, mode: ChartRenderMode): number {
|
|
264
|
+
return getDotX(index, pointCount, Math.max(width * 2, 1), mode);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function getPointTerminalColumn(index: number, pointCount: number, width: number, mode: ChartRenderMode): number {
|
|
268
|
+
if (width <= 1) return 0;
|
|
269
|
+
return Math.min(Math.max(Math.floor(getPointDotX(index, pointCount, width, mode) / 2), 0), width - 1);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Chart mode renderers
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
function drawLineSeries(
|
|
277
|
+
buf: PixelBuffer,
|
|
278
|
+
points: ProjectedChartPoint[],
|
|
279
|
+
chartTop: number,
|
|
280
|
+
chartBottom: number,
|
|
281
|
+
lineColor: string,
|
|
282
|
+
min: number,
|
|
283
|
+
max: number,
|
|
284
|
+
) {
|
|
285
|
+
if (points.length === 0) return;
|
|
286
|
+
|
|
287
|
+
for (let i = 0; i < points.length; i++) {
|
|
288
|
+
const x = getDotX(i, points.length, buf.width, "line");
|
|
289
|
+
const y = getScaledY(points[i]!.close, min, max, chartTop, chartBottom);
|
|
290
|
+
if (i < points.length - 1) {
|
|
291
|
+
const x1 = getDotX(i + 1, points.length, buf.width, "line");
|
|
292
|
+
const y1 = getScaledY(points[i + 1]!.close, min, max, chartTop, chartBottom);
|
|
293
|
+
drawLine(buf, x, y, x1, y1, lineColor, LAYER_DATA);
|
|
294
|
+
} else {
|
|
295
|
+
setPixel(buf, x, y, lineColor, LAYER_DATA);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function drawAreaChart(
|
|
301
|
+
buf: PixelBuffer,
|
|
302
|
+
points: ProjectedChartPoint[],
|
|
303
|
+
chartTop: number,
|
|
304
|
+
chartBottom: number,
|
|
305
|
+
lineColor: string,
|
|
306
|
+
fillColor: string,
|
|
307
|
+
min: number,
|
|
308
|
+
max: number,
|
|
309
|
+
) {
|
|
310
|
+
if (points.length === 0) return;
|
|
311
|
+
|
|
312
|
+
// Draw fill first (lower layer), then line on top
|
|
313
|
+
for (let i = 0; i < points.length; i++) {
|
|
314
|
+
const x = getDotX(i, points.length, buf.width, "area");
|
|
315
|
+
const y = getScaledY(points[i]!.close, min, max, chartTop, chartBottom);
|
|
316
|
+
|
|
317
|
+
fillColumn(buf, x, y + 1, chartBottom, fillColor, LAYER_FILL);
|
|
318
|
+
|
|
319
|
+
if (i < points.length - 1) {
|
|
320
|
+
const x1 = getDotX(i + 1, points.length, buf.width, "area");
|
|
321
|
+
const y1 = getScaledY(points[i + 1]!.close, min, max, chartTop, chartBottom);
|
|
322
|
+
|
|
323
|
+
// Interpolate fill between consecutive points
|
|
324
|
+
for (let cx = Math.min(x, x1); cx <= Math.max(x, x1); cx++) {
|
|
325
|
+
if (cx === x || cx === x1) continue;
|
|
326
|
+
const t = (cx - x) / Math.max(Math.abs(x1 - x), 1);
|
|
327
|
+
const iy = Math.round(y + t * (y1 - y));
|
|
328
|
+
fillColumn(buf, cx, iy + 1, chartBottom, fillColor, LAYER_FILL);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Line on top
|
|
332
|
+
drawLine(buf, x, y, x1, y1, lineColor, LAYER_DATA);
|
|
333
|
+
} else {
|
|
334
|
+
setPixel(buf, x, y, lineColor, LAYER_DATA);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function drawCandlestickChart(
|
|
340
|
+
buf: PixelBuffer,
|
|
341
|
+
points: ProjectedChartPoint[],
|
|
342
|
+
chartTop: number,
|
|
343
|
+
chartBottom: number,
|
|
344
|
+
palette: ResolvedChartPalette,
|
|
345
|
+
min: number,
|
|
346
|
+
max: number,
|
|
347
|
+
) {
|
|
348
|
+
const halfW = getCandleHalfWidth(points.length, buf.width);
|
|
349
|
+
|
|
350
|
+
for (let i = 0; i < points.length; i++) {
|
|
351
|
+
const point = points[i]!;
|
|
352
|
+
const x = getDotX(i, points.length, buf.width, "candles");
|
|
353
|
+
const highY = getScaledY(point.high, min, max, chartTop, chartBottom);
|
|
354
|
+
const lowY = getScaledY(point.low, min, max, chartTop, chartBottom);
|
|
355
|
+
const openY = getScaledY(point.open, min, max, chartTop, chartBottom);
|
|
356
|
+
const closeY = getScaledY(point.close, min, max, chartTop, chartBottom);
|
|
357
|
+
const isUp = point.close >= point.open;
|
|
358
|
+
const wickColor = isUp ? palette.wickUp : palette.wickDown;
|
|
359
|
+
const bodyColor = isUp ? palette.candleUp : palette.candleDown;
|
|
360
|
+
|
|
361
|
+
// Body: proportional width
|
|
362
|
+
const bodyTop = Math.min(openY, closeY);
|
|
363
|
+
const bodyBottom = Math.max(openY, closeY);
|
|
364
|
+
if (bodyTop === bodyBottom) {
|
|
365
|
+
drawLine(buf, x, highY, x, lowY, wickColor, LAYER_DATA);
|
|
366
|
+
const dojiBottom = Math.min(bodyBottom + 1, chartBottom);
|
|
367
|
+
const dojiTop = Math.max(chartTop, dojiBottom - 1);
|
|
368
|
+
fillRect(buf, x - halfW, dojiTop, x + halfW, dojiBottom, bodyColor, LAYER_DATA);
|
|
369
|
+
} else {
|
|
370
|
+
drawWickOutsideBody(buf, x, highY, lowY, bodyTop, bodyBottom, wickColor);
|
|
371
|
+
fillRect(buf, x - halfW, bodyTop, x + halfW, bodyBottom, bodyColor, LAYER_DATA);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function drawOhlcChart(
|
|
377
|
+
buf: PixelBuffer,
|
|
378
|
+
points: ProjectedChartPoint[],
|
|
379
|
+
chartTop: number,
|
|
380
|
+
chartBottom: number,
|
|
381
|
+
palette: ResolvedChartPalette,
|
|
382
|
+
min: number,
|
|
383
|
+
max: number,
|
|
384
|
+
) {
|
|
385
|
+
const tickLen = Math.max(getCandleHalfWidth(points.length, buf.width), 2);
|
|
386
|
+
|
|
387
|
+
for (let i = 0; i < points.length; i++) {
|
|
388
|
+
const point = points[i]!;
|
|
389
|
+
const x = getDotX(i, points.length, buf.width, "ohlc");
|
|
390
|
+
const highY = getScaledY(point.high, min, max, chartTop, chartBottom);
|
|
391
|
+
const lowY = getScaledY(point.low, min, max, chartTop, chartBottom);
|
|
392
|
+
const openY = getScaledY(point.open, min, max, chartTop, chartBottom);
|
|
393
|
+
const closeY = getScaledY(point.close, min, max, chartTop, chartBottom);
|
|
394
|
+
const isUp = point.close >= point.open;
|
|
395
|
+
const color = isUp ? palette.candleUp : palette.candleDown;
|
|
396
|
+
|
|
397
|
+
// Vertical line
|
|
398
|
+
drawLine(buf, x, highY, x, lowY, color, LAYER_DATA);
|
|
399
|
+
|
|
400
|
+
// Open tick extends left
|
|
401
|
+
for (let dx = 0; dx < tickLen; dx++) {
|
|
402
|
+
setPixel(buf, x - dx, openY, color, LAYER_DATA);
|
|
403
|
+
}
|
|
404
|
+
// Close tick extends right
|
|
405
|
+
for (let dx = 0; dx < tickLen; dx++) {
|
|
406
|
+
setPixel(buf, x + dx, closeY, color, LAYER_DATA);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// ---------------------------------------------------------------------------
|
|
412
|
+
// Volume bars
|
|
413
|
+
// ---------------------------------------------------------------------------
|
|
414
|
+
|
|
415
|
+
export function drawVolumeBars(
|
|
416
|
+
buf: PixelBuffer,
|
|
417
|
+
points: ProjectedChartPoint[],
|
|
418
|
+
yTop: number,
|
|
419
|
+
yBottom: number,
|
|
420
|
+
upColor: string,
|
|
421
|
+
downColor: string,
|
|
422
|
+
trendMode: VolumeTrendMode,
|
|
423
|
+
mode: ChartRenderMode,
|
|
424
|
+
) {
|
|
425
|
+
const volumes = points.map((point) => point.volume);
|
|
426
|
+
const maxVol = Math.max(...volumes, 1);
|
|
427
|
+
const volH = yBottom - yTop;
|
|
428
|
+
const halfW = getBarHalfWidth(points.length, buf.width);
|
|
429
|
+
|
|
430
|
+
for (let i = 0; i < points.length; i++) {
|
|
431
|
+
const point = points[i]!;
|
|
432
|
+
const x = getDotX(i, points.length, buf.width, mode);
|
|
433
|
+
const barH = Math.round((point.volume / maxVol) * volH);
|
|
434
|
+
if (barH === 0) continue;
|
|
435
|
+
|
|
436
|
+
const isUp = trendMode === "openClose"
|
|
437
|
+
? point.close >= point.open
|
|
438
|
+
: i === 0 || point.close >= points[i - 1]!.close;
|
|
439
|
+
const color = isUp ? upColor : downColor;
|
|
440
|
+
|
|
441
|
+
fillRect(buf, x - halfW, yBottom - barH, x + halfW, yBottom, color, LAYER_FILL);
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// ---------------------------------------------------------------------------
|
|
446
|
+
// Overlays
|
|
447
|
+
// ---------------------------------------------------------------------------
|
|
448
|
+
|
|
449
|
+
export function drawCrosshair(
|
|
450
|
+
buf: PixelBuffer,
|
|
451
|
+
x: number,
|
|
452
|
+
yTop: number,
|
|
453
|
+
yBottom: number,
|
|
454
|
+
color: string,
|
|
455
|
+
) {
|
|
456
|
+
if (x < 0 || x >= buf.width) return;
|
|
457
|
+
// Dashed pattern: 2 dots on, 2 dots off
|
|
458
|
+
for (let y = yTop; y <= yBottom; y++) {
|
|
459
|
+
if (y % 4 < 2) {
|
|
460
|
+
setPixel(buf, x, y, color, LAYER_CROSSHAIR);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
export function drawGridLines(
|
|
466
|
+
buf: PixelBuffer,
|
|
467
|
+
yPositions: number[],
|
|
468
|
+
color: string,
|
|
469
|
+
) {
|
|
470
|
+
for (const y of yPositions) {
|
|
471
|
+
if (y < 0 || y >= buf.height) continue;
|
|
472
|
+
// Dotted horizontal line — dot every 6th column (≈ every 3 terminal columns)
|
|
473
|
+
for (let x = 0; x < buf.width; x++) {
|
|
474
|
+
if (x % 6 === 0 && !buf.pixels[y]![x]) {
|
|
475
|
+
setPixel(buf, x, y, color, LAYER_GRID);
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ---------------------------------------------------------------------------
|
|
482
|
+
// Braille buffer → styled terminal output
|
|
483
|
+
// ---------------------------------------------------------------------------
|
|
484
|
+
|
|
485
|
+
export function bufferToBrailleLines(buf: PixelBuffer, bgColor: string): StyledContent[] {
|
|
486
|
+
const lines: StyledContent[] = [];
|
|
487
|
+
const termCols = Math.ceil(buf.width / 2);
|
|
488
|
+
const termRows = Math.ceil(buf.height / 4);
|
|
489
|
+
|
|
490
|
+
for (let row = 0; row < termRows; row++) {
|
|
491
|
+
const chunks: StyledChunk[] = [];
|
|
492
|
+
let runChar = "";
|
|
493
|
+
let runFg = "";
|
|
494
|
+
let runBg = "";
|
|
495
|
+
let runLen = 0;
|
|
496
|
+
|
|
497
|
+
const flushRun = () => {
|
|
498
|
+
if (runLen > 0) {
|
|
499
|
+
chunks.push(makeChunk(runChar.repeat(runLen), runFg, runBg));
|
|
500
|
+
runLen = 0;
|
|
501
|
+
}
|
|
502
|
+
};
|
|
503
|
+
|
|
504
|
+
for (let col = 0; col < termCols; col++) {
|
|
505
|
+
let topLayer = -1;
|
|
506
|
+
|
|
507
|
+
// Track dots per layer so we can render only the top layer's dots
|
|
508
|
+
const dotsByLayer: Map<number, number> = new Map(); // layer → braille bits
|
|
509
|
+
const colorByLayer: Map<number, Map<string, number>> = new Map();
|
|
510
|
+
|
|
511
|
+
for (let dy = 0; dy < 4; dy++) {
|
|
512
|
+
for (let dx = 0; dx < 2; dx++) {
|
|
513
|
+
const px = col * 2 + dx;
|
|
514
|
+
const py = row * 4 + dy;
|
|
515
|
+
if (px >= buf.width || py >= buf.height) continue;
|
|
516
|
+
const pixel = buf.pixels[py]?.[px] ?? null;
|
|
517
|
+
if (pixel) {
|
|
518
|
+
const bit = BRAILLE_DOT[dy]![dx]!;
|
|
519
|
+
dotsByLayer.set(pixel.layer, (dotsByLayer.get(pixel.layer) || 0) | bit);
|
|
520
|
+
if (!colorByLayer.has(pixel.layer)) colorByLayer.set(pixel.layer, new Map());
|
|
521
|
+
const counts = colorByLayer.get(pixel.layer)!;
|
|
522
|
+
counts.set(pixel.color, (counts.get(pixel.color) || 0) + 1);
|
|
523
|
+
if (pixel.layer > topLayer) {
|
|
524
|
+
topLayer = pixel.layer;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
let char: string;
|
|
531
|
+
let cellFg: string;
|
|
532
|
+
let cellBg: string;
|
|
533
|
+
|
|
534
|
+
if (topLayer < 0) {
|
|
535
|
+
char = " ";
|
|
536
|
+
cellFg = bgColor;
|
|
537
|
+
cellBg = bgColor;
|
|
538
|
+
} else {
|
|
539
|
+
// Only show dots from the highest layer — keeps lines thin in mixed
|
|
540
|
+
// cells (e.g. area chart where line and fill overlap).
|
|
541
|
+
const pattern = dotsByLayer.get(topLayer) || 0;
|
|
542
|
+
char = String.fromCharCode(BRAILLE_BASE + pattern);
|
|
543
|
+
|
|
544
|
+
const topCounts: Map<string, number> = colorByLayer.get(topLayer) ?? new Map();
|
|
545
|
+
let topColor = "";
|
|
546
|
+
let bestCount = 0;
|
|
547
|
+
for (const [c, n] of topCounts) {
|
|
548
|
+
if (n > bestCount) { bestCount = n; topColor = c; }
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
cellFg = topColor;
|
|
552
|
+
cellBg = bgColor;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
if (char === runChar && cellFg === runFg && cellBg === runBg) {
|
|
556
|
+
runLen++;
|
|
557
|
+
} else {
|
|
558
|
+
flushRun();
|
|
559
|
+
runChar = char;
|
|
560
|
+
runFg = cellFg;
|
|
561
|
+
runBg = cellBg;
|
|
562
|
+
runLen = 1;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
flushRun();
|
|
567
|
+
lines.push({ chunks });
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
return lines;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
// ---------------------------------------------------------------------------
|
|
574
|
+
// Formatting helpers
|
|
575
|
+
// ---------------------------------------------------------------------------
|
|
576
|
+
|
|
577
|
+
export function formatPrice(value: number): string {
|
|
578
|
+
return formatPriceWithCurrency(value);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
function formatPercentAxisValue(value: number): string {
|
|
582
|
+
const abs = Math.abs(value);
|
|
583
|
+
const decimals = abs >= 100 ? 0 : abs >= 10 ? 1 : 2;
|
|
584
|
+
const prefix = value > 0 ? "+" : "";
|
|
585
|
+
return `${prefix}${value.toFixed(decimals)}%`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const currencySymbols = new Map<string, string>();
|
|
589
|
+
|
|
590
|
+
function getCurrencySymbol(currency: string): string {
|
|
591
|
+
const cached = currencySymbols.get(currency);
|
|
592
|
+
if (cached) return cached;
|
|
593
|
+
|
|
594
|
+
const formatter = new Intl.NumberFormat("en-US", {
|
|
595
|
+
style: "currency",
|
|
596
|
+
currency,
|
|
597
|
+
currencyDisplay: "symbol",
|
|
598
|
+
minimumFractionDigits: 0,
|
|
599
|
+
maximumFractionDigits: 0,
|
|
600
|
+
});
|
|
601
|
+
const symbol = formatter.formatToParts(0).find((part) => part.type === "currency")?.value ?? currency;
|
|
602
|
+
currencySymbols.set(currency, symbol);
|
|
603
|
+
return symbol;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
export function formatPriceWithCurrency(value: number, currency = "USD"): string {
|
|
607
|
+
const abs = Math.abs(value);
|
|
608
|
+
const sign = value < 0 ? "-" : "";
|
|
609
|
+
const symbol = getCurrencySymbol(currency);
|
|
610
|
+
|
|
611
|
+
if (abs >= 1e6) return `${sign}${symbol}${(abs / 1e6).toFixed(1)}M`;
|
|
612
|
+
if (abs >= 1e3 && abs < 1e5) return `${sign}${symbol}${(abs / 1e3).toFixed(1)}K`;
|
|
613
|
+
return formatCurrency(value, currency);
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
export function formatAxisValue(value: number, axisMode: ChartAxisMode, basePrice: number, currency = "USD"): string {
|
|
617
|
+
if (axisMode === "percent" && basePrice !== 0) {
|
|
618
|
+
return formatPercentAxisValue(((value - basePrice) / basePrice) * 100);
|
|
619
|
+
}
|
|
620
|
+
return formatPriceWithCurrency(value, currency);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
624
|
+
|
|
625
|
+
export function formatDate(date: Date | string | number): string {
|
|
626
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
627
|
+
if (isNaN(d.getTime())) return "—";
|
|
628
|
+
return `${MONTHS[d.getMonth()]} ${d.getFullYear()}`;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
export function formatDateShort(date: Date | string | number): string {
|
|
632
|
+
const d = date instanceof Date ? date : new Date(date);
|
|
633
|
+
if (isNaN(d.getTime())) return "—";
|
|
634
|
+
return `${MONTHS[d.getMonth()]} ${d.getDate()}`;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const AXIS_MS_SECOND = 1_000;
|
|
638
|
+
const AXIS_MS_MINUTE = 60 * AXIS_MS_SECOND;
|
|
639
|
+
const AXIS_MS_HOUR = 60 * AXIS_MS_MINUTE;
|
|
640
|
+
const AXIS_MS_DAY = 24 * AXIS_MS_HOUR;
|
|
641
|
+
const AXIS_MS_MONTH = 30 * AXIS_MS_DAY;
|
|
642
|
+
const AXIS_MS_YEAR = 365 * AXIS_MS_DAY;
|
|
643
|
+
|
|
644
|
+
type AxisLabelUnit = "year" | "month" | "day" | "hour" | "minute" | "second" | "millisecond";
|
|
645
|
+
|
|
646
|
+
function formatClockTime(date: Date, unit: AxisLabelUnit): string {
|
|
647
|
+
const hours = String(date.getHours()).padStart(2, "0");
|
|
648
|
+
const minutes = String(date.getMinutes()).padStart(2, "0");
|
|
649
|
+
const seconds = String(date.getSeconds()).padStart(2, "0");
|
|
650
|
+
const milliseconds = String(date.getMilliseconds()).padStart(3, "0");
|
|
651
|
+
|
|
652
|
+
switch (unit) {
|
|
653
|
+
case "hour":
|
|
654
|
+
case "minute":
|
|
655
|
+
return `${hours}:${minutes}`;
|
|
656
|
+
case "second":
|
|
657
|
+
return `${hours}:${minutes}:${seconds}`;
|
|
658
|
+
case "millisecond":
|
|
659
|
+
return `${hours}:${minutes}:${seconds}.${milliseconds}`;
|
|
660
|
+
default:
|
|
661
|
+
return `${hours}:${minutes}`;
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function isSameCalendarDay(a: Date, b: Date): boolean {
|
|
666
|
+
return a.getFullYear() === b.getFullYear()
|
|
667
|
+
&& a.getMonth() === b.getMonth()
|
|
668
|
+
&& a.getDate() === b.getDate();
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function isSameCalendarMonth(a: Date, b: Date): boolean {
|
|
672
|
+
return a.getFullYear() === b.getFullYear()
|
|
673
|
+
&& a.getMonth() === b.getMonth();
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function getMinPositiveGapMs(dates: Date[]): number {
|
|
677
|
+
let minGapMs = Number.POSITIVE_INFINITY;
|
|
678
|
+
|
|
679
|
+
for (let index = 1; index < dates.length; index += 1) {
|
|
680
|
+
const current = dates[index]!;
|
|
681
|
+
const previous = dates[index - 1]!;
|
|
682
|
+
const gapMs = Math.abs(current.getTime() - previous.getTime());
|
|
683
|
+
if (gapMs > 0 && gapMs < minGapMs) {
|
|
684
|
+
minGapMs = gapMs;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
return Number.isFinite(minGapMs) ? minGapMs : 0;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
function resolveAxisLabelUnit(stepMs: number): AxisLabelUnit {
|
|
692
|
+
if (stepMs >= AXIS_MS_YEAR) return "year";
|
|
693
|
+
if (stepMs >= AXIS_MS_MONTH) return "month";
|
|
694
|
+
if (stepMs >= AXIS_MS_DAY) return "day";
|
|
695
|
+
if (stepMs >= AXIS_MS_HOUR) return "hour";
|
|
696
|
+
if (stepMs >= AXIS_MS_MINUTE) return "minute";
|
|
697
|
+
if (stepMs >= AXIS_MS_SECOND) return "second";
|
|
698
|
+
return "millisecond";
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
function estimateAxisLabelWidth(unit: AxisLabelUnit, first: Date, last: Date): number {
|
|
702
|
+
const spansMultipleDays = !isSameCalendarDay(first, last);
|
|
703
|
+
const spansMultipleYears = first.getFullYear() !== last.getFullYear();
|
|
704
|
+
|
|
705
|
+
switch (unit) {
|
|
706
|
+
case "year":
|
|
707
|
+
return 4;
|
|
708
|
+
case "month":
|
|
709
|
+
return spansMultipleYears ? 8 : 5;
|
|
710
|
+
case "day":
|
|
711
|
+
return spansMultipleYears ? 10 : 6;
|
|
712
|
+
case "hour":
|
|
713
|
+
case "minute":
|
|
714
|
+
return spansMultipleDays ? 12 : 8;
|
|
715
|
+
case "second":
|
|
716
|
+
return spansMultipleDays ? 17 : 12;
|
|
717
|
+
case "millisecond":
|
|
718
|
+
return spansMultipleDays ? 21 : 16;
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
function formatTimeAxisLabel(
|
|
723
|
+
date: Date,
|
|
724
|
+
previousDate: Date | null,
|
|
725
|
+
unit: AxisLabelUnit,
|
|
726
|
+
): string {
|
|
727
|
+
if (isNaN(date.getTime())) return "—";
|
|
728
|
+
|
|
729
|
+
switch (unit) {
|
|
730
|
+
case "year":
|
|
731
|
+
return `${date.getFullYear()}`;
|
|
732
|
+
case "month":
|
|
733
|
+
if (previousDate && previousDate.getFullYear() === date.getFullYear()) {
|
|
734
|
+
return MONTHS[date.getMonth()]!;
|
|
735
|
+
}
|
|
736
|
+
return `${MONTHS[date.getMonth()]} ${date.getFullYear()}`;
|
|
737
|
+
case "day":
|
|
738
|
+
if (previousDate && isSameCalendarMonth(previousDate, date)) {
|
|
739
|
+
return `${date.getDate()}`;
|
|
740
|
+
}
|
|
741
|
+
if (previousDate && previousDate.getFullYear() === date.getFullYear()) {
|
|
742
|
+
return `${MONTHS[date.getMonth()]} ${date.getDate()}`;
|
|
743
|
+
}
|
|
744
|
+
return `${MONTHS[date.getMonth()]} ${date.getDate()} ${date.getFullYear()}`;
|
|
745
|
+
case "hour":
|
|
746
|
+
case "minute":
|
|
747
|
+
case "second":
|
|
748
|
+
case "millisecond": {
|
|
749
|
+
const timeLabel = formatClockTime(date, unit);
|
|
750
|
+
if (previousDate && isSameCalendarDay(previousDate, date)) {
|
|
751
|
+
return timeLabel;
|
|
752
|
+
}
|
|
753
|
+
if (previousDate && previousDate.getFullYear() === date.getFullYear()) {
|
|
754
|
+
return `${MONTHS[date.getMonth()]} ${date.getDate()} ${timeLabel}`;
|
|
755
|
+
}
|
|
756
|
+
return `${MONTHS[date.getMonth()]} ${date.getDate()} ${date.getFullYear()} ${timeLabel}`;
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
function formatTimeAxisBoundaryLabel(date: Date, unit: AxisLabelUnit, counterpart: Date): string {
|
|
762
|
+
if (isNaN(date.getTime())) return "—";
|
|
763
|
+
|
|
764
|
+
switch (unit) {
|
|
765
|
+
case "year":
|
|
766
|
+
return `${date.getFullYear()}`;
|
|
767
|
+
case "month":
|
|
768
|
+
return `${MONTHS[date.getMonth()]} ${date.getFullYear()}`;
|
|
769
|
+
case "day":
|
|
770
|
+
return counterpart.getFullYear() === date.getFullYear()
|
|
771
|
+
? `${MONTHS[date.getMonth()]} ${date.getDate()}`
|
|
772
|
+
: `${MONTHS[date.getMonth()]} ${date.getDate()} ${date.getFullYear()}`;
|
|
773
|
+
case "hour":
|
|
774
|
+
case "minute":
|
|
775
|
+
case "second":
|
|
776
|
+
case "millisecond": {
|
|
777
|
+
const timeLabel = formatClockTime(date, unit);
|
|
778
|
+
if (isSameCalendarDay(date, counterpart)) {
|
|
779
|
+
return timeLabel;
|
|
780
|
+
}
|
|
781
|
+
if (counterpart.getFullYear() === date.getFullYear()) {
|
|
782
|
+
return `${MONTHS[date.getMonth()]} ${date.getDate()} ${timeLabel}`;
|
|
783
|
+
}
|
|
784
|
+
return `${MONTHS[date.getMonth()]} ${date.getDate()} ${date.getFullYear()} ${timeLabel}`;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function resolveAxisLabelStart(pos: number, label: string, width: number): number {
|
|
790
|
+
return Math.max(Math.min(pos - Math.floor(label.length / 2), width - label.length), 0);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function resolveCenteredAxisLabelStart(label: string, width: number): number {
|
|
794
|
+
return Math.max(Math.floor((width - label.length) / 2), 0);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function writeAxisLabel(axis: string[], start: number, label: string) {
|
|
798
|
+
for (let index = 0; index < label.length && start + index < axis.length; index += 1) {
|
|
799
|
+
axis[start + index] = label[index]!;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
export function buildTimeAxis(dates: Array<Date | string | number>, width: number): string {
|
|
804
|
+
if (dates.length === 0 || width <= 0) return "";
|
|
805
|
+
|
|
806
|
+
const normalizedDates = dates.map((value) => (value instanceof Date ? value : new Date(value)));
|
|
807
|
+
const first = normalizedDates[0]!;
|
|
808
|
+
const last = normalizedDates[normalizedDates.length - 1]!;
|
|
809
|
+
const rawSpanMs = last.getTime() - first.getTime();
|
|
810
|
+
const spanMs = Math.max(rawSpanMs, 1);
|
|
811
|
+
const roughLabelCount = Math.max(Math.floor(width / 10), 2);
|
|
812
|
+
const minGapMs = getMinPositiveGapMs(normalizedDates);
|
|
813
|
+
const effectiveStepMs = Math.max(spanMs / Math.max(roughLabelCount - 1, 1), minGapMs || 0);
|
|
814
|
+
const unit = resolveAxisLabelUnit(effectiveStepMs);
|
|
815
|
+
const allSameTimestamp = rawSpanMs === 0 && minGapMs === 0;
|
|
816
|
+
const axis = new Array(width).fill(" ");
|
|
817
|
+
const minGap = unit === "year" || unit === "month" ? 2 : 1;
|
|
818
|
+
const idealLabelWidth = estimateAxisLabelWidth(unit, first, last);
|
|
819
|
+
const targetLabelCount = Math.min(
|
|
820
|
+
normalizedDates.length,
|
|
821
|
+
Math.max(Math.floor(width / (idealLabelWidth + minGap)), 2),
|
|
822
|
+
);
|
|
823
|
+
const candidateIndices = [...new Set(
|
|
824
|
+
Array.from({ length: targetLabelCount }, (_, index) => (
|
|
825
|
+
targetLabelCount === 1
|
|
826
|
+
? 0
|
|
827
|
+
: Math.round((index / (targetLabelCount - 1)) * (normalizedDates.length - 1))
|
|
828
|
+
)),
|
|
829
|
+
)];
|
|
830
|
+
|
|
831
|
+
const firstLabel = formatTimeAxisBoundaryLabel(first, unit, last);
|
|
832
|
+
if (allSameTimestamp) {
|
|
833
|
+
const centeredStart = resolveCenteredAxisLabelStart(firstLabel, width);
|
|
834
|
+
writeAxisLabel(axis, centeredStart, firstLabel);
|
|
835
|
+
return axis.join("");
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const firstStart = resolveAxisLabelStart(0, firstLabel, width);
|
|
839
|
+
writeAxisLabel(axis, firstStart, firstLabel);
|
|
840
|
+
const placedLabels = new Set<string>([firstLabel]);
|
|
841
|
+
|
|
842
|
+
let lastPlacedDate = first;
|
|
843
|
+
let lastEnd = firstStart + firstLabel.length - 1;
|
|
844
|
+
|
|
845
|
+
const lastPos = width - 1;
|
|
846
|
+
const lastLabel = normalizedDates.length === 1
|
|
847
|
+
? firstLabel
|
|
848
|
+
: formatTimeAxisBoundaryLabel(last, unit, first);
|
|
849
|
+
const lastStart = resolveAxisLabelStart(lastPos, lastLabel, width);
|
|
850
|
+
const lastFits = normalizedDates.length === 1 || lastStart > lastEnd + minGap;
|
|
851
|
+
|
|
852
|
+
for (const index of candidateIndices.slice(1, -1)) {
|
|
853
|
+
const date = normalizedDates[index]!;
|
|
854
|
+
if (isNaN(date.getTime())) continue;
|
|
855
|
+
|
|
856
|
+
const pos = Math.round((index / (normalizedDates.length - 1)) * (width - 1));
|
|
857
|
+
const label = formatTimeAxisLabel(date, lastPlacedDate, unit);
|
|
858
|
+
if (placedLabels.has(label)) continue;
|
|
859
|
+
const start = resolveAxisLabelStart(pos, label, width);
|
|
860
|
+
const end = start + label.length - 1;
|
|
861
|
+
|
|
862
|
+
if (label === axis.slice(start, end + 1).join("")) continue;
|
|
863
|
+
if (start <= lastEnd + minGap) continue;
|
|
864
|
+
if (lastFits && end >= lastStart - minGap) continue;
|
|
865
|
+
|
|
866
|
+
writeAxisLabel(axis, start, label);
|
|
867
|
+
placedLabels.add(label);
|
|
868
|
+
lastPlacedDate = date;
|
|
869
|
+
lastEnd = end;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
if (normalizedDates.length > 1 && lastFits && !placedLabels.has(lastLabel)) {
|
|
873
|
+
writeAxisLabel(axis, lastStart, lastLabel);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
return axis.join("");
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// ---------------------------------------------------------------------------
|
|
880
|
+
// Grid lines & price axis
|
|
881
|
+
// ---------------------------------------------------------------------------
|
|
882
|
+
|
|
883
|
+
export function computeGridLines(
|
|
884
|
+
min: number,
|
|
885
|
+
max: number,
|
|
886
|
+
chartTop: number,
|
|
887
|
+
chartBottom: number,
|
|
888
|
+
numLines: number,
|
|
889
|
+
): { y: number; price: number }[] {
|
|
890
|
+
const range = max - min || 1;
|
|
891
|
+
const chartH = chartBottom - chartTop;
|
|
892
|
+
const result: { y: number; price: number }[] = [];
|
|
893
|
+
|
|
894
|
+
for (let i = 0; i <= numLines; i++) {
|
|
895
|
+
const frac = i / numLines;
|
|
896
|
+
result.push({
|
|
897
|
+
y: chartTop + Math.round(frac * chartH),
|
|
898
|
+
price: max - frac * range,
|
|
899
|
+
});
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
return result;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
// ---------------------------------------------------------------------------
|
|
906
|
+
// Main render entry point
|
|
907
|
+
// ---------------------------------------------------------------------------
|
|
908
|
+
|
|
909
|
+
export interface RenderChartOptions {
|
|
910
|
+
width: number;
|
|
911
|
+
height: number;
|
|
912
|
+
showVolume: boolean;
|
|
913
|
+
volumeHeight: number;
|
|
914
|
+
cursorX: number | null;
|
|
915
|
+
cursorY: number | null;
|
|
916
|
+
mode: ChartRenderMode;
|
|
917
|
+
axisMode?: ChartAxisMode;
|
|
918
|
+
currency?: string;
|
|
919
|
+
colors: ResolvedChartPalette;
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
export interface ChartScene {
|
|
923
|
+
points: ProjectedChartPoint[];
|
|
924
|
+
width: number;
|
|
925
|
+
height: number;
|
|
926
|
+
showVolume: boolean;
|
|
927
|
+
volumeHeight: number;
|
|
928
|
+
chartRows: number;
|
|
929
|
+
mode: ChartRenderMode;
|
|
930
|
+
colors: ResolvedChartPalette;
|
|
931
|
+
min: number;
|
|
932
|
+
max: number;
|
|
933
|
+
activeIdx: number;
|
|
934
|
+
activePoint: ProjectedChartPoint;
|
|
935
|
+
priceAtCursor: number;
|
|
936
|
+
crosshairPrice: number | null;
|
|
937
|
+
dateAtCursor: Date;
|
|
938
|
+
changeAtCursor: number;
|
|
939
|
+
changePctAtCursor: number;
|
|
940
|
+
timeLabels: string;
|
|
941
|
+
cursorX: number | null;
|
|
942
|
+
cursorY: number | null;
|
|
943
|
+
cursorColumn: number | null;
|
|
944
|
+
cursorRow: number | null;
|
|
945
|
+
cursorDotX: number | null;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
export interface RenderChartResult {
|
|
949
|
+
lines: StyledContent[];
|
|
950
|
+
axisLabels: { row: number; label: string }[];
|
|
951
|
+
timeLabels: string;
|
|
952
|
+
activePoint: ProjectedChartPoint | null;
|
|
953
|
+
priceAtCursor: number | null;
|
|
954
|
+
crosshairPrice: number | null;
|
|
955
|
+
dateAtCursor: Date | null;
|
|
956
|
+
changeAtCursor: number | null;
|
|
957
|
+
changePctAtCursor: number | null;
|
|
958
|
+
cursorColumn: number | null;
|
|
959
|
+
cursorRow: number | null;
|
|
960
|
+
/** Raw pixel buffer for GPU/Kitty rendering path */
|
|
961
|
+
pixelBuffer: PixelBuffer | null;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
export function getActivePointIndex(
|
|
965
|
+
pointCount: number,
|
|
966
|
+
width: number,
|
|
967
|
+
cursorX: number | null,
|
|
968
|
+
mode: ChartRenderMode,
|
|
969
|
+
): number {
|
|
970
|
+
if (pointCount <= 0) return 0;
|
|
971
|
+
if (cursorX === null || cursorX < 0 || cursorX >= width) {
|
|
972
|
+
return pointCount - 1;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
let bestIndex = pointCount - 1;
|
|
976
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
977
|
+
|
|
978
|
+
for (let index = 0; index < pointCount; index += 1) {
|
|
979
|
+
const pointColumn = getPointTerminalColumn(index, pointCount, width, mode);
|
|
980
|
+
const distance = Math.abs(pointColumn - cursorX);
|
|
981
|
+
if (distance <= bestDistance) {
|
|
982
|
+
bestDistance = distance;
|
|
983
|
+
bestIndex = index;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
return bestIndex;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
export function buildChartScene(
|
|
991
|
+
points: ProjectedChartPoint[],
|
|
992
|
+
opts: RenderChartOptions,
|
|
993
|
+
): ChartScene | null {
|
|
994
|
+
if (points.length === 0) return null;
|
|
995
|
+
|
|
996
|
+
const min = opts.mode === "candles" || opts.mode === "ohlc"
|
|
997
|
+
? Math.min(...points.map((point) => point.low))
|
|
998
|
+
: Math.min(...points.map((point) => point.close));
|
|
999
|
+
const max = opts.mode === "candles" || opts.mode === "ohlc"
|
|
1000
|
+
? Math.max(...points.map((point) => point.high))
|
|
1001
|
+
: Math.max(...points.map((point) => point.close));
|
|
1002
|
+
const activeIdx = getActivePointIndex(points.length, opts.width, opts.cursorX, opts.mode);
|
|
1003
|
+
const activePoint = points[activeIdx]!;
|
|
1004
|
+
const range = max - min || 1;
|
|
1005
|
+
const chartRows = opts.height - (opts.showVolume ? opts.volumeHeight : 0);
|
|
1006
|
+
const cursorX = opts.cursorX === null
|
|
1007
|
+
? null
|
|
1008
|
+
: Math.min(Math.max(opts.cursorX, 0), Math.max(opts.width - 1, 0));
|
|
1009
|
+
const cursorColumn = cursorX === null
|
|
1010
|
+
? null
|
|
1011
|
+
: Math.round(cursorX);
|
|
1012
|
+
const cursorDotX = cursorX === null
|
|
1013
|
+
? null
|
|
1014
|
+
: Math.round((cursorX / Math.max(opts.width - 1, 1)) * Math.max(opts.width * 2 - 1, 0));
|
|
1015
|
+
const cursorY = cursorX === null
|
|
1016
|
+
? null
|
|
1017
|
+
: opts.cursorY !== null
|
|
1018
|
+
? Math.min(Math.max(opts.cursorY, 0), Math.max(chartRows - 1, 0))
|
|
1019
|
+
: Math.min(
|
|
1020
|
+
Math.max(Math.round((1 - (activePoint.close - min) / range) * Math.max(chartRows - 1, 0)), 0),
|
|
1021
|
+
Math.max(chartRows - 1, 0),
|
|
1022
|
+
);
|
|
1023
|
+
const cursorRow = cursorY === null
|
|
1024
|
+
? null
|
|
1025
|
+
: Math.round(cursorY);
|
|
1026
|
+
const crosshairPrice = cursorY === null
|
|
1027
|
+
? null
|
|
1028
|
+
: max - (cursorY / Math.max(chartRows - 1, 1)) * range;
|
|
1029
|
+
|
|
1030
|
+
return {
|
|
1031
|
+
points,
|
|
1032
|
+
width: opts.width,
|
|
1033
|
+
height: opts.height,
|
|
1034
|
+
showVolume: opts.showVolume,
|
|
1035
|
+
volumeHeight: opts.volumeHeight,
|
|
1036
|
+
chartRows,
|
|
1037
|
+
mode: opts.mode,
|
|
1038
|
+
colors: opts.colors,
|
|
1039
|
+
min,
|
|
1040
|
+
max,
|
|
1041
|
+
activeIdx,
|
|
1042
|
+
activePoint,
|
|
1043
|
+
priceAtCursor: activePoint.close,
|
|
1044
|
+
crosshairPrice,
|
|
1045
|
+
dateAtCursor: activePoint.date,
|
|
1046
|
+
changeAtCursor: activePoint.close - points[0]!.close,
|
|
1047
|
+
changePctAtCursor: points[0]!.close ? ((activePoint.close - points[0]!.close) / points[0]!.close) * 100 : 0,
|
|
1048
|
+
timeLabels: buildTimeAxis(points.map((point) => point.date), opts.width),
|
|
1049
|
+
cursorX,
|
|
1050
|
+
cursorY,
|
|
1051
|
+
cursorColumn,
|
|
1052
|
+
cursorRow,
|
|
1053
|
+
cursorDotX,
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
export function renderChart(
|
|
1058
|
+
points: ProjectedChartPoint[],
|
|
1059
|
+
opts: RenderChartOptions,
|
|
1060
|
+
): RenderChartResult {
|
|
1061
|
+
const scene = buildChartScene(points, opts);
|
|
1062
|
+
if (!scene) {
|
|
1063
|
+
return {
|
|
1064
|
+
lines: [],
|
|
1065
|
+
axisLabels: [],
|
|
1066
|
+
timeLabels: "",
|
|
1067
|
+
activePoint: null,
|
|
1068
|
+
priceAtCursor: null,
|
|
1069
|
+
crosshairPrice: null,
|
|
1070
|
+
dateAtCursor: null,
|
|
1071
|
+
changeAtCursor: null,
|
|
1072
|
+
changePctAtCursor: null,
|
|
1073
|
+
cursorColumn: null,
|
|
1074
|
+
cursorRow: null,
|
|
1075
|
+
pixelBuffer: null,
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
const { width, colors: palette, mode } = opts;
|
|
1080
|
+
const axisMode = opts.axisMode ?? "price";
|
|
1081
|
+
const currency = opts.currency ?? "USD";
|
|
1082
|
+
|
|
1083
|
+
// Braille resolution: 2 dot-columns per terminal column, 4 dot-rows per terminal row
|
|
1084
|
+
const dotWidth = width * 2;
|
|
1085
|
+
const volTermRows = opts.showVolume ? opts.volumeHeight : 0;
|
|
1086
|
+
const chartTermRows = opts.height - volTermRows;
|
|
1087
|
+
const totalDotH = opts.height * 4;
|
|
1088
|
+
const chartDotBottom = chartTermRows * 4 - 1;
|
|
1089
|
+
const volDotTop = chartTermRows * 4;
|
|
1090
|
+
const volDotBottom = totalDotH - 1;
|
|
1091
|
+
|
|
1092
|
+
const buf = createPixelBuffer(dotWidth, totalDotH);
|
|
1093
|
+
const { min, max } = scene;
|
|
1094
|
+
|
|
1095
|
+
const gridLines = computeGridLines(min, max, 0, chartDotBottom, 3);
|
|
1096
|
+
drawGridLines(buf, gridLines.map((line) => line.y), palette.gridColor);
|
|
1097
|
+
|
|
1098
|
+
switch (mode) {
|
|
1099
|
+
case "area":
|
|
1100
|
+
drawAreaChart(buf, points, 0, chartDotBottom, palette.lineColor, palette.fillColor, min, max);
|
|
1101
|
+
break;
|
|
1102
|
+
case "line":
|
|
1103
|
+
drawLineSeries(buf, points, 0, chartDotBottom, palette.lineColor, min, max);
|
|
1104
|
+
break;
|
|
1105
|
+
case "candles":
|
|
1106
|
+
drawCandlestickChart(buf, points, 0, chartDotBottom, palette, min, max);
|
|
1107
|
+
break;
|
|
1108
|
+
case "ohlc":
|
|
1109
|
+
drawOhlcChart(buf, points, 0, chartDotBottom, palette, min, max);
|
|
1110
|
+
break;
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
if (opts.showVolume && volTermRows > 0) {
|
|
1114
|
+
drawVolumeBars(
|
|
1115
|
+
buf,
|
|
1116
|
+
points,
|
|
1117
|
+
volDotTop,
|
|
1118
|
+
volDotBottom,
|
|
1119
|
+
palette.volumeUp,
|
|
1120
|
+
palette.volumeDown,
|
|
1121
|
+
mode === "candles" || mode === "ohlc" ? "openClose" : "previousClose",
|
|
1122
|
+
mode,
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
// Cursor mapping stays in terminal-column space
|
|
1127
|
+
const { activePoint, priceAtCursor, crosshairPrice, dateAtCursor, changeAtCursor, changePctAtCursor } = scene;
|
|
1128
|
+
|
|
1129
|
+
if (scene.cursorDotX !== null) {
|
|
1130
|
+
drawCrosshair(buf, scene.cursorDotX, 0, opts.showVolume ? volDotBottom : chartDotBottom, palette.crosshairColor);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
const axisLabelsByRow = new Map<number, string>();
|
|
1134
|
+
for (const line of gridLines) {
|
|
1135
|
+
axisLabelsByRow.set(
|
|
1136
|
+
Math.min(Math.floor(line.y / 4), Math.max(chartTermRows - 1, 0)),
|
|
1137
|
+
formatAxisValue(line.price, axisMode, points[0]!.close, currency),
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
return {
|
|
1142
|
+
lines: bufferToBrailleLines(buf, palette.bgColor),
|
|
1143
|
+
axisLabels: [...axisLabelsByRow.entries()].map(([row, label]) => ({ row, label })),
|
|
1144
|
+
timeLabels: buildTimeAxis(points.map((point) => point.date), width),
|
|
1145
|
+
activePoint,
|
|
1146
|
+
priceAtCursor,
|
|
1147
|
+
crosshairPrice,
|
|
1148
|
+
dateAtCursor,
|
|
1149
|
+
changeAtCursor,
|
|
1150
|
+
changePctAtCursor,
|
|
1151
|
+
cursorColumn: scene.cursorColumn,
|
|
1152
|
+
cursorRow: scene.cursorRow,
|
|
1153
|
+
pixelBuffer: buf,
|
|
1154
|
+
};
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
// ---------------------------------------------------------------------------
|
|
1158
|
+
// Pixel buffer → RGBA conversion for Kitty graphics protocol
|
|
1159
|
+
// ---------------------------------------------------------------------------
|
|
1160
|
+
|
|
1161
|
+
function parseHex(hex: string): [number, number, number] {
|
|
1162
|
+
const h = hex.replace("#", "");
|
|
1163
|
+
return [parseInt(h.slice(0, 2), 16), parseInt(h.slice(2, 4), 16), parseInt(h.slice(4, 6), 16)];
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
/**
|
|
1167
|
+
* Convert a PixelBuffer to RGBA pixel data for use with drawSuperSampleBuffer.
|
|
1168
|
+
* Each pixel in the buffer maps to one RGBA pixel in the output.
|
|
1169
|
+
*/
|
|
1170
|
+
export function pixelBufferToRGBA(buf: PixelBuffer, bgColor: string): Uint8Array {
|
|
1171
|
+
const [bgR, bgG, bgB] = parseHex(bgColor);
|
|
1172
|
+
const data = new Uint8Array(buf.width * buf.height * 4);
|
|
1173
|
+
const colorCache = new Map<string, [number, number, number]>();
|
|
1174
|
+
|
|
1175
|
+
for (let y = 0; y < buf.height; y++) {
|
|
1176
|
+
for (let x = 0; x < buf.width; x++) {
|
|
1177
|
+
const offset = (y * buf.width + x) * 4;
|
|
1178
|
+
const pixel = buf.pixels[y]?.[x] ?? null;
|
|
1179
|
+
if (pixel) {
|
|
1180
|
+
let rgb = colorCache.get(pixel.color);
|
|
1181
|
+
if (!rgb) {
|
|
1182
|
+
rgb = parseHex(pixel.color);
|
|
1183
|
+
colorCache.set(pixel.color, rgb);
|
|
1184
|
+
}
|
|
1185
|
+
data[offset] = rgb[0];
|
|
1186
|
+
data[offset + 1] = rgb[1];
|
|
1187
|
+
data[offset + 2] = rgb[2];
|
|
1188
|
+
data[offset + 3] = 255;
|
|
1189
|
+
} else {
|
|
1190
|
+
data[offset] = bgR;
|
|
1191
|
+
data[offset + 1] = bgG;
|
|
1192
|
+
data[offset + 2] = bgB;
|
|
1193
|
+
data[offset + 3] = 255;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
return data;
|
|
1199
|
+
}
|