Hook

useChartScrub

One of the two things a chart cannot answer from inside its own box: which datum is being read. It is for the pattern where a price, a date and a delta are real text above the plot, with the chart itself drawing no tooltip at all. The same hook on all three platforms — a finger on iOS and Android, a pointer on the web, reported as the same phases.

Hand its onInteraction straight to a chart and read selection for what is being read. The selection returns to null when the finger lifts or the pointer leaves the plot, which is the signal a readout needs to go back to its resting value.

A scrub is a continuous reading, so not every form has one. On the web it is Chart.Line and Chart.Candlestick that turn the pointer into one; the other forms report the mark the pointer landed on instead, which carries no index and so leaves the selection alone. On native a finger is read by its position along the category axis, so the forms that have one — the line, area, bar and candlestick families — are the forms a scrub means something on. The radial and flow ones have no ordered axis to walk, and nothing usable comes back from them.
tsx
'use client'

import { Chart, marker, useChartScrub } from '@hzblj/zyplot'

export const Price = ({ prices, categories }: PriceProps) => {
  const { onInteraction, selection } = useChartScrub()
  const shown = selection ? prices[selection.index] : prices.at(-1)

  return (
    <>
      <Text>{shown}</Text>
      <Text>{selection ? categories[selection.index] : 'Today'}</Text>
      <Chart.Line
        categories={categories}
        interaction={{ crosshair: 'x', haptics: true, marker: marker.segment(), tooltip: false }}
        onInteraction={onInteraction}
        series={[{ id: 'price', label: 'Price', values: prices }]}
      />
    </>
  )
}

What it returns

PropTypeDefaultDescription
selectionChartScrubSelection | nullWhat the finger is on, or null when nothing is being scrubbed.
onInteraction(event) => voidHand this straight to the chart’s onInteraction.
reset() => voidDrops the selection, for a caller that has its own reason to.
geometryChartGeometry | nullWhere the plot and its annotations sit in the chart view, for drawing your own views over them.
tsx
type ChartScrubSelection = {
  /** Position of the mark in the chart’s own data order. */
  index: number
  category?: string
  /** Where the finger is, in the chart view’s own coordinate space. */
  nativeX?: number
  nativeY?: number
  value?: number
}

type ChartGeometry = {
  annotations: readonly { id: string; x: number; y: number }[]
  plot: { height: number; width: number; x: number; y: number }
}

Drawing your own overlay

For a view on an annotation, reach for annotationViews first. Key your own node by the annotation's id and the chart centres it on the spot, keeps its own mark for it out of the drawing, and moves it with the data — no geometry to hold, no positioning to write. A logo at the live reading, a letter on a rule, a price that stays with the last point. Every native form takes it, and on the web it is the five that report where an annotation landed — Chart.Line, Chart.Area, Chart.Bar, Chart.StackedBar and Chart.Candlestick.
tsx
import { annotation, Chart, useLastReading } from '@hzblj/zyplot'

export const Price = ({ prices, categories }: PriceProps) => {
  const live = useLastReading(categories, prices)

  return (
    <Chart.Line
      annotations={live ? [annotation.point({ id: 'live', x: live.category, y: live.value })] : []}
      annotationViews={{ live: <LiveBadge value={live?.value} /> }}
      categories={categories}
      series={[{ id: 'price', label: 'Price', values: prices }]}
    />
  )
}
geometry is the way down when a view is not on an annotation. It reports the plot's box and every annotation's x/y in the chart's own coordinates, so a card can follow the finger with selection.nativeX — which is the one thing annotationViews cannot place for you. An annotation that is only an anchor for such a view takes hidden: true: measured and reported as usual, drawn by nobody.
tsx
'use client'

import { annotation, Chart, useChartScrub } from '@hzblj/zyplot'

export const Price = ({ prices, categories }: PriceProps) => {
  const { geometry, onInteraction, selection } = useChartScrub()
  const dividend = geometry?.annotations.find((mark) => mark.id === 'dividend')

  return (
    <View>
      <Chart.Line
        annotations={[
          // No badge: the rule is the chart's, the head is yours.
          annotation.line({ axis: 'x', dash: [2, 4], id: 'dividend', value: '12 Jul' }),
        ]}
        categories={categories}
        interaction={{ crosshair: 'x', haptics: true, tooltip: false }}
        onInteraction={onInteraction}
        series={[{ id: 'price', label: 'Price', values: prices }]}
      />

      {dividend ? (
        <Pressable
          onPress={openDividend}
          style={[styles.badge, { left: dividend.x - 9, top: dividend.y - 9 }]}
        >
          <Text>D</Text>
        </Pressable>
      ) : null}

      {selection ? (
        <Card left={selection.nativeX} rows={events[selection.index]} />
      ) : null}
    </View>
  )
}
index is what makes it useful. A native interaction event carries the mark's position in your data, so the readout indexes your own arrays rather than reading numbers back off the chart. That is how a scrub can show a formatted price, a label and a percentage the chart never knew about.
The identity is stable. onInteraction and reset keep the same identity across renders, and the returned object only changes when the selection does — so a memo'd chart is not re-rendered by the readout updating above it.