Skip to content
Open
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
16 changes: 13 additions & 3 deletions src/helpers/helpers.dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,9 +191,19 @@ export function getMaximumSize(

const maintainHeight = bbWidth !== undefined || bbHeight !== undefined;

if (maintainHeight && aspectRatio && containerSize.height && height > containerSize.height) {
height = containerSize.height;
width = round1(Math.floor(height * aspectRatio));
if (maintainHeight && aspectRatio) {
// An auto-height container is just the canvas's own height read back, so it must not clamp the chart
// See https://github.com/chartjs/Chart.js/issues/12251
const liveHeight = getContainerSize(canvas, undefined, undefined).height;
const parent = canvas.parentElement;
const containerFollowsCanvas = liveHeight !== undefined
&& Math.abs(liveHeight - canvas.clientHeight) < 1
&& !(parent && parent.style.height);

if (!containerFollowsCanvas && liveHeight && height > liveHeight) {
height = liveHeight;
width = round1(height * aspectRatio);
}
}

return {width, height};
Expand Down
176 changes: 176 additions & 0 deletions test/specs/issue.12251.tests.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
// https://github.com/chartjs/Chart.js/issues/12251
// An auto-height container with maintainAspectRatio could only ever shrink.
describe('Issue #12251 - chart follows an auto-height container in both directions', function() {
let originalDescriptor;

beforeEach(function() {
originalDescriptor = Object.getOwnPropertyDescriptor(window, 'devicePixelRatio');
});

afterEach(function() {
if (originalDescriptor) {
Object.defineProperty(window, 'devicePixelRatio', originalDescriptor);
} else {
delete window.devicePixelRatio;
}
});

function acquire(onResize) {
return acquireChart({
type: 'line',
data: {labels: ['a', 'b', 'c'], datasets: [{data: [1, 2, 3]}]},
options: {
responsive: true,
maintainAspectRatio: true,
animation: false,
onResize
}
}, {
canvas: {style: ''},
// No explicit height: the container is as tall as the canvas, as on chartjs.org.
wrapper: {style: 'width: 300px; position: relative'}
});
}

[1, 1.35].forEach(function(dpr) {
it('follows the container both when it grows and when it shrinks at DPR ' + dpr, function(done) {
Object.defineProperty(window, 'devicePixelRatio', {configurable: true, get: () => dpr});
const resizes = [];
const chart = acquire((c, size) => resizes.push({...size, cw: c.canvas.width, ch: c.canvas.height}));
const wrapper = chart.canvas.parentNode;
const tracked = [];

// Widths chosen so width * DPR is not an integer, which is where rounding bites.
const widths = [333.3, 401.7, 250.1, 377.9];
let step = 0;

function next() {
if (step === widths.length) {
// The bug: growth was refused while shrink was honoured, so the chart
// could only ever get smaller. Every change must be followed, both ways.
tracked.forEach(({target, got}) => {
expect(Math.abs(got - target)).toBeLessThan(1, `chart.width ${got} should follow container ${target}`);
});
done();
return;
}
wrapper.style.width = widths[step++] + 'px';
// Give the observer several frames to settle so any ping-pong is counted.
setTimeout(() => {
const target = parseFloat(wrapper.style.width);
tracked.push({target, got: chart.width});
next();
}, 250);
}
next();
});
});

it('does not lose size across repeated identical resizes', function(done) {
Object.defineProperty(window, 'devicePixelRatio', {configurable: true, get: () => 1.35});
const chart = acquire();
const wrapper = chart.canvas.parentNode;
let i = 0;
const seen = [];

function bounce() {
if (i === 12) {
const distinct = new Set(seen);
expect(distinct.size).toBe(1, 'same container width must always yield the same canvas size, got: ' + [...distinct].join(','));
done();
return;
}
// Alternate between two widths; every return to 333.3 must give the same result.
wrapper.style.width = (i % 2 === 0 ? 333.3 : 400) + 'px';
setTimeout(() => {
if (i % 2 === 0) {
seen.push(chart.canvas.width);
}
i++;
bounce();
}, 150);
}
bounce();
});

it('stays stable while a looping animation keeps the animator running', function(done) {
// A looping animation keeps the animator running, so every resize is deferred.
Object.defineProperty(window, 'devicePixelRatio', {configurable: true, get: () => 1.35});
const chart = acquireChart({
type: 'line',
data: {labels: ['a', 'b', 'c'], datasets: [{data: [1, 2, 3]}]},
options: {
responsive: true,
maintainAspectRatio: true,
animations: {tension: {duration: 200, easing: 'linear', from: 1, to: 0, loop: true}}
}
}, {
canvas: {style: ''},
wrapper: {style: 'width: 300px; position: relative'}
});
const wrapper = chart.canvas.parentNode;
const widths = [];

// Hold the container still and watch: a stable chart keeps one width.
setTimeout(() => {
wrapper.style.width = '380px';
const sample = setInterval(() => widths.push(chart.width), 100);
setTimeout(() => {
clearInterval(sample);
const settled = widths.slice(-5);
expect(new Set(settled).size).toBe(1, 'chart must settle to one width, got: ' + settled.join(','));
expect(Math.abs(settled[0] - 380)).toBeLessThan(1, 'chart must follow the container to 380px');
chart.destroy();
done();
}, 1500);
}, 400);
});

// With the animator running, resizes are deferred and applied with a stale height,
// which used to be mistaken for a fixed container and clamped to.
it('grows back after shrinking, instead of clamping to its own previous height', function(done) {
const chart = acquireChart({
type: 'line',
data: {labels: ['a', 'b', 'c'], datasets: [{data: [1, 2, 3]}]},
options: {
responsive: true,
maintainAspectRatio: true,
animations: {tension: {duration: 200, easing: 'linear', from: 1, to: 0, loop: true}}
}
}, {canvas: {style: ''}, wrapper: {style: 'width: 552.6px; position: relative'}});
const wrapper = chart.canvas.parentNode;
const trace = [];

function drag(path, then) {
let i = 0;
(function frame() {
if (i < path.length) {
wrapper.style.width = path[i++] + 'px';
requestAnimationFrame(frame);
} else {
setTimeout(then, 400);
}
}());
}
const steps = (from, to, by) => {
const out = [];
for (let w = from; by > 0 ? w <= to : w >= to; w += by) {
out.push(+w.toFixed(1));
}
return out;
};

setTimeout(() => {
drag(steps(552.6, 360.6, -6), () => {
trace.push(`after shrink: container ${wrapper.getBoundingClientRect().width.toFixed(1)} chart ${chart.width}`);
drag(steps(360.6, 614, 6), () => {
const cw = wrapper.getBoundingClientRect().width;
trace.push(`after grow: container ${cw.toFixed(1)} chart ${chart.width}`);
expect(Math.abs(chart.width - cw)).toBeLessThan(1, `chart ${chart.width} must follow the container back up to ${cw.toFixed(1)}`);
chart.destroy();
done();
});
});
}, 300);
});
});