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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ stats/

.env

demos/mermaid.esm.mjs
demos/dev/**
!/demos/dev/example.html
!/demos/dev/reload.js
Expand Down
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 @@ -19,6 +19,7 @@ export interface Axis extends ChartComponent {
getTickDistance(): number;
recalculateOuterPaddingToDrawBar(): void;
setRange(range: [number, number]): void;
getRange(): [number, number];
}

export function getAxis(
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,28 @@ 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);
// Guard: don't flip below if it would collide with the x-axis.
// yAxis.getRange()[1] is the inner bottom boundary of the plot area.
const [yRangeMin, yRangeMax] = this.yAxis.getRange();
const plotBottom = Math.max(yRangeMin, yRangeMax);
const wouldClipAxis = py + labelOffset + fontSize / 2 > plotBottom;
const actualFlip = flip && !wouldClipAxis;
textData.push({
x: px,
y: py - labelOffset,
y: actualFlip ? py + labelOffset : py - labelOffset,
text: label,
fill: this.plotData.strokeFill,
verticalPos: 'middle',
Expand Down
Binary file added qa-screenshots/collision-01-steep-descent.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/collision-02-gentle-slope.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/collision-03-zigzag.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
84 changes: 84 additions & 0 deletions scripts/qa-screenshots.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* QA screenshot script using Playwright.
* Renders xychart collision test cases and saves screenshots to qa-screenshots/.
*/
import pkg from '/opt/node22/lib/node_modules/playwright/index.js';
const { chromium } = pkg;
import { readFileSync, mkdirSync } from 'fs';
import { fileURLToPath } from 'url';
import path from 'path';

const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROOT = path.resolve(__dirname, '..');
const OUT_DIR = path.join(ROOT, 'qa-screenshots');
const MERMAID_JS = readFileSync(path.join(ROOT, 'packages/mermaid/dist/mermaid.js'), 'utf-8');

mkdirSync(OUT_DIR, { recursive: true });

const CHARTS = [
{
name: 'collision-01-steep-descent',
diagram: `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"]`,
},
{
name: 'collision-02-gentle-slope',
diagram: `xychart
x-axis ["A", "B", "C"]
y-axis 0 --> 100
line [40 "Start", 50 "Mid", 45 "End"]`,
},
{
name: 'collision-03-zigzag',
diagram: `xychart
x-axis ["P1", "P2", "P3", "P4"]
y-axis 0 --> 500
line [400 "High", 50 "Low", 450 "Peak", 30 "Bottom"]`,
},
];

const browser = await chromium.launch();
const page = await browser.newPage();

page.on('console', (msg) => {
if (msg.type() === 'error') console.error(' BROWSER ERROR:', msg.text());
});
page.on('pageerror', (err) => console.error(' PAGE ERROR:', err.message));

await page.setViewportSize({ width: 900, height: 500 });

for (const chart of CHARTS) {
// Navigate to a blank page so addScriptTag works
await page.goto('about:blank');

await page.setContent(`<!doctype html>
<html>
<head><meta charset="utf-8"/>
<style>body { margin: 20px; background: white; }</style>
</head>
<body>
<pre class="mermaid">${chart.diagram}</pre>
</body>
</html>`, { waitUntil: 'commit' });

// Inject the mermaid UMD bundle
await page.addScriptTag({ content: MERMAID_JS });

// Initialize and run mermaid
await page.evaluate(() => {
window.mermaid.initialize({ theme: 'default', logLevel: 3, securityLevel: 'loose' });
return window.mermaid.run();
});

await page.waitForTimeout(500);

const outPath = path.join(OUT_DIR, `${chart.name}.png`);
await page.screenshot({ path: outPath, clip: { x: 0, y: 0, width: 900, height: 500 } });
console.log(`✓ ${chart.name}.png`);
}

await browser.close();
console.log('\nAll screenshots saved to qa-screenshots/');