mirror of
https://github.com/temetro/temetro.git
synced 2026-08-08 17:53:15 +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>
562 lines
13 KiB
TypeScript
562 lines
13 KiB
TypeScript
"use client";
|
|
|
|
import { memo, useEffect, useMemo, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { cn } from "@/lib/utils";
|
|
import { useChart, useChartStable } from "./chart-context";
|
|
import { shortDateFmt } from "./chart-formatters";
|
|
import { DEFAULT_Y_DOMAIN_TWEEN_MS } from "./chart-phase";
|
|
import { LINE_LOADING_PULSE_EASE } from "./line-loading-timing";
|
|
|
|
const X_AXIS_POSITION_TWEEN_MS = DEFAULT_Y_DOMAIN_TWEEN_MS;
|
|
|
|
export interface XAxisProps {
|
|
/** Number of ticks to show (including first and last). Default: 5. Used when `tickMode` is `"domain"`. */
|
|
numTicks?: number;
|
|
/** Width of the date ticker box for fade calculation. Default: 50 */
|
|
tickerHalfWidth?: number;
|
|
/**
|
|
* `"domain"` — evenly spaced ticks across the time domain (default).
|
|
* `"data"` — one label per data row at its x value (better with sparse or monthly bars).
|
|
*/
|
|
tickMode?: "domain" | "data";
|
|
}
|
|
|
|
interface AxisTick {
|
|
date: Date;
|
|
x: number;
|
|
label: string;
|
|
}
|
|
|
|
interface XAxisLabelProps {
|
|
label: string;
|
|
x: number;
|
|
crosshairX: number | null;
|
|
hoveredLabel: string | null;
|
|
isHovering: boolean;
|
|
tickerHalfWidth: number;
|
|
animatePosition: boolean;
|
|
}
|
|
|
|
function XAxisLabel({
|
|
label,
|
|
x,
|
|
crosshairX,
|
|
hoveredLabel,
|
|
isHovering,
|
|
tickerHalfWidth,
|
|
animatePosition,
|
|
}: XAxisLabelProps) {
|
|
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 (hoveredLabel && label === hoveredLabel) {
|
|
opacity = 0;
|
|
} else if (distance < fadeRadius) {
|
|
opacity = (distance - tickerHalfWidth) / fadeBuffer;
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className="absolute"
|
|
style={{
|
|
left: x,
|
|
bottom: 12,
|
|
width: 0,
|
|
display: "flex",
|
|
justifyContent: "center",
|
|
transition: animatePosition
|
|
? `left ${X_AXIS_POSITION_TWEEN_MS}ms cubic-bezier(${LINE_LOADING_PULSE_EASE.join(", ")})`
|
|
: undefined,
|
|
}}
|
|
>
|
|
<span
|
|
className={cn("whitespace-nowrap text-chart-label text-xs")}
|
|
style={{
|
|
opacity,
|
|
transition: "opacity 0.4s ease-in-out",
|
|
}}
|
|
>
|
|
{label}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const MAX_GAP_LAYOUTS = 400;
|
|
|
|
function binomial(n: number, k: number): number {
|
|
if (k < 0 || k > n) {
|
|
return 0;
|
|
}
|
|
let result = 1;
|
|
for (let i = 0; i < k; i++) {
|
|
result = (result * (n - i)) / (i + 1);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
/** All ways to split `span` into `parts` positive integer gaps. */
|
|
function composePositiveSum(sum: number, parts: number): number[][] {
|
|
if (parts === 1) {
|
|
return sum >= 1 ? [[sum]] : [];
|
|
}
|
|
|
|
const layouts: number[][] = [];
|
|
for (let gap = 1; gap <= sum - (parts - 1); gap++) {
|
|
for (const tail of composePositiveSum(sum - gap, parts - 1)) {
|
|
layouts.push([gap, ...tail]);
|
|
}
|
|
}
|
|
return layouts;
|
|
}
|
|
|
|
function gapsToIndices(gaps: number[]): number[] {
|
|
const indices = [0];
|
|
let position = 0;
|
|
for (const gap of gaps) {
|
|
position += gap;
|
|
indices.push(position);
|
|
}
|
|
return indices;
|
|
}
|
|
|
|
function indicesForTickCount(length: number, tickCount: number): number[] {
|
|
const span = length - 1;
|
|
if (span <= 0) {
|
|
return [0];
|
|
}
|
|
|
|
const rawIndices = Array.from({ length: tickCount }, (_, index) =>
|
|
Math.round((index / (tickCount - 1)) * span)
|
|
);
|
|
|
|
const indices = [...new Set(rawIndices)].sort((a, b) => a - b);
|
|
if (indices[0] !== 0) {
|
|
indices.unshift(0);
|
|
}
|
|
if (indices.at(-1) !== span) {
|
|
indices.push(span);
|
|
}
|
|
|
|
return [...new Set(indices)].sort((a, b) => a - b);
|
|
}
|
|
|
|
function allIndexLayouts(length: number, tickCount: number): number[][] {
|
|
const span = length - 1;
|
|
if (span <= 0) {
|
|
return [[0]];
|
|
}
|
|
|
|
const gapCount = tickCount - 1;
|
|
if (gapCount <= 0) {
|
|
return [[0]];
|
|
}
|
|
|
|
const layoutCount = binomial(span - 1, gapCount - 1);
|
|
if (layoutCount > MAX_GAP_LAYOUTS) {
|
|
return [indicesForTickCount(length, tickCount)];
|
|
}
|
|
|
|
return composePositiveSum(span, gapCount).map(gapsToIndices);
|
|
}
|
|
|
|
function dedupeIndicesByLabel(
|
|
indices: number[],
|
|
data: Record<string, unknown>[],
|
|
dateLabels: string[],
|
|
xAccessor: (d: Record<string, unknown>) => Date
|
|
): number[] {
|
|
const seenLabels = new Set<string>();
|
|
const deduped: number[] = [];
|
|
|
|
for (const index of indices) {
|
|
const point = data[index];
|
|
if (!point) {
|
|
continue;
|
|
}
|
|
const label = dateLabels[index] ?? shortDateFmt.format(xAccessor(point));
|
|
if (seenLabels.has(label)) {
|
|
continue;
|
|
}
|
|
seenLabels.add(label);
|
|
deduped.push(index);
|
|
}
|
|
|
|
return deduped;
|
|
}
|
|
|
|
interface TickLayoutScore {
|
|
score: number;
|
|
symmetryPenalty: number;
|
|
countDistance: number;
|
|
/** 0 = smallest gap at end, 1 = at start, 2 = in the middle */
|
|
edgePreference: number;
|
|
}
|
|
|
|
function indexGaps(indices: number[]): number[] {
|
|
const gaps: number[] = [];
|
|
for (let i = 1; i < indices.length; i++) {
|
|
const current = indices[i];
|
|
const previous = indices[i - 1];
|
|
if (current == null || previous == null) {
|
|
continue;
|
|
}
|
|
gaps.push(current - previous);
|
|
}
|
|
return gaps;
|
|
}
|
|
|
|
function smallestGapEdgePreference(indices: number[]): number {
|
|
const gaps = indexGaps(indices);
|
|
const smallestGap = Math.min(...gaps);
|
|
const smallestGapIndex = gaps.indexOf(smallestGap);
|
|
if (smallestGapIndex === gaps.length - 1) {
|
|
return 0;
|
|
}
|
|
if (smallestGapIndex === 0) {
|
|
return 1;
|
|
}
|
|
return 2;
|
|
}
|
|
|
|
function scoreTickLayout(
|
|
indices: number[],
|
|
resolveXPx: (index: number) => number,
|
|
targetCount: number
|
|
): TickLayoutScore {
|
|
if (indices.length < 2) {
|
|
return {
|
|
score: Number.POSITIVE_INFINITY,
|
|
symmetryPenalty: Number.POSITIVE_INFINITY,
|
|
countDistance: Number.POSITIVE_INFINITY,
|
|
edgePreference: Number.POSITIVE_INFINITY,
|
|
};
|
|
}
|
|
|
|
const pixelGaps: number[] = [];
|
|
for (let i = 1; i < indices.length; i++) {
|
|
const current = indices[i];
|
|
const previous = indices[i - 1];
|
|
if (current == null || previous == null) {
|
|
continue;
|
|
}
|
|
pixelGaps.push(resolveXPx(current) - resolveXPx(previous));
|
|
}
|
|
|
|
const minGap = Math.min(...pixelGaps);
|
|
const maxGap = Math.max(...pixelGaps);
|
|
const meanGap =
|
|
pixelGaps.reduce((sum, gap) => sum + gap, 0) / pixelGaps.length;
|
|
const spreadRatio =
|
|
meanGap > 0 ? (maxGap - minGap) / meanGap : maxGap - minGap;
|
|
const countDistance = Math.abs(indices.length - targetCount);
|
|
|
|
const gaps = indexGaps(indices);
|
|
const smallestGap = Math.min(...gaps);
|
|
const smallestGapIndex = gaps.indexOf(smallestGap);
|
|
const interiorPenalty =
|
|
smallestGapIndex > 0 && smallestGapIndex < gaps.length - 1 ? 0.08 : 0;
|
|
|
|
const symmetryPenalty =
|
|
gaps.reduce((penalty, gap, index) => {
|
|
return penalty + Math.abs(gap - (gaps.at(-1 - index) ?? gap));
|
|
}, 0) / gaps.length;
|
|
|
|
return {
|
|
score:
|
|
spreadRatio +
|
|
0.1 * countDistance +
|
|
interiorPenalty +
|
|
symmetryPenalty * 0.02,
|
|
symmetryPenalty,
|
|
countDistance,
|
|
edgePreference: smallestGapEdgePreference(indices),
|
|
};
|
|
}
|
|
|
|
function isBetterTickLayout(
|
|
next: TickLayoutScore,
|
|
best: TickLayoutScore,
|
|
nextCountDistance: number,
|
|
bestCountDistance: number
|
|
): boolean {
|
|
if (next.score < best.score - 1e-6) {
|
|
return true;
|
|
}
|
|
if (Math.abs(next.score - best.score) > 1e-6) {
|
|
return false;
|
|
}
|
|
if (nextCountDistance < bestCountDistance) {
|
|
return true;
|
|
}
|
|
if (nextCountDistance > bestCountDistance) {
|
|
return false;
|
|
}
|
|
if (next.symmetryPenalty < best.symmetryPenalty - 1e-6) {
|
|
return true;
|
|
}
|
|
if (next.symmetryPenalty > best.symmetryPenalty + 1e-6) {
|
|
return false;
|
|
}
|
|
return next.edgePreference < best.edgePreference;
|
|
}
|
|
|
|
/**
|
|
* Picks tick indices with the most even on-screen spacing. Tries
|
|
* `targetCount ± 1` and evaluates every gap layout when feasible.
|
|
*/
|
|
export function selectEvenlySpacedIndices(
|
|
length: number,
|
|
targetCount: number,
|
|
options?: {
|
|
data?: Record<string, unknown>[];
|
|
dateLabels?: string[];
|
|
xAccessor?: (d: Record<string, unknown>) => Date;
|
|
resolveXPx?: (index: number) => number;
|
|
}
|
|
): number[] {
|
|
if (length <= 0) {
|
|
return [];
|
|
}
|
|
if (length === 1) {
|
|
return [0];
|
|
}
|
|
if (length <= targetCount) {
|
|
return Array.from({ length }, (_, index) => index);
|
|
}
|
|
|
|
const resolveXPx = options?.resolveXPx ?? ((index: number) => index);
|
|
|
|
const minCount = Math.max(2, targetCount - 1);
|
|
const maxCount = Math.min(length, targetCount + 1);
|
|
|
|
let bestIndices = indicesForTickCount(length, targetCount);
|
|
let bestScore = scoreTickLayout(bestIndices, resolveXPx, targetCount);
|
|
let bestCountDistance = bestScore.countDistance;
|
|
|
|
for (let tickCount = minCount; tickCount <= maxCount; tickCount++) {
|
|
for (const rawIndices of allIndexLayouts(length, tickCount)) {
|
|
const indices =
|
|
options?.data && options.dateLabels && options.xAccessor
|
|
? dedupeIndicesByLabel(
|
|
rawIndices,
|
|
options.data,
|
|
options.dateLabels,
|
|
options.xAccessor
|
|
)
|
|
: rawIndices;
|
|
|
|
if (indices.length < 2) {
|
|
continue;
|
|
}
|
|
|
|
const layoutScore = scoreTickLayout(indices, resolveXPx, targetCount);
|
|
const countDistance = Math.abs(indices.length - targetCount);
|
|
|
|
if (
|
|
isBetterTickLayout(
|
|
layoutScore,
|
|
bestScore,
|
|
countDistance,
|
|
bestCountDistance
|
|
)
|
|
) {
|
|
bestIndices = indices;
|
|
bestScore = layoutScore;
|
|
bestCountDistance = countDistance;
|
|
}
|
|
}
|
|
}
|
|
|
|
return bestIndices;
|
|
}
|
|
|
|
function buildDataAlignedTicks({
|
|
data,
|
|
dateLabels,
|
|
marginLeft,
|
|
targetTickCount,
|
|
xAccessor,
|
|
xScale,
|
|
}: {
|
|
data: Record<string, unknown>[];
|
|
dateLabels: string[];
|
|
marginLeft: number;
|
|
targetTickCount: number;
|
|
xAccessor: (d: Record<string, unknown>) => Date;
|
|
xScale: (date: Date) => number | undefined;
|
|
}): AxisTick[] {
|
|
const seenLabels = new Set<string>();
|
|
const ticks: AxisTick[] = [];
|
|
|
|
const resolveXPx = (index: number) => {
|
|
const point = data[index];
|
|
if (!point) {
|
|
return index;
|
|
}
|
|
return xScale(xAccessor(point)) ?? 0;
|
|
};
|
|
|
|
for (const index of selectEvenlySpacedIndices(data.length, targetTickCount, {
|
|
data,
|
|
dateLabels,
|
|
resolveXPx,
|
|
xAccessor,
|
|
})) {
|
|
const point = data[index];
|
|
if (!point) {
|
|
continue;
|
|
}
|
|
const date = xAccessor(point);
|
|
const label = dateLabels[index] ?? shortDateFmt.format(date);
|
|
if (seenLabels.has(label)) {
|
|
continue;
|
|
}
|
|
seenLabels.add(label);
|
|
ticks.push({
|
|
date,
|
|
label,
|
|
x: (xScale(date) ?? 0) + marginLeft,
|
|
});
|
|
}
|
|
|
|
return ticks;
|
|
}
|
|
|
|
function buildDomainTicks({
|
|
marginLeft,
|
|
numTicks,
|
|
xScale,
|
|
}: {
|
|
marginLeft: number;
|
|
numTicks: number;
|
|
xScale: {
|
|
domain: () => Date[];
|
|
(date: Date): number | undefined;
|
|
};
|
|
}): AxisTick[] {
|
|
const domain = xScale.domain();
|
|
const startDate = domain[0];
|
|
const endDate = domain[1];
|
|
|
|
if (!(startDate && endDate)) {
|
|
return [];
|
|
}
|
|
|
|
const startTime = startDate.getTime();
|
|
const endTime = endDate.getTime();
|
|
const timeRange = endTime - startTime;
|
|
const tickCount = Math.max(2, numTicks);
|
|
const seenLabels = new Set<string>();
|
|
const ticks: AxisTick[] = [];
|
|
|
|
for (let i = 0; i < tickCount; i++) {
|
|
const t = i / (tickCount - 1);
|
|
const date = new Date(startTime + t * timeRange);
|
|
const label = shortDateFmt.format(date);
|
|
if (seenLabels.has(label)) {
|
|
continue;
|
|
}
|
|
seenLabels.add(label);
|
|
ticks.push({
|
|
date,
|
|
label,
|
|
x: (xScale(date) ?? 0) + marginLeft,
|
|
});
|
|
}
|
|
|
|
return ticks;
|
|
}
|
|
|
|
export function XAxis(props: XAxisProps) {
|
|
const { containerRef } = useChartStable();
|
|
const [mounted, setMounted] = useState(false);
|
|
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
const container = containerRef.current;
|
|
if (!(mounted && container)) {
|
|
return null;
|
|
}
|
|
|
|
return <XAxisInner {...props} container={container} />;
|
|
}
|
|
|
|
const XAxisInner = memo(function XAxisInner({
|
|
numTicks = 5,
|
|
tickerHalfWidth = 50,
|
|
tickMode = "domain",
|
|
container,
|
|
}: XAxisProps & { container: HTMLDivElement }) {
|
|
const { xScale, margin, tooltipData, data, xAccessor, dateLabels, xDomain } =
|
|
useChart();
|
|
|
|
const labelsToShow = useMemo(() => {
|
|
// Brush (any extent): snap ticks to data rows with even index spacing.
|
|
if (tickMode === "data" || xDomain != null) {
|
|
return buildDataAlignedTicks({
|
|
data,
|
|
dateLabels,
|
|
marginLeft: margin.left,
|
|
targetTickCount: numTicks,
|
|
xAccessor,
|
|
xScale,
|
|
});
|
|
}
|
|
|
|
return buildDomainTicks({
|
|
marginLeft: margin.left,
|
|
numTicks,
|
|
xScale,
|
|
});
|
|
}, [
|
|
tickMode,
|
|
xDomain,
|
|
data,
|
|
dateLabels,
|
|
xAccessor,
|
|
xScale,
|
|
margin.left,
|
|
numTicks,
|
|
]);
|
|
|
|
const isHovering = tooltipData !== null;
|
|
const crosshairX = tooltipData ? tooltipData.x + margin.left : null;
|
|
const hoveredLabel =
|
|
isHovering && tooltipData
|
|
? (dateLabels[tooltipData.index] ??
|
|
shortDateFmt.format(xAccessor(tooltipData.point)))
|
|
: null;
|
|
|
|
return createPortal(
|
|
<div className="pointer-events-none absolute inset-0">
|
|
{labelsToShow.map((item) => (
|
|
<XAxisLabel
|
|
animatePosition={xDomain == null}
|
|
crosshairX={crosshairX}
|
|
hoveredLabel={hoveredLabel}
|
|
isHovering={isHovering}
|
|
key={`${item.date.getTime()}-${item.x}`}
|
|
label={item.label}
|
|
tickerHalfWidth={tickerHalfWidth}
|
|
x={item.x}
|
|
/>
|
|
))}
|
|
</div>,
|
|
container
|
|
);
|
|
});
|
|
|
|
XAxis.displayName = "XAxis";
|
|
|
|
export default XAxis;
|