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>
This commit is contained in:
Khalid Abdi
2026-06-12 19:22:07 +03:00
parent e2bcb2cfbc
commit 321a6298a4
81 changed files with 11538 additions and 67 deletions
@@ -0,0 +1,389 @@
"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;
@@ -0,0 +1,150 @@
"use client";
import { motion, useSpring } from "motion/react";
import { memo, useMemo, useRef } from "react";
const TICKER_ITEM_HEIGHT = 24;
/** Full scroll stacks are skipped above this count — single label + instant updates. */
const COMPACT_TICKER_THRESHOLD = 60;
export interface DateTickerProps {
currentIndex: number;
labels: string[];
visible: boolean;
}
const DateTickerCompact = memo(function DateTickerCompact({
currentIndex,
labels,
}: Omit<DateTickerProps, "visible">) {
const label = labels[currentIndex] ?? labels[0] ?? "";
return (
<div className="overflow-hidden rounded-full bg-zinc-900 px-4 py-1 text-white shadow-lg dark:bg-zinc-100 dark:text-zinc-900">
<div className="flex h-6 items-center justify-center">
<span className="whitespace-nowrap font-medium text-sm">{label}</span>
</div>
</div>
);
});
const DateTickerInner = memo(function DateTickerInner({
currentIndex,
labels,
}: Omit<DateTickerProps, "visible">) {
// Parse labels into month and day parts
const parsedLabels = useMemo(() => {
return labels.map((label, index) => {
const parts = label.split(" ");
const month = parts[0] || "";
const day = parts[1] || "";
return { month, day, full: label, key: `${label}::${index}` };
});
}, [labels]);
// Month segments: one entry per consecutive run (Jan → Feb → …), keyed by start index
const monthSegments = useMemo(() => {
const segments: { month: string; key: string; startIndex: number }[] = [];
parsedLabels.forEach((label, index) => {
const prev = segments.at(-1);
if (!prev || prev.month !== label.month) {
segments.push({
month: label.month,
key: `${label.month}-${index}`,
startIndex: index,
});
}
});
return segments;
}, [parsedLabels]);
// Index into monthSegments for the current data point
const currentMonthIndex = useMemo(() => {
if (currentIndex < 0 || currentIndex >= parsedLabels.length) {
return 0;
}
for (let i = monthSegments.length - 1; i >= 0; i--) {
const segment = monthSegments[i];
if (segment && segment.startIndex <= currentIndex) {
return i;
}
}
return 0;
}, [currentIndex, parsedLabels.length, monthSegments]);
// Track previous month index
const prevMonthIndexRef = useRef(-1);
// Animated Y offsets
const dayY = useSpring(0, { stiffness: 400, damping: 35 });
const monthY = useSpring(0, { stiffness: 400, damping: 35 });
dayY.set(-currentIndex * TICKER_ITEM_HEIGHT);
if (currentMonthIndex >= 0) {
const isFirstRender = prevMonthIndexRef.current === -1;
const monthChanged = prevMonthIndexRef.current !== currentMonthIndex;
if (isFirstRender || monthChanged) {
monthY.set(-currentMonthIndex * TICKER_ITEM_HEIGHT);
prevMonthIndexRef.current = currentMonthIndex;
}
}
return (
<div className="overflow-hidden rounded-full bg-zinc-900 px-4 py-1 text-white shadow-lg dark:bg-zinc-100 dark:text-zinc-900">
<div className="relative h-6 overflow-hidden">
<div className="flex items-center justify-center gap-1">
{/* Month stack */}
<div className="relative h-6 overflow-hidden">
<motion.div className="flex flex-col" style={{ y: monthY }}>
{monthSegments.map((segment) => (
<div
className="flex h-6 shrink-0 items-center justify-center"
key={segment.key}
>
<span className="whitespace-nowrap font-medium text-sm">
{segment.month}
</span>
</div>
))}
</motion.div>
</div>
{/* Day stack */}
<div className="relative h-6 overflow-hidden">
<motion.div className="flex flex-col" style={{ y: dayY }}>
{parsedLabels.map((label) => (
<div
className="flex h-6 shrink-0 items-center justify-center"
key={label.key}
>
<span className="whitespace-nowrap font-medium text-sm">
{label.day}
</span>
</div>
))}
</motion.div>
</div>
</div>
</div>
</div>
);
});
export function DateTicker({ currentIndex, labels, visible }: DateTickerProps) {
if (!visible || labels.length === 0) {
return null;
}
if (labels.length > COMPACT_TICKER_THRESHOLD) {
return <DateTickerCompact currentIndex={currentIndex} labels={labels} />;
}
return <DateTickerInner currentIndex={currentIndex} labels={labels} />;
}
DateTicker.displayName = "DateTicker";
export default DateTicker;
@@ -0,0 +1,14 @@
export { ChartTooltip, type ChartTooltipProps } from "./chart-tooltip";
export { DateTicker, type DateTickerProps } from "./date-ticker";
export { TooltipBox, type TooltipBoxProps } from "./tooltip-box";
export {
TooltipContent,
type TooltipContentProps,
type TooltipRow,
} from "./tooltip-content";
export { TooltipDot, type TooltipDotProps } from "./tooltip-dot";
export {
type IndicatorWidth,
TooltipIndicator,
type TooltipIndicatorProps,
} from "./tooltip-indicator";
@@ -0,0 +1,195 @@
"use client";
import { motion, useSpring } from "motion/react";
import type { RefObject } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { type SpringConfig, useChartConfig } from "../chart-config-context";
export interface TooltipBoxProps {
/** X position in pixels (relative to container) */
x: number;
/** Y position in pixels (relative to container) */
y: number;
/** Whether the tooltip is visible */
visible: boolean;
/** Container ref for portal rendering */
containerRef: RefObject<HTMLDivElement | null>;
/** Container width for flip detection */
containerWidth: number;
/** Container height for bounds clamping */
containerHeight: number;
/** Offset from the target position */
offset?: number;
/** Custom class name */
className?: string;
/** Tooltip content */
children: React.ReactNode;
/** Override left position (bypasses internal calculation) */
left?: number | ReturnType<typeof useSpring>;
/** Override top position (bypasses internal calculation) */
top?: number | ReturnType<typeof useSpring>;
/** Force flip direction (for custom positioning) */
flipped?: boolean;
/** Per-chart override; falls back to `ChartConfigProvider.tooltipBoxSpring`. */
springConfig?: SpringConfig;
/** Animate panel position with a spring. Default: true */
animate?: boolean;
/** Inline styles for the inner tooltip panel. */
panelStyle?: React.CSSProperties;
}
// Inner-only-on-visible so `useSpring` initializes at the cursor's actual x/y
// instead of (0, 0) on first hover.
export function TooltipBox(props: TooltipBoxProps) {
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const container = props.containerRef.current;
if (!(mounted && container)) {
return null;
}
if (!props.visible) {
return null;
}
return <TooltipBoxInner {...props} container={container} />;
}
function TooltipBoxInner({
x,
y,
containerWidth,
containerHeight,
offset = 16,
className = "",
children,
left: leftOverride,
top: topOverride,
flipped: flippedOverride,
springConfig,
animate = true,
panelStyle,
container,
}: Omit<TooltipBoxProps, "visible" | "containerRef"> & {
container: HTMLElement;
}) {
const { tooltipBoxSpring } = useChartConfig();
const effectiveSpring = springConfig ?? tooltipBoxSpring;
const tooltipRef = useRef<HTMLDivElement>(null);
const tooltipWidthRef = useRef(180);
const tooltipHeightRef = useRef(80);
const [staticPosition, setStaticPosition] = useState({ left: x, top: y });
const tw = tooltipWidthRef.current;
const th = tooltipHeightRef.current;
const shouldFlipX = x + tw + offset > containerWidth;
const targetX = shouldFlipX ? x - offset - tw : x + offset;
const targetY = Math.max(
offset,
Math.min(y - th / 2, containerHeight - th - offset)
);
const animatedLeft = useSpring(targetX, effectiveSpring);
const animatedTop = useSpring(targetY, effectiveSpring);
if (animate && leftOverride === undefined) {
animatedLeft.set(targetX);
}
if (animate && topOverride === undefined) {
animatedTop.set(targetY);
}
useLayoutEffect(() => {
if (!tooltipRef.current) {
return;
}
const el = tooltipRef.current;
const w = el.offsetWidth;
const h = el.offsetHeight;
if (w > 0) {
tooltipWidthRef.current = w;
}
if (h > 0) {
tooltipHeightRef.current = h;
}
const w2 = tooltipWidthRef.current;
const h2 = tooltipHeightRef.current;
const flip = x + w2 + offset > containerWidth;
const tx = flip ? x - offset - w2 : x + offset;
const ty = Math.max(
offset,
Math.min(y - h2 / 2, containerHeight - h2 - offset)
);
if (!animate) {
setStaticPosition({ left: tx, top: ty });
return;
}
if (leftOverride === undefined) {
animatedLeft.set(tx);
}
if (topOverride === undefined) {
animatedTop.set(ty);
}
}, [
x,
y,
containerWidth,
containerHeight,
offset,
leftOverride,
topOverride,
animate,
animatedLeft,
animatedTop,
]);
const prevFlipRef = useRef(shouldFlipX);
const [flipKey, setFlipKey] = useState(0);
useEffect(() => {
if (prevFlipRef.current !== shouldFlipX) {
setFlipKey((k) => k + 1);
prevFlipRef.current = shouldFlipX;
}
}, [shouldFlipX]);
const finalLeft = animate
? (leftOverride ?? animatedLeft)
: staticPosition.left;
const finalTop = animate ? (topOverride ?? animatedTop) : staticPosition.top;
const isFlipped = flippedOverride ?? shouldFlipX;
const transformOrigin = isFlipped ? "right top" : "left top";
return createPortal(
<motion.div
animate={{ opacity: 1 }}
className={cn("pointer-events-none absolute z-50", className)}
exit={{ opacity: 0 }}
initial={{ opacity: 0 }}
ref={tooltipRef}
style={{ left: finalLeft, top: finalTop }}
transition={{ duration: 0.1 }}
>
<motion.div
animate={{ scale: 1, opacity: 1, x: 0 }}
className="min-w-[140px] overflow-hidden rounded-lg bg-chart-tooltip-background text-chart-tooltip-foreground shadow-lg backdrop-blur-md"
initial={{ scale: 0.85, opacity: 0, x: isFlipped ? 20 : -20 }}
key={flipKey}
style={{ transformOrigin, ...panelStyle }}
transition={{ type: "spring", stiffness: 300, damping: 25 }}
>
{children}
</motion.div>
</motion.div>,
container
);
}
TooltipBox.displayName = "TooltipBox";
export default TooltipBox;
@@ -0,0 +1,62 @@
"use client";
import type { ReactNode } from "react";
import { intFmt } from "../chart-formatters";
export interface TooltipRow {
color: string;
label: string;
value: string | number;
}
export interface TooltipContentProps {
title?: string;
rows: TooltipRow[];
/** Optional additional content (e.g., markers) */
children?: ReactNode;
}
export function TooltipContent({ title, rows, children }: TooltipContentProps) {
return (
<div className="overflow-hidden">
<div className="px-3 py-2.5">
{title && (
<div className="mb-2 font-medium text-chart-tooltip-foreground text-xs">
{title}
</div>
)}
<div className="space-y-1.5">
{rows.map((row) => (
<div
className="flex items-center justify-between gap-4"
key={`${row.label}-${row.color}`}
>
<div className="flex items-center gap-2">
<span
className="h-2.5 w-2.5 shrink-0 rounded-full"
style={{ backgroundColor: row.color }}
/>
<span className="text-chart-tooltip-muted text-sm">
{row.label}
</span>
</div>
<span className="font-medium text-chart-tooltip-foreground text-sm tabular-nums">
{typeof row.value === "number" ? intFmt(row.value) : row.value}
</span>
</div>
))}
</div>
{children && (
<div className="mt-2 transition-opacity duration-200 ease-out">
{children}
</div>
)}
</div>
</div>
);
}
TooltipContent.displayName = "TooltipContent";
export default TooltipContent;
@@ -0,0 +1,73 @@
"use client";
import { motion, useSpring } from "motion/react";
import { type SpringConfig, useChartConfig } from "../chart-config-context";
import { chartCssVars } from "../chart-context";
export interface TooltipDotProps {
x: number;
y: number;
visible: boolean;
color: string;
size?: number;
strokeColor?: string;
strokeWidth?: number;
/** Per-chart override; falls back to `ChartConfigProvider.tooltipSpring`. */
springConfig?: SpringConfig;
/** Animate position with a spring. Default: true */
animate?: boolean;
}
export function TooltipDot({
x,
y,
visible,
color,
size = 5,
strokeColor = chartCssVars.background,
strokeWidth = 2,
springConfig,
animate = true,
}: TooltipDotProps) {
const { tooltipSpring } = useChartConfig();
const effectiveSpring = springConfig ?? tooltipSpring;
const animatedX = useSpring(x, effectiveSpring);
const animatedY = useSpring(y, effectiveSpring);
if (animate) {
animatedX.set(x);
animatedY.set(y);
}
if (!visible) {
return null;
}
if (!animate) {
return (
<circle
cx={x}
cy={y}
fill={color}
r={size}
stroke={strokeColor}
strokeWidth={strokeWidth}
/>
);
}
return (
<motion.circle
cx={animatedX}
cy={animatedY}
fill={color}
r={size}
stroke={strokeColor}
strokeWidth={strokeWidth}
/>
);
}
TooltipDot.displayName = "TooltipDot";
export default TooltipDot;
@@ -0,0 +1,211 @@
"use client";
import { motion, useSpring } from "motion/react";
import { useEffect } from "react";
import { type SpringConfig, useChartConfig } from "../chart-config-context";
import { chartCssVars } from "../chart-context";
import {
type IndicatorFadeEdges,
indicatorFadeGradientStops,
resolveVerticalFadeSides,
} from "../indicator-fade";
export type IndicatorWidth =
| number // Pixel width
| "line" // 1px line (default)
| "thin" // 2px
| "medium" // 4px
| "thick"; // 8px
export interface TooltipIndicatorProps {
/** X position in pixels (center of the indicator) */
x: number;
/** Height of the indicator */
height: number;
/** Whether the indicator is visible */
visible: boolean;
/**
* Width of the indicator - number (pixels) or preset.
* Ignored if `span` is provided.
*/
width?: IndicatorWidth;
/**
* Number of columns/days to span, with current point centered.
* Requires `columnWidth` to be set.
*/
span?: number;
/** Width of a single column/day in pixels. Required when using `span`. */
columnWidth?: number;
/** Primary color at edges (10% and 90%) */
colorEdge?: string;
/** Secondary color at center (50%) */
colorMid?: string;
/** Vertical fade: both ends, top, bottom, or none (solid). */
fadeEdges?: IndicatorFadeEdges | boolean;
/** Fade zone size as a percentage of indicator height. Default: 10 */
fadeLength?: number;
/** Animate position with a spring. Default: true */
animate?: boolean;
/** Unique ID for the gradient */
gradientId?: string;
/** Per-chart override; falls back to `ChartConfigProvider.tooltipSpring`. */
springConfig?: SpringConfig;
/** SVG stroke dash pattern. When set, renders a dashed stroke instead of a solid fill. */
strokeDasharray?: string;
}
function resolveWidth(width: IndicatorWidth): number {
if (typeof width === "number") {
return width;
}
switch (width) {
case "line":
return 1;
case "thin":
return 2;
case "medium":
return 4;
case "thick":
return 8;
default:
return 1;
}
}
// Inner-only-on-visible so `useSpring` initializes at the real cursor x
// instead of 0 on first hover.
export function TooltipIndicator(props: TooltipIndicatorProps) {
if (!props.visible) {
return null;
}
return <TooltipIndicatorInner {...props} />;
}
function TooltipIndicatorInner({
x,
visible,
height,
width = "line",
span,
columnWidth,
colorEdge = chartCssVars.crosshair,
colorMid = chartCssVars.crosshair,
fadeEdges = "both",
fadeLength = 10,
animate = true,
gradientId = "tooltip-indicator-gradient",
springConfig,
strokeDasharray,
}: TooltipIndicatorProps) {
const { tooltipSpring } = useChartConfig();
const effectiveSpring = springConfig ?? tooltipSpring;
const pixelWidth =
span !== undefined && columnWidth !== undefined
? span * columnWidth
: resolveWidth(width);
const rectX = x - pixelWidth / 2;
const lineX = x;
const animatedX = useSpring(rectX, effectiveSpring);
const animatedLineX = useSpring(lineX, effectiveSpring);
if (animate) {
animatedX.set(rectX);
animatedLineX.set(lineX);
}
// biome-ignore lint/correctness/useExhaustiveDependencies: we need to jump the animatedX when the visible prop changes
useEffect(() => {
animatedX.set(rectX);
animatedLineX.set(lineX);
}, [animatedLineX, animatedX, lineX, rectX, visible]);
const indicatorFill = colorMid || colorEdge;
const fadeSides = resolveVerticalFadeSides(fadeEdges);
const dashed = Boolean(strokeDasharray);
if (dashed) {
const strokeWidth = Math.max(1, pixelWidth);
return animate ? (
<motion.line
stroke={indicatorFill}
strokeDasharray={strokeDasharray}
strokeWidth={strokeWidth}
x1={animatedLineX}
x2={animatedLineX}
y1={0}
y2={height}
/>
) : (
<line
stroke={indicatorFill}
strokeDasharray={strokeDasharray}
strokeWidth={strokeWidth}
x1={lineX}
x2={lineX}
y1={0}
y2={height}
/>
);
}
if (!fadeSides.any) {
return animate ? (
<motion.rect
fill={indicatorFill}
height={height}
width={pixelWidth}
x={animatedX}
y={0}
/>
) : (
<rect
fill={indicatorFill}
height={height}
width={pixelWidth}
x={rectX}
y={0}
/>
);
}
const fadeStops = indicatorFadeGradientStops(fadeSides, fadeLength);
return (
<g>
<defs>
<linearGradient id={gradientId} x1="0%" x2="0%" y1="0%" y2="100%">
{fadeStops.map((stop) => (
<stop
key={stop.offset}
offset={stop.offset}
style={{ stopColor: indicatorFill, stopOpacity: stop.opacity }}
/>
))}
</linearGradient>
</defs>
{animate ? (
<motion.rect
fill={`url(#${gradientId})`}
height={height}
width={pixelWidth}
x={animatedX}
y={0}
/>
) : (
<rect
fill={`url(#${gradientId})`}
height={height}
width={pixelWidth}
x={rectX}
y={0}
/>
)}
</g>
);
}
TooltipIndicator.displayName = "TooltipIndicator";
export default TooltipIndicator;