termcast 1.3.52 → 1.3.54
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/dist/app.d.ts.map +1 -1
- package/dist/app.js +209 -7
- package/dist/app.js.map +1 -1
- package/dist/cli.js +4 -4
- package/dist/cli.js.map +1 -1
- package/dist/components/candle-chart.d.ts +110 -0
- package/dist/components/candle-chart.d.ts.map +1 -0
- package/dist/components/candle-chart.js +295 -0
- package/dist/components/candle-chart.js.map +1 -0
- package/dist/components/list.d.ts.map +1 -1
- package/dist/components/list.js +3 -0
- package/dist/components/list.js.map +1 -1
- package/dist/components/table.d.ts +2 -0
- package/dist/components/table.d.ts.map +1 -1
- package/dist/components/table.js +41 -4
- package/dist/components/table.js.map +1 -1
- package/dist/examples/simple-candle-chart-data.d.ts +9064 -0
- package/dist/examples/simple-candle-chart-data.d.ts.map +1 -0
- package/dist/examples/simple-candle-chart-data.js +12683 -0
- package/dist/examples/simple-candle-chart-data.js.map +1 -0
- package/dist/examples/simple-candle-chart.d.ts +2 -0
- package/dist/examples/simple-candle-chart.d.ts.map +1 -0
- package/dist/examples/simple-candle-chart.js +125 -0
- package/dist/examples/simple-candle-chart.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/internal/dialog.d.ts +1 -0
- package/dist/internal/dialog.d.ts.map +1 -1
- package/dist/internal/dialog.js +4 -0
- package/dist/internal/dialog.js.map +1 -1
- package/dist/state.d.ts +1 -0
- package/dist/state.d.ts.map +1 -1
- package/dist/state.js.map +1 -1
- package/package.json +1 -1
- package/src/app.tsx +269 -8
- package/src/cli.tsx +5 -5
- package/src/components/candle-chart.tsx +410 -0
- package/src/components/list.tsx +3 -0
- package/src/components/table.tsx +46 -4
- package/src/examples/simple-candle-chart-data.ts +12683 -0
- package/src/examples/simple-candle-chart.tsx +363 -0
- package/src/examples/simple-candle-chart.vitest.tsx +269 -0
- package/src/examples/simple-detail-table.vitest.tsx +2 -2
- package/src/examples/simple-table-wrap.vitest.tsx +19 -19
- package/src/examples/table-flex-grow.vitest.tsx +8 -8
- package/src/index.tsx +7 -0
- package/src/internal/dialog.tsx +5 -0
- package/src/state.tsx +1 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CandleChart component for rendering trading-style OHLC candlestick charts.
|
|
3
|
+
*
|
|
4
|
+
* Uses a custom opentui Renderable (CandleChartRenderable) for direct drawing
|
|
5
|
+
* to OptimizedBuffer via setCell(). Each data point occupies exactly one
|
|
6
|
+
* terminal column, rendering:
|
|
7
|
+
* - Body (open-to-close): left-half block characters (▌/▘/▖) with 2x
|
|
8
|
+
* vertical sub-row resolution
|
|
9
|
+
* - Wick (high-to-low): thin vertical line (│) extending above/below body
|
|
10
|
+
*
|
|
11
|
+
* Color encodes direction:
|
|
12
|
+
* - Green (default): close >= open (bullish)
|
|
13
|
+
* - Red (default): close < open (bearish)
|
|
14
|
+
*
|
|
15
|
+
* Layout reuses the same Y-axis + X-axis label pattern as Graph component.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { useMemo } from 'react'
|
|
19
|
+
import { Renderable, RGBA } from '@opentui/core'
|
|
20
|
+
import type { RenderableOptions, RenderContext } from '@opentui/core'
|
|
21
|
+
import type { OptimizedBuffer } from '@opentui/core'
|
|
22
|
+
import { extend } from '@opentui/react'
|
|
23
|
+
import { useTheme } from 'termcast/src/theme'
|
|
24
|
+
import { Color, resolveColor } from 'termcast/src/colors'
|
|
25
|
+
|
|
26
|
+
// ── Data types ───────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
export interface CandleData {
|
|
29
|
+
/** Price at the start of the period */
|
|
30
|
+
open: number
|
|
31
|
+
/** Price at the end of the period */
|
|
32
|
+
close: number
|
|
33
|
+
/** Highest price during the period */
|
|
34
|
+
high: number
|
|
35
|
+
/** Lowest price during the period */
|
|
36
|
+
low: number
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ── Block characters (same as Graph filled variant) ──────────────────
|
|
40
|
+
// Left-half and quadrant characters give 2x vertical sub-row resolution
|
|
41
|
+
// while keeping each bar 50% cell width for visible gaps between columns.
|
|
42
|
+
const LEFT_HALF = '▌' // U+258C — full height, left 50%
|
|
43
|
+
const QUAD_UL = '▘' // U+2598 — top half, left 50%
|
|
44
|
+
const QUAD_LL = '▖' // U+2596 — bottom half, left 50%
|
|
45
|
+
|
|
46
|
+
// ── CandleChartRenderable ────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
export interface CandleChartPlotOptions extends RenderableOptions {
|
|
49
|
+
candles?: CandleData[]
|
|
50
|
+
xLabels?: string[]
|
|
51
|
+
yMin?: number
|
|
52
|
+
yMax?: number
|
|
53
|
+
yTicks?: number
|
|
54
|
+
yFormat?: (v: number) => string
|
|
55
|
+
axisColor?: string
|
|
56
|
+
upColor?: string // hex color for bullish candles (close >= open)
|
|
57
|
+
downColor?: string // hex color for bearish candles (close < open)
|
|
58
|
+
wickColor?: string // hex color for wick lines
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export class CandleChartRenderable extends Renderable {
|
|
62
|
+
private _candles: CandleData[] = []
|
|
63
|
+
private _xLabels: string[] = []
|
|
64
|
+
private _yMin = 0
|
|
65
|
+
private _yMax = 100
|
|
66
|
+
private _yTicks = 5
|
|
67
|
+
private _yFormat: (v: number) => string = (v) => {
|
|
68
|
+
return v >= 1000 ? v.toFixed(0) : v.toFixed(1)
|
|
69
|
+
}
|
|
70
|
+
private _axisColor: string = '#666666'
|
|
71
|
+
private _upColor: string = '#34EE7F' // Color.Green
|
|
72
|
+
private _downColor: string = '#FF7B7B' // Color.Red
|
|
73
|
+
private _wickColor: string = '#666666'
|
|
74
|
+
|
|
75
|
+
constructor(ctx: RenderContext, options: CandleChartPlotOptions) {
|
|
76
|
+
super(ctx, options)
|
|
77
|
+
if (options.candles) this._candles = options.candles
|
|
78
|
+
if (options.xLabels) this._xLabels = options.xLabels
|
|
79
|
+
if (options.yMin !== undefined) this._yMin = options.yMin
|
|
80
|
+
if (options.yMax !== undefined) this._yMax = options.yMax
|
|
81
|
+
if (options.yTicks !== undefined) this._yTicks = options.yTicks
|
|
82
|
+
if (options.yFormat) this._yFormat = options.yFormat
|
|
83
|
+
if (options.axisColor) this._axisColor = options.axisColor
|
|
84
|
+
if (options.upColor) this._upColor = options.upColor
|
|
85
|
+
if (options.downColor) this._downColor = options.downColor
|
|
86
|
+
if (options.wickColor) this._wickColor = options.wickColor
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
set candles(value: CandleData[]) { this._candles = value; this.requestRender() }
|
|
90
|
+
set xLabels(value: string[]) { this._xLabels = value; this.requestRender() }
|
|
91
|
+
set yMin(value: number) { this._yMin = value; this.requestRender() }
|
|
92
|
+
set yMax(value: number) { this._yMax = value; this.requestRender() }
|
|
93
|
+
set yTicks(value: number) { this._yTicks = value; this.requestRender() }
|
|
94
|
+
set yFormat(value: (v: number) => string) { this._yFormat = value; this.requestRender() }
|
|
95
|
+
set axisColor(value: string) { this._axisColor = value; this.requestRender() }
|
|
96
|
+
set upColor(value: string) { this._upColor = value; this.requestRender() }
|
|
97
|
+
set downColor(value: string) { this._downColor = value; this.requestRender() }
|
|
98
|
+
set wickColor(value: string) { this._wickColor = value; this.requestRender() }
|
|
99
|
+
|
|
100
|
+
// ── Layout: compute plot area and Y labels ─────────────────────
|
|
101
|
+
private computeLayout(): {
|
|
102
|
+
plotX: number; plotY: number; plotW: number; plotH: number
|
|
103
|
+
yAxisWidth: number; yLabels: string[]
|
|
104
|
+
} | null {
|
|
105
|
+
const totalW = this.width
|
|
106
|
+
const totalH = this.height
|
|
107
|
+
if (totalW <= 0 || totalH <= 0) return null
|
|
108
|
+
|
|
109
|
+
const safeTicks = Math.max(2, this._yTicks)
|
|
110
|
+
const yLabels: string[] = []
|
|
111
|
+
for (let i = 0; i < safeTicks; i++) {
|
|
112
|
+
const value = this._yMin + (this._yMax - this._yMin) * (1 - i / (safeTicks - 1))
|
|
113
|
+
yLabels.push(this._yFormat(value))
|
|
114
|
+
}
|
|
115
|
+
const yAxisWidth = Math.max(...yLabels.map((l) => l.length))
|
|
116
|
+
|
|
117
|
+
const plotX = this.x + yAxisWidth + 1
|
|
118
|
+
const plotY = this.y
|
|
119
|
+
const plotH = totalH - 1
|
|
120
|
+
const plotW = totalW - yAxisWidth - 1
|
|
121
|
+
if (plotW <= 0 || plotH <= 0) return null
|
|
122
|
+
|
|
123
|
+
return { plotX, plotY, plotW, plotH, yAxisWidth, yLabels }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── Axes: Y labels + separator, X labels ──────────────────────
|
|
127
|
+
private drawAxes(buffer: OptimizedBuffer, layout: {
|
|
128
|
+
plotX: number; plotY: number; plotW: number; plotH: number
|
|
129
|
+
yAxisWidth: number; yLabels: string[]
|
|
130
|
+
}): void {
|
|
131
|
+
const { plotX, plotY, plotW, plotH, yAxisWidth, yLabels } = layout
|
|
132
|
+
const axisRgba = RGBA.fromHex(this._axisColor)
|
|
133
|
+
|
|
134
|
+
// Y-axis labels + separator
|
|
135
|
+
const safeTicks = Math.max(2, this._yTicks)
|
|
136
|
+
const labelRows = new Set<number>()
|
|
137
|
+
for (let i = 0; i < safeTicks; i++) {
|
|
138
|
+
const row = Math.round(plotY + (i / (safeTicks - 1)) * (plotH - 1))
|
|
139
|
+
labelRows.add(row)
|
|
140
|
+
const label = yLabels[i]!
|
|
141
|
+
buffer.drawText(label, this.x + yAxisWidth - label.length, row, axisRgba)
|
|
142
|
+
buffer.drawText('│', this.x + yAxisWidth, row, axisRgba)
|
|
143
|
+
}
|
|
144
|
+
for (let row = plotY; row < plotY + plotH; row++) {
|
|
145
|
+
if (!labelRows.has(row)) {
|
|
146
|
+
buffer.drawText('│', this.x + yAxisWidth, row, axisRgba)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// X-axis labels
|
|
151
|
+
if (this._xLabels.length > 0) {
|
|
152
|
+
const xAxisRow = plotY + plotH
|
|
153
|
+
const labelCount = this._xLabels.length
|
|
154
|
+
for (let i = 0; i < labelCount; i++) {
|
|
155
|
+
const label = this._xLabels[i]!
|
|
156
|
+
const labelX = plotX + Math.round((i / Math.max(1, labelCount - 1)) * (plotW - 1))
|
|
157
|
+
const centeredX = Math.max(plotX, Math.min(labelX - Math.floor(label.length / 2), plotX + plotW - label.length))
|
|
158
|
+
buffer.drawText(label, centeredX, xAxisRow, axisRgba)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Convert a price value to a virtual sub-row position ────────
|
|
164
|
+
// Returns a float in [0, virtualH-1] where 0 = top (yMax), virtualH-1 = bottom (yMin)
|
|
165
|
+
private priceToVRow({ price, virtualH }: { price: number; virtualH: number }): number {
|
|
166
|
+
const yRange = this._yMax - this._yMin
|
|
167
|
+
if (yRange === 0) return 0
|
|
168
|
+
const normalized = (price - this._yMin) / yRange
|
|
169
|
+
return (1 - normalized) * (virtualH - 1)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Aggregate candles into column buckets ───────────────────────
|
|
173
|
+
// When data.length > plotW, multiple candles share a column. We bucket
|
|
174
|
+
// them with standard OHLC aggregation: open=first.open, close=last.close,
|
|
175
|
+
// high=max(highs), low=min(lows). When data.length <= plotW, candles map
|
|
176
|
+
// 1:1 to contiguous columns (right-aligned so latest data is at the right edge).
|
|
177
|
+
private aggregateToColumns({ plotW }: { plotW: number }): CandleData[] {
|
|
178
|
+
const candles = this._candles
|
|
179
|
+
if (candles.length === 0) return []
|
|
180
|
+
|
|
181
|
+
if (candles.length <= plotW) {
|
|
182
|
+
// 1:1 mapping — return as-is, caller will right-align
|
|
183
|
+
return candles
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Bucket candles into plotW columns
|
|
187
|
+
const result: CandleData[] = []
|
|
188
|
+
for (let col = 0; col < plotW; col++) {
|
|
189
|
+
const start = Math.floor((col * candles.length) / plotW)
|
|
190
|
+
const end = Math.floor(((col + 1) * candles.length) / plotW)
|
|
191
|
+
const slice = candles.slice(start, Math.max(start + 1, end))
|
|
192
|
+
const first = slice[0]!
|
|
193
|
+
const last = slice[slice.length - 1]!
|
|
194
|
+
result.push({
|
|
195
|
+
open: first.open,
|
|
196
|
+
close: last.close,
|
|
197
|
+
high: Math.max(...slice.map((c) => c.high)),
|
|
198
|
+
low: Math.min(...slice.map((c) => c.low)),
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
return result
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Render candles ─────────────────────────────────────────────
|
|
205
|
+
// Each candle occupies exactly one terminal column. Uses 2x vertical
|
|
206
|
+
// sub-row resolution (same as Graph filled variant).
|
|
207
|
+
//
|
|
208
|
+
// For each column:
|
|
209
|
+
// 1. Map OHLC to virtual Y positions
|
|
210
|
+
// 2. Draw wick (│) from high to low, excluding body range
|
|
211
|
+
// 3. Draw body (▌/▘/▖) from min(open,close) to max(open,close)
|
|
212
|
+
// 4. Color: green if close >= open, red otherwise
|
|
213
|
+
private renderCandles(buffer: OptimizedBuffer, plotX: number, plotY: number, plotW: number, plotH: number): void {
|
|
214
|
+
const transparent = RGBA.fromValues(0, 0, 0, 0)
|
|
215
|
+
const virtualH = plotH * 2 // 2 sub-rows per terminal row
|
|
216
|
+
const yRange = this._yMax - this._yMin
|
|
217
|
+
if (yRange === 0) return
|
|
218
|
+
|
|
219
|
+
const upRgba = RGBA.fromHex(this._upColor)
|
|
220
|
+
const downRgba = RGBA.fromHex(this._downColor)
|
|
221
|
+
const wickRgba = RGBA.fromHex(this._wickColor)
|
|
222
|
+
|
|
223
|
+
const columns = this.aggregateToColumns({ plotW })
|
|
224
|
+
if (columns.length === 0) return
|
|
225
|
+
|
|
226
|
+
// Right-align: latest candles at the right edge of the plot.
|
|
227
|
+
// When fewer candles than columns, offset pushes them rightward.
|
|
228
|
+
const offset = plotW - columns.length
|
|
229
|
+
|
|
230
|
+
for (let i = 0; i < columns.length; i++) {
|
|
231
|
+
const candle = columns[i]!
|
|
232
|
+
const col = offset + i
|
|
233
|
+
|
|
234
|
+
if (col < 0 || col >= plotW) continue
|
|
235
|
+
|
|
236
|
+
const isBullish = candle.close >= candle.open
|
|
237
|
+
const bodyColor = isBullish ? upRgba : downRgba
|
|
238
|
+
|
|
239
|
+
// Convert prices to virtual sub-row positions (0 = top, virtualH-1 = bottom)
|
|
240
|
+
const highVRow = Math.round(this.priceToVRow({ price: candle.high, virtualH }))
|
|
241
|
+
const lowVRow = Math.round(this.priceToVRow({ price: candle.low, virtualH }))
|
|
242
|
+
const openVRow = Math.round(this.priceToVRow({ price: candle.open, virtualH }))
|
|
243
|
+
const closeVRow = Math.round(this.priceToVRow({ price: candle.close, virtualH }))
|
|
244
|
+
|
|
245
|
+
const bodyTopVRow = Math.min(openVRow, closeVRow)
|
|
246
|
+
const bodyBotVRow = Math.max(openVRow, closeVRow)
|
|
247
|
+
|
|
248
|
+
// Clamp all values to valid range
|
|
249
|
+
const wickTop = Math.max(0, Math.min(highVRow, virtualH - 1))
|
|
250
|
+
const wickBot = Math.max(0, Math.min(lowVRow, virtualH - 1))
|
|
251
|
+
const bodyTop = Math.max(0, Math.min(bodyTopVRow, virtualH - 1))
|
|
252
|
+
const bodyBot = Math.max(0, Math.min(bodyBotVRow, virtualH - 1))
|
|
253
|
+
|
|
254
|
+
// Draw each terminal row for this column
|
|
255
|
+
for (let row = 0; row < plotH; row++) {
|
|
256
|
+
const vTop = row * 2 // virtual sub-row for top half of this terminal row
|
|
257
|
+
const vBot = row * 2 + 1 // virtual sub-row for bottom half
|
|
258
|
+
|
|
259
|
+
// Check what each sub-row contains: body, wick, or nothing
|
|
260
|
+
const topIsBody = vTop >= bodyTop && vTop <= bodyBot
|
|
261
|
+
const botIsBody = vBot >= bodyTop && vBot <= bodyBot
|
|
262
|
+
const topIsWick = !topIsBody && vTop >= wickTop && vTop <= wickBot
|
|
263
|
+
const botIsWick = !botIsBody && vBot >= wickTop && vBot <= wickBot
|
|
264
|
+
|
|
265
|
+
if (topIsBody || botIsBody) {
|
|
266
|
+
// Body takes priority — draw block character
|
|
267
|
+
if (topIsBody && botIsBody) {
|
|
268
|
+
buffer.setCell(plotX + col, plotY + row, LEFT_HALF, bodyColor, transparent)
|
|
269
|
+
} else if (topIsBody) {
|
|
270
|
+
// Top is body, bottom might be wick
|
|
271
|
+
buffer.setCell(plotX + col, plotY + row, QUAD_UL, bodyColor, transparent)
|
|
272
|
+
} else {
|
|
273
|
+
// Bottom is body, top might be wick
|
|
274
|
+
buffer.setCell(plotX + col, plotY + row, QUAD_LL, bodyColor, transparent)
|
|
275
|
+
}
|
|
276
|
+
} else if (topIsWick || botIsWick) {
|
|
277
|
+
// Only wick in this terminal row — draw thin line
|
|
278
|
+
buffer.setCell(plotX + col, plotY + row, '│', wickRgba, transparent)
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Handle doji case: when open == close, body is a single sub-row line.
|
|
283
|
+
// If bodyTop == bodyBot, ensure at least one cell is drawn with body color.
|
|
284
|
+
if (bodyTop === bodyBot) {
|
|
285
|
+
const row = Math.floor(bodyTop / 2)
|
|
286
|
+
const subRow = bodyTop % 2
|
|
287
|
+
if (row >= 0 && row < plotH) {
|
|
288
|
+
const char = subRow === 0 ? QUAD_UL : QUAD_LL
|
|
289
|
+
buffer.setCell(plotX + col, plotY + row, char, bodyColor, transparent)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// ── Main render ────────────────────────────────────────────────
|
|
296
|
+
protected renderSelf(buffer: OptimizedBuffer): void {
|
|
297
|
+
const layout = this.computeLayout()
|
|
298
|
+
if (!layout) return
|
|
299
|
+
|
|
300
|
+
this.drawAxes(buffer, layout)
|
|
301
|
+
|
|
302
|
+
const yRange = this._yMax - this._yMin
|
|
303
|
+
if (yRange === 0 || this._candles.length === 0) return
|
|
304
|
+
|
|
305
|
+
const { plotX, plotY, plotW, plotH } = layout
|
|
306
|
+
this.renderCandles(buffer, plotX, plotY, plotW, plotH)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// ── Register the custom renderable ───────────────────────────────────
|
|
311
|
+
|
|
312
|
+
extend({ 'candle-chart-plot': CandleChartRenderable })
|
|
313
|
+
|
|
314
|
+
declare module '@opentui/react' {
|
|
315
|
+
interface OpenTUIComponents {
|
|
316
|
+
'candle-chart-plot': typeof CandleChartRenderable
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── CandleChart React component ──────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
export interface CandleChartProps {
|
|
323
|
+
/** OHLC candle data. Each entry maps to one terminal column. When there
|
|
324
|
+
* are more candles than available columns, adjacent candles are aggregated
|
|
325
|
+
* into OHLC buckets (open=first, close=last, high=max, low=min). */
|
|
326
|
+
data: CandleData[]
|
|
327
|
+
/** Height of the plot area in terminal rows. The total rendered height is
|
|
328
|
+
* this value plus one extra row for X-axis labels (if provided). Default: 15. */
|
|
329
|
+
height?: number
|
|
330
|
+
/** Labels displayed along the X-axis below the chart, evenly spaced
|
|
331
|
+
* from left to right (e.g. `['12d', '8d', '4d', 'Now']`). */
|
|
332
|
+
xLabels?: string[]
|
|
333
|
+
/** Explicit Y-axis range as `[min, max]`. When omitted the range is
|
|
334
|
+
* auto-computed from the data's lowest `low` and highest `high` values
|
|
335
|
+
* with 5% padding so candles don't touch the top/bottom edges. */
|
|
336
|
+
yRange?: [number, number]
|
|
337
|
+
/** Number of evenly spaced tick labels drawn along the Y-axis. Controls
|
|
338
|
+
* how many price labels appear on the left side of the chart. Default: 5.
|
|
339
|
+
* Minimum effective value is 2 (top and bottom). */
|
|
340
|
+
yTicks?: number
|
|
341
|
+
/** Formatter for Y-axis tick labels. Receives the numeric price value and
|
|
342
|
+
* should return a display string (e.g. `(v) => '$' + v.toFixed(0)`).
|
|
343
|
+
* Default: values >= 1000 use `.toFixed(0)`, otherwise `.toFixed(1)`. */
|
|
344
|
+
yFormat?: (v: number) => string
|
|
345
|
+
/** Color for bullish candles where close >= open. Default: Color.Green. */
|
|
346
|
+
upColor?: Color.ColorLike
|
|
347
|
+
/** Color for bearish candles where close < open. Default: Color.Red. */
|
|
348
|
+
downColor?: Color.ColorLike
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
interface CandleChartType {
|
|
352
|
+
(props: CandleChartProps): any
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const CandleChart: CandleChartType = (props) => {
|
|
356
|
+
const theme = useTheme()
|
|
357
|
+
const {
|
|
358
|
+
data,
|
|
359
|
+
height = 15,
|
|
360
|
+
xLabels = [],
|
|
361
|
+
yRange,
|
|
362
|
+
yTicks = 5,
|
|
363
|
+
yFormat,
|
|
364
|
+
upColor,
|
|
365
|
+
downColor,
|
|
366
|
+
} = props
|
|
367
|
+
|
|
368
|
+
const resolvedUpColor = resolveColor(upColor) || Color.Green
|
|
369
|
+
const resolvedDownColor = resolveColor(downColor) || Color.Red
|
|
370
|
+
|
|
371
|
+
// Auto-compute Y range from data high/low if not provided
|
|
372
|
+
const computedYRange = useMemo<[number, number]>(() => {
|
|
373
|
+
if (yRange) return yRange
|
|
374
|
+
let min = Infinity
|
|
375
|
+
let max = -Infinity
|
|
376
|
+
for (const candle of data) {
|
|
377
|
+
if (candle.low < min) min = candle.low
|
|
378
|
+
if (candle.high > max) max = candle.high
|
|
379
|
+
}
|
|
380
|
+
if (min === Infinity) return [0, 100]
|
|
381
|
+
// Add padding so candles don't touch edges.
|
|
382
|
+
// When max === min (flat data), use absolute padding to avoid zero range.
|
|
383
|
+
const padding = max === min
|
|
384
|
+
? Math.max(Math.abs(max) * 0.01, 1)
|
|
385
|
+
: (max - min) * 0.05
|
|
386
|
+
return [min - padding, max + padding]
|
|
387
|
+
}, [data, yRange])
|
|
388
|
+
|
|
389
|
+
// Total height = plot rows + 1 for x-axis labels
|
|
390
|
+
const totalHeight = height + (xLabels.length > 0 ? 1 : 0)
|
|
391
|
+
|
|
392
|
+
return (
|
|
393
|
+
<candle-chart-plot
|
|
394
|
+
width="100%"
|
|
395
|
+
height={totalHeight}
|
|
396
|
+
candles={data}
|
|
397
|
+
xLabels={xLabels}
|
|
398
|
+
yMin={computedYRange[0]}
|
|
399
|
+
yMax={computedYRange[1]}
|
|
400
|
+
yTicks={yTicks}
|
|
401
|
+
yFormat={yFormat}
|
|
402
|
+
axisColor={theme.textMuted}
|
|
403
|
+
upColor={resolvedUpColor}
|
|
404
|
+
downColor={resolvedDownColor}
|
|
405
|
+
wickColor={theme.textMuted}
|
|
406
|
+
/>
|
|
407
|
+
)
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
export { CandleChart }
|
package/src/components/list.tsx
CHANGED
package/src/components/table.tsx
CHANGED
|
@@ -159,6 +159,33 @@ export class TableRenderable extends Renderable {
|
|
|
159
159
|
return new StyledText(styledChunks)
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
private getCellContentWidth(content: TableCellContent): number {
|
|
163
|
+
return this.toStyledText(content).chunks.reduce((width, chunk) => {
|
|
164
|
+
return width + chunk.text.length
|
|
165
|
+
}, 0)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private getColumnWidths({ colCount }: { colCount: number }): number[] {
|
|
169
|
+
const minColumnWidth = 3
|
|
170
|
+
const maxColumnWidth = 32
|
|
171
|
+
const headerWidths = Array.from({ length: colCount }, (_, col) => {
|
|
172
|
+
const headerContent = this._headers[col] ?? ''
|
|
173
|
+
return this.getCellContentWidth(headerContent) + 2
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
const rowWidths = this._rows.reduce((widths, row) => {
|
|
177
|
+
return widths.map((currentWidth, col) => {
|
|
178
|
+
const cellContent = row[col] ?? ''
|
|
179
|
+
const cellWidth = this.getCellContentWidth(cellContent) + 2
|
|
180
|
+
return Math.max(currentWidth, cellWidth)
|
|
181
|
+
})
|
|
182
|
+
}, headerWidths)
|
|
183
|
+
|
|
184
|
+
return rowWidths.map((width) => {
|
|
185
|
+
return Math.min(maxColumnWidth, Math.max(minColumnWidth, width))
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
|
|
162
189
|
private rebuild(): void {
|
|
163
190
|
// Remove all existing children (copy array since remove mutates it)
|
|
164
191
|
const children = [...(this as any)._childrenInLayoutOrder] as Renderable[]
|
|
@@ -260,6 +287,8 @@ export class TableRenderable extends Renderable {
|
|
|
260
287
|
headerFg: StyleDefinition['fg'],
|
|
261
288
|
stripeBg: StyleDefinition['fg'],
|
|
262
289
|
): void {
|
|
290
|
+
const columnWidths = this.getColumnWidths({ colCount })
|
|
291
|
+
|
|
263
292
|
if (this._headers.length > 0) {
|
|
264
293
|
const headerRow = new BoxRenderable(this.ctx, {
|
|
265
294
|
id: `${this.id}-header-row`,
|
|
@@ -267,6 +296,7 @@ export class TableRenderable extends Renderable {
|
|
|
267
296
|
backgroundColor: headerBg,
|
|
268
297
|
})
|
|
269
298
|
for (let col = 0; col < colCount; col++) {
|
|
299
|
+
const columnWidth = columnWidths[col] ?? 1
|
|
270
300
|
const headerContent = this._headers[col] ?? ''
|
|
271
301
|
let headerStyledText = this.toStyledText(headerContent)
|
|
272
302
|
headerStyledText = this.styledHeaderChunks(headerStyledText, headingStyle, headerFg)
|
|
@@ -275,8 +305,9 @@ export class TableRenderable extends Renderable {
|
|
|
275
305
|
new TextRenderable(this.ctx, {
|
|
276
306
|
id: `${this.id}-header-${col}`,
|
|
277
307
|
content: headerStyledText,
|
|
278
|
-
flexGrow:
|
|
279
|
-
|
|
308
|
+
flexGrow: columnWidth,
|
|
309
|
+
flexShrink: 1,
|
|
310
|
+
flexBasis: columnWidth,
|
|
280
311
|
paddingLeft: 1,
|
|
281
312
|
paddingRight: 1,
|
|
282
313
|
}),
|
|
@@ -294,6 +325,7 @@ export class TableRenderable extends Renderable {
|
|
|
294
325
|
})
|
|
295
326
|
|
|
296
327
|
for (let col = 0; col < colCount; col++) {
|
|
328
|
+
const columnWidth = columnWidths[col] ?? 1
|
|
297
329
|
const cell = this._rows[row]?.[col] ?? ''
|
|
298
330
|
const cellContent = this.toStyledText(cell)
|
|
299
331
|
|
|
@@ -301,8 +333,9 @@ export class TableRenderable extends Renderable {
|
|
|
301
333
|
new TextRenderable(this.ctx, {
|
|
302
334
|
id: `${this.id}-row-${row}-col-${col}`,
|
|
303
335
|
content: cellContent,
|
|
304
|
-
flexGrow:
|
|
305
|
-
|
|
336
|
+
flexGrow: columnWidth,
|
|
337
|
+
flexShrink: 1,
|
|
338
|
+
flexBasis: columnWidth,
|
|
306
339
|
paddingLeft: 1,
|
|
307
340
|
paddingRight: 1,
|
|
308
341
|
}),
|
|
@@ -384,6 +417,7 @@ export class TableRenderable extends Renderable {
|
|
|
384
417
|
const allRows = (this as any)._childrenInLayoutOrder as Renderable[]
|
|
385
418
|
const colCount = this._headers.length || this._rows[0]?.length || 0
|
|
386
419
|
const hasHeaders = this._headers.length > 0
|
|
420
|
+
const columnWidths = this.getColumnWidths({ colCount })
|
|
387
421
|
|
|
388
422
|
if (hasHeaders) {
|
|
389
423
|
const headerRow = allRows[0]
|
|
@@ -391,11 +425,15 @@ export class TableRenderable extends Renderable {
|
|
|
391
425
|
headerRow.backgroundColor = headerBg ?? 'transparent'
|
|
392
426
|
const headerCells = (headerRow as any)._childrenInLayoutOrder as Renderable[]
|
|
393
427
|
for (let col = 0; col < colCount; col++) {
|
|
428
|
+
const columnWidth = columnWidths[col] ?? 1
|
|
394
429
|
const headerText = headerCells[col]
|
|
395
430
|
if (headerText instanceof TextRenderable) {
|
|
396
431
|
const headerContent = this._headers[col] ?? ''
|
|
397
432
|
let headerStyledText = this.toStyledText(headerContent)
|
|
398
433
|
headerStyledText = this.styledHeaderChunks(headerStyledText, headingStyle, headerFg)
|
|
434
|
+
headerText.flexGrow = columnWidth
|
|
435
|
+
headerText.flexShrink = 1
|
|
436
|
+
headerText.flexBasis = columnWidth
|
|
399
437
|
headerText.content = headerStyledText
|
|
400
438
|
}
|
|
401
439
|
}
|
|
@@ -412,9 +450,13 @@ export class TableRenderable extends Renderable {
|
|
|
412
450
|
|
|
413
451
|
const rowCells = (rowBox as any)._childrenInLayoutOrder as Renderable[]
|
|
414
452
|
for (let col = 0; col < colCount; col++) {
|
|
453
|
+
const columnWidth = columnWidths[col] ?? 1
|
|
415
454
|
const cellText = rowCells[col]
|
|
416
455
|
if (cellText instanceof TextRenderable) {
|
|
417
456
|
const cell = this._rows[row]?.[col] ?? ''
|
|
457
|
+
cellText.flexGrow = columnWidth
|
|
458
|
+
cellText.flexShrink = 1
|
|
459
|
+
cellText.flexBasis = columnWidth
|
|
418
460
|
cellText.content = this.toStyledText(cell)
|
|
419
461
|
}
|
|
420
462
|
}
|