import type { ComponentProps } from "react" import { usePlotArea, useYAxisScale } from "recharts" import { ChartTooltipContent } from "./chart" // Custom Dot, value label and tooltip for HMLineChart. They are rendered inside the chart, so they can read the plot area and scales. /** Space kept between the plot edge and the first / last point so their Dot and label are not clipped. */ export const POINT_AXIS_PADDING = 22 const DOT_RADIUS = 5 const LABEL_GAP = 7 // between the Dot edge and the label const FAR_LABEL_GAP = 18 // same, for the second try when the near spot touches a line const CHAR_WIDTH = 7.2 // estimated 12px digit width const LABEL_ASCENT = 10 const LABEL_DESCENT = 2 type Row = Record type Curve = "monotone" | "linear" | "step" type Point = { x: number; y: number } type Box = { left: number; right: number; top: number; bottom: number } type Placement = { x: number; y: number; anchor: "start" | "middle" | "end"; box: Box } function pointColor(row: Row | undefined, colorKey: string | undefined, fallback: string) { const own = colorKey ? row?.[colorKey] : undefined return typeof own === "string" && own ? own : fallback } /** Dot with a surface-colored ring: the series color, or the row's own color when `colorKey` is set. */ export function PointDot({ cx, cy, payload, colorKey, fallback, radius = DOT_RADIUS, }: { cx?: number | string cy?: number | string payload?: Row colorKey?: string fallback: string radius?: number }) { const x = Number(cx) const y = Number(cy) if (!Number.isFinite(x) || !Number.isFinite(y)) return null return ( ) } function inflate(box: Box, by: number): Box { return { left: box.left - by, right: box.right + by, top: box.top - by, bottom: box.bottom + by } } const inside = (box: Box, x: number, y: number) => x >= box.left && x <= box.right && y >= box.top && y <= box.bottom /** Number of sample points (about every 2px) of the segment that fall inside the box. */ function segmentHits(box: Box, a: Point, b: Point) { const steps = Math.max(1, Math.ceil(Math.hypot(b.x - a.x, b.y - a.y) / 2)) let hits = 0 for (let i = 0; i <= steps; i += 1) { const t = i / steps if (inside(box, a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)) hits += 1 } return hits } function circleTouchesBox(box: Box, c: Point, radius: number) { const dx = Math.max(box.left - c.x, 0, c.x - box.right) const dy = Math.max(box.top - c.y, 0, c.y - box.bottom) return Math.hypot(dx, dy) < radius } const boxesOverlap = (a: Box, b: Box) => !(a.right <= b.left || b.right <= a.left || a.bottom <= b.top || b.bottom <= a.top) /** Line segments a series draws between two neighbouring points (a curve is approximated by its chord). */ function connection(a: Point, b: Point, curve: Curve): Array<[Point, Point]> { if (curve !== "step") return [[a, b]] const mid = (a.x + b.x) / 2 return [ [a, { x: mid, y: a.y }], [{ x: mid, y: a.y }, { x: mid, y: b.y }], [{ x: mid, y: b.y }, b], ] } function candidateBoxes(p: Point, width: number, below: boolean): Placement[] { const above = (gap: number): Placement => { const baseline = p.y - DOT_RADIUS - gap return { x: p.x, y: baseline, anchor: "middle", box: { left: p.x - width / 2, right: p.x + width / 2, top: baseline - LABEL_ASCENT, bottom: baseline + LABEL_DESCENT } } } const under = (gap: number): Placement => { const top = p.y + DOT_RADIUS + gap const baseline = top + LABEL_ASCENT return { x: p.x, y: baseline, anchor: "middle", box: { left: p.x - width / 2, right: p.x + width / 2, top, bottom: baseline + LABEL_DESCENT } } } const side = (dir: 1 | -1): Placement => { const x = p.x + dir * (DOT_RADIUS + 4) return { x, y: p.y + 4, anchor: dir === 1 ? "start" : "end", box: { left: dir === 1 ? x : x - width, right: dir === 1 ? x + width : x, top: p.y - 6, bottom: p.y + 6 }, } } const [first, second] = below ? [under, above] : [above, under] return [first(LABEL_GAP), first(FAR_LABEL_GAP), second(LABEL_GAP), second(FAR_LABEL_GAP), side(1), side(-1)] } /** * Value label of a Dot, in the Dot's color. * - Default side: one series is labelled above its Dot. With several series the higher value at a category goes above and the lower * below (a tie keeps the series order). * - The label then takes the first free spot of: default side near, default side farther, the other side near and farther, right, * left. A spot is free when it touches no line (curve, straight or step), no Dot, no earlier series' label and is neither above * the plot top nor on the axis labels. With no free spot the label is omitted; the Dot and the tooltip remain. * - When points are closer than a label is wide (narrow screens), only every n-th label is drawn; the tooltip shows every value. */ export function PointValueLabel({ x, y, index, data, seriesKeys, seriesIndex, curve, colorKey, fallback, }: { x?: number | string y?: number | string index?: number data: ReadonlyArray seriesKeys: readonly string[] seriesIndex: number curve: Curve colorKey?: string fallback: string }) { const plot = usePlotArea() const yScale = useYAxisScale() const px = Number(x) const py = Number(y) const at = index ?? 0 const row = data[at] if (!Number.isFinite(px) || !Number.isFinite(py) || !row) return null const textOf = (key: string) => Number(row[key]).toLocaleString() const spacing = plot && data.length > 1 ? (plot.width - 2 * POINT_AXIS_PADDING) / (data.length - 1) : Infinity const widest = Math.max(...seriesKeys.flatMap(key => data.map(item => Number(item[key]).toLocaleString().length))) const step = Math.max(1, Math.ceil((widest * CHAR_WIDTH + 6) / spacing)) if (at % step !== 0) return null // Pixel position of any series' point at any category, from this point and the evenly spaced category axis. const scaleOk = typeof yScale === "function" && Number.isFinite(Number(yScale(Number(row[seriesKeys[seriesIndex]])))) && Math.abs(Number(yScale(Number(row[seriesKeys[seriesIndex]]))) - py) < 1.5 const pointOf = (seriesNo: number, i: number): Point | undefined => { if (!scaleOk || !Number.isFinite(spacing)) return undefined const value = Number(data[i]?.[seriesKeys[seriesNo]]) const py2 = Number(yScale?.(value)) return Number.isFinite(value) && Number.isFinite(py2) ? { x: px + (i - at) * spacing, y: py2 } : undefined } const first = Math.max(0, at - 2) const last = Math.min(data.length - 1, at + 2) const bottomLimit = plot ? plot.y + plot.height + 4 : Infinity const topLimit = 2 const cost = (candidate: Placement, taken: Box[]) => { const box = inflate(candidate.box, 1.5) let total = 0 if (candidate.box.bottom > bottomLimit || candidate.box.top < topLimit) total += 200 for (const other of taken) if (boxesOverlap(candidate.box, other)) total += 30 for (let s = 0; s < seriesKeys.length; s += 1) { for (let i = first; i <= last; i += 1) { const here = pointOf(s, i) if (!here) continue if (circleTouchesBox(box, here, DOT_RADIUS + 1)) total += 8 const next = i < last ? pointOf(s, i + 1) : undefined if (next) for (const [a, b] of connection(here, next, curve)) total += segmentHits(box, a, b) } } return total } const place = (seriesNo: number): Placement | null => { const own = seriesNo === seriesIndex ? { x: px, y: py } : pointOf(seriesNo, at) if (!own) return null const taken: Box[] = [] for (let earlier = 0; earlier < seriesNo; earlier += 1) { const placed = place(earlier) if (placed) taken.push(placed.box) } const mine = Number(row[seriesKeys[seriesNo]]) const below = seriesKeys.some((key, i) => i !== seriesNo && (Number(row[key]) > mine || (Number(row[key]) === mine && i < seriesNo))) const width = textOf(seriesKeys[seriesNo]).length * CHAR_WIDTH + 2 let best: Placement | null = null let bestCost = Infinity for (const candidate of candidateBoxes(own, width, below)) { const c = cost(candidate, taken) if (c < bestCost) { best = candidate bestCost = c } if (c === 0) break } // No free spot: omit this label (its Dot and the tooltip stay) rather than touch a line, a Dot or another label. return bestCost === 0 ? best : null } // Without a usable scale (it never happens inside HMLineChart) fall back to "first series above, others below". const fallbackPlacement = (): Placement => { const below = seriesIndex > 0 return candidateBoxes({ x: px, y: py }, textOf(seriesKeys[seriesIndex]).length * CHAR_WIDTH + 2, below)[0] } const placement = scaleOk ? place(seriesIndex) : fallbackPlacement() if (!placement) return null return ( {textOf(seriesKeys[seriesIndex])} ) } /** * Tooltip whose row indicators use each series' per-point color (`colorKeys`: series key -> row field). The legend and the * line keep the series color. */ export function PointColorTooltip({ colorKeys, payload, ...props }: ComponentProps & { colorKeys: Readonly> }) { const colored = payload?.map(item => { const field = colorKeys[String(item.dataKey)] const color = field ? (item.payload as Row | undefined)?.[field] : undefined return typeof color === "string" && color ? { ...item, payload: { ...(item.payload as Row), fill: color } } : item }) return }