Skip to content

Commit

Permalink
fix(zoom): Fix zoom state on data load
Browse files Browse the repository at this point in the history
Fix dynamic data loading when zoom-in state,
to keep current zoom-in state and update scale according new
data range

Fix #3422
  • Loading branch information
netil committed Dec 19, 2023
1 parent e22da73 commit 967207b
Show file tree
Hide file tree
Showing 6 changed files with 263 additions and 25 deletions.
25 changes: 5 additions & 20 deletions src/Chart/api/zoom.ts
Expand Up @@ -35,8 +35,7 @@ import type {TDomainRange} from "../../ChartInternal/data/IData";
// NOTE: declared funciton assigning to variable to prevent duplicated method generation in JSDoc.
const zoom = function<T = TDomainRange>(domainValue?: T): T | undefined {
const $$ = this.internal;
const {$el, axis, config, org, scale, state} = $$;
const isRotated = config.axis_rotated;
const {axis, config, org, scale, state} = $$;
const isCategorized = axis.isCategorized();
let domain;

Expand All @@ -57,9 +56,7 @@ const zoom = function<T = TDomainRange>(domainValue?: T): T | undefined {
if (isWithinRange) {
state.domain = domain;

if (isCategorized) {
domain = domain.map((v, i) => Number(v) + (i === 0 ? 0 : 1));
}
domain = $$.getZoomDomainValue(domain);

// hide any possible tooltip show before the zoom
$$.api.tooltip.hide();
Expand All @@ -73,19 +70,7 @@ const zoom = function<T = TDomainRange>(domainValue?: T): T | undefined {
// in case of 'config.zoom_rescale=true', use org.xScale
const x = isCategorized ? scale.x.orgScale() : (org.xScale || scale.x);

// Get transform from given domain value
// https://github.com/d3/d3-zoom/issues/57#issuecomment-246434951
const translate = [-x(domain[0]), 0];
const transform = d3ZoomIdentity
.scale(x.range()[1] / (
x(domain[1]) - x(domain[0])
))
.translate(
...(isRotated ? translate.reverse() : translate) as [number, number]
);

$el.eventRect
.call($$.zoom.transform, transform);
$$.updateCurrentZoomTransform(x, domain);
}

$$.setZoomResetButton();
Expand Down Expand Up @@ -229,9 +214,9 @@ export default {
*/
unzoom(): void {
const $$ = this.internal;
const {config, $el: {eventRect, zoomResetBtn}, state} = $$;
const {config, $el: {eventRect, zoomResetBtn}, scale: {zoom}, state} = $$;

if ($$.scale.zoom) {
if (zoom) {
config.subchart_show ?
$$.brush.getSelection().call($$.brush.move, null) :
$$.zoom.updateTransformScale(d3ZoomIdentity);
Expand Down
38 changes: 37 additions & 1 deletion src/ChartInternal/data/load.ts
Expand Up @@ -22,8 +22,13 @@ export function callDone(fn, resizeAfter = false) {
export default {
load(rawTargets, args): void {
const $$ = this;
const {data} = $$;
const {axis, data, org, scale} = $$;
const {append} = args;
const zoomState = {
domain: <any> null,
currentDomain: <any> null,
x: <any> null
};
let targets = rawTargets;

if (targets) {
Expand Down Expand Up @@ -60,13 +65,44 @@ export default {
// Set targets
$$.updateTargets(data.targets);

if (scale.zoom) {
zoomState.x = axis.isCategorized() ? scale.x.orgScale() : (org.xScale || scale.x).copy();
zoomState.domain = $$.getXDomain(data.targets); // get updated xDomain

zoomState.x.domain(zoomState.domain);
zoomState.currentDomain = $$.zoom.getDomain(); // current zoomed domain

// reset zoom state when new data loaded is out of range
if (!$$.withinRange(zoomState.currentDomain, undefined, zoomState.domain)) {
scale.x.domain(zoomState.domain);
scale.zoom = null;
$$.$el.eventRect.property("__zoom", null);
}
}

// Redraw with new targets
$$.redraw({
withUpdateOrgXDomain: true,
withUpdateXDomain: true,
withLegend: true
});

// when load happens on zoom state
if (scale.zoom) {
// const x = (axis.isCategorized() ? scale.x.orgScale() : (org.xScale || scale.x)).copy();

org.xDomain = zoomState.domain;
org.xScale = zoomState.x;

if (axis.isCategorized()) {
zoomState.currentDomain = $$.getZoomDomainValue(zoomState.currentDomain);
org.xDomain = $$.getZoomDomainValue(org.xDomain);
org.xScale = zoomState.x.domain(org.xDomain);
}

$$.updateCurrentZoomTransform(zoomState.x, zoomState.currentDomain);
}

// Update current state chart type and elements list after redraw
$$.updateTypesElements();

Expand Down
28 changes: 28 additions & 0 deletions src/ChartInternal/interactions/zoom.ts
Expand Up @@ -116,6 +116,9 @@ export default {
// copy current initial x scale in case of rescale option is used
!org.xScale && (org.xScale = scale.x.copy());
scale.x.domain(domain);
} else if (org.xScale) {
scale.x.domain(org.xScale.domain());
org.xScale = null;
}
};

Expand Down Expand Up @@ -285,6 +288,31 @@ export default {
}
},

/**
* Set zoom transform to event rect
* @param {Function} x x Axis scale function
* @param {Array} domain Domain value to be set
* @private
*/
updateCurrentZoomTransform(x, domain: [number, number]): void {
const $$ = this;
const {$el: {eventRect}, config} = $$;
const isRotated = config.axis_rotated;

// Get transform from given domain value
// https://github.com/d3/d3-zoom/issues/57#issuecomment-246434951
const translate = [-x(domain[0]), 0];
const transform = d3ZoomIdentity
.scale(x.range()[1] / (
x(domain[1]) - x(domain[0])
))
.translate(
...(isRotated ? translate.reverse() : translate) as [number, number]
);

eventRect.call($$.zoom.transform, transform);
},

/**
* Attach zoom event on <rect>
* @private
Expand Down
25 changes: 24 additions & 1 deletion src/ChartInternal/internals/domain.ts
Expand Up @@ -396,6 +396,29 @@ export default {
return [min, max];
},

/**
* Return zoom domain from given domain
* - 'category' type need to add offset to original value
* @param {Array} domainValue domain value
* @returns {Array} Zoom domain
* @private
*/
getZoomDomainValue<T = TDomainRange>(domainValue: T): T | undefined {
const $$ = this;
const {config, axis} = $$;

if (axis.isCategorized() && Array.isArray(domainValue)) {
const isInverted = config.axis_x_inverted;

// need to add offset to original value for 'category' type
const domain = domainValue.map((v, i) => Number(v) + (i === 0 ? +isInverted : +!isInverted));

return domain as T;
}

return domainValue;
},

/**
* Converts pixels to axis' scale values
* @param {string} type Axis type
Expand Down Expand Up @@ -427,7 +450,7 @@ export default {
* @returns {boolean}
* @private
*/
withinRange<T = TDomainRange>(domain: T, current: T, range: T): boolean {
withinRange<T = TDomainRange>(domain: T, current = [0, 0], range: T): boolean {
const $$ = this;
const isInverted = $$.config.axis_x_inverted;
const [min, max] = range as number[];
Expand Down
6 changes: 3 additions & 3 deletions src/ChartInternal/internals/scale.ts
Expand Up @@ -39,16 +39,16 @@ export function getScale(type = "linear", min = 0, max = 1): any {
export default {
/**
* Get x Axis scale function
* @param {number} min Min value
* @param {number} max Max value
* @param {number} min Min range value
* @param {number} max Max range value
* @param {Array} domain Domain value
* @param {Function} offset The offset getter to be sum
* @returns {Function} scale
* @private
*/
getXScale(min: number, max: number, domain: number[], offset: Function) {
const $$ = this;
const scale = $$.scale.zoom || getScale($$.axis.getAxisType("x"), min, max);
const scale = ($$.state.loading !== "append" && $$.scale.zoom) || getScale($$.axis.getAxisType("x"), min, max);

return $$.getCustomizedXScale(
domain ? scale.domain(domain) : scale,
Expand Down
166 changes: 166 additions & 0 deletions test/api/load-spec.ts
Expand Up @@ -831,6 +831,172 @@ describe("API load", function() {
});
});

describe("Append data loading with zoom-in state", () => {
before(() => {
args = {
data: {
columns: [
["sample", 30, 200, 100, 400, 150],
],
type: "line"
},
zoom: {
enabled: true
},
transition: {
duration: 0
}
};
});

it("indexed axis type", done => {
const {$el, scale} = chart.internal;
const zoomDomain = [1, 2];
const orgDomain = scale.x.orgDomain().map(Math.abs);

// when
chart.zoom(zoomDomain);

chart.load({
columns: [['sample', 130, 150]],
append: true,
done() {
// zoom state shoud persist
expect(this.zoom()).to.deep.equal(zoomDomain);

// zoom domain range should be increased
expect(
scale.x.orgDomain()
.map(Math.abs)
.every((v, i) => v > orgDomain[i])
).to.be.true;

// when
chart.unzoom();

const values = chart.data.values("sample");
const ticks = $el.axis.x.selectAll(".tick:first-of-type, .tick:last-of-type")
.nodes().map(v => +v.textContent);

expect(ticks).to.be.deep.equal([0, values.length - 1]);

done();
}
});
});

it("set options: axis.x.type='category", () => {
args.axis = {
x: {
type: "category"
}
};
});

it("category axis type", done => {
const {$el, scale} = chart.internal;
const zoomDomain = [1, 2];
const orgDomain = scale.x.orgDomain().map(Math.abs);

// when
chart.zoom(zoomDomain);

chart.load({
columns: [['sample', 130, 150]],
append: true,
done() {
// zoom state shoud persist
expect(this.zoom()).to.deep.equal(zoomDomain);

// zoom domain range should be increased
expect(
scale.x.orgDomain()
.map(Math.abs)
.some((v, i) => v > orgDomain[i])
).to.be.true;

// when
chart.unzoom();

const values = chart.data.values("sample");
const ticks = $el.axis.x.selectAll(".tick:first-of-type, .tick:last-of-type")
.nodes().map(v => +v.textContent);

expect(ticks).to.be.deep.equal([0, values.length - 1]);

done();
}
});
});

it("set options: axis.x.type='timeseries", () => {
args = {
data: {
x: "x",
columns: [
["x", "2023-10-01", "2023-10-02", "2023-10-03", "2023-10-04", "2023-10-05"],
['sample', 30, 200, 100, 400, 150],
],
type: "line",
},
zoom: {
enabled: true,
},
transition: {
duration: 0,
},
axis: {
x: {
type: "timeseries",
tick: {
format: "%Y-%m-%d"
}
}
}
};
});

it("timeseires axis type", done => {
const {$el, scale} = chart.internal;
const orgDomain = scale.x.orgDomain().map(Number);
let zoomDomain = ["2023-10-2", "2023-10-3"];

// when
chart.zoom(zoomDomain);

zoomDomain = scale.zoom.domain().map(Number);

chart.load({
columns: [
["x", "2023-10-06", "2023-10-07"],
['sample', 130, 150]
],
append: true,
done() {
// zoom state shoud persist
expect(scale.zoom.domain().map(Number)).to.deep.equal(zoomDomain);

// zoom domain range should be increased
expect(
scale.x.orgDomain()
.map(Number)
.some((v, i) => v > orgDomain[i])
).to.be.true;

// when
chart.unzoom();

const values = chart.data.values("sample");
const ticks = $el.axis.x.selectAll(".tick:first-of-type, .tick:last-of-type")
.nodes().map(v => +new Date(v.textContent));

expect(ticks).to.be.deep.equal(["2023-10-01", "2023-10-07"].map(v => +new Date(v)));
done();
}
});
});
});

describe("Multiple consecutive load call", () => {
before(() => {
args = {
Expand Down

0 comments on commit 967207b

Please sign in to comment.