( series: ReadonlyArray<ResolvedSeriesConfig>, x: number, y: number, xScale: LinearScale, yScale: LinearScale, maxDistance: number = DEFAULT_MAX_DISTANCE_PX, )
| 474 | * - Skips non-finite points and any points whose scaled coordinates are NaN. |
| 475 | */ |
| 476 | export function findNearestPoint( |
| 477 | series: ReadonlyArray<ResolvedSeriesConfig>, |
| 478 | x: number, |
| 479 | y: number, |
| 480 | xScale: LinearScale, |
| 481 | yScale: LinearScale, |
| 482 | maxDistance: number = DEFAULT_MAX_DISTANCE_PX, |
| 483 | ): NearestPointMatch | null { |
| 484 | if (!Number.isFinite(x) || !Number.isFinite(y)) return null; |
| 485 | |
| 486 | const md = Number.isFinite(maxDistance) |
| 487 | ? Math.max(0, maxDistance) |
| 488 | : DEFAULT_MAX_DISTANCE_PX; |
| 489 | const maxDistSq = md * md; |
| 490 | |
| 491 | const xTarget = xScale.invert(x); |
| 492 | if (!Number.isFinite(xTarget)) return null; |
| 493 | |
| 494 | let bestSeriesIndex = -1; |
| 495 | let bestDataIndex = -1; |
| 496 | let bestPoint: DataPoint | null = null; |
| 497 | let bestDistSq = Number.POSITIVE_INFINITY; |
| 498 | |
| 499 | // Story 4.6: Bar hit-testing (range-space bounds). |
| 500 | // - Only counts as a match when cursor is inside a bar rect. |
| 501 | // - For stacked bars, uses the same stacking bucket logic as the bar renderer (xKey bucketing). |
| 502 | // - If multiple segments match (shared edges), prefer visually topmost (smallest `top` in CSS px). |
| 503 | // If still tied, prefer larger `seriesIndex` for determinism. |
| 504 | const barSeriesConfigs: ResolvedBarSeriesConfig[] = []; |
| 505 | const barSeriesIndexByBar: number[] = []; |
| 506 | for (let s = 0; s < series.length; s++) { |
| 507 | const cfg = series[s]; |
| 508 | if (cfg?.type === 'bar') { |
| 509 | barSeriesConfigs.push(cfg); |
| 510 | barSeriesIndexByBar.push(s); |
| 511 | } |
| 512 | } |
| 513 | |
| 514 | if (barSeriesConfigs.length > 0) { |
| 515 | const layoutPx = computeBarLayoutPx(barSeriesConfigs, xScale); |
| 516 | if (layoutPx.barWidthPx > 0 && layoutPx.clusterWidthPx >= 0) { |
| 517 | const plotHeightPx = inferPlotHeightPxForBarHitTesting(barSeriesConfigs, yScale); |
| 518 | const { baselineDomain, baselinePx } = computeBaselineDomainAndPx(barSeriesConfigs, yScale, plotHeightPx); |
| 519 | |
| 520 | const { clusterSlots, barWidthPx, gapPx, clusterWidthPx, categoryWidthPx, categoryStep } = layoutPx; |
| 521 | const stackSumsByStackId = new Map<string, Map<number, { posSum: number; negSum: number }>>(); |
| 522 | |
| 523 | let bestBarHit: |
| 524 | | { |
| 525 | readonly seriesIndex: number; |
| 526 | readonly dataIndex: number; |
| 527 | readonly top: number; |
| 528 | } |
| 529 | | null = null; |
| 530 | |
| 531 | for (let b = 0; b < barSeriesConfigs.length; b++) { |
| 532 | const seriesCfg = barSeriesConfigs[b]; |
| 533 | const originalSeriesIndex = barSeriesIndexByBar[b] ?? -1; |
no test coverage detected