( series: ReadonlyArray<ResolvedSeriesConfig>, xValue: number, xScale: LinearScale, tolerance?: number, )
| 144 | * back to the existing nearest-x behavior (so axis-trigger tooltips still work away from bars). |
| 145 | */ |
| 146 | export function findPointsAtX( |
| 147 | series: ReadonlyArray<ResolvedSeriesConfig>, |
| 148 | xValue: number, |
| 149 | xScale: LinearScale, |
| 150 | tolerance?: number, |
| 151 | ): ReadonlyArray<PointsAtXMatch> { |
| 152 | if (!Number.isFinite(xValue)) return []; |
| 153 | |
| 154 | const maxDx = |
| 155 | tolerance === undefined || !Number.isFinite(tolerance) ? Number.POSITIVE_INFINITY : Math.max(0, tolerance); |
| 156 | const maxDxSq = maxDx * maxDx; |
| 157 | |
| 158 | const xTarget = xScale.invert(xValue); |
| 159 | if (!Number.isFinite(xTarget)) return []; |
| 160 | |
| 161 | const matches: PointsAtXMatch[] = []; |
| 162 | const barLayout = computeBarHitTestLayout(series, xScale); |
| 163 | |
| 164 | for (let s = 0; s < series.length; s++) { |
| 165 | const seriesConfig = series[s]; |
| 166 | // Pie and candlestick are non-cartesian (or not yet implemented); they can't match an x position. |
| 167 | if (seriesConfig.type === 'pie' || seriesConfig.type === 'candlestick') continue; |
| 168 | |
| 169 | const data = seriesConfig.data; |
| 170 | const n = data.length; |
| 171 | if (n === 0) continue; |
| 172 | |
| 173 | const first = data[0]; |
| 174 | const isTuple = Array.isArray(first); |
| 175 | |
| 176 | // Bar series: return the correct bar dataIndex for xValue when inside the bar interval. |
| 177 | // When tolerance is finite: require an (expanded) interval hit. |
| 178 | // When tolerance is non-finite: attempt exact hit, otherwise fall back to nearest-x behavior below. |
| 179 | if (seriesConfig.type === 'bar' && barLayout) { |
| 180 | const clusterIndex = barLayout.clusterIndexByGlobalSeriesIndex.get(s); |
| 181 | if (clusterIndex !== undefined) { |
| 182 | const { barWidth, gap, clusterWidth } = barLayout; |
| 183 | const offsetLeftFromCategoryCenter = -clusterWidth / 2 + clusterIndex * (barWidth + gap); |
| 184 | |
| 185 | const hitTol = |
| 186 | tolerance === undefined || !Number.isFinite(tolerance) ? 0 : Math.max(0, tolerance); |
| 187 | |
| 188 | // If we can't safely compute an interval hit, don't guess when tolerance is finite. |
| 189 | if (Number.isFinite(barWidth) && barWidth > 0 && Number.isFinite(offsetLeftFromCategoryCenter)) { |
| 190 | let hitIndex = -1; |
| 191 | |
| 192 | const isHit = (xCenterRange: number): boolean => { |
| 193 | if (!Number.isFinite(xCenterRange)) return false; |
| 194 | const left = xCenterRange + offsetLeftFromCategoryCenter; |
| 195 | const right = left + barWidth; |
| 196 | // Expanded interval: [left - tol, right + tol) |
| 197 | return xValue >= left - hitTol && xValue < right + hitTol; |
| 198 | }; |
| 199 | |
| 200 | if (seriesHasNaNX(data, isTuple)) { |
| 201 | // NaN breaks ordering; linear scan for correctness. |
| 202 | if (isTuple) { |
| 203 | const tupleData = data as ReadonlyArray<TuplePoint>; |
no test coverage detected