Files
temetro/frontend/components/charts/bar-x-axis.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

154 lines
3.7 KiB
TypeScript

"use client";
import { motion } from "motion/react";
import { memo, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { useChart, useChartStable } from "./chart-context";
export interface BarXAxisProps {
/** Width of the date ticker box for fade calculation. Default: 50 */
tickerHalfWidth?: number;
/** Whether to show all labels or skip some for dense data. Default: false */
showAllLabels?: boolean;
/** Maximum number of labels to show. Default: 12 */
maxLabels?: number;
}
interface BarXAxisLabelProps {
label: string;
x: number;
crosshairX: number | null;
isHovering: boolean;
tickerHalfWidth: number;
}
function BarXAxisLabel({
label,
x,
crosshairX,
isHovering,
tickerHalfWidth,
}: BarXAxisLabelProps) {
const fadeBuffer = 20;
const fadeRadius = tickerHalfWidth + fadeBuffer;
let opacity = 1;
if (isHovering && crosshairX !== null) {
const distance = Math.abs(x - crosshairX);
if (distance < tickerHalfWidth) {
opacity = 0;
} else if (distance < fadeRadius) {
opacity = (distance - tickerHalfWidth) / fadeBuffer;
}
}
// Zero-width container approach for perfect centering
return (
<div
className="absolute"
style={{
left: x,
bottom: 12,
width: 0,
display: "flex",
justifyContent: "center",
}}
>
<motion.span
animate={{ opacity }}
className={cn("whitespace-nowrap text-chart-label text-xs")}
initial={{ opacity: 1 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
>
{label}
</motion.span>
</div>
);
}
export function BarXAxis(props: BarXAxisProps) {
const { containerRef, barScale } = useChartStable();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const container = containerRef.current;
if (!(mounted && container)) {
return null;
}
if (!barScale) {
return null;
}
return <BarXAxisInner {...props} container={container} />;
}
const BarXAxisInner = memo(function BarXAxisInner({
tickerHalfWidth = 50,
showAllLabels = false,
maxLabels = 12,
container,
}: BarXAxisProps & { container: HTMLDivElement }) {
const { margin, tooltipData, barScale, bandWidth, barXAccessor, data } =
useChart();
// Generate labels for each bar
const labelsToShow = useMemo(() => {
if (!(barScale && bandWidth && barXAccessor)) {
return [];
}
const allLabels = data.map((d) => {
const label = barXAccessor(d);
const bandX = barScale(label) ?? 0;
// Center the label under the bar group
const x = bandX + bandWidth / 2 + margin.left;
return { label, x };
});
// If showAllLabels is true or we have fewer than maxLabels, show all
if (showAllLabels || allLabels.length <= maxLabels) {
return allLabels;
}
// Otherwise, skip some labels to avoid crowding
const step = Math.ceil(allLabels.length / maxLabels);
return allLabels.filter((_, i) => i % step === 0);
}, [
barScale,
bandWidth,
barXAccessor,
data,
margin.left,
showAllLabels,
maxLabels,
]);
const isHovering = tooltipData !== null;
const crosshairX = tooltipData ? tooltipData.x + margin.left : null;
return createPortal(
<div className="pointer-events-none absolute inset-0">
{labelsToShow.map((item) => (
<BarXAxisLabel
crosshairX={crosshairX}
isHovering={isHovering}
key={`${item.label}-${item.x}`}
label={item.label}
tickerHalfWidth={tickerHalfWidth}
x={item.x}
/>
))}
</div>,
container
);
});
BarXAxis.displayName = "BarXAxis";
export default BarXAxis;