mirror of
https://github.com/temetro/temetro.git
synced 2026-08-27 10:56:58 +00:00
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:
@@ -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;
|
||||
Reference in New Issue
Block a user