// ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ // ┃ ██████ ██████ ██████ █ █ █ █ █ █▄ ▀███ █ ┃ // ┃ ▄▄▄▄▄█ █▄▄▄▄▄ ▄▄▄▄▄█ ▀▀▀▀▀█▀▀▀▀▀ █ ▀▀▀▀▀█ ████████▌▐███ ███▄ ▀█ █ ▀▀▀▀▀ ┃ // ┃ █▀▀▀▀▀ █▀▀▀▀▀ █▀██▀▀ ▄▄▄▄▄ █ ▄▄▄▄▄█ ▄▄▄▄▄█ ████████▌▐███ █████▄ █ ▄▄▄▄▄ ┃ // ┃ █ ██████ █ ▀█▄ █ ██████ █ ███▌▐███ ███████▄ █ ┃ // ┣━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┫ // ┃ Copyright (c) 2017, the Perspective Authors. ┃ // ┃ ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌ ┃ // ┃ This file is part of the Perspective library, distributed under the terms ┃ // ┃ of the [Apache License 2.0](https://www.apache.org/licenses/LICENSE-2.0). ┃ // ┗━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┛ import type { ColumnDataMap } from "../../data/view-reader"; import type { WebGLContextManager } from "../../webgl/context-manager"; import { CategoricalYChart } from "../common/categorical-y-chart"; import { buildCandlestickPipeline, emptyCandleColumns, type CandleColumns, type CandleSeriesInfo, type NumericCategoryDomain, } from "./candlestick-build"; import { renderCandlestickFrame, renderCandlestickChromeOverlay, invalidateGlyphBuffers, rebuildGlyphBuffers, ensureUpDownColors, } from "./candlestick-render"; import { handleCandlestickHover, showCandlestickPinnedTooltip, } from "./candlestick-interact"; import { BodyWickGlyph } from "./glyphs/draw-candlesticks"; import { OHLCGlyph } from "./glyphs/draw-ohlc"; import { expandDomainInPlace } from "../common/expand-domain"; /** * Per-frame memo of the auto-fit Y extent for a {@link CandlestickChart}, * keyed on the visible X window. Hover-only redraws hit the cache. */ export interface CandlestickAutoFitCache { // Cache key — the categorical (X) window. xMin: number; xMax: number; // VisibleExtent payload — value axis min/max + hasFit flag. min: number; max: number; hasFit: boolean; } export interface CandlestickLocations { u_proj_left: WebGLUniformLocation | null; u_proj_right: WebGLUniformLocation | null; u_hover_series: WebGLUniformLocation | null; a_corner: number; a_x_center: number; a_half_width: number; a_y0: number; a_y1: number; a_color: number; a_series_id: number; a_axis: number; } /** * Candlestick / OHLC chart. Both plugins (`y-candlestick`, `y-ohlc`) * share this class — the only per-plugin difference is * `_defaultChartType` (`"candlestick"` vs `"ohlc"`), which * {@link renderCandlestickFrame} uses to pick the glyph draw function. * * Fields are package-internal (no `private`) so helper modules in this * folder can read/write them. */ export class CandlestickChart extends CategoricalYChart { _locations: CandlestickLocations | null = null; // `_rowPaths`, `_numCategories`, `_rowOffset`, `_program`, // `_cornerBuffer`, `_lastLayout`, `_lastXDomain`, `_lastYDomain`, // and `_lastYTicks` all live on `CategoricalYChart`. _splitPrefixes: string[] = []; _series: CandleSeriesInfo[] = []; /** * Columnar candle records. Indexed in `[0, _candles.count)`. * Replaces the legacy `CandleRecord[]` to avoid per-record POJO * allocation on data load. */ _candles: CandleColumns = emptyCandleColumns(); _yDomain: { min: number; max: number } = { min: 0, max: 1 }; /** * `domain_mode: "expand"` accumulators. Hold the running union of * the value-axis (and, in numeric-category mode, category-axis) * extent across data loads. Cleared in `resetExpandedDomain` — * wired from the worker's `resetAllZooms` and from view-config * mutations on `AbstractChart`. `null` whenever the option is * `"fit"` or the accumulator has just been cleared. */ _expandedYDomain: { min: number; max: number } | null = null; _expandedCategoryDomain: { min: number; max: number } | null = null; /** * Numeric category-axis state (single non-string group_by). */ _categoryAxisMode: "category" | "numeric" = "category"; _numericCategoryDomain: NumericCategoryDomain | null = null; _categoryPositions: Float64Array | null = null; _lastCatTicks: number[] | null = null; /** * Origin used to rebase candle xCenters before f32 narrowing — see {@link SeriesChart._categoryOrigin}. */ _categoryOrigin = 0; /** * Gradient-sampled colors for the up (close ≥ open) / down sides. * Cached via `_upDownColorKey` — only `restyle()` (which clears the * theme cache) or a data load forces re-sampling. */ _upColor: [number, number, number] = [0, 0.8, 0.4]; _downColor: [number, number, number] = [0.8, 0.2, 0.2]; /** * Identity of the gradient-stops reference last used to sample the * up/down colors. When this matches the current `theme.gradientStops` * reference, `ensureUpDownColors` short-circuits. */ _upDownColorKey: unknown = null; _hoveredIdx = -1; _pinnedIdx = -1; /** * Typed glyph composition. Each glyph owns its own program cache * and persistent vertex buffers privately; the chart routes * draw/rebuild/invalidate via `_glyphs`. `_defaultChartType` * (`"candlestick"` vs `"ohlc"`) selects which glyph the frame * builder dispatches to. */ readonly _glyphs = { bodyWick: new BodyWickGlyph(), ohlc: new OHLCGlyph(), } as const; /** * Auto-fit the price (Y) axis to the `low`/`high` extent of * candles whose `xCenter` falls inside the visible X window. Pairs * with the locked Y axis: the lock means user input can't zoom Y * directly, auto-fit is what actually moves it as the user scrolls * through time. Default: on (financial-chart convention). */ override _autoFitValue = true; /** * Per-frame memo of the auto-fit Y extent keyed on the visible X * window. Hover-only redraws (X window unchanged) hit the cache. * Reset to null on data upload. */ _autoFitCache: CandlestickAutoFitCache | null = null; protected override tooltipCallbacks() { return { onHover: (mx: number, my: number) => handleCandlestickHover(this, mx, my), onLeave: () => { if (this._hoveredIdx !== -1) { this._hoveredIdx = -1; // Hover state only affects the chrome overlay // (tooltip box) — the WebGL pass is unchanged. Skip // the full repaint (which would rebuild glyph // buffers and shake out one more frame of latency). renderCandlestickChromeOverlay(this); } }, onPin: (mx: number, my: number) => { // Refresh the hit-test at the click coords so the pin // path doesn't depend on the RAF-throttled hover state // — see comment in `series.ts` `onPin`. handleCandlestickHover(this, mx, my); if (this._hoveredIdx >= 0) { const idx = this._hoveredIdx; showCandlestickPinnedTooltip(this, idx); void this._emitCandleClickSelect(idx); } }, onUnpin: () => { this.emitUnselect(); }, }; } /** * Resolve a clicked candle into a `PerspectiveClickDetail` and * emit both `perspective-click` and * `perspective-global-filter selected:true`. * * One candle per (catIdx, splitIdx). Like the series pipeline, * `catIdx + _rowOffset` is the source-view row; the column name is * the Close column (the canonical "y" target for OHLC). Group-by * values come from `_rowPaths`; split-by values come from * `_splitPrefixes[splitIdx]` split on `|`. */ private async _emitCandleClickSelect(idx: number): Promise { if (idx < 0 || idx >= this._candles.count) { return; } const catIdx = this._candles.catIdx[idx]; const splitIdx = this._candles.splitIdx[idx]; const groupByValues: (string | null)[] = this._rowPaths.map( (level) => level.labels[catIdx] ?? null, ); const splitKey = this._splitPrefixes[splitIdx] ?? ""; const splitByValues = this._splitBy.length > 0 && splitKey !== "" ? splitKey.split("|") : []; // OHLC plugins put Close in slot 1 (FIN_NAMES = ["Open", // "Close", "High", "Low", "Tooltip"]). Fall back to the first // non-null slot if Close isn't configured. const columnName = this._columnSlots[1] || this._columnSlots.find((s): s is string => !!s) || ""; await this.emitClickAndSelect({ rowIdx: catIdx + this._rowOffset, columnName, groupByValues, splitByValues, }); } override invalidateTheme(): void { super.invalidateTheme(); // Up/down colors are sampled from `theme.gradientStops`. Drop the // identity key so the next render re-samples after a `restyle()`. this._upDownColorKey = null; } async uploadAndRender( glManager: WebGLContextManager, columns: ColumnDataMap, startRow: number, endRow: number, ): Promise { this._glManager = glManager; if (startRow !== 0) { return; } const result = buildCandlestickPipeline({ columns, numRows: endRow, columnSlots: this._columnSlots, groupBy: this._groupBy, splitBy: this._splitBy, groupByTypes: this._groupByTypes, bandInnerFrac: this._pluginConfig.band_inner_frac, barInnerPad: this._pluginConfig.bar_inner_pad, scratchCandles: this._candles, }); // `domain_mode: "expand"` post-build union — mirrors the series // pipeline. `expandDomainInPlace` mutates `result.*` so the // assignments below pick up the grown extent automatically. if (this._pluginConfig.domain_mode === "expand") { this._expandedYDomain = expandDomainInPlace( this._expandedYDomain, result.yDomain, ); if (result.numericCategoryDomain) { this._expandedCategoryDomain = expandDomainInPlace( this._expandedCategoryDomain, result.numericCategoryDomain, ); } } else { this._expandedYDomain = null; this._expandedCategoryDomain = null; } this._rowPaths = result.rowPaths; this._numCategories = result.numCategories; this._rowOffset = result.rowOffset; this._splitPrefixes = result.splitPrefixes; this._series = result.series; this._candles = result.candles; this._yDomain = result.yDomain; this._categoryAxisMode = result.axisMode.mode; this._numericCategoryDomain = result.numericCategoryDomain; this._categoryPositions = result.categoryPositions; this._categoryOrigin = result.numericCategoryDomain?.min ?? 0; // New candles invalidate any auto-fit extent memo. Color key // stays — gradient stops are theme-bound, not data-bound — but // the persistent vertex buffers must be rebuilt to reflect the // new candle set. this._autoFitCache = null; // Resolve up/down colors (cheap on cache hit) before rebuilding // glyph buffers so the persistent body buffer captures the // correct per-candle RGB. Then rebuild buffers. ensureUpDownColors(this); invalidateGlyphBuffers(this); rebuildGlyphBuffers(this, glManager); await this.requestRender(glManager); } _fullRender(glManager: WebGLContextManager): void { this._glManager = glManager; renderCandlestickFrame(this, glManager); } override resetExpandedDomain(): void { this._expandedYDomain = null; this._expandedCategoryDomain = null; } protected destroyInternal(): void { if (this._glManager) { const gl = this._glManager.gl; if (this._cornerBuffer) { gl.deleteBuffer(this._cornerBuffer); } // Each glyph owns its program-local GPU resources + // persistent vertex buffers; `destroy` frees both. this._glyphs.bodyWick.destroy(this); this._glyphs.ohlc.destroy(this); } this._program = null; this._locations = null; this._cornerBuffer = null; this._candles = emptyCandleColumns(); this._series = []; this._rowPaths = []; this._numCategories = 0; this._upDownColorKey = null; } }