mirror of
https://github.com/temetro/temetro.git
synced 2026-08-10 18:49:29 +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>
137 lines
3.5 KiB
TypeScript
137 lines
3.5 KiB
TypeScript
export function decimateTimeSeries<T extends Record<string, unknown>>(
|
|
data: T[],
|
|
maxPoints: number,
|
|
valueKeys: string[] = []
|
|
): T[] {
|
|
const len = data.length;
|
|
if (maxPoints >= len || maxPoints < 3) {
|
|
return data;
|
|
}
|
|
|
|
const getY = (point: T, index: number): number => {
|
|
if (valueKeys.length === 0) {
|
|
for (const val of Object.values(point)) {
|
|
if (typeof val === "number") {
|
|
return val;
|
|
}
|
|
}
|
|
return index;
|
|
}
|
|
|
|
let sum = 0;
|
|
let count = 0;
|
|
for (const key of valueKeys) {
|
|
const val = point[key];
|
|
if (typeof val === "number") {
|
|
sum += val;
|
|
count++;
|
|
}
|
|
}
|
|
return count > 0 ? sum / count : index;
|
|
};
|
|
|
|
const sampled: T[] = [data[0] as T];
|
|
const bucketSize = (len - 2) / (maxPoints - 2);
|
|
let previousIndex = 0;
|
|
|
|
for (let i = 0; i < maxPoints - 2; i++) {
|
|
const rangeStart = Math.floor((i + 1) * bucketSize) + 1;
|
|
const rangeEnd = Math.min(Math.floor((i + 2) * bucketSize) + 1, len - 1);
|
|
|
|
const nextRangeStart = Math.floor((i + 2) * bucketSize) + 1;
|
|
const nextRangeEnd = Math.min(Math.floor((i + 3) * bucketSize) + 1, len);
|
|
const nextCount = Math.max(0, nextRangeEnd - nextRangeStart);
|
|
|
|
let avgX = len - 1;
|
|
let avgY = getY(data[len - 1] as T, len - 1);
|
|
if (nextCount > 0) {
|
|
avgX = 0;
|
|
avgY = 0;
|
|
for (let j = nextRangeStart; j < nextRangeEnd; j++) {
|
|
avgX += j;
|
|
avgY += getY(data[j] as T, j);
|
|
}
|
|
avgX /= nextCount;
|
|
avgY /= nextCount;
|
|
}
|
|
|
|
const pointA = data[previousIndex] as T;
|
|
const ax = previousIndex;
|
|
const ay = getY(pointA, previousIndex);
|
|
|
|
let maxArea = -1;
|
|
let maxIndex = rangeStart;
|
|
|
|
for (let j = rangeStart; j < rangeEnd; j++) {
|
|
const area =
|
|
Math.abs(
|
|
(ax - avgX) * (getY(data[j] as T, j) - ay) - (ax - j) * (avgY - ay)
|
|
) * 0.5;
|
|
if (area > maxArea) {
|
|
maxArea = area;
|
|
maxIndex = j;
|
|
}
|
|
}
|
|
|
|
sampled.push(data[maxIndex] as T);
|
|
previousIndex = maxIndex;
|
|
}
|
|
|
|
sampled.push(data[len - 1] as T);
|
|
return sampled;
|
|
}
|
|
|
|
/** ~1.5 points per pixel — enough for crisp curves without over-drawing. */
|
|
export function maxRenderPointsForWidth(innerWidth: number): number {
|
|
return Math.max(64, Math.ceil(innerWidth * 1.5));
|
|
}
|
|
|
|
/** Bucket OHLC rows into fewer candles while preserving high/low extremes. */
|
|
export function decimateOhlcData<T extends Record<string, unknown>>(
|
|
data: T[],
|
|
maxPoints: number
|
|
): T[] {
|
|
const len = data.length;
|
|
if (maxPoints >= len || maxPoints < 2) {
|
|
return data;
|
|
}
|
|
|
|
const bucketSize = len / maxPoints;
|
|
const sampled: T[] = [];
|
|
|
|
for (let i = 0; i < maxPoints; i++) {
|
|
const start = Math.floor(i * bucketSize);
|
|
const end = Math.min(len, Math.floor((i + 1) * bucketSize));
|
|
if (start >= end) {
|
|
continue;
|
|
}
|
|
|
|
const bucket = data.slice(start, end);
|
|
const first = bucket[0] as T;
|
|
const last = bucket.at(-1) as T;
|
|
|
|
let high = Number.NEGATIVE_INFINITY;
|
|
let low = Number.POSITIVE_INFINITY;
|
|
for (const row of bucket) {
|
|
const rowHigh = row.high;
|
|
const rowLow = row.low;
|
|
if (typeof rowHigh === "number" && rowHigh > high) {
|
|
high = rowHigh;
|
|
}
|
|
if (typeof rowLow === "number" && rowLow < low) {
|
|
low = rowLow;
|
|
}
|
|
}
|
|
|
|
sampled.push({
|
|
...last,
|
|
open: first.open,
|
|
high: Number.isFinite(high) ? high : last.high,
|
|
low: Number.isFinite(low) ? low : last.low,
|
|
close: last.close,
|
|
} as T);
|
|
}
|
|
|
|
return sampled;
|
|
}
|