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
5 changes: 5 additions & 0 deletions .changeset/focus-guide-id-prefix-paint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/charts': patch
---

Scope `url(#id)` paint references on focus guides and crosshairs when `idPrefix` is configured.
47 changes: 47 additions & 0 deletions packages/charts-core/src/motion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 9 additions & 2 deletions packages/charts-core/src/motion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -824,6 +825,7 @@ function createMotionSvgChartRenderer<
(points: readonly ChartPoint<TDatum, TXValue, TYValue>[]) => void
>()
let renderOptions: ChartSurfaceRenderOptions | undefined
let focusGuideHooks: ChartSvgRenderHooks | undefined
let stateTransition: ChartMarkStateTransition | undefined
let stateTransitions:
Readonly<Record<string, ChartMarkStateTransition>> | undefined
Expand Down Expand Up @@ -950,6 +952,7 @@ function createMotionSvgChartRenderer<
idPrefix: renderOptions.idPrefix,
motion,
visible: visibleFocusGuides,
hooks: focusGuideHooks,
})
return presented.scene
}
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1100,6 +1104,7 @@ function createMotionSvgChartRenderer<
idPrefix: renderOptions.idPrefix,
motion,
visible: visibleFocusGuides,
hooks: focusGuideHooks,
})
return resolved.scene
}
Expand Down Expand Up @@ -1171,6 +1176,7 @@ function paintMotionSvgFocusGuides<TDatum>(options: {
idPrefix?: string
motion: ChartSvgMotionDriver<TDatum>
visible: Set<'under' | 'over'>
hooks?: ChartSvgRenderHooks
}) {
const {
container,
Expand All @@ -1182,6 +1188,7 @@ function paintMotionSvgFocusGuides<TDatum>(options: {
idPrefix = '',
motion,
visible,
hooks,
} = options
const presentation = resolveFocusGuides(scene, focus, pointer, cursor)
const reduced =
Expand All @@ -1206,7 +1213,7 @@ function paintMotionSvgFocusGuides<TDatum>(options: {
continue
}

const markup = renderFocusGuideLayer(nodes, placement, idPrefix)
const markup = renderFocusGuideLayer(nodes, placement, idPrefix, hooks)
if (reduced || !visible.has(placement)) {
reconcileChartSvgFragment(layer, markup)
} else {
Expand Down
20 changes: 20 additions & 0 deletions packages/charts-core/src/svg-focus-guide-layer.ts
Original file line number Diff line number Diff line change
@@ -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<SvgFocusGuidePlacement, SVGGElement>
>

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 {
Expand Down
55 changes: 55 additions & 0 deletions packages/charts-core/src/svg-surface.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<SVGGElement>(
'[data-ts-focus-guide-layer="over"]',
)
const xRule = layer?.querySelector<SVGLineElement>(
'[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({
Expand Down
14 changes: 12 additions & 2 deletions packages/charts-core/src/svg-surface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<SVGSVGElement>('svg.ts-chart')
if (!svg) {
Expand Down Expand Up @@ -123,6 +125,7 @@ export function createSvgChartRenderer<
}
scene = nextScene
renderOptions = options
focusGuideHooks = createFocusGuideRenderHooks(nextScene)
stateTransition = undefined
markStatePainted = false
retargetedFocus = false
Expand Down Expand Up @@ -193,6 +196,7 @@ export function createSvgChartRenderer<
cursor,
renderOptions,
renderSvg,
focusGuideHooks,
)
return resolved.scene
},
Expand Down Expand Up @@ -307,6 +311,7 @@ function paintSvgFocusGuides<
cursor: ChartCursorPresentation | null | undefined,
renderOptions: ChartSurfaceRenderOptions,
renderSvg: ChartSvgRenderer<TDatum, TXValue, TYValue>,
hooks?: ChartSvgRenderHooks,
) {
const presentation = resolveFocusGuides(scene, focus, pointer, cursor)
const cancellations: (() => void)[] = []
Expand All @@ -323,7 +328,12 @@ function paintSvgFocusGuides<
}
const markup =
renderSvg === renderChartSvg
? renderFocusGuideLayer(nodes, placement, renderOptions.idPrefix ?? '')
? renderFocusGuideLayer(
nodes,
placement,
renderOptions.idPrefix ?? '',
hooks,
)
: renderFocusGuideLayerWithRenderer(
svg,
scene,
Expand Down