From 2a430f20b0e65e4e82a7421782e128ff47e19bc4 Mon Sep 17 00:00:00 2001 From: Kunpeng Xie <68572236+pentaoa@users.noreply.github.com> Date: Tue, 22 Sep 2026 17:02:45 +0800 Subject: [PATCH] Fix hitboxes for aligned vertical legend labels Cover aligned text and symbols while preserving RTL positioning. Add rendered-label regression coverage. Assisted-by: OpenAI Codex --- src/plugins/plugin.legend.js | 24 +++++++++-- test/specs/plugin.legend.tests.js | 70 +++++++++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 4 deletions(-) diff --git a/src/plugins/plugin.legend.js b/src/plugins/plugin.legend.js index 6ed99413536..3359eae3c24 100644 --- a/src/plugins/plugin.legend.js +++ b/src/plugins/plugin.legend.js @@ -38,6 +38,15 @@ const getBoxSize = (labelOpts, fontSize) => { const itemsEqual = (a, b) => a !== null && b !== null && a.datasetIndex === b.datasetIndex && a.index === b.index; +function getAlignedHitBox(x, width, boxWidth, fontSize, textAlign, right) { + const textWidth = width - boxWidth - fontSize / 2; + const textX = _textX(textAlign, x + boxWidth + fontSize / 2, right, false); + const textLeft = textX - (textAlign === 'center' ? textWidth / 2 : textAlign === 'right' ? textWidth : 0); + const left = Math.min(x, textLeft); + // Cover both the symbol and the aligned text in LTR coordinates. + return {left, width: Math.max(x + boxWidth, textLeft + textWidth) - left}; +} + export class Legend extends Element { /** @@ -232,7 +241,8 @@ export class Legend extends Element { return; } const titleHeight = this._computeTitleHeight(); - const {legendHitBoxes: hitboxes, options: {align, labels: {padding}, rtl}} = this; + const {legendHitBoxes: hitboxes, options: {align, labels, rtl}} = this; + const {padding} = labels; const rtlHelper = getRtlAdapter(rtl, this.left, this.width); if (this.isHorizontal()) { let row = 0; @@ -247,16 +257,22 @@ export class Legend extends Element { left += hitbox.width + padding; } } else { + const fontSize = toFont(labels.font).size; + const {boxWidth} = getBoxSize(labels, fontSize); let col = 0; let top = _alignStartEnd(align, this.top + titleHeight + padding, this.bottom - this.columnSizes[col].height); - for (const hitbox of hitboxes) { + for (let i = 0; i < hitboxes.length; i++) { + const hitbox = hitboxes[i]; if (hitbox.col !== col) { col = hitbox.col; top = _alignStartEnd(align, this.top + titleHeight + padding, this.bottom - this.columnSizes[col].height); } hitbox.top = top; - hitbox.left += this.left + padding; - hitbox.left = rtlHelper.leftForLtr(rtlHelper.x(hitbox.left), hitbox.width); + const x = hitbox.left + this.left + padding; + const textAlign = this.legendItems[i].textAlign || labels.textAlign; + const aligned = getAlignedHitBox(x, hitbox.width, boxWidth, fontSize, textAlign, this.right); + hitbox.width = aligned.width; + hitbox.left = rtlHelper.leftForLtr(rtlHelper.x(aligned.left), hitbox.width); top += hitbox.height + padding; } } diff --git a/test/specs/plugin.legend.tests.js b/test/specs/plugin.legend.tests.js index e0bed42c263..646ca2b57ee 100644 --- a/test/specs/plugin.legend.tests.js +++ b/test/specs/plugin.legend.tests.js @@ -604,6 +604,76 @@ describe('Legend block tests', function() { expect(hitBox.height).toBe(40); }); + for (const position of ['left', 'right', 'top', 'bottom']) { + for (const textAlign of ['left', 'center', 'right']) { + for (const rtl of [false, true]) { + it(`should hit test the full ${position} legend label with ${textAlign} alignment and rtl=${rtl}`, function() { + const chart = window.acquireChart({ + type: 'line', + data: { + datasets: ['Short', 'A much longer dataset label'].map(label => ({label, data: []})) + }, + options: { + plugins: {legend: {position, rtl, labels: {textAlign}}} + } + }, {canvas: {width: 600, height: 300}}); + const {ctx, legend} = chart; + const labels = []; + const symbols = []; + const rect = ctx.rect.bind(ctx); + spyOn(ctx, 'rect').and.callFake(function(x, y, width, height) { + if (width === legend.options.labels.boxWidth) { + symbols.push({x: x + width / 2, y: y + height / 2}); + } + rect(x, y, width, height); + }); + const fillText = ctx.fillText.bind(ctx); + spyOn(ctx, 'fillText').and.callFake(function(text, x, y, maxWidth) { + if (text === 'Short') { + const width = ctx.measureText(text).width; + const left = x - (ctx.textAlign === 'center' ? width / 2 : ctx.textAlign === 'right' ? width : 0); + labels.push({left, right: left + width, y}); + } + fillText(text, x, y, maxWidth); + }); + + chart.draw(); + + expect(labels.length).toBe(1); + const {left, right, y} = labels[0]; + expect(symbols.length).toBe(2); + expect(legend._getLegendItemAt(symbols[0].x, symbols[0].y)).toBe(legend.legendItems[0]); + expect(legend._getLegendItemAt(left + 1, y)).toBe(legend.legendItems[0]); + expect(legend._getLegendItemAt(right - 1, y)).toBe(legend.legendItems[0]); + }); + } + } + } + + it('should use a legend item text alignment before the global label alignment for hit testing', function() { + const chart = window.acquireChart({ + type: 'line', + data: {datasets: []}, + options: { + plugins: { + legend: { + position: 'right', + labels: { + textAlign: 'left', + generateLabels: () => [ + {text: 'Short', textAlign: 'right'}, + {text: 'A much longer dataset label'} + ] + } + } + } + } + }); + const {legend} = chart; + const hitbox = legend.legendHitBoxes[0]; + expect(legend._getLegendItemAt(legend.right - 1, hitbox.top + hitbox.height / 2)).toBe(legend.legendItems[0]); + }); + it('should pick up the first item when the property is an array', function() { var chart = window.acquireChart({ type: 'bar',