diff --git a/.changeset/focus-guide-id-prefix-paint.md b/.changeset/focus-guide-id-prefix-paint.md new file mode 100644 index 00000000..2522eef1 --- /dev/null +++ b/.changeset/focus-guide-id-prefix-paint.md @@ -0,0 +1,5 @@ +--- +'@tanstack/charts': patch +--- + +Scope `url(#id)` paint references on focus guides and crosshairs when `idPrefix` is configured. diff --git a/packages/charts-core/src/motion.test.ts b/packages/charts-core/src/motion.test.ts index ef4049b0..9508bfe4 100644 --- a/packages/charts-core/src/motion.test.ts +++ b/packages/charts-core/src/motion.test.ts @@ -2859,6 +2859,53 @@ describe('SVG motion', () => { frames.restore() }) + it('scopes focus guide url(#id) paints with idPrefix', () => { + const scene = createChartScene( + defineChart({ + marks: [ + areaY( + [ + { x: 0, y: 1 }, + { x: 1, y: 3 }, + { x: 2, y: 2 }, + ], + { x: 'x', y: 'y', fill: 'url(#fade)' }, + ), + crosshair({ y: false, stroke: 'url(#fade)' }), + ], + scales: { + x: { scale: scaleLinear().domain([0, 2]) }, + y: { scale: scaleLinear().domain([0, 4]) }, + }, + gradients: [ + { + id: 'fade', + stops: [ + { offset: 0, color: '#2563eb', opacity: 0.6 }, + { offset: 1, color: '#2563eb', opacity: 0 }, + ], + }, + ], + }), + { width: 320, height: 200 }, + ) + const el = document.createElement('div') + const surface = motion({ initial: false }).mount(el, () => {}) + surface.render(scene, { ariaLabel: 'Scoped focus guide', idPrefix: 'a' }) + const p = scene.points[0]! + surface.paintFocus({ + primary: p, + group: [p], + source: 'pointer', + pinned: false, + }) + const rule = el.querySelector( + '[data-ts-focus-guide-layer] [data-ts-key$=":x-rule"]', + ) + expect(rule?.getAttribute('stroke')).toBe('url(#a-fade)') + surface.destroy() + }) + it('interpolates numeric tick-label typography and snaps categorical anchors', () => { const definition = ( fontSize: number, diff --git a/packages/charts-core/src/motion.ts b/packages/charts-core/src/motion.ts index a0656cb3..d12877a1 100644 --- a/packages/charts-core/src/motion.ts +++ b/packages/charts-core/src/motion.ts @@ -21,8 +21,9 @@ import type { RollingPathSnapshot, RollingPathTransform, } from './motion-path' -import { renderFocusGuideLayer } from './svg-renderer' +import { renderFocusGuideLayer, type ChartSvgRenderHooks } from './svg-renderer' import { + createFocusGuideRenderHooks, detachSvgFocusGuideLayers, ensureSvgFocusGuideLayer, removeSvgFocusGuideLayer, @@ -824,6 +825,7 @@ function createMotionSvgChartRenderer< (points: readonly ChartPoint[]) => void >() let renderOptions: ChartSurfaceRenderOptions | undefined + let focusGuideHooks: ChartSvgRenderHooks | undefined let stateTransition: ChartMarkStateTransition | undefined let stateTransitions: Readonly> | undefined @@ -950,6 +952,7 @@ function createMotionSvgChartRenderer< idPrefix: renderOptions.idPrefix, motion, visible: visibleFocusGuides, + hooks: focusGuideHooks, }) return presented.scene } @@ -1007,6 +1010,7 @@ function createMotionSvgChartRenderer< stateTransition = undefined scene = nextScene renderOptions = options + focusGuideHooks = createFocusGuideRenderHooks(nextScene) dataMotionActive = animate && !viewportMoved pendingStateFocus = dataMotionActive ? desiredStateFocus : undefined if (animate && !viewportMoved) { @@ -1100,6 +1104,7 @@ function createMotionSvgChartRenderer< idPrefix: renderOptions.idPrefix, motion, visible: visibleFocusGuides, + hooks: focusGuideHooks, }) return resolved.scene } @@ -1171,6 +1176,7 @@ function paintMotionSvgFocusGuides(options: { idPrefix?: string motion: ChartSvgMotionDriver visible: Set<'under' | 'over'> + hooks?: ChartSvgRenderHooks }) { const { container, @@ -1182,6 +1188,7 @@ function paintMotionSvgFocusGuides(options: { idPrefix = '', motion, visible, + hooks, } = options const presentation = resolveFocusGuides(scene, focus, pointer, cursor) const reduced = @@ -1206,7 +1213,7 @@ function paintMotionSvgFocusGuides(options: { continue } - const markup = renderFocusGuideLayer(nodes, placement, idPrefix) + const markup = renderFocusGuideLayer(nodes, placement, idPrefix, hooks) if (reduced || !visible.has(placement)) { reconcileChartSvgFragment(layer, markup) } else { diff --git a/packages/charts-core/src/svg-focus-guide-layer.ts b/packages/charts-core/src/svg-focus-guide-layer.ts index a06abafe..66426761 100644 --- a/packages/charts-core/src/svg-focus-guide-layer.ts +++ b/packages/charts-core/src/svg-focus-guide-layer.ts @@ -1,9 +1,29 @@ +import { renderSvgClip, type ChartSvgRenderHooks } from './svg-renderer' +import type { ChartScene } from './types' + export type SvgFocusGuidePlacement = 'under' | 'over' export type SvgFocusGuideLayers = Partial< Record > +export function createFocusGuideRenderHooks( + scene: ChartScene, +): ChartSvgRenderHooks | undefined { + if (!scene.gradients.length) return undefined + const gradientIds = new Set(scene.gradients.map((gradient) => gradient.id)) + return { + renderGroup: renderSvgClip, + resolvePaint: (value, idPrefix) => { + const match = /^url\(#([^)]+)\)$/.exec(value) + const id = match?.[1] + if (!id || !gradientIds.has(id)) return value + const sanitized = idPrefix.replaceAll(/[^a-zA-Z0-9_-]/g, '') + return `url(#${sanitized ? `${sanitized}-${id}` : id})` + }, + } +} + export function detachSvgFocusGuideLayers( svg: SVGSVGElement | null, ): SvgFocusGuideLayers { diff --git a/packages/charts-core/src/svg-surface.test.ts b/packages/charts-core/src/svg-surface.test.ts index d078ba09..49898a04 100644 --- a/packages/charts-core/src/svg-surface.test.ts +++ b/packages/charts-core/src/svg-surface.test.ts @@ -674,6 +674,61 @@ describe('SVG surface coordinates', () => { surface.destroy() }) + it('scopes focus guide url(#id) paints with idPrefix', () => { + const scene = createChartScene( + defineChart({ + marks: [ + dot( + [ + { x: 1, y: 1 }, + { x: 2, y: 2 }, + ], + { x: 'x', y: 'y', fill: 'url(#cursor)' }, + ), + crosshair({ y: false, stroke: 'url(#cursor)' }), + ], + gradients: [ + { + id: 'cursor', + stops: [ + { offset: 0, color: 'red' }, + { offset: 1, color: 'blue' }, + ], + }, + ], + scales: { + x: { scale: scaleLinear().domain([0, 4]) }, + y: { scale: scaleLinear().domain([0, 5]) }, + }, + guides: false, + margin: 20, + }), + { width: 320, height: 200 }, + ) + const [first] = scene.points + if (!first) throw new Error('Expected focus guide point') + const container = document.createElement('div') + const surface = createSvgChartRenderer().mount(container, () => {}) + surface.render(scene, { + ariaLabel: 'Resource crosshair', + idPrefix: 'chart:one', + }) + surface.paintFocus({ + primary: first, + group: [first], + source: 'pointer', + pinned: false, + }) + const layer = container.querySelector( + '[data-ts-focus-guide-layer="over"]', + ) + const xRule = layer?.querySelector( + '[data-ts-key$=":x-rule"]', + ) + expect(xRule?.getAttribute('stroke')).toBe('url(#chartone-cursor)') + surface.destroy() + }) + it('adds guide clipping when a custom serializer omits group resources', () => { const scene = createChartScene( defineChart({ diff --git a/packages/charts-core/src/svg-surface.ts b/packages/charts-core/src/svg-surface.ts index b8ad14df..18d2dd07 100644 --- a/packages/charts-core/src/svg-surface.ts +++ b/packages/charts-core/src/svg-surface.ts @@ -8,12 +8,13 @@ import { import { isDefaultFocusLayer } from './default-focus-internal' import { withSvgRenderChildren } from './svg-render-context-internal' import { resolveFocusGuides } from './focus-presentation' -import { renderFocusGuideLayer } from './svg-renderer' +import { renderFocusGuideLayer, type ChartSvgRenderHooks } from './svg-renderer' import { renderFocusGuideLayerWithRenderer, renderSvgFocusLayerWithRenderer, } from './svg-focus-guide-serializer' import { + createFocusGuideRenderHooks, detachSvgFocusGuideLayers, ensureSvgFocusGuideLayer, removeSvgFocusGuideLayer, @@ -59,6 +60,7 @@ export function createSvgChartRenderer< let retargetedFocus = false let currentFocus: ChartFocusState | null = null let eagerFocus = false + let focusGuideHooks: ChartSvgRenderHooks | undefined const svgElement = () => { const svg = container.querySelector('svg.ts-chart') if (!svg) { @@ -123,6 +125,7 @@ export function createSvgChartRenderer< } scene = nextScene renderOptions = options + focusGuideHooks = createFocusGuideRenderHooks(nextScene) stateTransition = undefined markStatePainted = false retargetedFocus = false @@ -193,6 +196,7 @@ export function createSvgChartRenderer< cursor, renderOptions, renderSvg, + focusGuideHooks, ) return resolved.scene }, @@ -307,6 +311,7 @@ function paintSvgFocusGuides< cursor: ChartCursorPresentation | null | undefined, renderOptions: ChartSurfaceRenderOptions, renderSvg: ChartSvgRenderer, + hooks?: ChartSvgRenderHooks, ) { const presentation = resolveFocusGuides(scene, focus, pointer, cursor) const cancellations: (() => void)[] = [] @@ -323,7 +328,12 @@ function paintSvgFocusGuides< } const markup = renderSvg === renderChartSvg - ? renderFocusGuideLayer(nodes, placement, renderOptions.idPrefix ?? '') + ? renderFocusGuideLayer( + nodes, + placement, + renderOptions.idPrefix ?? '', + hooks, + ) : renderFocusGuideLayerWithRenderer( svg, scene,