Files
temetro/frontend/components/charts/tooltip/chart-tooltip.tsx
T
Khalid Abdi 321a6298a4 frontend: pharmacy inventory, lab add-result & analysis charts
- Pharmacy: the sidebar entry now expands into Pharmacy + a new Inventory page
  (searchable medication stock with derived in-stock/low/out availability,
  backed by /api/inventory). Fix the dispensing-queue "Expiring" badge so it
  only flags courses ending within the next 7 days, not ones already elapsed.
- Lab "Add analysis result": the patient picker no longer lists patients until
  you type and supports arrow-key + Enter selection; the test field offers a
  catalog of common analyses with an Advanced option (custom analysis + ref
  range); submitted results now show immediately in a Recent results feed.
- Analysis: add a Live panel (real-time line chart) and replace the flat trend
  sparklines with bklit-ui area + bar charts (installed from the @bklit shadcn
  registry; chart CSS variables wired into the theme for light and dark).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 19:22:07 +03:00

390 lines
11 KiB
TypeScript

"use client";
import { motion, useSpring } from "motion/react";
import { memo, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import {
resolveTooltipBoxMotion,
type SpringConfig,
useChartConfig,
} from "../chart-config-context";
import {
chartCssVars,
type LineConfig,
useChart,
useChartStable,
} from "../chart-context";
import { weekdayDateFmt } from "../chart-formatters";
import type { IndicatorFadeEdges } from "../indicator-fade";
import { DateTicker } from "./date-ticker";
import { TooltipBox } from "./tooltip-box";
import { TooltipContent, type TooltipRow } from "./tooltip-content";
import { TooltipDot } from "./tooltip-dot";
import { TooltipIndicator } from "./tooltip-indicator";
export interface ChartTooltipProps {
/** Whether to show the date pill at bottom. Default: true */
showDatePill?: boolean;
/** Whether to show the vertical crosshair line. Default: true */
showCrosshair?: boolean;
/** Whether to show dots on the lines. Default: true */
showDots?: boolean;
/**
* Color for the crosshair/indicator line. When a function, receives the hovered point
* (e.g. for candlestick: match candle color from close vs open). Default: --chart-crosshair.
*/
indicatorColor?: string | ((point: Record<string, unknown>) => string);
/** Custom content renderer for the tooltip box */
content?: (props: {
point: Record<string, unknown>;
index: number;
}) => React.ReactNode;
/** Custom row renderer - return array of TooltipRow */
rows?: (point: Record<string, unknown>) => TooltipRow[];
/**
* Override tooltip dot fill. When omitted and `rows` is set, dot colors match row colors.
* When a function, receives the hovered point and line config.
*/
dotColor?:
| string
| ((point: Record<string, unknown>, line: LineConfig) => string);
/** Additional content to show below rows (e.g., markers) */
children?: React.ReactNode;
/** Custom class name */
className?: string;
/** Per-chart override for the crosshair / dot / date-pill spring. */
springConfig?: SpringConfig;
/**
* When `true`, the floating panel uses the crosshair spring and stays in sync.
* Default `false` — panel follow uses `damping` (`20`).
*/
matchCrosshair?: boolean;
/**
* Spring damping for the floating tooltip panel when `matchCrosshair` is `false`.
* `0` disables spring motion (instant). Default: `20`.
*/
damping?: number;
/** SVG stroke dash pattern for the crosshair. Omit for solid. */
indicatorDasharray?: string;
/** Vertical crosshair fade: `both`, `top`, `bottom`, or `none` (solid). Default: `both`. */
indicatorFadeEdges?: IndicatorFadeEdges;
/** Crosshair fade zone size (% of height). Default: `10`. */
indicatorFadeLength?: number;
/** Per-chart override for the floating-panel spring. */
boxSpringConfig?: SpringConfig;
/** Inline styles for the tooltip panel (background, blur, etc.). */
panelStyle?: React.CSSProperties;
}
interface ChartTooltipInnerProps extends ChartTooltipProps {
container: HTMLElement;
}
const ChartTooltipInner = memo(function ChartTooltipInner({
showDatePill = true,
showCrosshair = true,
showDots = true,
indicatorColor: indicatorColorProp,
content,
rows: rowsRenderer,
dotColor: dotColorProp,
children,
className = "",
container,
springConfig,
matchCrosshair = false,
damping,
indicatorDasharray,
indicatorFadeEdges,
indicatorFadeLength,
boxSpringConfig,
panelStyle,
}: ChartTooltipInnerProps) {
const {
tooltipData,
width,
height,
innerHeight,
margin,
columnWidth,
lines,
xAccessor,
dateLabels,
containerRef,
orientation,
barXAccessor,
} = useChart();
const { tooltipSpring } = useChartConfig();
const isHorizontal = orientation === "horizontal";
const discreteInteraction = dateLabels.length > 60;
const boxMotion = useMemo(() => {
if (boxSpringConfig) {
return {
animate: !discreteInteraction,
springConfig: boxSpringConfig,
};
}
if (matchCrosshair) {
return {
animate: !discreteInteraction,
springConfig: springConfig ?? tooltipSpring,
};
}
return resolveTooltipBoxMotion(damping);
}, [
boxSpringConfig,
damping,
discreteInteraction,
matchCrosshair,
springConfig,
tooltipSpring,
]);
const visible = tooltipData !== null;
const x = tooltipData?.x ?? 0;
const xWithMargin = x + margin.left;
// For horizontal charts, get the y position from the first line's yPosition (center of bar)
const firstLineDataKey = lines[0]?.dataKey;
const firstLineY = firstLineDataKey
? (tooltipData?.yPositions[firstLineDataKey] ?? 0)
: 0;
const yWithMargin = firstLineY + margin.top;
const tooltipRows = useMemo(() => {
if (!tooltipData) {
return [];
}
if (rowsRenderer) {
return rowsRenderer(tooltipData.point);
}
// Default: generate rows from registered lines
return lines.map((line) => ({
color: line.stroke,
label: line.dataKey,
value: (tooltipData.point[line.dataKey] as number) ?? 0,
}));
}, [tooltipData, lines, rowsRenderer]);
const resolveDotColor = useMemo(() => {
return (line: LineConfig, index: number): string => {
if (rowsRenderer && tooltipRows[index]?.color) {
return tooltipRows[index].color;
}
if (dotColorProp != null) {
if (typeof dotColorProp === "function" && tooltipData) {
return dotColorProp(tooltipData.point, line);
}
if (typeof dotColorProp === "string") {
return dotColorProp;
}
}
return line.stroke;
};
}, [dotColorProp, rowsRenderer, tooltipData, tooltipRows]);
// Resolve indicator color (static or from hovered point)
const indicatorColor = useMemo(() => {
if (indicatorColorProp == null) {
return chartCssVars.crosshair;
}
if (typeof indicatorColorProp === "function") {
return tooltipData
? indicatorColorProp(tooltipData.point)
: chartCssVars.crosshair;
}
return indicatorColorProp;
}, [indicatorColorProp, tooltipData]);
// Title from date or category
const title = useMemo(() => {
if (!tooltipData) {
return undefined;
}
// For bar charts (horizontal or vertical), use the category name
if (barXAccessor) {
return barXAccessor(tooltipData.point);
}
// For line/area charts, use the date
return weekdayDateFmt.format(xAccessor(tooltipData.point));
}, [tooltipData, barXAccessor, xAccessor]);
const tooltipContent = (
<>
{/* Crosshair indicator - rendered as SVG overlay */}
{showCrosshair && (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0"
height="100%"
width="100%"
>
<g transform={`translate(${margin.left},${margin.top})`}>
<TooltipIndicator
animate={!discreteInteraction}
colorEdge={indicatorColor}
colorMid={indicatorColor}
columnWidth={columnWidth}
fadeEdges={
indicatorDasharray ? "none" : (indicatorFadeEdges ?? "both")
}
fadeLength={indicatorFadeLength}
height={innerHeight}
springConfig={springConfig}
strokeDasharray={indicatorDasharray}
visible={visible}
width="line"
x={x}
/>
</g>
</svg>
)}
{/* Dots on bars/lines - show for vertical charts only */}
{showDots && visible && !isHorizontal && (
<svg
aria-hidden="true"
className="pointer-events-none absolute inset-0"
height="100%"
width="100%"
>
<g transform={`translate(${margin.left},${margin.top})`}>
{lines.map((line, index) => (
<TooltipDot
color={resolveDotColor(line, index)}
key={line.dataKey}
springConfig={springConfig}
strokeColor={chartCssVars.background}
visible={visible}
x={tooltipData?.xPositions?.[line.dataKey] ?? x}
y={tooltipData?.yPositions[line.dataKey] ?? 0}
/>
))}
</g>
</svg>
)}
{/* Tooltip Box */}
<TooltipBox
animate={boxMotion.animate}
className={className}
containerHeight={height}
containerRef={containerRef}
containerWidth={width}
panelStyle={panelStyle}
springConfig={boxMotion.springConfig}
top={isHorizontal ? undefined : margin.top}
visible={visible}
x={xWithMargin}
y={isHorizontal ? yWithMargin : margin.top}
>
{content && tooltipData
? content({
point: tooltipData.point,
index: tooltipData.index,
})
: !content && (
<TooltipContent rows={tooltipRows} title={title}>
{children}
</TooltipContent>
)}
</TooltipBox>
{/* Date/Category Ticker - only show for vertical charts */}
<DatePillTracker
currentIndex={tooltipData?.index ?? 0}
discreteInteraction={discreteInteraction}
enabled={showDatePill && !isHorizontal}
labels={dateLabels}
springConfig={springConfig}
visible={visible}
xWithMargin={xWithMargin}
/>
</>
);
return createPortal(tooltipContent, container);
});
export function ChartTooltip(props: ChartTooltipProps) {
const { containerRef } = useChartStable();
const [mounted, setMounted] = useState(false);
// Only render portals on client side after mount
useEffect(() => {
setMounted(true);
}, []);
const container = containerRef.current;
if (!(mounted && container)) {
return null;
}
return <ChartTooltipInner {...props} container={container} />;
}
ChartTooltip.displayName = "ChartTooltip";
interface DatePillTrackerProps {
enabled: boolean;
visible: boolean;
labels: string[];
currentIndex: number;
xWithMargin: number;
discreteInteraction: boolean;
springConfig?: SpringConfig;
}
// Inner-only-on-visible so `useSpring` initializes at the real cursor x
// instead of `margin.left` on first hover.
function DatePillTracker(props: DatePillTrackerProps) {
if (!(props.enabled && props.visible && props.labels.length > 0)) {
return null;
}
return <DatePillTrackerInner {...props} />;
}
function DatePillTrackerInner({
labels,
currentIndex,
xWithMargin,
discreteInteraction,
springConfig,
visible,
}: DatePillTrackerProps) {
const { tooltipSpring } = useChartConfig();
const effectiveSpring = springConfig ?? tooltipSpring;
const animatedX = useSpring(xWithMargin, effectiveSpring);
if (!discreteInteraction) {
animatedX.set(xWithMargin);
}
// biome-ignore lint/correctness/useExhaustiveDependencies: we need to jump the animatedX when the visible prop changes
useEffect(() => {
animatedX.set(xWithMargin);
}, [animatedX, visible]);
return (
<motion.div
className="pointer-events-none absolute z-50"
style={{
left: discreteInteraction ? xWithMargin : animatedX,
transform: "translateX(-50%)",
bottom: 4,
}}
>
<DateTicker
currentIndex={currentIndex}
labels={labels}
visible={visible}
/>
</motion.div>
);
}
export default ChartTooltip;