mirror of
https://github.com/temetro/temetro.git
synced 2026-07-26 11:58:14 +00:00
321a6298a4
- 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>
390 lines
11 KiB
TypeScript
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;
|