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
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
import { getDatumByValue } from '../../../../src/component/crosshair/utils/common';
import { layoutCrosshair } from '../../../../src/component/crosshair/utils/cartesian';
import type { CrossHairStateItem } from '../../../../src/component/crosshair/interface';

describe('crosshair utils', () => {
describe('getDatumByValue', () => {
const data = [
{ start: 0, end: 0.2, name: 'A' },
{ start: 0.2, end: 0.6, name: 'B' },
{ start: 0.6, end: 1, name: 'C' }
];

test('should find datum when startField < endField (histogram-like)', () => {
const result = getDatumByValue(data, 0.1, 'start', 'end');
expect(result).toEqual(data[0]);

const result2 = getDatumByValue(data, 0.4, 'start', 'end');
expect(result2).toEqual(data[1]);

const result3 = getDatumByValue(data, 0.8, 'start', 'end');
expect(result3).toEqual(data[2]);
});

test('should find datum when startField > endField (mosaic-like reversed fields)', () => {
const reversedData = [
{ catEnd: 0.2, catStart: 0, name: 'A' },
{ catEnd: 0.6, catStart: 0.2, name: 'B' },
{ catEnd: 1, catStart: 0.6, name: 'C' }
];
// In mosaic, fieldX[0] = catEnd (larger), fieldX2 = catStart (smaller)
// so startField=catEnd, endField=catStart → startValue > endValue
const result = getDatumByValue(reversedData, 0.1, 'catEnd', 'catStart');
expect(result).toEqual(reversedData[0]);

const result2 = getDatumByValue(reversedData, 0.4, 'catEnd', 'catStart');
expect(result2).toEqual(reversedData[1]);

const result3 = getDatumByValue(reversedData, 0.8, 'catEnd', 'catStart');
expect(result3).toEqual(reversedData[2]);
});

test('should return null when value is out of range', () => {
const result = getDatumByValue(data, 1.5, 'start', 'end');
expect(result).toBeNull();

const result2 = getDatumByValue(data, -0.1, 'start', 'end');
expect(result2).toBeNull();
});

test('should return null for empty data', () => {
const result = getDatumByValue([], 0.5, 'start', 'end');
expect(result).toBeNull();
});

test('should handle single field (no endField)', () => {
const pointData = [{ x: 5 }, { x: 10 }, { x: 15 }];
const result = getDatumByValue(pointData, 10, 'x');
expect(result).toEqual(pointData[1]);
});

test('should match boundary values', () => {
const result = getDatumByValue(data, 0, 'start', 'end');
expect(result).toEqual(data[0]);

const result2 = getDatumByValue(data, 0.2, 'start', 'end');
// 0.2 matches both data[0] (end) and data[1] (start), returns first match
expect(result2).toEqual(data[0]);
});
});

describe('layoutCrosshair for rect type', () => {
test('should compute correct rect position when coord is at left edge (normal order)', () => {
const stateItem: CrossHairStateItem = {
coordKey: 'x',
anotherAxisKey: 'y',
currentValue: new Map(),
bandSize: 100,
offsetSize: 0,
cacheInfo: {
coord: 50,
coordRange: [0, 500],
sizeRange: [0, 300],
visible: true,
labels: {},
labelsTextStyle: {},
axis: { getLayoutRect: () => ({ width: 500, height: 300 }) } as any
},
attributes: {
visible: true,
type: 'rect'
}
};

const result = layoutCrosshair(stateItem);
expect(result).toBeDefined();
expect(result.visible).toBe(true);
// bandSize=100, getRectSize returns [0, 100] for bandSize > 0
// start.x = max(50 + 0, 0) = 50, end.x = min(50 + 100, 500) = 150
expect(result.start.x).toBe(50);
expect(result.end.x).toBe(150);
expect(result.start.y).toBe(0);
expect(result.end.y).toBe(300);
});

test('should compute correct rect position for mosaic-like scenario', () => {
// In mosaic after the fix, coord = Math.min(posStart, posEnd)
// so coord is always at the left edge of the band
const stateItem: CrossHairStateItem = {
coordKey: 'x',
anotherAxisKey: 'y',
currentValue: new Map(),
bandSize: 200,
offsetSize: 0,
cacheInfo: {
coord: 100,
coordRange: [0, 500],
sizeRange: [0, 300],
visible: true,
labels: {},
labelsTextStyle: {},
axis: { getLayoutRect: () => ({ width: 500, height: 300 }) } as any
},
attributes: {
visible: true,
type: 'rect'
}
};

const result = layoutCrosshair(stateItem);
expect(result).toBeDefined();
// bandSize=200, getRectSize returns [0, 200]
// start.x = max(100 + 0, 0) = 100, end.x = min(100 + 200, 500) = 300
expect(result.start.x).toBe(100);
expect(result.end.x).toBe(300);
});

test('should clamp rect to coordRange', () => {
const stateItem: CrossHairStateItem = {
coordKey: 'x',
anotherAxisKey: 'y',
currentValue: new Map(),
bandSize: 100,
offsetSize: 0,
cacheInfo: {
coord: 450,
coordRange: [0, 500],
sizeRange: [0, 300],
visible: true,
labels: {},
labelsTextStyle: {},
axis: { getLayoutRect: () => ({ width: 500, height: 300 }) } as any
},
attributes: {
visible: true,
type: 'rect'
}
};

const result = layoutCrosshair(stateItem);
// end.x = min(450 + 100, 500) = 500, clamped
expect(result.end.x).toBe(500);
});
});
});
10 changes: 5 additions & 5 deletions packages/vchart/src/component/crosshair/utils/cartesian.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,16 @@ export const layoutByValue = (
const field2 = field === 'xField' ? series.fieldX2 : series.fieldY2; // todo
const datum = getDatumByValue(series.getViewData().latestData, +value, field1, field2);
if (datum) {
const startX = field === 'xField' ? series.dataToPositionX(datum) : series.dataToPositionY(datum);
const posStart = field === 'xField' ? series.dataToPositionX(datum) : series.dataToPositionY(datum);
if (field2) {
bandSize = Math.abs(
startX - (field === 'xField' ? series.dataToPositionX1(datum) : series.dataToPositionY1(datum))
);
const posEnd = field === 'xField' ? series.dataToPositionX1(datum) : series.dataToPositionY1(datum);
bandSize = Math.abs(posStart - posEnd);
coord = Math.min(posStart, posEnd);
value = `${datum[field1]} ~ ${datum[field2]}`;
Comment on lines +95 to 97
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When field2 exists and the underlying data uses reversed start/end fields (e.g. mosaic where field1 can be the larger bound), the label text ${datum[field1]} ~ ${datum[field2]} will render in reversed order (e.g. 0.6 ~ 0.2). Since coord is normalized with Math.min(posStart, posEnd), consider also normalizing the displayed range to ascending order (based on the datum values) so the crosshair label matches the visual rect direction.

Suggested change
bandSize = Math.abs(posStart - posEnd);
coord = Math.min(posStart, posEnd);
value = `${datum[field1]} ~ ${datum[field2]}`;
const startValue = datum[field1];
const endValue = datum[field2];
const [rangeStartValue, rangeEndValue] =
startValue <= endValue ? [startValue, endValue] : [endValue, startValue];
bandSize = Math.abs(posStart - posEnd);
coord = Math.min(posStart, posEnd);
value = `${rangeStartValue} ~ ${rangeEndValue}`;

Copilot uses AI. Check for mistakes.
} else {
bandSize = 1;
coord = posStart;
}
coord = startX;
}
niceLabelFormatter = (axis as ILinearAxis).niceLabelFormatter;
}
Expand Down
4 changes: 3 additions & 1 deletion packages/vchart/src/component/crosshair/utils/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,8 +61,10 @@ export function getDatumByValue(data: Datum[], value: number, startField: string
if (record) {
const startValue = record[startField];
const endValue = record[endField || startField];
const min = Math.min(startValue, endValue);
const max = Math.max(startValue, endValue);

if (startValue <= value && endValue >= value) {
if (min <= value && max >= value) {
return record;
}
}
Expand Down
Loading