140 lines
5.7 KiB
TypeScript
140 lines
5.7 KiB
TypeScript
export type ChartSample = { x: number; y: number; dataIndex: number; breakBefore: boolean };
|
|
type Bounds = { xMin: number; xMax: number; yMin: number; yMax: number; sorted: boolean };
|
|
const seriesCache = new WeakMap<number[], WeakMap<number[], ChartSample[]>>();
|
|
const boundsCache = new WeakMap<Array<{ x: number; y: number }>, Bounds>();
|
|
const cursorCache = new WeakMap<number[], ChartSample[]>();
|
|
const selectionCache = new WeakMap<ChartSample[], Map<string, ChartSample[]>>();
|
|
|
|
/** Cached only by immutable source arrays; collected together with their result snapshot. */
|
|
export function preparedChartSamples(time: number[], values: number[]) {
|
|
let byTime = seriesCache.get(values);
|
|
if (!byTime) { byTime = new WeakMap(); seriesCache.set(values, byTime); }
|
|
let samples = byTime.get(time);
|
|
if (samples) return samples;
|
|
samples = [];
|
|
let breakBefore = true;
|
|
for (let i = 0; i < time.length; i++) {
|
|
const x = Number(time[i]), y = Number(values[i]);
|
|
if (!Number.isFinite(x)) { breakBefore = true; continue; }
|
|
// Preserve the existing isolated-force-event interpolation policy.
|
|
if (!Number.isFinite(y)) continue;
|
|
samples.push({ x, y, dataIndex: i, breakBefore });
|
|
breakBefore = false;
|
|
}
|
|
byTime.set(time, samples);
|
|
return samples;
|
|
}
|
|
|
|
export function chartCursorSamples(time: number[]) {
|
|
let samples = cursorCache.get(time);
|
|
if (!samples) {
|
|
samples = [];
|
|
for (let i = 0; i < time.length; i++) {
|
|
if (Number.isFinite(time[i])) samples.push({ x: time[i], y: i, dataIndex: i, breakBefore: false });
|
|
}
|
|
cursorCache.set(time, samples);
|
|
}
|
|
return samples;
|
|
}
|
|
|
|
export function chartSampleBounds(samples: Array<{ x: number; y: number }>): Bounds {
|
|
const cached = boundsCache.get(samples);
|
|
if (cached) return cached;
|
|
const bounds = { xMin: Infinity, xMax: -Infinity, yMin: Infinity, yMax: -Infinity, sorted: true };
|
|
let previousX = -Infinity;
|
|
for (const sample of samples) {
|
|
bounds.xMin = Math.min(bounds.xMin, sample.x);
|
|
bounds.xMax = Math.max(bounds.xMax, sample.x);
|
|
bounds.yMin = Math.min(bounds.yMin, sample.y);
|
|
bounds.yMax = Math.max(bounds.yMax, sample.y);
|
|
if (sample.x < previousX) bounds.sorted = false;
|
|
previousX = sample.x;
|
|
}
|
|
boundsCache.set(samples, bounds);
|
|
return bounds;
|
|
}
|
|
|
|
export function combinedChartBounds(curves: Array<{ samples: Array<{ x: number; y: number }> }>) {
|
|
const result = { xMin: Infinity, xMax: -Infinity, yMin: Infinity, yMax: -Infinity };
|
|
for (const curve of curves) {
|
|
const bounds = chartSampleBounds(curve.samples);
|
|
result.xMin = Math.min(result.xMin, bounds.xMin);
|
|
result.xMax = Math.max(result.xMax, bounds.xMax);
|
|
result.yMin = Math.min(result.yMin, bounds.yMin);
|
|
result.yMax = Math.max(result.yMax, bounds.yMax);
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function lowerBound(samples: Array<{ x: number }>, x: number, upper = false) {
|
|
let low = 0, high = samples.length;
|
|
while (low < high) {
|
|
const middle = (low + high) >>> 1;
|
|
if (samples[middle].x < x || (upper && samples[middle].x === x)) low = middle + 1;
|
|
else high = middle;
|
|
}
|
|
return low;
|
|
}
|
|
|
|
export function visibleSampleRange(samples: Array<{ x: number; y: number }>, xMin: number, xMax: number) {
|
|
if (!chartSampleBounds(samples).sorted) return [0, samples.length] as const;
|
|
// Include both outside neighbours, preserving segments crossing viewport boundaries.
|
|
return [Math.max(0, lowerBound(samples, xMin) - 1), Math.min(samples.length, lowerBound(samples, xMax, true) + 1)] as const;
|
|
}
|
|
|
|
/** Keep first/min/max/last per pixel column in original order; no averaging of peaks. */
|
|
export function chartDisplaySamples(samples: ChartSample[], xMin: number, xMax: number, pixelWidth: number) {
|
|
const width = Math.max(1, Math.ceil(pixelWidth));
|
|
const key = `${xMin}:${xMax}:${width}`;
|
|
let cache = selectionCache.get(samples);
|
|
if (!cache) { cache = new Map(); selectionCache.set(samples, cache); }
|
|
const cached = cache.get(key);
|
|
if (cached) return cached;
|
|
const [start, end] = visibleSampleRange(samples, xMin, xMax);
|
|
const result: ChartSample[] = [];
|
|
if (end - start <= width * 4 || !chartSampleBounds(samples).sorted) {
|
|
for (let i = start; i < end; i++) result.push(samples[i]);
|
|
} else {
|
|
let bucket = -Infinity;
|
|
let first = -1, last = -1, minimum = -1, maximum = -1;
|
|
const flush = () => {
|
|
if (first < 0) return;
|
|
const indices = [first, minimum, maximum, last].sort((a, b) => a - b);
|
|
let previous = -1;
|
|
for (const index of indices) {
|
|
if (index !== previous) result.push(samples[index]);
|
|
previous = index;
|
|
}
|
|
};
|
|
for (let i = start; i < end; i++) {
|
|
const sample = samples[i];
|
|
const nextBucket = Math.max(-1, Math.min(width, Math.floor((sample.x - xMin) / Math.max(xMax - xMin, 1e-12) * width)));
|
|
if (nextBucket !== bucket || sample.breakBefore || first < 0) {
|
|
flush();
|
|
first = last = minimum = maximum = i;
|
|
bucket = nextBucket;
|
|
} else {
|
|
last = i;
|
|
if (sample.y < samples[minimum].y) minimum = i;
|
|
if (sample.y > samples[maximum].y) maximum = i;
|
|
}
|
|
}
|
|
flush();
|
|
}
|
|
// A few active windows/viewports may share a curve without retaining unbounded history.
|
|
if (cache.size >= 4) cache.delete(cache.keys().next().value!);
|
|
cache.set(key, result);
|
|
return result;
|
|
}
|
|
|
|
export function sampledChartPath(
|
|
time: number[], values: number[],
|
|
xPosition: (value: number) => number, yPosition: (value: number) => number,
|
|
xMin: number, xMax: number, pixelWidth: number,
|
|
) {
|
|
const samples = chartDisplaySamples(preparedChartSamples(time, values), xMin, xMax, pixelWidth);
|
|
return samples.map((sample, index) =>
|
|
`${index === 0 || sample.breakBefore ? "M" : "L"} ${xPosition(sample.x).toFixed(2)} ${yPosition(sample.y).toFixed(2)}`,
|
|
).join(" ");
|
|
}
|