Files
temetro/frontend/components/charts/y-axis-scales.ts
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

116 lines
2.9 KiB
TypeScript

import { scaleLinear } from "@visx/scale";
import type { LineConfig } from "./chart-context";
/** Default axis id when `yAxisId` is omitted (Recharts-style `0` / primary left axis). */
export const DEFAULT_Y_AXIS_ID = "left";
export type YAxisOrientation = "left" | "right";
export function normalizeYAxisId(id?: string | number): string {
if (id == null || id === "") {
return DEFAULT_Y_AXIS_ID;
}
return String(id);
}
export function groupLinesByYAxisId(
lines: LineConfig[]
): Map<string, LineConfig[]> {
const groups = new Map<string, LineConfig[]>();
for (const line of lines) {
const axisId = normalizeYAxisId(line.yAxisId);
const bucket = groups.get(axisId) ?? [];
bucket.push(line);
groups.set(axisId, bucket);
}
return groups;
}
type YScale = ReturnType<typeof scaleLinear<number>>;
export function getPrimaryYScale(
yScales: Record<string, YScale>,
fallback: YScale
): YScale {
const primary = yScales[DEFAULT_Y_AXIS_ID];
if (primary) {
return primary;
}
const first = Object.values(yScales)[0];
return first ?? fallback;
}
export function buildYScalesForLines({
lines,
innerHeight,
resolveDomain,
}: {
lines: LineConfig[];
/** Passed by callers; domain is resolved via `resolveDomain`. */
data?: Record<string, unknown>[];
innerHeight: number;
resolveDomain: (dataKeys: string[]) => [number, number];
}): Record<string, YScale> {
const groups = groupLinesByYAxisId(lines);
const scales: Record<string, YScale> = {};
for (const [axisId, axisLines] of groups) {
const dataKeys = axisLines.map((line) => line.dataKey);
const domain = resolveDomain(dataKeys);
scales[axisId] = scaleLinear({
range: [innerHeight, 0],
domain,
nice: true,
});
}
if (!scales[DEFAULT_Y_AXIS_ID]) {
scales[DEFAULT_Y_AXIS_ID] = scaleLinear({
range: [innerHeight, 0],
domain: [0, 100],
nice: true,
});
}
return scales;
}
/** Build y-scales from pre-computed (already nice'd) domain endpoints. */
export function buildYScalesFromDomains({
lines,
innerHeight,
domainsByAxis,
}: {
lines: LineConfig[];
innerHeight: number;
domainsByAxis: Record<string, [number, number]>;
}): Record<string, YScale> {
const groups = groupLinesByYAxisId(lines);
const scales: Record<string, YScale> = {};
for (const [axisId] of groups) {
const domain =
domainsByAxis[axisId] ??
domainsByAxis[DEFAULT_Y_AXIS_ID] ??
([0, 100] as [number, number]);
scales[axisId] = scaleLinear({
range: [innerHeight, 0],
domain,
});
}
if (!scales[DEFAULT_Y_AXIS_ID]) {
scales[DEFAULT_Y_AXIS_ID] = scaleLinear({
range: [innerHeight, 0],
domain: domainsByAxis[DEFAULT_Y_AXIS_ID] ?? [0, 100],
});
}
return scales;
}
/** Single-axis charts (bar, scatter, candlestick, live line). */
export function wrapSingleYScale(yScale: YScale): Record<string, YScale> {
return { [DEFAULT_Y_AXIS_ID]: yScale };
}