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.
Files changed (106) hide show
  1. package/dist/build.d.ts.map +1 -1
  2. package/dist/build.js +22 -5
  3. package/dist/build.js.map +1 -1
  4. package/dist/compile.d.ts.map +1 -1
  5. package/dist/compile.js +7 -1
  6. package/dist/compile.js.map +1 -1
  7. package/dist/components/bar-chart.d.ts.map +1 -1
  8. package/dist/components/bar-chart.js +14 -3
  9. package/dist/components/bar-chart.js.map +1 -1
  10. package/dist/components/bar-graph.d.ts +4 -4
  11. package/dist/components/bar-graph.d.ts.map +1 -1
  12. package/dist/components/bar-graph.js +23 -5
  13. package/dist/components/bar-graph.js.map +1 -1
  14. package/dist/components/candle-chart.d.ts +15 -0
  15. package/dist/components/candle-chart.d.ts.map +1 -1
  16. package/dist/components/candle-chart.js +41 -3
  17. package/dist/components/candle-chart.js.map +1 -1
  18. package/dist/components/chart-tooltip.d.ts +83 -0
  19. package/dist/components/chart-tooltip.d.ts.map +1 -0
  20. package/dist/components/chart-tooltip.js +127 -0
  21. package/dist/components/chart-tooltip.js.map +1 -0
  22. package/dist/components/dotted-line-graph.d.ts +11 -0
  23. package/dist/components/dotted-line-graph.d.ts.map +1 -1
  24. package/dist/components/dotted-line-graph.js +43 -2
  25. package/dist/components/dotted-line-graph.js.map +1 -1
  26. package/dist/components/graph.d.ts +11 -0
  27. package/dist/components/graph.d.ts.map +1 -1
  28. package/dist/components/graph.js +53 -4
  29. package/dist/components/graph.js.map +1 -1
  30. package/dist/components/horizontal-bar-graph.d.ts.map +1 -1
  31. package/dist/components/horizontal-bar-graph.js +16 -5
  32. package/dist/components/horizontal-bar-graph.js.map +1 -1
  33. package/dist/components/list.d.ts +7 -0
  34. package/dist/components/list.d.ts.map +1 -1
  35. package/dist/components/list.js +75 -14
  36. package/dist/components/list.js.map +1 -1
  37. package/dist/examples/chart-tooltips.d.ts +2 -0
  38. package/dist/examples/chart-tooltips.d.ts.map +1 -0
  39. package/dist/examples/chart-tooltips.js +16 -0
  40. package/dist/examples/chart-tooltips.js.map +1 -0
  41. package/dist/examples/list-detail-height-ratchet.d.ts +2 -0
  42. package/dist/examples/list-detail-height-ratchet.d.ts.map +1 -0
  43. package/dist/examples/list-detail-height-ratchet.js +26 -0
  44. package/dist/examples/list-detail-height-ratchet.js.map +1 -0
  45. package/dist/extensions/dev.d.ts.map +1 -1
  46. package/dist/extensions/dev.js +1 -0
  47. package/dist/extensions/dev.js.map +1 -1
  48. package/dist/globals.js +8 -0
  49. package/dist/globals.js.map +1 -1
  50. package/dist/package-json.d.ts +2 -0
  51. package/dist/package-json.d.ts.map +1 -1
  52. package/dist/package-json.js +20 -17
  53. package/dist/package-json.js.map +1 -1
  54. package/dist/profiler.d.ts +2 -0
  55. package/dist/profiler.d.ts.map +1 -0
  56. package/dist/profiler.js +390 -0
  57. package/dist/profiler.js.map +1 -0
  58. package/package.json +14 -15
  59. package/src/build.tsx +27 -5
  60. package/src/cli.tsx +0 -0
  61. package/src/compile.tsx +9 -1
  62. package/src/compile.vitest.tsx +8 -8
  63. package/src/components/bar-chart.tsx +23 -3
  64. package/src/components/bar-graph.tsx +32 -13
  65. package/src/components/candle-chart.tsx +63 -16
  66. package/src/components/chart-tooltip.tsx +191 -0
  67. package/src/components/dotted-line-graph.tsx +49 -3
  68. package/src/components/graph.tsx +76 -18
  69. package/src/components/horizontal-bar-graph.tsx +24 -4
  70. package/src/components/list.tsx +93 -20
  71. package/src/examples/action-shortcut.vitest.tsx +4 -4
  72. package/src/examples/actions-context.vitest.tsx +2 -2
  73. package/src/examples/bar-graph-weekly.vitest.tsx +97 -97
  74. package/src/examples/chart-tooltips.tsx +54 -0
  75. package/src/examples/form-basic.vitest.tsx +8 -8
  76. package/src/examples/github.vitest.tsx +19 -28
  77. package/src/examples/graph-bar-chart.vitest.tsx +40 -40
  78. package/src/examples/graph-polymarket.vitest.tsx +24 -24
  79. package/src/examples/graph-row.vitest.tsx +8 -8
  80. package/src/examples/graph-styles.vitest.tsx +65 -65
  81. package/src/examples/horizontal-bar-graph-weekly.vitest.tsx +52 -52
  82. package/src/examples/list-detail-height-ratchet.tsx +48 -0
  83. package/src/examples/list-detail-height-ratchet.vitest.tsx +161 -0
  84. package/src/examples/list-detail-metadata.vitest.tsx +49 -49
  85. package/src/examples/list-dropdown-default.vitest.tsx +27 -27
  86. package/src/examples/list-fetch-data.vitest.tsx +3 -3
  87. package/src/examples/list-item-accessories.vitest.tsx +2 -2
  88. package/src/examples/list-loading-empty-view.vitest.tsx +1 -1
  89. package/src/examples/list-no-actions.vitest.tsx +3 -3
  90. package/src/examples/list-scrollbox.vitest.tsx +6 -6
  91. package/src/examples/list-spacing-mode.vitest.tsx +3 -3
  92. package/src/examples/list-with-detail.vitest.tsx +11 -11
  93. package/src/examples/list-with-dropdown.vitest.tsx +7 -7
  94. package/src/examples/list-with-sections.vitest.tsx +32 -32
  95. package/src/examples/list-with-toast.vitest.tsx +4 -4
  96. package/src/examples/simple-candle-chart.vitest.tsx +63 -61
  97. package/src/examples/simple-grid.vitest.tsx +13 -13
  98. package/src/examples/simple-navigation.vitest.tsx +25 -25
  99. package/src/examples/simple-progress-bar.vitest.tsx +8 -8
  100. package/src/examples/swift-extension.vitest.tsx +3 -3
  101. package/src/examples/toast-action.vitest.tsx +4 -4
  102. package/src/extensions/dev.tsx +2 -1
  103. package/src/extensions/dev.vitest.tsx +17 -17
  104. package/src/globals.ts +9 -0
  105. package/src/package-json.tsx +24 -23
  106. 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
- * instead of painted backgrounds, which keeps the chart airy like Histogram.
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: 3) */
40
+ /** Width of each bar in terminal columns (default: 2) */
39
41
  barWidth?: number
40
- /** Gap between bars in terminal columns (default: 1) */
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 = 3,
77
- barGap = 1,
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
- lower-block glyph wraps inside the fixed-width segment, so it
236
- stays visible in snapshots without filling the whole cell. */}
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
- <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
- />
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">
@@ -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 series = useMemo<SeriesData[]>(() => {
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
- <graph-plot
489
- width="100%"
490
- height={totalHeight}
491
- series={series}
492
- xLabels={xLabels}
493
- yMin={computedYRange[0]}
494
- yMax={computedYRange[1]}
495
- yTicks={yTicks}
496
- yFormat={yFormat}
497
- axisColor={theme.textMuted}
498
- variant={variant}
499
- stripeColor1={resolvedStripe1}
500
- stripeColor2={resolvedStripe2}
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