Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
218 changes: 141 additions & 77 deletions src/screen-name/graph/GraphDataManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,27 @@ export interface GridVisualizationConfig {
}

export default class GraphDataManager {
private readonly dataPoints: Vector2[] = [];
// ── Circular buffer ──────────────────────────────────────────────────────
// Fixed-size ring buffer so that oldest-point eviction is O(1) rather than
// O(n) (Array.shift reindexes every remaining element after removal).
// Same pattern used by AutoTrackerNode for the trail buffer.
private readonly dataBuf: (Vector2 | undefined)[];
private dataHead = 0; // index where the NEXT write will land
private dataSize = 0; // number of valid entries (0 … maxDataPoints)

// ── Running min/max ──────────────────────────────────────────────────────
// Maintained incrementally so that axis rescaling never scans all stored
// points on the 30 Hz hot path. A full rescan is triggered only when an
// evicted point was at an axis extreme (rare in practice).
private xMin = Infinity;
private xMax = -Infinity;
private yMin = Infinity;
private yMax = -Infinity;

private readonly maxDataPoints: number;
private readonly chartTransform: ChartTransform;
private readonly linePlot: LinePlot;
private isManuallyZoomed: boolean = false;
private isManuallyZoomed = false;

// Grid and tick components
private readonly verticalGridLineSet: GridLineSet;
Expand All @@ -43,6 +59,7 @@ export default class GraphDataManager {
this.chartTransform = chartTransform;
this.linePlot = linePlot;
this.maxDataPoints = maxDataPoints;
this.dataBuf = new Array(maxDataPoints);
this.verticalGridLineSet = gridConfig.verticalGridLineSet;
this.horizontalGridLineSet = gridConfig.horizontalGridLineSet;
this.xTickMarkSet = gridConfig.xTickMarkSet;
Expand All @@ -51,29 +68,110 @@ export default class GraphDataManager {
this.yTickLabelSet = gridConfig.yTickLabelSet;
}

/**
* Returns all stored points in insertion order (oldest → newest).
* Reconstructs an ordered array from the ring buffer for the line plot.
*/
private getOrderedPoints(): Vector2[] {
const result: Vector2[] = new Array(this.dataSize);
for (let i = 0; i < this.dataSize; i++) {
const idx = (this.dataHead - this.dataSize + i + this.maxDataPoints) % this.maxDataPoints;
result[i] = this.dataBuf[idx]!;
}
return result;
}

/**
* Write one point to the ring buffer.
* Returns the evicted Vector2 if the buffer was already full, otherwise undefined.
*/
private writePoint(x: number, y: number): Vector2 | undefined {
let evicted: Vector2 | undefined;
if (this.dataSize === this.maxDataPoints) {
// Buffer full: dataHead is the oldest slot — save it before overwriting.
evicted = this.dataBuf[this.dataHead];
} else {
this.dataSize++;
}
this.dataBuf[this.dataHead] = new Vector2(x, y);
this.dataHead = (this.dataHead + 1) % this.maxDataPoints;
return evicted;
}

/**
* Full O(n) scan to recompute xMin/xMax/yMin/yMax from the ring buffer.
* Called only when an eviction may have invalidated a cached extreme.
*/
private recomputeMinMax(): void {
this.xMin = Infinity;
this.xMax = -Infinity;
this.yMin = Infinity;
this.yMax = -Infinity;
for (let i = 0; i < this.dataSize; i++) {
const idx = (this.dataHead - this.dataSize + i + this.maxDataPoints) % this.maxDataPoints;
const p = this.dataBuf[idx]!;
if (p.x < this.xMin) this.xMin = p.x;
if (p.x > this.xMax) this.xMax = p.x;
if (p.y < this.yMin) this.yMin = p.y;
if (p.y > this.yMax) this.yMax = p.y;
}
}

/**
* Incrementally update running extremes after inserting (x, y) and evicting
* `evicted`. Falls back to a full rescan only when the evicted point was an
* axis extreme (the common case — a buffer that has never filled — never rescans).
*/
private updateMinMaxIncremental(x: number, y: number, evicted: Vector2 | undefined): void {
if (x < this.xMin) this.xMin = x;
if (x > this.xMax) this.xMax = x;
if (y < this.yMin) this.yMin = y;
if (y > this.yMax) this.yMax = y;
if (
evicted !== undefined &&
(evicted.x <= this.xMin || evicted.x >= this.xMax || evicted.y <= this.yMin || evicted.y >= this.yMax)
) {
this.recomputeMinMax();
}
}

/**
* Apply the cached axis extremes to the chart transform, with padding and
* tick spacing updates. O(1) — reads only the four cached scalars.
*/
private applyAxisRangesFromExtremes(): void {
if (this.dataSize === 0) {
return;
}
const xSpan = this.xMax - this.xMin;
const ySpan = this.yMax - this.yMin;

const xPadding = Math.max(xSpan * 0.1, (2 - xSpan) / 2, 0.1);
const yPadding = Math.max(ySpan * 0.1, (2 - ySpan) / 2, 0.1);

const xRange = new Range(this.xMin - xPadding, this.xMax + xPadding);
const yRange = new Range(this.yMin - yPadding, this.yMax + yPadding);

this.chartTransform.setModelXRange(xRange);
this.chartTransform.setModelYRange(yRange);
this.updateTickSpacing(xRange, yRange);
}

/**
* Add a new data point to the graph
*/
public addDataPoint(xValue: number, yValue: number): void {
// Skip invalid values
if (!(Number.isFinite(xValue) && Number.isFinite(yValue))) {
return;
}

// Add point
this.dataPoints.push(new Vector2(xValue, yValue));
const evicted = this.writePoint(xValue, yValue);
this.updateMinMaxIncremental(xValue, yValue, evicted);

// Remove oldest point if we exceed max
if (this.dataPoints.length > this.maxDataPoints) {
this.dataPoints.shift();
}
this.linePlot.setDataSet(this.getOrderedPoints());

// Update the line plot
this.linePlot.setDataSet(this.dataPoints);

// Auto-scale the axes if we have data and user hasn't manually zoomed
if (this.dataPoints.length > 1 && !this.isManuallyZoomed) {
this.updateAxisRanges();
if (this.dataSize > 1 && !this.isManuallyZoomed) {
this.applyAxisRangesFromExtremes();
}
}

Expand All @@ -87,90 +185,67 @@ export default class GraphDataManager {
return;
}

// Add all valid points
let needsRescan = false;
for (const { x, y } of points) {
if (Number.isFinite(x) && Number.isFinite(y)) {
this.dataPoints.push(new Vector2(x, y));
const evicted = this.writePoint(x, y);
// Inline incremental update (avoids function-call overhead in tight loop).
if (x < this.xMin) this.xMin = x;
if (x > this.xMax) this.xMax = x;
if (y < this.yMin) this.yMin = y;
if (y > this.yMax) this.yMax = y;
if (
evicted !== undefined &&
(evicted.x <= this.xMin || evicted.x >= this.xMax || evicted.y <= this.yMin || evicted.y >= this.yMax)
) {
needsRescan = true;
}
}
}

// Remove oldest points if we exceed max
while (this.dataPoints.length > this.maxDataPoints) {
this.dataPoints.shift();
if (needsRescan) {
this.recomputeMinMax();
}

// Update the line plot
this.linePlot.setDataSet(this.dataPoints);
this.linePlot.setDataSet(this.getOrderedPoints());

// Auto-scale the axes if we have data and user hasn't manually zoomed
if (this.dataPoints.length > 1 && !this.isManuallyZoomed) {
this.updateAxisRanges();
if (this.dataSize > 1 && !this.isManuallyZoomed) {
this.applyAxisRangesFromExtremes();
}
}

/**
* Clear all data points
*/
public clearData(): void {
this.dataPoints.length = 0;
this.dataHead = 0;
this.dataSize = 0;
this.xMin = Infinity;
this.xMax = -Infinity;
this.yMin = Infinity;
this.yMax = -Infinity;
this.linePlot.setDataSet([]);

// Reset to default ranges
const defaultRange = new Range(-10, 10);
this.chartTransform.setModelXRange(defaultRange);
this.chartTransform.setModelYRange(defaultRange);

// Reset tick spacing
this.updateTickSpacing(defaultRange, defaultRange);

// Reset zoom state
this.isManuallyZoomed = false;
}

/**
* Update axis ranges to fit all data with some padding
* Update axis ranges to fit all data with some padding.
* Reads the cached min/max extremes — O(1) unless a rescan was triggered by eviction.
*/
public updateAxisRanges(): void {
const firstPoint = this.dataPoints[0];
if (this.dataPoints.length === 0 || !firstPoint) {
return;
}

let xMin = firstPoint.x;
let xMax = firstPoint.x;
let yMin = firstPoint.y;
let yMax = firstPoint.y;

for (const point of this.dataPoints) {
xMin = Math.min(xMin, point.x);
xMax = Math.max(xMax, point.x);
yMin = Math.min(yMin, point.y);
yMax = Math.max(yMax, point.y);
}

// Add 10% padding with a minimum to ensure reasonable range sizes
const xSpan = xMax - xMin;
const ySpan = yMax - yMin;

// Use 10% padding but ensure a minimum range of 2 units
const xPadding = Math.max(xSpan * 0.1, (2 - xSpan) / 2, 0.1);
const yPadding = Math.max(ySpan * 0.1, (2 - ySpan) / 2, 0.1);

const xRange = new Range(xMin - xPadding, xMax + xPadding);
const yRange = new Range(yMin - yPadding, yMax + yPadding);

this.chartTransform.setModelXRange(xRange);
this.chartTransform.setModelYRange(yRange);

// Update tick spacing for better readability
this.updateTickSpacing(xRange, yRange);
this.applyAxisRangesFromExtremes();
}

/**
* Update tick spacing based on the range
*/
public updateTickSpacing(xRange: Range, yRange: Range): void {
// Calculate appropriate tick spacing (aim for ~5 ticks to avoid clutter)
const xSpacing = GraphDataManager.calculateTickSpacing(xRange.getLength());
const ySpacing = GraphDataManager.calculateTickSpacing(yRange.getLength());

Expand All @@ -187,7 +262,6 @@ export default class GraphDataManager {
* This is a static utility method that doesn't depend on instance state.
*/
public static calculateTickSpacing(rangeLength: number): number {
// Handle edge cases
if (!Number.isFinite(rangeLength) || rangeLength <= 0) {
return 1;
}
Expand All @@ -196,7 +270,6 @@ export default class GraphDataManager {
const targetTicks = 5;
const roughSpacing = rangeLength / targetTicks;

// Handle very small spacings
if (roughSpacing < 1e-10) {
return 1e-10;
}
Expand All @@ -216,7 +289,6 @@ export default class GraphDataManager {
spacing = 10 * magnitude;
}

// Ensure minimum spacing to prevent too many ticks
return Math.max(spacing, rangeLength / 20);
}

Expand All @@ -227,19 +299,11 @@ export default class GraphDataManager {
this.isManuallyZoomed = value;
}

/**
* Get the manually zoomed state
* @deprecated Not currently called anywhere — consider removing.
*/
public isManualZoom(): boolean {
return this.isManuallyZoomed;
}

/**
* Get the number of data points
*/
public getDataPointCount(): number {
return this.dataPoints.length;
return this.dataSize;
}
}

Expand Down
Loading
Loading