termcast 1.6.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/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.map +1 -1
- package/dist/components/bar-graph.js +21 -3
- 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.map +1 -1
- package/dist/components/list.js +1 -3
- 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/package.json +1 -1
- package/src/components/bar-chart.tsx +23 -3
- package/src/components/bar-graph.tsx +23 -4
- 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 +1 -9
- package/src/examples/action-shortcut.vitest.tsx +1 -1
- package/src/examples/bar-graph-weekly.vitest.tsx +2 -2
- package/src/examples/chart-tooltips.tsx +54 -0
- package/src/examples/form-basic.vitest.tsx +8 -8
- package/src/examples/github.vitest.tsx +3 -3
- package/src/examples/graph-bar-chart.vitest.tsx +4 -4
- package/src/examples/graph-polymarket.vitest.tsx +2 -2
- package/src/examples/graph-row.vitest.tsx +4 -4
- package/src/examples/list-detail-height-ratchet.vitest.tsx +3 -3
- package/src/examples/list-detail-metadata.vitest.tsx +3 -3
- package/src/examples/list-fetch-data.vitest.tsx +3 -3
- package/src/examples/list-item-accessories.vitest.tsx +2 -2
- package/src/examples/list-no-actions.vitest.tsx +1 -1
- package/src/examples/list-spacing-mode.vitest.tsx +3 -3
- package/src/examples/list-with-detail.vitest.tsx +3 -3
- package/src/examples/list-with-dropdown.vitest.tsx +4 -4
- package/src/examples/list-with-sections.vitest.tsx +19 -19
- package/src/examples/simple-candle-chart.vitest.tsx +4 -4
- package/src/examples/simple-grid.vitest.tsx +13 -13
- package/src/examples/simple-navigation.vitest.tsx +14 -14
- package/src/examples/simple-progress-bar.vitest.tsx +1 -1
- package/src/examples/swift-extension.vitest.tsx +2 -2
- package/src/examples/toast-action.vitest.tsx +2 -2
|
@@ -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
|
|
|
@@ -6,10 +6,12 @@
|
|
|
6
6
|
* colored series rows and percentages.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import React, { ReactNode, useMemo } from 'react'
|
|
9
|
+
import React, { ReactNode, useMemo, useRef } from 'react'
|
|
10
10
|
import { BoxProps } from '@opentui/react'
|
|
11
|
+
import type { MouseEvent as OpenTUIMouseEvent } from '@opentui/core'
|
|
11
12
|
import { Color, resolveColor } from 'termcast/src/colors'
|
|
12
13
|
import { getThemePalette, useTheme } from 'termcast/src/theme'
|
|
14
|
+
import { ChartTooltip, useChartTooltip, formatTooltipLine } from 'termcast/src/components/chart-tooltip'
|
|
13
15
|
|
|
14
16
|
export interface HorizontalBarGraphSeriesProps {
|
|
15
17
|
/** One value per row/category position. */
|
|
@@ -97,6 +99,8 @@ const HorizontalBarGraph: HorizontalBarGraphType = (props) => {
|
|
|
97
99
|
} = props
|
|
98
100
|
|
|
99
101
|
const palette = getThemePalette(theme)
|
|
102
|
+
const containerRef = useRef<any>(null)
|
|
103
|
+
const { tooltip, show: showTooltip, hide: hideTooltip } = useChartTooltip()
|
|
100
104
|
|
|
101
105
|
const seriesList = useMemo<Array<{ data: number[]; color: string; title?: string }>>(() => {
|
|
102
106
|
const childArray = React.Children.toArray(children)
|
|
@@ -189,7 +193,8 @@ const HorizontalBarGraph: HorizontalBarGraphType = (props) => {
|
|
|
189
193
|
const chartHeight = headerHeight + visibleRows.length
|
|
190
194
|
|
|
191
195
|
return (
|
|
192
|
-
<box flexDirection="column" width="100%" flexShrink={0} {...rest}>
|
|
196
|
+
<box ref={containerRef} flexDirection="column" width="100%" flexShrink={0} {...rest} onMouseOut={hideTooltip}>
|
|
197
|
+
<ChartTooltip tooltip={tooltip} containerRef={containerRef} />
|
|
193
198
|
{showHeader && (
|
|
194
199
|
<>
|
|
195
200
|
<box flexDirection="row" height={1} flexShrink={0}>
|
|
@@ -238,7 +243,22 @@ const HorizontalBarGraph: HorizontalBarGraphType = (props) => {
|
|
|
238
243
|
}
|
|
239
244
|
const series = seriesList[seriesIndex]!
|
|
240
245
|
return (
|
|
241
|
-
<box
|
|
246
|
+
<box
|
|
247
|
+
key={seriesIndex}
|
|
248
|
+
flexGrow={value}
|
|
249
|
+
flexBasis={0}
|
|
250
|
+
flexShrink={1}
|
|
251
|
+
overflow="hidden"
|
|
252
|
+
onMouseMove={(evt: OpenTUIMouseEvent) => {
|
|
253
|
+
const label = row.label
|
|
254
|
+
const seriesTitle = series.title || `#${seriesIndex + 1}`
|
|
255
|
+
showTooltip({
|
|
256
|
+
x: evt.x,
|
|
257
|
+
y: evt.y,
|
|
258
|
+
lines: [label, formatTooltipLine(seriesTitle, value)],
|
|
259
|
+
})
|
|
260
|
+
}}
|
|
261
|
+
>
|
|
242
262
|
<box position="absolute" width="100%" height="100%" overflow="hidden">
|
|
243
263
|
<text fg={series.color} wrapMode="none">{barCharacter.repeat(200)}</text>
|
|
244
264
|
</box>
|
|
@@ -246,7 +266,7 @@ const HorizontalBarGraph: HorizontalBarGraphType = (props) => {
|
|
|
246
266
|
)
|
|
247
267
|
})}
|
|
248
268
|
{maxTotal > row.total && (
|
|
249
|
-
<box flexGrow={maxTotal - row.total} flexBasis={0} flexShrink={1} />
|
|
269
|
+
<box flexGrow={maxTotal - row.total} flexBasis={0} flexShrink={1} onMouseMove={hideTooltip} />
|
|
250
270
|
)}
|
|
251
271
|
</box>
|
|
252
272
|
</box>
|
package/src/components/list.tsx
CHANGED
|
@@ -164,15 +164,7 @@ function ListFooter(): any {
|
|
|
164
164
|
</text>
|
|
165
165
|
</Hoverable>
|
|
166
166
|
)}
|
|
167
|
-
|
|
168
|
-
<Hoverable
|
|
169
|
-
onMouseDown={() => {
|
|
170
|
-
executeVimCommand('vim')
|
|
171
|
-
}}
|
|
172
|
-
>
|
|
173
|
-
<text flexShrink={0} fg={theme.textMuted}>:vim</text>
|
|
174
|
-
</Hoverable>
|
|
175
|
-
)}
|
|
167
|
+
|
|
176
168
|
</box>
|
|
177
169
|
)
|
|
178
170
|
|
|
@@ -134,7 +134,7 @@ test('many series (8) bottom legend clips on one row', async () => {
|
|
|
134
134
|
│
|
|
135
135
|
│
|
|
136
136
|
│
|
|
137
|
-
↑↓ navigate ^k actions
|
|
137
|
+
↑↓ navigate ^k actions │
|
|
138
138
|
|
|
139
139
|
"
|
|
140
140
|
`)
|
|
@@ -188,7 +188,7 @@ test('long labels truncated by overflow hidden', async () => {
|
|
|
188
188
|
│
|
|
189
189
|
│
|
|
190
190
|
│
|
|
191
|
-
↑↓ navigate ^k actions
|
|
191
|
+
↑↓ navigate ^k actions │
|
|
192
192
|
|
|
193
193
|
"
|
|
194
194
|
`)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Example: Chart tooltip showcase. Hover over any bar, segment, or data point
|
|
2
|
+
// to see an absolute-positioned tooltip showing the label and value.
|
|
3
|
+
// Demonstrates tooltips on BarGraph, Graph, BarChart, and HorizontalBarGraph.
|
|
4
|
+
|
|
5
|
+
import React from 'react'
|
|
6
|
+
import { Detail, BarGraph, Graph, BarChart, HorizontalBarGraph, Row } from 'termcast'
|
|
7
|
+
import { renderWithProviders } from '../utils'
|
|
8
|
+
|
|
9
|
+
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
|
|
10
|
+
const values1 = [40, 30, 25, 15, 50, 40]
|
|
11
|
+
const values2 = [20, 25, 10, 10, 25, 20]
|
|
12
|
+
|
|
13
|
+
function ChartTooltipsExample() {
|
|
14
|
+
return (
|
|
15
|
+
<Detail
|
|
16
|
+
navigationTitle="Chart Tooltips"
|
|
17
|
+
markdown={[
|
|
18
|
+
'# Chart Tooltips',
|
|
19
|
+
'',
|
|
20
|
+
'Hover over any chart element to see a tooltip with the data label and value.',
|
|
21
|
+
'Works on BarGraph, Graph, BarChart, and HorizontalBarGraph.',
|
|
22
|
+
].join('\n')}
|
|
23
|
+
metadata={
|
|
24
|
+
<Detail.Metadata>
|
|
25
|
+
<Detail.Metadata.Label title="BarGraph (vertical stacked)" />
|
|
26
|
+
<BarGraph height={10} labels={days}>
|
|
27
|
+
<BarGraph.Series data={values1} title="Direct" />
|
|
28
|
+
<BarGraph.Series data={values2} title="Referral" />
|
|
29
|
+
</BarGraph>
|
|
30
|
+
<Detail.Metadata.Separator />
|
|
31
|
+
<Detail.Metadata.Label title="Graph (line chart)" />
|
|
32
|
+
<Graph height={8} xLabels={days}>
|
|
33
|
+
<Graph.Line data={values1} title="Traffic" />
|
|
34
|
+
</Graph>
|
|
35
|
+
<Detail.Metadata.Separator />
|
|
36
|
+
<Detail.Metadata.Label title="BarChart (horizontal segments)" />
|
|
37
|
+
<BarChart>
|
|
38
|
+
<BarChart.Segment value={60} label="Spent" />
|
|
39
|
+
<BarChart.Segment value={25} label="Remaining" />
|
|
40
|
+
<BarChart.Segment value={15} label="Reserved" />
|
|
41
|
+
</BarChart>
|
|
42
|
+
<Detail.Metadata.Separator />
|
|
43
|
+
<Detail.Metadata.Label title="HorizontalBarGraph (stacked rows)" />
|
|
44
|
+
<HorizontalBarGraph labels={days.slice(0, 4)}>
|
|
45
|
+
<HorizontalBarGraph.Series data={[40, 30, 25, 15]} title="Organic" />
|
|
46
|
+
<HorizontalBarGraph.Series data={[20, 25, 10, 10]} title="Paid" />
|
|
47
|
+
</HorizontalBarGraph>
|
|
48
|
+
</Detail.Metadata>
|
|
49
|
+
}
|
|
50
|
+
/>
|
|
51
|
+
)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
void renderWithProviders(<ChartTooltipsExample />)
|
|
@@ -221,7 +221,7 @@ test('form date picker selection with space and enter', async () => {
|
|
|
221
221
|
◇ Date of Birth
|
|
222
222
|
│
|
|
223
223
|
│ ← 2026 →
|
|
224
|
-
│ ←
|
|
224
|
+
│ ← June →
|
|
225
225
|
|
|
226
226
|
|
|
227
227
|
ctrl ↵ submit tab navigate ^k actions
|
|
@@ -279,7 +279,7 @@ test('form date picker selection with space and enter', async () => {
|
|
|
279
279
|
◇ Date of Birth
|
|
280
280
|
│
|
|
281
281
|
│ ← 2026 →
|
|
282
|
-
│ ←
|
|
282
|
+
│ ← June →
|
|
283
283
|
|
|
284
284
|
|
|
285
285
|
ctrl ↵ submit tab navigate ^k actions
|
|
@@ -338,7 +338,7 @@ test('form date picker selection with space and enter', async () => {
|
|
|
338
338
|
◇ Date of Birth
|
|
339
339
|
│
|
|
340
340
|
│ ← 2026 →
|
|
341
|
-
│ ←
|
|
341
|
+
│ ← June →
|
|
342
342
|
|
|
343
343
|
|
|
344
344
|
ctrl ↵ submit tab navigate ^k actions
|
|
@@ -413,7 +413,7 @@ test('form dropdown navigation', async () => {
|
|
|
413
413
|
◇ Date of Birth
|
|
414
414
|
│
|
|
415
415
|
│ ← 2026 →
|
|
416
|
-
│ ←
|
|
416
|
+
│ ← June →
|
|
417
417
|
|
|
418
418
|
|
|
419
419
|
ctrl ↵ submit tab navigate ^k actions
|
|
@@ -471,7 +471,7 @@ test('form dropdown navigation', async () => {
|
|
|
471
471
|
◇ Date of Birth
|
|
472
472
|
│
|
|
473
473
|
│ ← 2026 →
|
|
474
|
-
│ ←
|
|
474
|
+
│ ← June →
|
|
475
475
|
|
|
476
476
|
|
|
477
477
|
ctrl ↵ submit tab navigate ^k actions
|
|
@@ -531,7 +531,7 @@ test('form dropdown navigation', async () => {
|
|
|
531
531
|
◇ Date of Birth
|
|
532
532
|
│
|
|
533
533
|
│ ← 2026 →
|
|
534
|
-
│ ←
|
|
534
|
+
│ ← June →
|
|
535
535
|
|
|
536
536
|
|
|
537
537
|
ctrl ↵ submit tab navigate ^k actions
|
|
@@ -589,7 +589,7 @@ test('form dropdown navigation', async () => {
|
|
|
589
589
|
◇ Date of Birth
|
|
590
590
|
│
|
|
591
591
|
│ ← 2026 →
|
|
592
|
-
│ ←
|
|
592
|
+
│ ← June →
|
|
593
593
|
|
|
594
594
|
|
|
595
595
|
ctrl ↵ submit tab navigate ^k actions
|
|
@@ -763,7 +763,7 @@ test('arrow down from checkbox to dropdown lands on first item', async () => {
|
|
|
763
763
|
◇ Date of Birth
|
|
764
764
|
│
|
|
765
765
|
│ ← 2026 →
|
|
766
|
-
│ ←
|
|
766
|
+
│ ← June →
|
|
767
767
|
|
|
768
768
|
|
|
769
769
|
ctrl ↵ submit tab navigate ^k actions
|