From de499f032f29457ee57ab43539bbd562152af8fd Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Wed, 4 Mar 2026 16:31:25 -0800 Subject: [PATCH 1/4] feat: filter/exclude/copy actions on Event Deltas attribute values Add click-to-action popover on PropertyComparisonChart bars with Filter, Exclude, and Copy buttons. Implement flattenedKeyToSqlExpression and flattenedKeyToFilterKey utilities to convert dot-notation property keys (from flattenData) into ClickHouse bracket notation for Map columns, enabling correct filter integration with the search sidebar. Wire onAddFilter through DBDeltaChart -> DBSearchHeatmapChart -> DBSearchPage, clearing heatmap selection after applying a filter. Closes #1829 Co-Authored-By: Claude Opus 4.6 (1M context) --- .changeset/filter-actions.md | 5 + packages/app/src/DBSearchPage.tsx | 1 + packages/app/src/components/DBDeltaChart.tsx | 20 ++- .../components/PropertyComparisonChart.tsx | 163 +++++++++++++++++- .../Search/DBSearchHeatmapChart.tsx | 20 ++- .../__tests__/deltaChartFilterKeys.test.ts | 142 +++++++++++++++ .../app/src/components/deltaChartUtils.ts | 84 +++++++++ 7 files changed, 429 insertions(+), 6 deletions(-) create mode 100644 .changeset/filter-actions.md create mode 100644 packages/app/src/components/__tests__/deltaChartFilterKeys.test.ts diff --git a/.changeset/filter-actions.md b/.changeset/filter-actions.md new file mode 100644 index 000000000..92d1b8c60 --- /dev/null +++ b/.changeset/filter-actions.md @@ -0,0 +1,5 @@ +--- +"@hyperdx/app": patch +--- + +feat: filter/exclude/copy actions on Event Deltas attribute values diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index daa82baf9..53c93d536 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -1872,6 +1872,7 @@ function DBSearchPage() { }} isReady={isReady} source={searchedSource} + onAddFilter={searchFilters.setFilterValue} /> )} {analysisMode === 'results' && ( diff --git a/packages/app/src/components/DBDeltaChart.tsx b/packages/app/src/components/DBDeltaChart.tsx index fd5bd68de..d5d893447 100644 --- a/packages/app/src/components/DBDeltaChart.tsx +++ b/packages/app/src/components/DBDeltaChart.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { ClickHouseQueryError } from '@hyperdx/common-utils/dist/clickhouse'; import { ChartConfigWithDateRange, @@ -21,12 +21,17 @@ import { useQueriedChartConfig } from '@/hooks/useChartConfig'; import { getFirstTimestampValueExpression } from '@/source'; import { SQLPreview } from './ChartSQLPreview'; +import type { AddFilterFn } from './deltaChartUtils'; import { + flattenedKeyToFilterKey, getPropertyStatistics, isDenylisted, isHighCardinality, mergeValueStatisticsMaps, } from './deltaChartUtils'; + +// Re-export types so callers importing from DBDeltaChart don't need to change. +export type { AddFilterFn } from './deltaChartUtils'; import { CHART_GAP, CHART_HEIGHT, @@ -42,6 +47,7 @@ export default function DBDeltaChart({ xMax, yMin, yMax, + onAddFilter, }: { config: ChartConfigWithDateRange; valueExpr: string; @@ -49,6 +55,7 @@ export default function DBDeltaChart({ xMax: number; yMin: number; yMax: number; + onAddFilter?: AddFilterFn; }) { // Determine if the value expression uses aggregate functions const isAggregate = isAggregateFunction(valueExpr); @@ -210,6 +217,15 @@ export default function DBDeltaChart({ [outlierData?.meta, inlierData?.meta], ); + // Wrap onAddFilter to convert flattened dot-notation keys into ClickHouse bracket notation + const handleAddFilter = useCallback>( + (property, value, action) => { + if (!onAddFilter) return; + onAddFilter(flattenedKeyToFilterKey(property, columnMeta), value, action); + }, + [onAddFilter, columnMeta], + ); + // TODO: Is loading state const { visibleProperties, @@ -405,6 +421,7 @@ export default function DBDeltaChart({ inlierValueOccurences={ inlierValueOccurences.get(property) ?? new Map() } + onAddFilter={onAddFilter ? handleAddFilter : undefined} key={property} /> ))} @@ -441,6 +458,7 @@ export default function DBDeltaChart({ inlierValueOccurences={ inlierValueOccurences.get(key) ?? new Map() } + onAddFilter={onAddFilter ? handleAddFilter : undefined} key={key} /> ))} diff --git a/packages/app/src/components/PropertyComparisonChart.tsx b/packages/app/src/components/PropertyComparisonChart.tsx index 5954d8f69..15858e505 100644 --- a/packages/app/src/components/PropertyComparisonChart.tsx +++ b/packages/app/src/components/PropertyComparisonChart.tsx @@ -1,4 +1,5 @@ -import { memo } from 'react'; +import { memo, useEffect, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; import { withErrorBoundary } from 'react-error-boundary'; import type { TooltipProps } from 'recharts'; import { @@ -10,10 +11,17 @@ import { XAxis, YAxis, } from 'recharts'; -import { Text } from '@mantine/core'; +import { Flex, Text } from '@mantine/core'; +import { IconCopy, IconFilter, IconFilterX } from '@tabler/icons-react'; -import { getChartColorError, getChartColorSuccess } from '@/utils'; +import { + getChartColorError, + getChartColorSuccess, + truncateMiddle, +} from '@/utils'; +import { DBRowTableIconButton } from './DBTable/DBRowTableIconButton'; +import type { AddFilterFn } from './deltaChartUtils'; import { applyTopNAggregation, mergeValueStatisticsMaps, @@ -46,6 +54,7 @@ type TooltipContentProps = TooltipProps & { }; // Hover-only tooltip: shows value name and percentages. +// Actions are handled by the click popover in PropertyComparisonChart. const HDXBarChartTooltip = withErrorBoundary( memo(({ active, payload, label, title }: TooltipContentProps) => { if (active && payload && payload.length) { @@ -94,7 +103,7 @@ function TruncatedTick({ x = 0, y = 0, payload }: TickProps) { const value = String(payload?.value ?? ''); const MAX_CHARS = 12; const displayValue = - value.length > MAX_CHARS ? value.slice(0, MAX_CHARS) + '…' : value; + value.length > MAX_CHARS ? value.slice(0, MAX_CHARS) + '\u2026' : value; return ( {value} @@ -117,10 +126,12 @@ export function PropertyComparisonChart({ name, outlierValueOccurences, inlierValueOccurences, + onAddFilter, }: { name: string; outlierValueOccurences: Map; inlierValueOccurences: Map; + onAddFilter?: AddFilterFn; }) { const mergedValueStatistics = mergeValueStatisticsMaps( outlierValueOccurences, @@ -128,6 +139,57 @@ export function PropertyComparisonChart({ ); const chartData = applyTopNAggregation(mergedValueStatistics); + const [clickedBar, setClickedBar] = useState<{ + value: string; + clientX: number; + clientY: number; + } | null>(null); + const [copiedValue, setCopiedValue] = useState(false); + const popoverRef = useRef(null); + + // Dismiss popover on click outside, scroll, or Escape key + useEffect(() => { + if (!clickedBar) return; + const handleClickOutside = (e: MouseEvent) => { + if ( + popoverRef.current && + !popoverRef.current.contains(e.target as Node) + ) { + setClickedBar(null); + } + }; + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') setClickedBar(null); + }; + const handleScroll = () => setClickedBar(null); + document.addEventListener('mousedown', handleClickOutside); + document.addEventListener('keydown', handleKeyDown); + window.addEventListener('scroll', handleScroll, true); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + document.removeEventListener('keydown', handleKeyDown); + window.removeEventListener('scroll', handleScroll, true); + }; + }, [clickedBar]); + + const handleChartClick = (data: any, event: any) => { + if (!data?.activePayload?.length) { + setClickedBar(null); + return; + } + if (data.activePayload[0]?.payload?.isOther) { + setClickedBar(null); + return; + } + // Reset copy confirmation so it doesn't carry over to the new bar's popover + setCopiedValue(false); + setClickedBar({ + value: String(data.activeLabel ?? ''), + clientX: event.clientX, + clientY: event.clientY, + }); + }; + return (
} /> + {clickedBar && + createPortal( +
+ + {truncateMiddle(name, 40)} + + + {clickedBar.value.length === 0 ? ( + Empty String + ) : ( + clickedBar.value + )} + + + + Selection:{' '} + {(outlierValueOccurences.get(clickedBar.value) ?? 0).toFixed(1)} + % + + + Background:{' '} + {(inlierValueOccurences.get(clickedBar.value) ?? 0).toFixed(1)}% + + + + {onAddFilter && ( + <> + { + onAddFilter(name, clickedBar.value, 'include'); + setClickedBar(null); + }} + > + + + { + onAddFilter(name, clickedBar.value, 'exclude'); + setClickedBar(null); + }} + > + + + + )} + { + try { + await navigator.clipboard.writeText(clickedBar.value); + setCopiedValue(true); + setTimeout(() => setCopiedValue(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }} + > + + + +
, + document.body, + )}
); } diff --git a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx index 1b710ea1c..f4bfcb881 100644 --- a/packages/app/src/components/Search/DBSearchHeatmapChart.tsx +++ b/packages/app/src/components/Search/DBSearchHeatmapChart.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useCallback, useState } from 'react'; import { parseAsFloat, parseAsString, useQueryStates } from 'nuqs'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -21,6 +21,7 @@ import { IconPlayerPlay } from '@tabler/icons-react'; import { SQLInlineEditorControlled } from '@/components/SearchInput/SQLInlineEditor'; import { getDurationMsExpression } from '@/source'; +import type { AddFilterFn } from '../DBDeltaChart'; import DBDeltaChart from '../DBDeltaChart'; import DBHeatmapChart from '../DBHeatmapChart'; @@ -33,10 +34,12 @@ export function DBSearchHeatmapChart({ chartConfig, source, isReady, + onAddFilter, }: { chartConfig: ChartConfigWithDateRange; source: TSource; isReady: boolean; + onAddFilter?: AddFilterFn; }) { const [fields, setFields] = useQueryStates({ value: parseAsString.withDefault(getDurationMsExpression(source)), @@ -49,6 +52,18 @@ export function DBSearchHeatmapChart({ }); const [container, setContainer] = useState(null); + // After applying a filter, clear the heatmap selection so the delta chart + // resets instead of staying in comparison mode. + const handleAddFilterAndClearSelection = useCallback< + NonNullable + >( + (property, value, action) => { + setFields({ xMin: null, xMax: null, yMin: null, yMax: null }); + onAddFilter?.(property, value, action); + }, + [onAddFilter, setFields], + ); + return ( ) : (
diff --git a/packages/app/src/components/__tests__/deltaChartFilterKeys.test.ts b/packages/app/src/components/__tests__/deltaChartFilterKeys.test.ts new file mode 100644 index 000000000..740d00a17 --- /dev/null +++ b/packages/app/src/components/__tests__/deltaChartFilterKeys.test.ts @@ -0,0 +1,142 @@ +import { + flattenedKeyToFilterKey, + flattenedKeyToSqlExpression, +} from '../deltaChartUtils'; + +const traceColumnMeta = [ + { name: 'Timestamp', type: 'DateTime64(9)' }, + { name: 'TraceId', type: 'String' }, + { name: 'SpanId', type: 'String' }, + { name: 'ParentSpanId', type: 'String' }, + { name: 'ResourceAttributes', type: 'Map(String, String)' }, + { name: 'SpanAttributes', type: 'Map(String, String)' }, + { name: 'Events.Timestamp', type: 'Array(DateTime64(9))' }, + { name: 'Events.Name', type: 'Array(String)' }, + { name: 'Events.Attributes', type: 'Array(Map(String, String))' }, + { name: 'Links.TraceId', type: 'Array(String)' }, + { name: 'Links.SpanId', type: 'Array(String)' }, + { name: 'Links.Timestamp', type: 'Array(DateTime64(9))' }, + { name: 'Links.Attributes', type: 'Array(Map(String, String))' }, +]; + +describe('flattenedKeyToSqlExpression', () => { + it('converts Map column dot-notation to bracket notation', () => { + expect( + flattenedKeyToSqlExpression( + 'ResourceAttributes.service.name', + traceColumnMeta, + ), + ).toBe("ResourceAttributes['service.name']"); + }); + + it('converts SpanAttributes dot-notation to bracket notation', () => { + expect( + flattenedKeyToSqlExpression( + 'SpanAttributes.http.method', + traceColumnMeta, + ), + ).toBe("SpanAttributes['http.method']"); + }); + + it('converts Array(Map) dot-notation with 0-based index to 1-based bracket notation', () => { + expect( + flattenedKeyToSqlExpression( + 'Events.Attributes[0].message.type', + traceColumnMeta, + ), + ).toBe("Events.Attributes[1]['message.type']"); + }); + + it('increments the array index from 0-based JS to 1-based ClickHouse', () => { + expect( + flattenedKeyToSqlExpression('Events.Attributes[4].key', traceColumnMeta), + ).toBe("Events.Attributes[5]['key']"); + }); + + it('returns simple columns unchanged', () => { + expect(flattenedKeyToSqlExpression('TraceId', traceColumnMeta)).toBe( + 'TraceId', + ); + }); + + it('returns non-map nested columns unchanged', () => { + expect(flattenedKeyToSqlExpression('Events.Name[0]', traceColumnMeta)).toBe( + 'Events.Name[0]', + ); + }); + + it('returns key unchanged when no matching column found', () => { + expect( + flattenedKeyToSqlExpression('SomeUnknownColumn.key', traceColumnMeta), + ).toBe('SomeUnknownColumn.key'); + }); + + it('handles LowCardinality(Map) wrapped types', () => { + const meta = [ + { name: 'LogAttributes', type: 'LowCardinality(Map(String, String))' }, + ]; + expect(flattenedKeyToSqlExpression('LogAttributes.level', meta)).toBe( + "LogAttributes['level']", + ); + }); + + it('returns key unchanged for empty columnMeta', () => { + expect( + flattenedKeyToSqlExpression('ResourceAttributes.service.name', []), + ).toBe('ResourceAttributes.service.name'); + }); + + it('escapes single quotes in Map column keys to prevent SQL injection', () => { + expect( + flattenedKeyToSqlExpression( + "ResourceAttributes.it's.key", + traceColumnMeta, + ), + ).toBe("ResourceAttributes['it''s.key']"); + }); + + it('escapes single quotes in Array(Map) column keys', () => { + expect( + flattenedKeyToSqlExpression( + "Events.Attributes[0].it's.key", + traceColumnMeta, + ), + ).toBe("Events.Attributes[1]['it''s.key']"); + }); +}); + +describe('flattenedKeyToFilterKey', () => { + it('converts Map column keys to bracket notation', () => { + expect( + flattenedKeyToFilterKey( + 'ResourceAttributes.service.name', + traceColumnMeta, + ), + ).toBe("ResourceAttributes['service.name']"); + }); + + it('handles multi-segment dotted Map keys as single bracket key', () => { + expect( + flattenedKeyToFilterKey( + 'ResourceAttributes.service.instance.id', + traceColumnMeta, + ), + ).toBe("ResourceAttributes['service.instance.id']"); + }); + + it('escapes single quotes in Map keys', () => { + expect( + flattenedKeyToFilterKey("ResourceAttributes.it's.key", traceColumnMeta), + ).toBe("ResourceAttributes['it''s.key']"); + }); + + it('returns simple columns unchanged', () => { + expect(flattenedKeyToFilterKey('TraceId', traceColumnMeta)).toBe('TraceId'); + }); + + it('returns simple columns unchanged for non-Map types', () => { + expect(flattenedKeyToFilterKey('Timestamp', traceColumnMeta)).toBe( + 'Timestamp', + ); + }); +}); diff --git a/packages/app/src/components/deltaChartUtils.ts b/packages/app/src/components/deltaChartUtils.ts index cad5013bf..782772dd6 100644 --- a/packages/app/src/components/deltaChartUtils.ts +++ b/packages/app/src/components/deltaChartUtils.ts @@ -163,6 +163,90 @@ export function applyTopNAggregation( ]; } +// --------------------------------------------------------------------------- +// Filter key conversion helpers +// --------------------------------------------------------------------------- + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +/** + * Converts a flattened dot-notation property key (produced by flattenData()) + * into a valid ClickHouse SQL expression for use in filter conditions. + * + * flattenData() uses JavaScript's object/array iteration, producing keys like: + * "ResourceAttributes.service.name" for Map(String, String) columns + * "Events.Attributes[0].message.type" for Array(Map(String, String)) columns + * + * These must be converted to bracket notation for ClickHouse Map access: + * "ResourceAttributes['service.name']" + * "Events.Attributes[1]['message.type']" (note: 0-based JS -> 1-based CH index) + */ +export function flattenedKeyToSqlExpression( + key: string, + columnMeta: { name: string; type: string }[], +): string { + for (const col of columnMeta) { + const baseType = stripTypeWrappers(col.type); + + if (baseType.startsWith('Map(')) { + if (key.startsWith(col.name + '.')) { + const mapKey = key.slice(col.name.length + 1).replace(/'/g, "''"); + return `${col.name}['${mapKey}']`; + } + } else if (baseType.startsWith('Array(')) { + const innerType = stripTypeWrappers(baseType.slice('Array('.length, -1)); + if (innerType.startsWith('Map(')) { + const pattern = new RegExp( + `^${escapeRegExp(col.name)}\\[(\\d+)\\]\\.(.+)$`, + ); + const match = key.match(pattern); + if (match) { + const chIndex = parseInt(match[1]) + 1; + const mapKey = match[2].replace(/'/g, "''"); + return `${col.name}[${chIndex}]['${mapKey}']`; + } + } + } + } + return key; +} + +/** + * Converts a flattened dot-notation property key into a filter key using + * ClickHouse bracket notation for Map columns. + * This matches the search bar format (WHERE ResourceAttributes['k8s.pod.name'] = ...). + * For simple (non-Map) columns, returns the key unchanged. + */ +export function flattenedKeyToFilterKey( + key: string, + columnMeta: { name: string; type: string }[], +): string { + for (const col of columnMeta) { + const baseType = stripTypeWrappers(col.type); + + if (baseType.startsWith('Map(')) { + if (key.startsWith(col.name + '.')) { + const mapKey = key.slice(col.name.length + 1).replace(/'/g, "''"); + return `${col.name}['${mapKey}']`; + } + } else if (baseType.startsWith('Array(')) { + const innerType = stripTypeWrappers(baseType.slice('Array('.length, -1)); + if (innerType.startsWith('Map(')) { + return flattenedKeyToSqlExpression(key, columnMeta); + } + } + } + return key; +} + +export type AddFilterFn = ( + property: string, + value: string, + action?: 'only' | 'exclude' | 'include', +) => void; + // --------------------------------------------------------------------------- // Field classification helpers // --------------------------------------------------------------------------- From e46b7e5a2f4e8892ffb6593740ff3a3a7e7ebf8a Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Wed, 4 Mar 2026 17:05:09 -0800 Subject: [PATCH 2/4] fix: clean up copy setTimeout on unmount to prevent stale state updates Store timeout ID in a ref and clear it on unmount to avoid setCopiedValue firing after component is unmounted. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/PropertyComparisonChart.tsx | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/PropertyComparisonChart.tsx b/packages/app/src/components/PropertyComparisonChart.tsx index 15858e505..6561e6c13 100644 --- a/packages/app/src/components/PropertyComparisonChart.tsx +++ b/packages/app/src/components/PropertyComparisonChart.tsx @@ -146,6 +146,14 @@ export function PropertyComparisonChart({ } | null>(null); const [copiedValue, setCopiedValue] = useState(false); const popoverRef = useRef(null); + const copyTimeoutRef = useRef | null>(null); + + // Clean up copy confirmation timeout on unmount + useEffect(() => { + return () => { + if (copyTimeoutRef.current) clearTimeout(copyTimeoutRef.current); + }; + }, []); // Dismiss popover on click outside, scroll, or Escape key useEffect(() => { @@ -337,7 +345,12 @@ export function PropertyComparisonChart({ try { await navigator.clipboard.writeText(clickedBar.value); setCopiedValue(true); - setTimeout(() => setCopiedValue(false), 2000); + if (copyTimeoutRef.current) + clearTimeout(copyTimeoutRef.current); + copyTimeoutRef.current = setTimeout( + () => setCopiedValue(false), + 2000, + ); } catch (err) { console.error('Failed to copy:', err); } From 89e5ed95fffb0f091c581112a6de8fc4e1cd0130 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Wed, 4 Mar 2026 17:09:13 -0800 Subject: [PATCH 3/4] fix: clamp popover to viewport bounds and clarify filterKey separation - Clamp popover left position to keep it within viewport edges - Flip popover below click point when near the top of the viewport - Add comment explaining why flattenedKeyToFilterKey is intentionally separate from flattenedKeyToSqlExpression (future divergence) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/PropertyComparisonChart.tsx | 17 ++++++++++++++--- packages/app/src/components/deltaChartUtils.ts | 4 ++++ 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/PropertyComparisonChart.tsx b/packages/app/src/components/PropertyComparisonChart.tsx index 6561e6c13..e07333685 100644 --- a/packages/app/src/components/PropertyComparisonChart.tsx +++ b/packages/app/src/components/PropertyComparisonChart.tsx @@ -273,9 +273,20 @@ export function PropertyComparisonChart({ className={styles.chartTooltip} style={{ position: 'fixed', - left: clickedBar.clientX, - top: clickedBar.clientY - 8, - transform: 'translate(-50%, -100%)', + // Clamp horizontally so the popover stays within the viewport + left: Math.max( + 160, + Math.min(clickedBar.clientX, window.innerWidth - 160), + ), + // Render above click point; flip below if near the top + top: + clickedBar.clientY > 200 + ? clickedBar.clientY - 8 + : clickedBar.clientY + 16, + transform: + clickedBar.clientY > 200 + ? 'translate(-50%, -100%)' + : 'translate(-50%, 0)', zIndex: 1000, borderRadius: 4, padding: '8px 12px', diff --git a/packages/app/src/components/deltaChartUtils.ts b/packages/app/src/components/deltaChartUtils.ts index 782772dd6..d28761f68 100644 --- a/packages/app/src/components/deltaChartUtils.ts +++ b/packages/app/src/components/deltaChartUtils.ts @@ -218,6 +218,10 @@ export function flattenedKeyToSqlExpression( * ClickHouse bracket notation for Map columns. * This matches the search bar format (WHERE ResourceAttributes['k8s.pod.name'] = ...). * For simple (non-Map) columns, returns the key unchanged. + * + * NOTE: Currently produces the same output as flattenedKeyToSqlExpression for + * Map columns. Kept separate because filter keys may diverge in the future + * (e.g., sidebar facet format vs SQL WHERE clause format for Array(Map) columns). */ export function flattenedKeyToFilterKey( key: string, From 4cff395f269f1817c0af76aed2304a490482e165 Mon Sep 17 00:00:00 2001 From: Alex Fedotyev Date: Thu, 5 Mar 2026 07:30:16 -0800 Subject: [PATCH 4/4] fix: prevent hover tooltip from overflowing right edge of viewport Set allowEscapeViewBox x to false so Recharts keeps the tooltip within the chart's horizontal bounds. Vertical escape remains enabled for tall tooltips. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/app/src/components/PropertyComparisonChart.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/PropertyComparisonChart.tsx b/packages/app/src/components/PropertyComparisonChart.tsx index e07333685..aa72fa1f4 100644 --- a/packages/app/src/components/PropertyComparisonChart.tsx +++ b/packages/app/src/components/PropertyComparisonChart.tsx @@ -233,7 +233,7 @@ export function PropertyComparisonChart({ /> } - allowEscapeViewBox={{ x: true, y: true }} + allowEscapeViewBox={{ x: false, y: true }} wrapperStyle={{ zIndex: 1000 }} />