termcast 1.5.0 → 1.7.0
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/build.d.ts.map +1 -1
- package/dist/build.js +22 -5
- package/dist/build.js.map +1 -1
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +7 -1
- package/dist/compile.js.map +1 -1
- package/dist/components/bar-chart.d.ts.map +1 -1
- package/dist/components/bar-chart.js +14 -3
- package/dist/components/bar-chart.js.map +1 -1
- package/dist/components/bar-graph.d.ts +4 -4
- package/dist/components/bar-graph.d.ts.map +1 -1
- package/dist/components/bar-graph.js +23 -5
- package/dist/components/bar-graph.js.map +1 -1
- package/dist/components/candle-chart.d.ts +15 -0
- package/dist/components/candle-chart.d.ts.map +1 -1
- package/dist/components/candle-chart.js +41 -3
- package/dist/components/candle-chart.js.map +1 -1
- package/dist/components/chart-tooltip.d.ts +83 -0
- package/dist/components/chart-tooltip.d.ts.map +1 -0
- package/dist/components/chart-tooltip.js +127 -0
- package/dist/components/chart-tooltip.js.map +1 -0
- package/dist/components/dotted-line-graph.d.ts +11 -0
- package/dist/components/dotted-line-graph.d.ts.map +1 -1
- package/dist/components/dotted-line-graph.js +43 -2
- package/dist/components/dotted-line-graph.js.map +1 -1
- package/dist/components/graph.d.ts +11 -0
- package/dist/components/graph.d.ts.map +1 -1
- package/dist/components/graph.js +53 -4
- package/dist/components/graph.js.map +1 -1
- package/dist/components/horizontal-bar-graph.d.ts.map +1 -1
- package/dist/components/horizontal-bar-graph.js +16 -5
- package/dist/components/horizontal-bar-graph.js.map +1 -1
- package/dist/components/list.d.ts +7 -0
- package/dist/components/list.d.ts.map +1 -1
- package/dist/components/list.js +75 -14
- package/dist/components/list.js.map +1 -1
- package/dist/examples/chart-tooltips.d.ts +2 -0
- package/dist/examples/chart-tooltips.d.ts.map +1 -0
- package/dist/examples/chart-tooltips.js +16 -0
- package/dist/examples/chart-tooltips.js.map +1 -0
- package/dist/examples/list-detail-height-ratchet.d.ts +2 -0
- package/dist/examples/list-detail-height-ratchet.d.ts.map +1 -0
- package/dist/examples/list-detail-height-ratchet.js +26 -0
- package/dist/examples/list-detail-height-ratchet.js.map +1 -0
- package/dist/extensions/dev.d.ts.map +1 -1
- package/dist/extensions/dev.js +1 -0
- package/dist/extensions/dev.js.map +1 -1
- package/dist/globals.js +8 -0
- package/dist/globals.js.map +1 -1
- package/dist/package-json.d.ts +2 -0
- package/dist/package-json.d.ts.map +1 -1
- package/dist/package-json.js +20 -17
- package/dist/package-json.js.map +1 -1
- package/dist/profiler.d.ts +2 -0
- package/dist/profiler.d.ts.map +1 -0
- package/dist/profiler.js +390 -0
- package/dist/profiler.js.map +1 -0
- package/package.json +14 -15
- package/src/build.tsx +27 -5
- package/src/cli.tsx +0 -0
- package/src/compile.tsx +9 -1
- package/src/compile.vitest.tsx +8 -8
- package/src/components/bar-chart.tsx +23 -3
- package/src/components/bar-graph.tsx +32 -13
- package/src/components/candle-chart.tsx +63 -16
- package/src/components/chart-tooltip.tsx +191 -0
- package/src/components/dotted-line-graph.tsx +49 -3
- package/src/components/graph.tsx +76 -18
- package/src/components/horizontal-bar-graph.tsx +24 -4
- package/src/components/list.tsx +93 -20
- package/src/examples/action-shortcut.vitest.tsx +4 -4
- package/src/examples/actions-context.vitest.tsx +2 -2
- package/src/examples/bar-graph-weekly.vitest.tsx +97 -97
- package/src/examples/chart-tooltips.tsx +54 -0
- package/src/examples/form-basic.vitest.tsx +8 -8
- package/src/examples/github.vitest.tsx +19 -28
- package/src/examples/graph-bar-chart.vitest.tsx +40 -40
- package/src/examples/graph-polymarket.vitest.tsx +24 -24
- package/src/examples/graph-row.vitest.tsx +8 -8
- package/src/examples/graph-styles.vitest.tsx +65 -65
- package/src/examples/horizontal-bar-graph-weekly.vitest.tsx +52 -52
- package/src/examples/list-detail-height-ratchet.tsx +48 -0
- package/src/examples/list-detail-height-ratchet.vitest.tsx +161 -0
- package/src/examples/list-detail-metadata.vitest.tsx +49 -49
- package/src/examples/list-dropdown-default.vitest.tsx +27 -27
- package/src/examples/list-fetch-data.vitest.tsx +3 -3
- package/src/examples/list-item-accessories.vitest.tsx +2 -2
- package/src/examples/list-loading-empty-view.vitest.tsx +1 -1
- package/src/examples/list-no-actions.vitest.tsx +3 -3
- package/src/examples/list-scrollbox.vitest.tsx +6 -6
- package/src/examples/list-spacing-mode.vitest.tsx +3 -3
- package/src/examples/list-with-detail.vitest.tsx +11 -11
- package/src/examples/list-with-dropdown.vitest.tsx +7 -7
- package/src/examples/list-with-sections.vitest.tsx +32 -32
- package/src/examples/list-with-toast.vitest.tsx +4 -4
- package/src/examples/simple-candle-chart.vitest.tsx +63 -61
- package/src/examples/simple-grid.vitest.tsx +13 -13
- package/src/examples/simple-navigation.vitest.tsx +25 -25
- package/src/examples/simple-progress-bar.vitest.tsx +8 -8
- package/src/examples/swift-extension.vitest.tsx +3 -3
- package/src/examples/toast-action.vitest.tsx +4 -4
- package/src/extensions/dev.tsx +2 -1
- package/src/extensions/dev.vitest.tsx +17 -17
- package/src/globals.ts +9 -0
- package/src/package-json.tsx +24 -23
- package/src/profiler.tsx +487 -0
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Pure React/opentui implementation using <box> elements with justifyContent
|
|
5
5
|
* "space-evenly" for bar distribution. Each bar is a column of stacked colored
|
|
6
6
|
* segments sized via flexGrow. Segments render with a thin lower-block glyph
|
|
7
|
-
*
|
|
7
|
+
* using full-block glyphs for solid, gap-free columns.
|
|
8
8
|
* Y-axis labels render on the left. X-axis labels sit below each bar, truncated with
|
|
9
9
|
* overflow="hidden" when the bar is narrower than the label text.
|
|
10
10
|
*
|
|
@@ -14,10 +14,12 @@
|
|
|
14
14
|
* Color palette comes from getThemePalette() and cycles with %.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import React, { ReactNode, useMemo } from 'react'
|
|
17
|
+
import React, { ReactNode, useMemo, useRef } from 'react'
|
|
18
18
|
import { BoxProps } from '@opentui/react'
|
|
19
|
+
import type { MouseEvent as OpenTUIMouseEvent } from '@opentui/core'
|
|
19
20
|
import { useTheme, getThemePalette } from 'termcast/src/theme'
|
|
20
21
|
import { Color, resolveColor } from 'termcast/src/colors'
|
|
22
|
+
import { ChartTooltip, useChartTooltip, interpolateXLabel, formatTooltipLine } from 'termcast/src/components/chart-tooltip'
|
|
21
23
|
|
|
22
24
|
// ── Types ────────────────────────────────────────────────────────────
|
|
23
25
|
|
|
@@ -35,11 +37,11 @@ export interface BarGraphProps extends BoxProps {
|
|
|
35
37
|
height?: number
|
|
36
38
|
/** X-axis labels, one per bar position */
|
|
37
39
|
labels?: string[]
|
|
38
|
-
/** Width of each bar in terminal columns (default:
|
|
40
|
+
/** Width of each bar in terminal columns (default: 2) */
|
|
39
41
|
barWidth?: number
|
|
40
|
-
/** Gap between bars in terminal columns (default:
|
|
42
|
+
/** Gap between bars in terminal columns (default: 2) */
|
|
41
43
|
barGap?: number
|
|
42
|
-
/** Character used for bar cells (default: "
|
|
44
|
+
/** Character used for bar cells (default: "█") */
|
|
43
45
|
barCharacter?: string
|
|
44
46
|
/** Show Y-axis labels and separator (default: true) */
|
|
45
47
|
showYAxis?: boolean
|
|
@@ -73,9 +75,9 @@ const BarGraph: {
|
|
|
73
75
|
const {
|
|
74
76
|
height = 15,
|
|
75
77
|
labels = [],
|
|
76
|
-
barWidth =
|
|
77
|
-
barGap =
|
|
78
|
-
barCharacter = '
|
|
78
|
+
barWidth = 2,
|
|
79
|
+
barGap = 2,
|
|
80
|
+
barCharacter = '█',
|
|
79
81
|
showYAxis = true,
|
|
80
82
|
yTicks = 5,
|
|
81
83
|
yFormat,
|
|
@@ -86,6 +88,8 @@ const BarGraph: {
|
|
|
86
88
|
} = props
|
|
87
89
|
|
|
88
90
|
const palette = getThemePalette(theme)
|
|
91
|
+
const containerRef = useRef<any>(null)
|
|
92
|
+
const { tooltip, show: showTooltip, hide: hideTooltip } = useChartTooltip()
|
|
89
93
|
|
|
90
94
|
// Collect series from children
|
|
91
95
|
const seriesList = useMemo<Array<{ data: number[]; color: string; title?: string }>>(() => {
|
|
@@ -187,11 +191,12 @@ const BarGraph: {
|
|
|
187
191
|
}
|
|
188
192
|
|
|
189
193
|
return (
|
|
190
|
-
<box flexDirection={legendOnRight ? 'row' : 'column'} {...rest}>
|
|
194
|
+
<box ref={containerRef} flexDirection={legendOnRight ? 'row' : 'column'} {...rest} onMouseOut={hideTooltip}>
|
|
195
|
+
<ChartTooltip tooltip={tooltip} containerRef={containerRef} />
|
|
191
196
|
<box flexDirection="column" flexGrow={1} flexShrink={1} overflow="hidden">
|
|
192
197
|
<box flexDirection="row" height={height} width="100%" alignItems="flex-start" overflow="hidden">
|
|
193
198
|
{showYAxis ? (
|
|
194
|
-
<box flexDirection="column" width={yAxisWidth + 1} height={height} flexShrink={0} overflow="hidden">
|
|
199
|
+
<box flexDirection="column" width={yAxisWidth + 1} height={height} flexShrink={0} overflow="hidden" onMouseMove={hideTooltip}>
|
|
195
200
|
{Array.from({ length: plotHeight }, (_, row) => {
|
|
196
201
|
const label = yAxisLabelByRow.get(row) || ''
|
|
197
202
|
return (
|
|
@@ -217,13 +222,27 @@ const BarGraph: {
|
|
|
217
222
|
|
|
218
223
|
return (
|
|
219
224
|
<React.Fragment key={barIdx}>
|
|
220
|
-
{barIdx > 0 && safeBarGap > 0 ? <box width={safeBarGap} flexShrink={0} /> : null}
|
|
225
|
+
{barIdx > 0 && safeBarGap > 0 ? <box width={safeBarGap} flexShrink={0} onMouseMove={hideTooltip} /> : null}
|
|
221
226
|
<box
|
|
222
227
|
flexDirection="column"
|
|
223
228
|
height="100%"
|
|
224
229
|
flexGrow={0}
|
|
225
230
|
flexShrink={0}
|
|
226
231
|
width={safeBarWidth}
|
|
232
|
+
onMouseMove={(evt: OpenTUIMouseEvent) => {
|
|
233
|
+
const lines: string[] = []
|
|
234
|
+
const label = interpolateXLabel({ dataIndex: barIdx, dataLength: numBars, xLabels: labels })
|
|
235
|
+
for (const series of seriesList) {
|
|
236
|
+
const value = series.data[barIdx] || 0
|
|
237
|
+
if (value <= 0) continue
|
|
238
|
+
const name = series.title || label
|
|
239
|
+
lines.push(formatTooltipLine(name, value))
|
|
240
|
+
}
|
|
241
|
+
if (lines.length === 0) return
|
|
242
|
+
// Always show x-axis label as the header line
|
|
243
|
+
lines.unshift(label)
|
|
244
|
+
showTooltip({ x: evt.x, y: evt.y, lines })
|
|
245
|
+
}}
|
|
227
246
|
>
|
|
228
247
|
{/* Plot area: spacer on top pushes colored segments to the bottom
|
|
229
248
|
so all bars are bottom-aligned regardless of total value. */}
|
|
@@ -232,8 +251,8 @@ const BarGraph: {
|
|
|
232
251
|
<box flexGrow={emptyGrow} />
|
|
233
252
|
)}
|
|
234
253
|
{/* Segments: last series at top, first at bottom. The repeated
|
|
235
|
-
|
|
236
|
-
|
|
254
|
+
full-block glyph wraps inside the fixed-width segment, filling
|
|
255
|
+
the entire cell for solid, gap-free bars. */}
|
|
237
256
|
{[...seriesList].reverse().map((series, reverseIdx) => {
|
|
238
257
|
const value = series.data[barIdx] || 0
|
|
239
258
|
if (value <= 0) {
|
|
@@ -15,13 +15,14 @@
|
|
|
15
15
|
* Layout reuses the same Y-axis + X-axis label pattern as Graph component.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import { useMemo } from 'react'
|
|
18
|
+
import { useMemo, useRef } from 'react'
|
|
19
19
|
import { Renderable, RGBA } from '@opentui/core'
|
|
20
|
-
import type { RenderableOptions, RenderContext } from '@opentui/core'
|
|
20
|
+
import type { RenderableOptions, RenderContext, MouseEvent as OpenTUIMouseEvent } from '@opentui/core'
|
|
21
21
|
import type { OptimizedBuffer } from '@opentui/core'
|
|
22
22
|
import { extend } from '@opentui/react'
|
|
23
23
|
import { useTheme } from 'termcast/src/theme'
|
|
24
24
|
import { Color, resolveColor } from 'termcast/src/colors'
|
|
25
|
+
import { ChartTooltip, useChartTooltip, interpolateXLabel, formatTooltipLine } from 'termcast/src/components/chart-tooltip'
|
|
25
26
|
|
|
26
27
|
// ── Data types ───────────────────────────────────────────────────────
|
|
27
28
|
|
|
@@ -97,6 +98,15 @@ export class CandleChartRenderable extends Renderable {
|
|
|
97
98
|
set downColor(value: string) { this._downColor = value; this.requestRender() }
|
|
98
99
|
set wickColor(value: string) { this._wickColor = value; this.requestRender() }
|
|
99
100
|
|
|
101
|
+
/** Public accessor for tooltip coordinate mapping */
|
|
102
|
+
getPlotLayout() { return this.computeLayout() }
|
|
103
|
+
|
|
104
|
+
/** Current candle data for tooltip value lookup */
|
|
105
|
+
getCandles() { return this._candles }
|
|
106
|
+
|
|
107
|
+
/** Get aggregated columns for the current plot width */
|
|
108
|
+
getAggregatedColumns({ plotW }: { plotW: number }) { return this.aggregateToColumns({ plotW }) }
|
|
109
|
+
|
|
100
110
|
// ── Layout: compute plot area and Y labels ─────────────────────
|
|
101
111
|
private computeLayout(): {
|
|
102
112
|
plotX: number; plotY: number; plotW: number; plotH: number
|
|
@@ -367,6 +377,9 @@ const CandleChart: CandleChartType = (props) => {
|
|
|
367
377
|
|
|
368
378
|
const resolvedUpColor = resolveColor(upColor) || Color.Green
|
|
369
379
|
const resolvedDownColor = resolveColor(downColor) || Color.Red
|
|
380
|
+
const containerRef = useRef<any>(null)
|
|
381
|
+
const plotRef = useRef<CandleChartRenderable>(null)
|
|
382
|
+
const { tooltip, show: showTooltip, hide: hideTooltip } = useChartTooltip()
|
|
370
383
|
|
|
371
384
|
// Auto-compute Y range from data high/low if not provided
|
|
372
385
|
const computedYRange = useMemo<[number, number]>(() => {
|
|
@@ -389,21 +402,55 @@ const CandleChart: CandleChartType = (props) => {
|
|
|
389
402
|
// Total height = plot rows + 1 for x-axis labels
|
|
390
403
|
const totalHeight = height + (xLabels.length > 0 ? 1 : 0)
|
|
391
404
|
|
|
405
|
+
const handleMouseMove = (evt: OpenTUIMouseEvent) => {
|
|
406
|
+
const plot = plotRef.current
|
|
407
|
+
if (!plot) return
|
|
408
|
+
const layout = plot.getPlotLayout()
|
|
409
|
+
if (!layout) return
|
|
410
|
+
|
|
411
|
+
const columns = plot.getAggregatedColumns({ plotW: layout.plotW })
|
|
412
|
+
if (columns.length === 0) return
|
|
413
|
+
|
|
414
|
+
// CandleChart right-aligns candles: offset = plotW - columns.length
|
|
415
|
+
const offset = layout.plotW - columns.length
|
|
416
|
+
const col = evt.x - layout.plotX - offset
|
|
417
|
+
if (col < 0 || col >= columns.length) {
|
|
418
|
+
hideTooltip()
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const candle = columns[col]!
|
|
423
|
+
const label = interpolateXLabel({ dataIndex: col, dataLength: columns.length, xLabels })
|
|
424
|
+
const lines = [
|
|
425
|
+
label,
|
|
426
|
+
formatTooltipLine('O', candle.open.toFixed(2)),
|
|
427
|
+
formatTooltipLine('H', candle.high.toFixed(2)),
|
|
428
|
+
formatTooltipLine('L', candle.low.toFixed(2)),
|
|
429
|
+
formatTooltipLine('C', candle.close.toFixed(2)),
|
|
430
|
+
]
|
|
431
|
+
showTooltip({ x: evt.x, y: evt.y, lines })
|
|
432
|
+
}
|
|
433
|
+
|
|
392
434
|
return (
|
|
393
|
-
<
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
435
|
+
<box ref={containerRef} width="100%" onMouseOut={hideTooltip}>
|
|
436
|
+
<ChartTooltip tooltip={tooltip} containerRef={containerRef} />
|
|
437
|
+
<candle-chart-plot
|
|
438
|
+
ref={plotRef}
|
|
439
|
+
width="100%"
|
|
440
|
+
height={totalHeight}
|
|
441
|
+
candles={data}
|
|
442
|
+
xLabels={xLabels}
|
|
443
|
+
yMin={computedYRange[0]}
|
|
444
|
+
yMax={computedYRange[1]}
|
|
445
|
+
yTicks={yTicks}
|
|
446
|
+
yFormat={yFormat}
|
|
447
|
+
axisColor={theme.textMuted}
|
|
448
|
+
upColor={resolvedUpColor}
|
|
449
|
+
downColor={resolvedDownColor}
|
|
450
|
+
wickColor={theme.textMuted}
|
|
451
|
+
onMouseMove={handleMouseMove}
|
|
452
|
+
/>
|
|
453
|
+
</box>
|
|
407
454
|
)
|
|
408
455
|
}
|
|
409
456
|
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared tooltip component for all chart/graph components.
|
|
3
|
+
*
|
|
4
|
+
* Renders an absolute-positioned box near the cursor showing the x-axis label
|
|
5
|
+
* and y-axis value for the hovered data point. Used by BarGraph, Graph,
|
|
6
|
+
* DottedLineGraph, HorizontalBarGraph, BarChart, and CandleChart.
|
|
7
|
+
*
|
|
8
|
+
* Each chart wraps its plot area with a container that handles mouse events
|
|
9
|
+
* and converts cursor coordinates to tooltip content via `useChartTooltip()`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import React, { useState, useRef } from 'react'
|
|
13
|
+
import type { MouseEvent as OpenTUIMouseEvent } from '@opentui/core'
|
|
14
|
+
import { useTheme } from 'termcast/src/theme'
|
|
15
|
+
|
|
16
|
+
// ── Tooltip state ────────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
export interface TooltipData {
|
|
19
|
+
/** Absolute terminal X where the tooltip should appear */
|
|
20
|
+
x: number
|
|
21
|
+
/** Absolute terminal Y where the tooltip should appear */
|
|
22
|
+
y: number
|
|
23
|
+
/** Lines of text to show in the tooltip */
|
|
24
|
+
lines: string[]
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ChartTooltipState {
|
|
28
|
+
tooltip: TooltipData | null
|
|
29
|
+
show: (data: TooltipData) => void
|
|
30
|
+
hide: () => void
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Hook to manage tooltip state. Each chart component calls this once
|
|
35
|
+
* and passes `show`/`hide` to mouse event handlers.
|
|
36
|
+
*/
|
|
37
|
+
export function useChartTooltip(): ChartTooltipState {
|
|
38
|
+
const [tooltip, setTooltip] = useState<TooltipData | null>(null)
|
|
39
|
+
const showRef = useRef((data: TooltipData) => {
|
|
40
|
+
setTooltip(data)
|
|
41
|
+
})
|
|
42
|
+
const hideRef = useRef(() => {
|
|
43
|
+
setTooltip(null)
|
|
44
|
+
})
|
|
45
|
+
return { tooltip, show: showRef.current, hide: hideRef.current }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Tooltip component ────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
interface ChartTooltipProps {
|
|
51
|
+
tooltip: TooltipData | null
|
|
52
|
+
/** The ref of the chart container box, used to compute relative position */
|
|
53
|
+
containerRef: React.RefObject<{ x: number; y: number; width: number; height: number } | null>
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Absolute-positioned tooltip that floats near the hovered data point.
|
|
58
|
+
* Must be rendered inside the chart's outermost `<box>` container.
|
|
59
|
+
*
|
|
60
|
+
* Position is computed relative to the container. The tooltip shifts
|
|
61
|
+
* left when it would overflow the right edge, and shifts above the
|
|
62
|
+
* cursor when it would overflow the bottom.
|
|
63
|
+
*/
|
|
64
|
+
export function ChartTooltip({ tooltip, containerRef }: ChartTooltipProps): any {
|
|
65
|
+
const theme = useTheme()
|
|
66
|
+
|
|
67
|
+
if (!tooltip || !containerRef.current) {
|
|
68
|
+
return null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const container = containerRef.current
|
|
72
|
+
const maxWidth = Math.max(...tooltip.lines.map((line) => line.length)) + 2
|
|
73
|
+
const tooltipHeight = tooltip.lines.length
|
|
74
|
+
|
|
75
|
+
// Convert absolute terminal coordinates to relative container coordinates
|
|
76
|
+
let relX = tooltip.x - container.x + 2
|
|
77
|
+
let relY = tooltip.y - container.y - tooltipHeight
|
|
78
|
+
|
|
79
|
+
// Shift left if tooltip would overflow the right edge
|
|
80
|
+
if (relX + maxWidth > container.width) {
|
|
81
|
+
relX = Math.max(0, container.width - maxWidth)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Shift below cursor if tooltip would overflow the top
|
|
85
|
+
if (relY < 0) {
|
|
86
|
+
relY = tooltip.y - container.y + 1
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<box
|
|
91
|
+
position="absolute"
|
|
92
|
+
left={relX}
|
|
93
|
+
top={relY}
|
|
94
|
+
height={tooltipHeight}
|
|
95
|
+
overflow="hidden"
|
|
96
|
+
flexShrink={0}
|
|
97
|
+
backgroundColor={theme.backgroundPanel}
|
|
98
|
+
>
|
|
99
|
+
{tooltip.lines.map((line, i) => {
|
|
100
|
+
return (
|
|
101
|
+
<text key={i} fg={theme.text} wrapMode="none"> {line} </text>
|
|
102
|
+
)
|
|
103
|
+
})}
|
|
104
|
+
</box>
|
|
105
|
+
)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ── Helpers for renderable-based charts ──────────────────────────────
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* For renderable-based charts (Graph, DottedLineGraph, CandleChart), compute
|
|
112
|
+
* which data index the cursor is hovering over based on the mouse X coordinate
|
|
113
|
+
* relative to the plot area.
|
|
114
|
+
*/
|
|
115
|
+
export function computeDataIndexFromMouseX({
|
|
116
|
+
mouseX,
|
|
117
|
+
plotX,
|
|
118
|
+
plotW,
|
|
119
|
+
dataLength,
|
|
120
|
+
}: {
|
|
121
|
+
mouseX: number
|
|
122
|
+
plotX: number
|
|
123
|
+
plotW: number
|
|
124
|
+
dataLength: number
|
|
125
|
+
}): number {
|
|
126
|
+
if (dataLength <= 0 || plotW <= 0) return -1
|
|
127
|
+
const relX = mouseX - plotX
|
|
128
|
+
if (relX < 0 || relX >= plotW) return -1
|
|
129
|
+
return Math.round((relX / Math.max(1, plotW - 1)) * (dataLength - 1))
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Resolve the x-axis label for a given data index.
|
|
134
|
+
*
|
|
135
|
+
* When xLabels has at least as many entries as data points (1:1 mapping,
|
|
136
|
+
* typical for BarGraph where each bar has its own label), the label is
|
|
137
|
+
* looked up directly by index.
|
|
138
|
+
*
|
|
139
|
+
* When xLabels is shorter (typical for line charts where a few tick labels
|
|
140
|
+
* are spread across many data points), the label is interpolated by mapping
|
|
141
|
+
* the normalized data position to the nearest label.
|
|
142
|
+
*
|
|
143
|
+
* Falls back to the stringified index only if no labels are provided at all.
|
|
144
|
+
*/
|
|
145
|
+
export function interpolateXLabel({
|
|
146
|
+
dataIndex,
|
|
147
|
+
dataLength,
|
|
148
|
+
xLabels,
|
|
149
|
+
}: {
|
|
150
|
+
dataIndex: number
|
|
151
|
+
dataLength: number
|
|
152
|
+
xLabels: string[]
|
|
153
|
+
}): string {
|
|
154
|
+
if (xLabels.length === 0) return `${dataIndex}`
|
|
155
|
+
|
|
156
|
+
// 1:1 mapping: xLabels covers every data point
|
|
157
|
+
const hasDirectLabels = xLabels.length >= dataLength
|
|
158
|
+
if (hasDirectLabels) {
|
|
159
|
+
const direct = xLabels[dataIndex]
|
|
160
|
+
if (direct !== undefined && direct !== '') return direct
|
|
161
|
+
return `${dataIndex}`
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Sparse labels: interpolate by normalized position
|
|
165
|
+
if (dataLength <= 1) return xLabels[0] || `${dataIndex}`
|
|
166
|
+
const t = dataIndex / (dataLength - 1)
|
|
167
|
+
const labelIdx = Math.round(t * (xLabels.length - 1))
|
|
168
|
+
const resolved = xLabels[labelIdx]
|
|
169
|
+
if (resolved !== undefined && resolved !== '') return resolved
|
|
170
|
+
|
|
171
|
+
// Scan for nearest non-empty label
|
|
172
|
+
let bestIdx = -1
|
|
173
|
+
let bestDist = Infinity
|
|
174
|
+
for (let i = 0; i < xLabels.length; i++) {
|
|
175
|
+
if (xLabels[i] === undefined || xLabels[i] === '') continue
|
|
176
|
+
const dist = Math.abs(i - labelIdx)
|
|
177
|
+
if (dist < bestDist) {
|
|
178
|
+
bestDist = dist
|
|
179
|
+
bestIdx = i
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (bestIdx >= 0) return xLabels[bestIdx]!
|
|
183
|
+
return `${dataIndex}`
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Format a single tooltip line from a label and numeric value.
|
|
188
|
+
*/
|
|
189
|
+
export function formatTooltipLine(label: string, value: number | string): string {
|
|
190
|
+
return `${label}: ${value}`
|
|
191
|
+
}
|
|
@@ -5,12 +5,13 @@
|
|
|
5
5
|
* can move by half columns and quarter rows instead of snapping to full cells.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import React, { ReactNode, useMemo } from 'react'
|
|
8
|
+
import React, { ReactNode, useMemo, useRef } from 'react'
|
|
9
9
|
import { Renderable, RGBA } from '@opentui/core'
|
|
10
|
-
import type { OptimizedBuffer, RenderableOptions, RenderContext } from '@opentui/core'
|
|
10
|
+
import type { OptimizedBuffer, RenderableOptions, RenderContext, MouseEvent as OpenTUIMouseEvent } from '@opentui/core'
|
|
11
11
|
import { extend } from '@opentui/react'
|
|
12
12
|
import { Color, resolveColor } from 'termcast/src/colors'
|
|
13
13
|
import { getThemePalette, useTheme } from 'termcast/src/theme'
|
|
14
|
+
import { ChartTooltip, useChartTooltip, computeDataIndexFromMouseX, interpolateXLabel, formatTooltipLine } from 'termcast/src/components/chart-tooltip'
|
|
14
15
|
|
|
15
16
|
const BRAILLE_BITS: number[][] = [
|
|
16
17
|
[1, 8],
|
|
@@ -73,6 +74,12 @@ class DottedLineGraphPlotRenderable extends Renderable {
|
|
|
73
74
|
set axisColor(value: string) { this._axisColor = value; this.requestRender() }
|
|
74
75
|
set dotSpacing(value: number) { this._dotSpacing = value; this.requestRender() }
|
|
75
76
|
|
|
77
|
+
/** Public accessor for tooltip coordinate mapping */
|
|
78
|
+
getPlotLayout() { return this.computeLayout() }
|
|
79
|
+
|
|
80
|
+
/** Current series data for tooltip value lookup */
|
|
81
|
+
getSeries() { return this._series }
|
|
82
|
+
|
|
76
83
|
private computeLayout(): {
|
|
77
84
|
plotX: number
|
|
78
85
|
plotY: number
|
|
@@ -314,6 +321,9 @@ const DottedLineGraph: DottedLineGraphType = (props) => {
|
|
|
314
321
|
children,
|
|
315
322
|
} = props
|
|
316
323
|
const palette = getThemePalette(theme)
|
|
324
|
+
const containerRef = useRef<any>(null)
|
|
325
|
+
const plotRef = useRef<DottedLineGraphPlotRenderable>(null)
|
|
326
|
+
const { tooltip, show: showTooltip, hide: hideTooltip } = useChartTooltip()
|
|
317
327
|
|
|
318
328
|
const series = useMemo<Array<DottedLineGraphSeriesData & { title?: string }>>(() => {
|
|
319
329
|
return React.Children.toArray(children)
|
|
@@ -364,11 +374,46 @@ const DottedLineGraph: DottedLineGraphType = (props) => {
|
|
|
364
374
|
})) + 1
|
|
365
375
|
}, [computedYRange, yFormat, yTicks])
|
|
366
376
|
|
|
377
|
+
const handleMouseMove = (evt: OpenTUIMouseEvent) => {
|
|
378
|
+
const plot = plotRef.current
|
|
379
|
+
if (!plot) return
|
|
380
|
+
const layout = plot.getPlotLayout()
|
|
381
|
+
if (!layout) return
|
|
382
|
+
|
|
383
|
+
const allSeries = plot.getSeries()
|
|
384
|
+
const maxDataLen = Math.max(0, ...allSeries.map((s) => s.data.length))
|
|
385
|
+
const idx = computeDataIndexFromMouseX({
|
|
386
|
+
mouseX: evt.x,
|
|
387
|
+
plotX: layout.plotX,
|
|
388
|
+
plotW: layout.plotW,
|
|
389
|
+
dataLength: maxDataLen,
|
|
390
|
+
})
|
|
391
|
+
if (idx < 0) {
|
|
392
|
+
hideTooltip()
|
|
393
|
+
return
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const label = interpolateXLabel({ dataIndex: idx, dataLength: maxDataLen, xLabels })
|
|
397
|
+
const lines = series
|
|
398
|
+
.filter((s) => idx < s.data.length)
|
|
399
|
+
.map((s) => {
|
|
400
|
+
const value = s.data[idx] ?? 0
|
|
401
|
+
const prefix = s.title ? `${s.title}` : label
|
|
402
|
+
return formatTooltipLine(prefix, Number(value.toFixed(2)))
|
|
403
|
+
})
|
|
404
|
+
if (lines.length === 0) return
|
|
405
|
+
// Always show x-axis label as the header line
|
|
406
|
+
lines.unshift(label)
|
|
407
|
+
showTooltip({ x: evt.x, y: evt.y, lines })
|
|
408
|
+
}
|
|
409
|
+
|
|
367
410
|
if (series.length === 0) return null
|
|
368
411
|
|
|
369
412
|
return (
|
|
370
|
-
<box flexDirection="column" width="100%" flexShrink={0}>
|
|
413
|
+
<box ref={containerRef} flexDirection="column" width="100%" flexShrink={0} onMouseOut={hideTooltip}>
|
|
414
|
+
<ChartTooltip tooltip={tooltip} containerRef={containerRef} />
|
|
371
415
|
<dotted-line-graph-plot
|
|
416
|
+
ref={plotRef}
|
|
372
417
|
width="100%"
|
|
373
418
|
height={height + (xLabels.length > 0 ? 1 : 0)}
|
|
374
419
|
series={plotSeries}
|
|
@@ -379,6 +424,7 @@ const DottedLineGraph: DottedLineGraphType = (props) => {
|
|
|
379
424
|
yFormat={yFormat}
|
|
380
425
|
axisColor={theme.textMuted}
|
|
381
426
|
dotSpacing={dotSpacing}
|
|
427
|
+
onMouseMove={handleMouseMove}
|
|
382
428
|
/>
|
|
383
429
|
{legendVisible ? (
|
|
384
430
|
<box height={1} width="100%" flexShrink={0} overflow="hidden" flexDirection="row">
|
package/src/components/graph.tsx
CHANGED
|
@@ -20,13 +20,14 @@
|
|
|
20
20
|
* For a plot of W cols x H rows we get W*2 x H*4 virtual pixels.
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
|
-
import React, { ReactNode, useMemo } from 'react'
|
|
23
|
+
import React, { ReactNode, useMemo, useRef } from 'react'
|
|
24
24
|
import { Renderable, RGBA } from '@opentui/core'
|
|
25
|
-
import type { RenderableOptions, RenderContext } from '@opentui/core'
|
|
25
|
+
import type { RenderableOptions, RenderContext, MouseEvent as OpenTUIMouseEvent } from '@opentui/core'
|
|
26
26
|
import type { OptimizedBuffer } from '@opentui/core'
|
|
27
27
|
import { extend } from '@opentui/react'
|
|
28
28
|
import { useTheme, getThemePalette } from 'termcast/src/theme'
|
|
29
29
|
import { Color, resolveColor } from 'termcast/src/colors'
|
|
30
|
+
import { ChartTooltip, useChartTooltip, computeDataIndexFromMouseX, interpolateXLabel, formatTooltipLine } from 'termcast/src/components/chart-tooltip'
|
|
30
31
|
|
|
31
32
|
// ── Graph variant ────────────────────────────────────────────────────
|
|
32
33
|
// Three rendering modes for the plot area:
|
|
@@ -124,6 +125,12 @@ export class GraphPlotRenderable extends Renderable {
|
|
|
124
125
|
set stripeColor1(value: string) { this._stripeColor1 = value; this.requestRender() }
|
|
125
126
|
set stripeColor2(value: string) { this._stripeColor2 = value; this.requestRender() }
|
|
126
127
|
|
|
128
|
+
/** Public accessor for tooltip coordinate mapping */
|
|
129
|
+
getPlotLayout() { return this.computeLayout() }
|
|
130
|
+
|
|
131
|
+
/** Current series data for tooltip value lookup */
|
|
132
|
+
getSeries() { return this._series }
|
|
133
|
+
|
|
127
134
|
// ── Shared: compute layout and draw axes ─────────────────────
|
|
128
135
|
private computeLayout(): {
|
|
129
136
|
plotX: number; plotY: number; plotW: number; plotH: number
|
|
@@ -440,10 +447,13 @@ const Graph: GraphType = (props) => {
|
|
|
440
447
|
const { height = 15, xLabels = [], yRange, yTicks = 5, yFormat, variant = 'area', stripeColors, children } = props
|
|
441
448
|
|
|
442
449
|
const palette = getThemePalette(theme)
|
|
450
|
+
const containerRef = useRef<any>(null)
|
|
451
|
+
const plotRef = useRef<GraphPlotRenderable>(null)
|
|
452
|
+
const { tooltip, show: showTooltip, hide: hideTooltip } = useChartTooltip()
|
|
443
453
|
|
|
444
454
|
// Collect series data from Graph.Line children
|
|
445
|
-
const
|
|
446
|
-
const result: SeriesData
|
|
455
|
+
const seriesWithTitles = useMemo<Array<SeriesData & { title?: string }>>(() => {
|
|
456
|
+
const result: Array<SeriesData & { title?: string }> = []
|
|
447
457
|
let colorIndex = 0
|
|
448
458
|
React.Children.forEach(children, (child) => {
|
|
449
459
|
if (!React.isValidElement(child)) return
|
|
@@ -454,12 +464,18 @@ const Graph: GraphType = (props) => {
|
|
|
454
464
|
result.push({
|
|
455
465
|
data: childProps.data,
|
|
456
466
|
color,
|
|
467
|
+
title: childProps.title,
|
|
457
468
|
})
|
|
458
469
|
colorIndex++
|
|
459
470
|
})
|
|
460
471
|
return result
|
|
461
472
|
}, [children, palette])
|
|
462
473
|
|
|
474
|
+
// Strip titles for the renderable (it only needs data + color)
|
|
475
|
+
const series = useMemo<SeriesData[]>(() => {
|
|
476
|
+
return seriesWithTitles.map((s) => ({ data: s.data, color: s.color }))
|
|
477
|
+
}, [seriesWithTitles])
|
|
478
|
+
|
|
463
479
|
// Auto-compute Y range if not provided
|
|
464
480
|
const computedYRange = useMemo<[number, number]>(() => {
|
|
465
481
|
if (yRange) return yRange
|
|
@@ -484,21 +500,63 @@ const Graph: GraphType = (props) => {
|
|
|
484
500
|
// Total height = plot rows + 1 for x-axis labels
|
|
485
501
|
const totalHeight = height + (xLabels.length > 0 ? 1 : 0)
|
|
486
502
|
|
|
503
|
+
const handleMouseMove = (evt: OpenTUIMouseEvent) => {
|
|
504
|
+
const plot = plotRef.current
|
|
505
|
+
if (!plot) return
|
|
506
|
+
const layout = plot.getPlotLayout()
|
|
507
|
+
if (!layout) return
|
|
508
|
+
|
|
509
|
+
const allSeries = plot.getSeries()
|
|
510
|
+
const maxDataLen = Math.max(0, ...allSeries.map((s) => s.data.length))
|
|
511
|
+
const idx = computeDataIndexFromMouseX({
|
|
512
|
+
mouseX: evt.x,
|
|
513
|
+
plotX: layout.plotX,
|
|
514
|
+
plotW: layout.plotW,
|
|
515
|
+
dataLength: maxDataLen,
|
|
516
|
+
})
|
|
517
|
+
if (idx < 0) {
|
|
518
|
+
hideTooltip()
|
|
519
|
+
return
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
const label = interpolateXLabel({ dataIndex: idx, dataLength: maxDataLen, xLabels })
|
|
523
|
+
const lines = allSeries
|
|
524
|
+
.map((s, si) => ({ series: s, title: seriesWithTitles[si]?.title }))
|
|
525
|
+
.filter(({ series }) => idx < series.data.length)
|
|
526
|
+
.map(({ series, title }) => {
|
|
527
|
+
const value = series.data[idx]!
|
|
528
|
+
const prefix = title || label
|
|
529
|
+
return formatTooltipLine(prefix, Number(value.toFixed(2)))
|
|
530
|
+
})
|
|
531
|
+
if (lines.length === 0) {
|
|
532
|
+
hideTooltip()
|
|
533
|
+
return
|
|
534
|
+
}
|
|
535
|
+
// Always show x-axis label as the header line
|
|
536
|
+
lines.unshift(label)
|
|
537
|
+
showTooltip({ x: evt.x, y: evt.y, lines })
|
|
538
|
+
}
|
|
539
|
+
|
|
487
540
|
return (
|
|
488
|
-
<
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
541
|
+
<box ref={containerRef} width="100%" onMouseOut={hideTooltip}>
|
|
542
|
+
<ChartTooltip tooltip={tooltip} containerRef={containerRef} />
|
|
543
|
+
<graph-plot
|
|
544
|
+
ref={plotRef}
|
|
545
|
+
width="100%"
|
|
546
|
+
height={totalHeight}
|
|
547
|
+
series={series}
|
|
548
|
+
xLabels={xLabels}
|
|
549
|
+
yMin={computedYRange[0]}
|
|
550
|
+
yMax={computedYRange[1]}
|
|
551
|
+
yTicks={yTicks}
|
|
552
|
+
yFormat={yFormat}
|
|
553
|
+
axisColor={theme.textMuted}
|
|
554
|
+
variant={variant}
|
|
555
|
+
stripeColor1={resolvedStripe1}
|
|
556
|
+
stripeColor2={resolvedStripe2}
|
|
557
|
+
onMouseMove={handleMouseMove}
|
|
558
|
+
/>
|
|
559
|
+
</box>
|
|
502
560
|
)
|
|
503
561
|
}
|
|
504
562
|
|