Skip to content
Closed
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
37 changes: 37 additions & 0 deletions cypress/integration/rendering/xyChart.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -879,4 +879,41 @@ describe('XY Chart', () => {
});
});
});

describe('Point label collision avoidance', () => {
it('should flip labels below line when steep descent causes collision', () => {
imgSnapshotTest(
`
xychart
title "Smallest AI models scoring above 60% on MMLU"
x-axis "Date" ["Apr 2022", "Feb 2023", "Jul 2023", "Sep 2023", "Apr 2024"]
y-axis "Parameters (B)" 0 --> 600
line [540 "PaLM", 65 "LLaMA-65B", 34 "Llama 2 34B", 7 "Mistral 7B", 3.8 "Phi-3-mini"]
`,
{}
);
});
it('should keep labels above line when no collision on gentle slope', () => {
imgSnapshotTest(
`
xychart
x-axis ["A", "B", "C"]
y-axis 0 --> 100
line [40 "Start", 50 "Mid", 45 "End"]
`,
{}
);
});
it('should handle mixed collision: some labels above, some below', () => {
imgSnapshotTest(
`
xychart
x-axis ["P1", "P2", "P3", "P4"]
y-axis 0 --> 500
line [400 "High", 50 "Low", 450 "Peak", 30 "Bottom"]
`,
{}
);
});
});
});
19 changes: 19 additions & 0 deletions demos/xychart.html
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,25 @@ <h1>Line chart with mixed labels (some points labeled, some not)</h1>
line [25 "Launch", 45, 72, 90 "Target Hit"]
</pre>

<hr />
<h1>Label collision avoidance: steep descent</h1>
<pre class="mermaid">
xychart
title "Smallest AI models scoring above 60% on MMLU"
x-axis "Date" ["Apr 2022", "Feb 2023", "Jul 2023", "Sep 2023", "Apr 2024"]
y-axis "Parameters (B)" 0 --> 600
line [540 "PaLM", 65 "LLaMA-65B", 34 "Llama 2 34B", 7 "Mistral 7B", 3.8 "Phi-3-mini"]
</pre>

<hr />
<h1>Label collision avoidance: zigzag pattern</h1>
<pre class="mermaid">
xychart
x-axis ["P1", "P2", "P3", "P4"]
y-axis 0 --> 500
line [400 "High", 50 "Low", 450 "Peak", 30 "Bottom"]
</pre>

<script type="module">
import mermaid from './mermaid.esm.mjs';
mermaid.initialize({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,177 @@ import { line } from 'd3';
import type { DrawableElem, LinePlotData, TextElem, XYChartConfig } from '../../interfaces.js';
import type { Axis } from '../axis/index.js';

const CHAR_WIDTH_FACTOR = 0.7;

/**
* Interpolate a line segment's y-value at a given x.
* Returns null if x is outside the segment's x-range.
*/
function lineYAtX(segStart: [number, number], segEnd: [number, number], x: number): number | null {
const [x0, y0] = segStart;
const [x1, y1] = segEnd;
const minX = Math.min(x0, x1);
const maxX = Math.max(x0, x1);
if (x < minX || x > maxX) {
return null;
}
if (x0 === x1) {
return null; // vertical segment — can't interpolate by x
}
const t = (x - x0) / (x1 - x0);
return y0 + t * (y1 - y0);
}

/**
* Check if a line segment passes through an axis-aligned bounding box.
*
* Computes the y-range of the segment within the box's x-range and checks
* if it overlaps with the box's y-range. This correctly detects the case
* where a steep line enters from above and exits below the box (or vice
* versa) even if neither endpoint nor any single sample point falls inside.
*/
function doesSegmentIntersectBox(
segStart: [number, number],
segEnd: [number, number],
boxLeft: number,
boxRight: number,
boxTop: number,
boxBottom: number
): boolean {
// Check if either endpoint is inside the box
for (const [ex, ey] of [segStart, segEnd]) {
if (ex >= boxLeft && ex <= boxRight && ey >= boxTop && ey <= boxBottom) {
return true;
}
}

// Compute the y-values of the line at the box's left and right x-edges
const yAtLeft = lineYAtX(segStart, segEnd, boxLeft);
const yAtRight = lineYAtX(segStart, segEnd, boxRight);

// Collect valid y-values (where segment overlaps box's x-range)
const ys: number[] = [];
if (yAtLeft !== null) {
ys.push(yAtLeft);
}
if (yAtRight !== null) {
ys.push(yAtRight);
}

// Also include segment endpoints that fall within the box's x-range
for (const [ex, ey] of [segStart, segEnd]) {
if (ex >= boxLeft && ex <= boxRight) {
ys.push(ey);
}
}

if (ys.length === 0) {
return false;
}

// The segment's y-range within the box's x-range
const segMinY = Math.min(...ys);
const segMaxY = Math.max(...ys);

// Check if the segment's y-range overlaps with the box's y-range
return segMaxY >= boxTop && segMinY <= boxBottom;
}

/**
* Determine if a label placed above (vertical) or to the right (horizontal)
* of a data point would collide with an adjacent line segment.
*/
function shouldFlipLabelVertical(
finalData: [number, number][],
index: number,
labelText: string,
fontSize: number,
labelOffset: number
): boolean {
const [px, py] = finalData[index];
const textWidth = fontSize * labelText.length * CHAR_WIDTH_FACTOR;
const halfWidth = textWidth / 2;
const halfHeight = fontSize / 2;

// Bounding box of label placed above the point
const boxLeft = px - halfWidth;
const boxRight = px + halfWidth;
const boxTop = py - labelOffset - halfHeight;
const boxBottom = py - labelOffset + halfHeight;

// Check previous segment
if (
index > 0 &&
doesSegmentIntersectBox(
finalData[index - 1],
finalData[index],
boxLeft,
boxRight,
boxTop,
boxBottom
)
) {
return true;
}
// Check next segment
if (
index < finalData.length - 1 &&
doesSegmentIntersectBox(
finalData[index],
finalData[index + 1],
boxLeft,
boxRight,
boxTop,
boxBottom
)
) {
return true;
}
return false;
}

function shouldFlipLabelHorizontal(
finalData: [number, number][],
index: number,
labelText: string,
fontSize: number,
labelOffset: number
): boolean {
// In horizontal orientation, finalData is still [xScaled, yScaled] but
// the path uses y(d[0]) and x(d[1]), so pixel positions are swapped.
// px = xAxis.getScaleValue (maps to SVG y), py = yAxis.getScaleValue (maps to SVG x)
// Label is placed at SVG (py + labelOffset, px) — to the right of the point.
// We check if that box collides with adjacent segments in SVG space.
const [px, py] = finalData[index];
const textWidth = fontSize * labelText.length * CHAR_WIDTH_FACTOR;
const halfHeight = fontSize / 2;

// In SVG space for horizontal: svgX = py, svgY = px
// Label "to the right": svgX = py + labelOffset, svgY = px
// Box in SVG coords:
const boxLeft = py + labelOffset;
const boxRight = py + labelOffset + textWidth;
const boxTop = px - halfHeight;
const boxBottom = px + halfHeight;

// Segments in SVG space: point i has svgX=py_i, svgY=px_i
const toSvg = (idx: number): [number, number] => [finalData[idx][1], finalData[idx][0]];

if (
index > 0 &&
doesSegmentIntersectBox(toSvg(index - 1), toSvg(index), boxLeft, boxRight, boxTop, boxBottom)
) {
return true;
}
if (
index < finalData.length - 1 &&
doesSegmentIntersectBox(toSvg(index), toSvg(index + 1), boxLeft, boxRight, boxTop, boxBottom)
) {
return true;
}
return false;
}

export class LinePlot {
constructor(
private plotData: LinePlotData,
Expand Down Expand Up @@ -57,20 +228,22 @@ export class LinePlot {
}

if (this.orientation === 'horizontal') {
const flip = shouldFlipLabelHorizontal(finalData, i, label, fontSize, labelOffset);
textData.push({
x: py + labelOffset,
x: flip ? py - labelOffset : py + labelOffset,
y: px,
text: label,
fill: this.plotData.strokeFill,
verticalPos: 'middle',
horizontalPos: 'left',
horizontalPos: flip ? 'right' : 'left',
fontSize,
rotation: 0,
});
} else {
const flip = shouldFlipLabelVertical(finalData, i, label, fontSize, labelOffset);
textData.push({
x: px,
y: py - labelOffset,
y: flip ? py + labelOffset : py - labelOffset,
text: label,
fill: this.plotData.strokeFill,
verticalPos: 'middle',
Expand Down
Binary file added qa-screenshots/01-basic-labels.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added qa-screenshots/02-mixed-labels.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added qa-screenshots/03-no-labels.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added qa-screenshots/04-horizontal.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added qa-screenshots/05-multi-lines.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added qa-screenshots/06-real-world.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.