Skip to content

Commit 3b68897

Browse files
authored
feat(timeline): add zoom preservation to timeline series (#4498)
exposes a boolean property that when set allows the gantt chart to maintain its current zoom level instead of zooming to fit all items on update Signed-off-by: Jeremy Clements <[email protected]>
1 parent e26c952 commit 3b68897

File tree

2 files changed

+81
-5
lines changed

2 files changed

+81
-5
lines changed

packages/timeline/src/ReactAxisGanttSeries.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,8 @@ export interface ReactAxisGanttSeries {
237237
bucketColumn(_: string): this;
238238
maxZoom(): number;
239239
maxZoom(_: number): this;
240+
preserveZoom(): boolean;
241+
preserveZoom(_: boolean): this;
240242
}
241243
ReactAxisGanttSeries.prototype.publish("tickFormat", null, "string", "Format rule applied to axis tick labels", undefined, { optional: true });
242244
ReactAxisGanttSeries.prototype.publish("axisHeight", 22, "number", "Height of axes (pixels)");
@@ -260,6 +262,7 @@ ReactAxisGanttSeries.prototype.publishProxy("colorColumn", "_gantt");
260262
ReactAxisGanttSeries.prototype.publishProxy("seriesColumn", "_gantt");
261263
ReactAxisGanttSeries.prototype.publishProxy("bucketColumn", "_gantt");
262264
ReactAxisGanttSeries.prototype.publishProxy("maxZoom", "_gantt");
265+
ReactAxisGanttSeries.prototype.publishProxy("preserveZoom", "_gantt");
263266
ReactAxisGanttSeries.prototype.publishProxy("evenSeriesBackground", "_gantt");
264267
ReactAxisGanttSeries.prototype.publishProxy("oddSeriesBackground", "_gantt");
265268
ReactAxisGanttSeries.prototype.publishProxy("bucketHeight", "_gantt");

packages/timeline/src/ReactGantt.ts

Lines changed: 78 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export class ReactGantt extends SVGZoomWidget {
3636
public _minStart: number;
3737
public _maxEnd: number;
3838

39+
protected _prevZoomState: { visibleStart: number; visibleEnd: number } | null = null;
40+
3941
protected _title_idx = 0;
4042
protected _startDate_idx = 1;
4143
protected _endDate_idx = 2;
@@ -190,12 +192,14 @@ export class ReactGantt extends SVGZoomWidget {
190192
this._buckets = this.calcBuckets(this.data(), this._startDate_idx, this._endDate_idx);
191193
}
192194
}
193-
const interpedStart = this._interpolateX(this._minStart);
194195

195-
this.zoomTo(
196-
[interpedStart, 0],
197-
1
198-
);
196+
if (!this.preserveZoom() || !this._prevZoomState) {
197+
const interpedStart = this._interpolateX(this._minStart);
198+
this.zoomTo(
199+
[interpedStart, 0],
200+
1
201+
);
202+
}
199203

200204
const bucketHeight = this.bucketHeight();
201205

@@ -331,6 +335,51 @@ export class ReactGantt extends SVGZoomWidget {
331335
})
332336
;
333337
element.on("dblclick.zoom", null);
338+
339+
// restore zoom state after all rendering is set up
340+
if (this.preserveZoom() && this._prevZoomState && this._interpolateX) {
341+
const width = this.width();
342+
if (width > 0) {
343+
const visibleStart = this._minStart;
344+
const visibleEnd = this._maxEnd;
345+
const clampedStart = Math.max(visibleStart, Math.min(visibleEnd, this._prevZoomState.visibleStart));
346+
let clampedEnd = Math.max(visibleStart, Math.min(visibleEnd, this._prevZoomState.visibleEnd));
347+
if (clampedEnd <= clampedStart) {
348+
const visibleWidth = visibleEnd - visibleStart;
349+
const epsilon = visibleWidth * 1e-6 || 1e-6;
350+
clampedEnd = Math.min(visibleEnd, clampedStart + epsilon);
351+
}
352+
const startPixel = this._interpolateX(clampedStart);
353+
const endPixel = this._interpolateX(clampedEnd);
354+
const span = endPixel - startPixel;
355+
if (isFinite(span) && Math.abs(span) > 1e-9) {
356+
const rawScale = width / span;
357+
const minScale = 0.05; // must match zoomExtent minimum set at start of update()
358+
const maxScale = this.maxZoom();
359+
const targetScale = Math.max(minScale, Math.min(maxScale, rawScale));
360+
361+
if (targetScale > 0 && isFinite(targetScale)) {
362+
const centerPixel = (startPixel + endPixel) / 2;
363+
const halfViewport = width / (2 * targetScale);
364+
const x0 = this._interpolateX(visibleStart);
365+
const x1 = this._interpolateX(visibleEnd);
366+
367+
let clampedCenter = centerPixel;
368+
if (clampedCenter - halfViewport < x0) {
369+
clampedCenter = x0 + halfViewport;
370+
}
371+
if (clampedCenter + halfViewport > x1) {
372+
clampedCenter = x1 - halfViewport;
373+
}
374+
375+
const translateX = (width / 2) - (targetScale * clampedCenter);
376+
if (isFinite(translateX)) {
377+
this.zoomTo([translateX, 0], targetScale);
378+
}
379+
}
380+
}
381+
}
382+
}
334383
}
335384
exit(domNode, element) {
336385
this._tooltip.target(null);
@@ -442,6 +491,27 @@ export class ReactGantt extends SVGZoomWidget {
442491
public _transform = { k: 1, x: 0, y: 0 };
443492
zoomed(transform) {
444493
this._transform = transform;
494+
// store current visible range for zoom preservation
495+
if (this._interpolateX && typeof this._interpolateX.invert === "function") {
496+
const width = this.width();
497+
if (width > 0 && isFinite(transform.k) && transform.k !== 0) {
498+
const startPixel = (0 - transform.x) / transform.k;
499+
const endPixel = (width - transform.x) / transform.k;
500+
let visibleStart = this._interpolateX.invert(startPixel);
501+
let visibleEnd = this._interpolateX.invert(endPixel);
502+
if (isFinite(visibleStart) && isFinite(visibleEnd)) {
503+
if (visibleStart > visibleEnd) {
504+
const tmp = visibleStart;
505+
visibleStart = visibleEnd;
506+
visibleEnd = tmp;
507+
}
508+
this._prevZoomState = {
509+
visibleStart,
510+
visibleEnd
511+
};
512+
}
513+
}
514+
}
445515
switch (this.renderMode()) {
446516
case "scale-all":
447517
this._zoomScale = transform.k;
@@ -667,6 +737,8 @@ export interface ReactGantt {
667737
fitWidthToContent(_: boolean): this;
668738
fitHeightToContent(): boolean;
669739
fitHeightToContent(_: boolean): this;
740+
preserveZoom(): boolean;
741+
preserveZoom(_: boolean): this;
670742
evenSeriesBackground(): string;
671743
evenSeriesBackground(_: string): this;
672744
oddSeriesBackground(): string;
@@ -675,6 +747,7 @@ export interface ReactGantt {
675747

676748
ReactGantt.prototype.publish("fitWidthToContent", false, "boolean", "If true, resize will simply reapply the bounding box width");
677749
ReactGantt.prototype.publish("fitHeightToContent", false, "boolean", "If true, resize will simply reapply the bounding box height");
750+
ReactGantt.prototype.publish("preserveZoom", false, "boolean", "If true, maintain zoom level when data is updated");
678751
ReactGantt.prototype.publish("titleColumn", null, "string", "Column name to for the title");
679752
ReactGantt.prototype.publish("startDateColumn", null, "string", "Column name to for the start date");
680753
ReactGantt.prototype.publish("endDateColumn", null, "string", "Column name to for the end date");

0 commit comments

Comments
 (0)