diff --git a/packages/constants/constants.ts b/packages/constants/constants.ts index d1f528702..ab0d3aa74 100644 --- a/packages/constants/constants.ts +++ b/packages/constants/constants.ts @@ -182,6 +182,7 @@ export const LINE_TYPE_SCALE = 'lineType'; export const LINEAR_COLOR_SCALE = 'linearColor'; export const LINE_WIDTH_SCALE = 'lineWidth'; export const OPACITY_SCALE = 'opacity'; +export const PATTERN_SCALE = 'pattern'; export const SYMBOL_SHAPE_SCALE = 'symbolShape'; export const SYMBOL_SIZE_SCALE = 'symbolSize'; export const SYMBOL_PATH_WIDTH_SCALE = 'symbolPathWidth'; diff --git a/packages/react-spectrum-charts-s2/src/RscChart.tsx b/packages/react-spectrum-charts-s2/src/RscChart.tsx index 6e4ced83f..ab5710d08 100644 --- a/packages/react-spectrum-charts-s2/src/RscChart.tsx +++ b/packages/react-spectrum-charts-s2/src/RscChart.tsx @@ -57,6 +57,7 @@ export const RscChart = ({ ref, ...props }: RscChartProps & { ref?: Ref { await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1)); }); }); + +describe('canvas pattern-fill interception', () => { + const patternId = 'test-stripe'; + const patternSpec = { + marks: [{ encode: { enter: { fill: { value: { pattern: patternId } } } } }], + } as unknown as Spec; + + let renderedCanvas: HTMLCanvasElement | undefined; + + beforeEach(() => { + jest.clearAllMocks(); + clearPatternFillRegistry(); + registerPatternFill({ id: patternId, tileSize: { width: 4, height: 4 }, draw: jest.fn() }); + renderedCanvas = undefined; + mockEmbed.mockImplementation((container) => { + renderedCanvas = document.createElement('canvas'); + (container as HTMLElement).appendChild(renderedCanvas); + return Promise.resolve({ view: createMockView() } as unknown as Awaited>); + }); + }); + + test('engages the interception when renderer is canvas and the spec has a pattern-fill reference', async () => { + render(); + + await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1)); + + const ctx = renderedCanvas?.getContext('2d') as CanvasRenderingContext2D; + ctx.fillStyle = { pattern: patternId } as unknown as string; + + expect(ctx.fillStyle).not.toEqual({ pattern: patternId }); + }); + + test('does not engage the interception when renderer is svg', async () => { + render(); + + await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1)); + + const ctx = renderedCanvas?.getContext('2d') as CanvasRenderingContext2D; + ctx.fillStyle = { pattern: patternId } as unknown as string; + + expect(ctx.fillStyle).toBe('#000000'); + }); + + test('does not engage the interception when the spec has no pattern-fill reference', async () => { + render(); + + await waitFor(() => expect(mockEmbed).toHaveBeenCalledTimes(1)); + + const ctx = renderedCanvas?.getContext('2d') as CanvasRenderingContext2D; + ctx.fillStyle = { pattern: patternId } as unknown as string; + + expect(ctx.fillStyle).toBe('#000000'); + }); +}); diff --git a/packages/react-spectrum-charts-s2/src/VegaChart.tsx b/packages/react-spectrum-charts-s2/src/VegaChart.tsx index d94bb2d88..4e67af0ee 100644 --- a/packages/react-spectrum-charts-s2/src/VegaChart.tsx +++ b/packages/react-spectrum-charts-s2/src/VegaChart.tsx @@ -22,6 +22,7 @@ import { ChartData, UserMeta, applyUserMetaConfigPatches, getVegaEmbedOptions } import { useDebugSpec } from './hooks/useDebugSpec'; import { extractValues, isVegaData } from './hooks/useSpec'; import { ChartProps } from './types'; +import { patchCanvasContextForPatternFill, specHasPatternFill } from './utils'; // Register a custom expression function that returns the full container width (including axis space). // `view._viewWidth` is the container width minus spec-level padding; adding padding back gives the @@ -136,6 +137,10 @@ export const VegaChart: FC = ({ embed(containerRef.current, specCopy, { ...embedOptions, config: finalConfig, tooltip }).then(({ view }) => { chartView.current = view; onNewView(view); + if (renderer === 'canvas' && specHasPatternFill(specCopy)) { + const ctx = containerRef.current?.querySelector('canvas')?.getContext('2d'); + if (ctx) patchCanvasContextForPatternFill(ctx); + } view.resize(); view.runAsync(); // One additional render to settle all resize calculations diff --git a/packages/react-spectrum-charts-s2/src/components/Bar/Bar.tsx b/packages/react-spectrum-charts-s2/src/components/Bar/Bar.tsx index 530f93866..31ac0304d 100644 --- a/packages/react-spectrum-charts-s2/src/components/Bar/Bar.tsx +++ b/packages/react-spectrum-charts-s2/src/components/Bar/Bar.tsx @@ -30,6 +30,7 @@ const Bar: FC = ({ type = 'stacked', opacity = { value: 1 }, lineType = { value: 'solid' }, + pattern, orientation = 'vertical', trellisOrientation = 'horizontal', trellisPadding = TRELLIS_PADDING, diff --git a/packages/react-spectrum-charts-s2/src/hooks/useSpec.tsx b/packages/react-spectrum-charts-s2/src/hooks/useSpec.tsx index b1de5b160..63d721c3a 100644 --- a/packages/react-spectrum-charts-s2/src/hooks/useSpec.tsx +++ b/packages/react-spectrum-charts-s2/src/hooks/useSpec.tsx @@ -35,6 +35,7 @@ export default function useSpec({ lineTypes, lineWidths, opacities, + patterns, symbolShapes, symbolSizes, title, @@ -68,6 +69,7 @@ export default function useSpec({ lineTypes, lineWidths, opacities, + patterns, symbolShapes, symbolSizes, title, @@ -91,6 +93,7 @@ export default function useSpec({ lineTypes, lineWidths, opacities, + patterns, symbolShapes, symbolSizes, title, diff --git a/packages/react-spectrum-charts-s2/src/stories/CanvasPatternFillPrototype.story.tsx b/packages/react-spectrum-charts-s2/src/stories/CanvasPatternFillPrototype.story.tsx new file mode 100644 index 000000000..78e22ed02 --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/stories/CanvasPatternFillPrototype.story.tsx @@ -0,0 +1,343 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import React, { ReactElement, useState } from 'react'; + +import { StoryFn } from '@storybook/react'; +import { Spec } from 'vega'; + +import { TABLE } from '@spectrum-charts/constants'; + +import { Chart } from '../Chart'; +import useChartProps from '../hooks/useChartProps'; +import { bindWithProps } from '../test-utils'; +import { PatternFillValue, registerPatternFill } from '../utils'; +import { barData } from './components/Bar/data'; + +const patternValue = (pattern: string): PatternFillValue => ({ pattern }); + +// Throwaway stories validating the canvas pattern-fill interception (planning/specs/chart/pattern-fill-rendering.json) +// against a real browser, including edge cases from the spec. Uses UNSAFE_vegaSpec since no real mark prop exists +// yet to drive this fill value through normal chart composition - delete once a real Pattern Scale prop lands. + +const ResizableWrapper = ({ children }: { children: ReactElement }): ReactElement => ( +
+ {children} +
+); + +const drawStripeTile = (baseColor: string, stripeColor: string) => (ctx: CanvasRenderingContext2D, { width, height }: { width: number; height: number }) => { + ctx.fillStyle = baseColor; + ctx.fillRect(0, 0, width, height); + ctx.fillStyle = stripeColor; + ctx.fillRect(0, 0, width, height / 2); +}; + +const drawDotsTile = (baseColor: string, dotColor: string) => (ctx: CanvasRenderingContext2D, { width, height }: { width: number; height: number }) => { + ctx.fillStyle = baseColor; + ctx.fillRect(0, 0, width, height); + ctx.fillStyle = dotColor; + ctx.beginPath(); + ctx.arc(width / 2, height / 2, Math.min(width, height) / 4, 0, Math.PI * 2); + ctx.fill(); +}; + +const drawCrosshatchTile = (baseColor: string, lineColor: string) => (ctx: CanvasRenderingContext2D, { width, height }: { width: number; height: number }) => { + ctx.fillStyle = baseColor; + ctx.fillRect(0, 0, width, height); + ctx.strokeStyle = lineColor; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(width / 2, 0); + ctx.lineTo(width / 2, height); + ctx.moveTo(0, height / 2); + ctx.lineTo(width, height / 2); + ctx.stroke(); +}; + +const TILE_SIZE = { width: 10, height: 10 }; + +const candyStripeId = 'candy-stripe'; +const dotsId = 'dots'; +const crosshatchId = 'crosshatch'; +const identityLightId = 'identity-light'; +const identityDarkId = 'identity-dark'; + +registerPatternFill({ id: candyStripeId, tileSize: TILE_SIZE, rotation: 45, draw: drawStripeTile('#2680eb', '#ffffff') }); +registerPatternFill({ id: dotsId, tileSize: TILE_SIZE, draw: drawDotsTile('#e68619', '#ffffff') }); +registerPatternFill({ id: crosshatchId, tileSize: TILE_SIZE, draw: drawCrosshatchTile('#d34fa1', '#ffffff') }); +// Two separate registered identities standing in for "the resolved color changed" (e.g. a colorScheme swap) - +// a real PatternScale would derive the id from the resolved color, so a change in color is naturally a new id. +registerPatternFill({ id: identityLightId, tileSize: TILE_SIZE, rotation: 45, draw: drawStripeTile('#2680eb', '#ffffff') }); +registerPatternFill({ id: identityDarkId, tileSize: TILE_SIZE, rotation: 45, draw: drawStripeTile('#0b1e3d', '#5aa9ff') }); + +const buildBarSpec = ( + fill: Record, + options?: { + hoverFillOpacity?: number; + extraScales?: Record[]; + extraSignals?: Record[]; + } +): Spec => + ({ + $schema: 'https://vega.github.io/schema/vega/v5.json', + signals: options?.extraSignals ?? [], + scales: [ + { name: 'xscale', type: 'band', domain: { data: TABLE, field: 'browser' }, range: 'width', padding: 0.1, round: true }, + { name: 'yscale', domain: { data: TABLE, field: 'downloads' }, nice: true, range: 'height' }, + ...(options?.extraScales ?? []), + ], + axes: [ + { orient: 'bottom', scale: 'xscale' }, + { orient: 'left', scale: 'yscale' }, + ], + marks: [ + { + type: 'rect', + from: { data: TABLE }, + encode: { + enter: { + x: { scale: 'xscale', field: 'browser' }, + width: { scale: 'xscale', band: 1 }, + y: { scale: 'yscale', field: 'downloads' }, + y2: { scale: 'yscale', value: 0 }, + fill, + }, + ...(options?.hoverFillOpacity !== undefined && { + update: { fillOpacity: { value: 1 } }, + hover: { fillOpacity: { value: options.hoverFillOpacity } }, + }), + }, + }, + ], + }) as unknown as Spec; + +// Dodged by region (like DodgedStacked's operatingSystem), stacked by period within each region's bar (like +// DodgedStacked's version) - previous period segment candy-striped, current period segment solid. +const dodgedStackedPeriodData = barData.flatMap(({ browser, downloads }) => + (['East', 'West'] as const).flatMap((region, i) => { + const current = Math.round(downloads * (i === 0 ? 0.6 : 0.4)); + const previous = Math.round(current * 0.8); + return [ + { browser, region, period: 'Previous', value: previous }, + { browser, region, period: 'Current', value: current }, + ]; + }) +); + +const dodgedStackedPeriodChartData = [ + { name: TABLE, values: dodgedStackedPeriodData }, + { + name: 'stacked', + source: TABLE, + transform: [{ type: 'stack', groupby: ['browser', 'region'], sort: { field: 'period', order: 'descending' }, field: 'value' }], + }, +]; + +// Vega's canonical grouped-bar shape: an outer xscale (browser) facets into per-browser groups, each with its +// own inner xsubscale (region) sized from the outer band's width - the rects inside stack by period via the +// stack transform's y0/y1, with fill resolved from a real ordinal scale so the legend generates natively. +const dodgedStackedPeriodSpec: Spec = { + $schema: 'https://vega.github.io/schema/vega/v5.json', + // Vega's scale-range parser rejects literal objects in a `range: [...]` array (the same restriction that + // applies to Gradient objects) - the structured pattern-fill value is carried through a signal instead. + signals: [{ name: 'periodPatternRange', value: [patternValue(candyStripeId), '#2680eb'] }], + scales: [ + { name: 'xscale', type: 'band', range: 'width', domain: { data: TABLE, field: 'browser' } }, + { name: 'yscale', domain: { data: 'stacked', field: 'y1' }, nice: true, zero: true, range: 'height' }, + { + name: 'periodScale', + type: 'ordinal', + domain: ['Previous', 'Current'], + range: { signal: 'periodPatternRange' }, + }, + ], + axes: [ + { orient: 'bottom', scale: 'xscale' }, + { orient: 'left', scale: 'yscale' }, + ], + legends: [{ fill: 'periodScale', title: 'Period', symbolType: 'square' }], + marks: [ + { + type: 'group', + from: { facet: { data: 'stacked', name: 'facetedBrowser', groupby: 'browser' } }, + encode: { enter: { x: { scale: 'xscale', field: 'browser' } } }, + signals: [{ name: 'width', update: "bandwidth('xscale')" }], + scales: [ + { + name: 'xsubscale', + type: 'band', + range: 'width', + domain: { data: 'facetedBrowser', field: 'region' }, + paddingInner: 0.1, + }, + ], + marks: [ + { + type: 'rect', + from: { data: 'facetedBrowser' }, + encode: { + enter: { + x: { scale: 'xsubscale', field: 'region' }, + width: { scale: 'xsubscale', band: 1 }, + y: { scale: 'yscale', field: 'y0' }, + y2: { scale: 'yscale', field: 'y1' }, + fill: { scale: 'periodScale', field: 'period' }, + }, + }, + }, + ], + }, + ], +} as unknown as Spec; + +export default { + title: 'RSC/Chart/CanvasPatternFillPrototype', + component: Chart, +}; + +const CanvasPatternFillStory: StoryFn = (args): ReactElement => { + const chartProps = useChartProps(args); + return ( + + + + ); +}; + +const baseArgs = { + data: barData, + width: 'auto', + height: '100%', + renderer: 'canvas' as const, +}; + +const CandyStripeBar = bindWithProps(CanvasPatternFillStory); +CandyStripeBar.args = { + ...baseArgs, + description: 'A bar chart with a candy-stripe canvas pattern fill instead of a solid color.', + UNSAFE_vegaSpec: buildBarSpec({ value: patternValue(candyStripeId) }), +}; + +// Modeled on Bar/Features/Dodged Bar's DodgedStacked story (dodge by region, stack by period instead of +// operatingSystem/version): previous period segment candy-striped, current period segment solid, with a real +// legend (from periodScale) labeling "Previous"/"Current". +const DodgedStackedPeriodComparison = bindWithProps(CanvasPatternFillStory); +DodgedStackedPeriodComparison.args = { + ...baseArgs, + data: dodgedStackedPeriodChartData, + description: 'Dodged by region, stacked by period - previous candy-striped, current solid, with a legend.', + UNSAFE_vegaSpec: dodgedStackedPeriodSpec, +}; + +// Edge case: many marks resolving to only a few distinct pattern identities, via an ordinal scale (the direction +// pattern fills are headed - see project memory). Chrome/Edge share the stripe id, Firefox/Explorer share dots, +// exercising both distinct-identity rendering and per-identity cache reuse in one chart. +const MultiplePatternsScale = bindWithProps(CanvasPatternFillStory); +MultiplePatternsScale.args = { + ...baseArgs, + description: 'Multiple distinct pattern identities on one chart, resolved via an ordinal scale like color.', + UNSAFE_vegaSpec: buildBarSpec( + { scale: 'patternScale', field: 'browser' }, + { + // Vega's scale-range parser rejects literal objects in a `range: [...]` array (the same restriction + // that applies to Gradient objects) - the structured pattern-fill values are carried through a signal. + extraSignals: [ + { + name: 'patternScaleRange', + value: [ + patternValue(candyStripeId), + patternValue(dotsId), + patternValue(crosshatchId), + patternValue(candyStripeId), + patternValue(dotsId), + ], + }, + ], + extraScales: [ + { + name: 'patternScale', + type: 'ordinal', + domain: { data: TABLE, field: 'browser' }, + range: { signal: 'patternScaleRange' }, + }, + ], + } + ), +}; + +// Edge case: opacity/highlight compositing over a pattern fill (hover in this raw spec stands in for the +// legend/controlled-highlight case, which all drive opacity the same way). Hovering a bar should dim it +// without the pattern disappearing or rendering incorrectly. +const HoverOpacityCompositing = bindWithProps(CanvasPatternFillStory); +HoverOpacityCompositing.args = { + ...baseArgs, + description: 'Hovering a bar dims it via fillOpacity - the pattern should stay visible underneath, just dimmer.', + UNSAFE_vegaSpec: buildBarSpec({ value: patternValue(candyStripeId) }, { hoverFillOpacity: 0.3 }), +}; + +// Edge case: a changed resolved color/identity must produce a visually distinct pattern rather than reusing a +// stale cached one. Compare these two stories side by side - each renders once, but both share the same +// underlying mechanism, so an implementation that ignored identity would render them the same. +const PatternIdentityLight = bindWithProps(CanvasPatternFillStory); +PatternIdentityLight.args = { + ...baseArgs, + description: 'Resolved identity "light" - compare against PatternIdentityDark.', + UNSAFE_vegaSpec: buildBarSpec({ value: patternValue(identityLightId) }), +}; + +const PatternIdentityDark = bindWithProps(CanvasPatternFillStory); +PatternIdentityDark.args = { + ...baseArgs, + description: 'Resolved identity "dark" - compare against PatternIdentityLight.', + UNSAFE_vegaSpec: buildBarSpec({ value: patternValue(identityDarkId) }), +}; + +// Edge case: the view is destroyed and recreated (a new canvas element). Forces a full unmount/remount via a +// React key, verifying the interception re-engages cleanly on the fresh canvas with no stale/leaked state. +const RemountStress = (): ReactElement => { + const [remountKey, setRemountKey] = useState(0); + const chartProps = useChartProps({ + ...baseArgs, + UNSAFE_vegaSpec: buildBarSpec({ value: patternValue(candyStripeId) }), + }); + return ( +
+ + + + +
+ ); +}; + +export { + CandyStripeBar, + DodgedStackedPeriodComparison, + MultiplePatternsScale, + HoverOpacityCompositing, + PatternIdentityLight, + PatternIdentityDark, + RemountStress, +}; diff --git a/packages/react-spectrum-charts-s2/src/stories/components/Bar/PatternFill.story.tsx b/packages/react-spectrum-charts-s2/src/stories/components/Bar/PatternFill.story.tsx new file mode 100644 index 000000000..115a52a64 --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/stories/components/Bar/PatternFill.story.tsx @@ -0,0 +1,104 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { ReactElement } from 'react'; + +import { StoryFn } from '@storybook/react'; + +import { Chart } from '../../../Chart'; +import { Axis, Bar, Legend } from '../../../components'; +import useChartProps from '../../../hooks/useChartProps'; +import { bindWithProps } from '../../../test-utils'; +import { BarProps } from '../../../types'; +import { barData } from './data'; + +export default { + title: 'React Spectrum Charts 2/Bar/Features/Pattern Fill', + component: Bar, +}; + +// Real pattern/patterns props (planning/specs/bar/pattern-scale.json) - pattern behaves exactly like color's +// dual-facet: [region, period] dodges by region, stacks by period. Current is listed first per region so it +// stacks on the bottom (Vega preserves data order for stacking when no explicit sort is given). +const dodgedStackedPeriodData = barData.flatMap(({ browser, downloads }) => + (['East', 'West'] as const).flatMap((region, i) => { + const current = Math.round(downloads * (i === 0 ? 0.6 : 0.4)); + const previous = Math.round(current * 0.8); + return [ + { browser, region, period: 'Current', downloads: current }, + { browser, region, period: 'Previous', downloads: previous }, + ]; + }) +); + +const PreviousCurrentStory: StoryFn = (args): ReactElement => { + const chartProps = useChartProps({ + data: dodgedStackedPeriodData, + width: 800, + height: 600, + // One row per region (East, West), each its own S2 categorical color token, mirroring DodgedStacked's + // per-OS color family - patterns resolves S2 tokens the same way colors does. A built-in name paired + // with a color in the same row is recolored to match it, so the stripe renders in that region's own + // color rather than a fixed neutral tile. Order is [Current, Previous] to match the secondaryPattern + // domain order (data order): index 0 solid, index 1 stripe. + patterns: [ + ['categorical-100', 'diagonal-stripe'], + ['categorical-200', 'diagonal-stripe'], + ], + // Pattern fill is only implemented for the canvas renderer - the SVG phase is a separate, + // not-yet-built spec (planning/specs/chart/pattern-fill-rendering.json). + renderer: 'canvas', + }); + return ( + + + + + {/* No `keys` - matches DodgedStacked's own dual-facet legend, which shows the full region x period cross-product. */} + + + ); +}; + +const defaultProps: BarProps = { + type: 'dodged', + dimension: 'browser', + metric: 'downloads', +}; + +// pattern takes precedence over color for fill; the [region, period] tuple dodges by region and stacks by +// period, exactly like a color dual-facet tuple would. +const PreviousCurrentComparison = bindWithProps(PreviousCurrentStory); +PreviousCurrentComparison.args = { + ...defaultProps, + pattern: ['region', 'period'], +}; + +const DefaultPaletteStory: StoryFn = (args): ReactElement => { + const chartProps = useChartProps({ data: barData, width: 800, height: 600, renderer: 'canvas' }); + return ( + + + + + + + ); +}; + +// No patterns override - each browser gets a distinct tile from the built-in default palette. +const DefaultPalette = bindWithProps(DefaultPaletteStory); +DefaultPalette.args = { + ...defaultProps, + pattern: 'browser', +}; + +export { PreviousCurrentComparison, DefaultPalette }; diff --git a/packages/react-spectrum-charts-s2/src/utils/canvasPatternFillUtils.test.ts b/packages/react-spectrum-charts-s2/src/utils/canvasPatternFillUtils.test.ts new file mode 100644 index 000000000..60c688d57 --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/utils/canvasPatternFillUtils.test.ts @@ -0,0 +1,210 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { patchCanvasContextForPatternFill } from './canvasPatternFillUtils'; +import { clearPatternFillRegistry, PatternFillValue, PatternTileSource, registerPatternFill } from './patternFillUtils'; + +const getContext = (): CanvasRenderingContext2D => { + const canvas = document.createElement('canvas'); + return canvas.getContext('2d') as CanvasRenderingContext2D; +}; + +const getContextWithPixelScale = (scale: number): CanvasRenderingContext2D => { + const canvas = document.createElement('canvas'); + Object.defineProperty(canvas, 'clientWidth', { value: 100, configurable: true }); + canvas.width = 100 * scale; + return canvas.getContext('2d') as CanvasRenderingContext2D; +}; + +const setFillStyle = (ctx: CanvasRenderingContext2D, value: PatternFillValue | string): void => { + ctx.fillStyle = value as unknown as string; +}; + +const stripeSource: PatternTileSource = { + id: 'stripe-blue', + tileSize: { width: 8, height: 8 }, + draw: jest.fn(), +}; + +afterEach(() => { + clearPatternFillRegistry(); + jest.clearAllMocks(); +}); + +describe('patchCanvasContextForPatternFill()', () => { + test('leaves a plain color fill unaffected', () => { + const ctx = getContext(); + patchCanvasContextForPatternFill(ctx); + + ctx.fillStyle = '#ff0000'; + + expect(ctx.fillStyle).toBe('#ff0000'); + }); + + test('resolves a registered pattern-fill reference to a CanvasPattern', () => { + registerPatternFill(stripeSource); + const ctx = getContext(); + patchCanvasContextForPatternFill(ctx); + + setFillStyle(ctx, { pattern: 'stripe-blue' }); + + expect(ctx.fillStyle).not.toEqual({ pattern: 'stripe-blue' }); + expect(stripeSource.draw).toHaveBeenCalledTimes(1); + }); + + test('caches the CanvasPattern per identity, reusing it across assignments', () => { + registerPatternFill(stripeSource); + const ctx = getContext(); + patchCanvasContextForPatternFill(ctx); + + setFillStyle(ctx, { pattern: 'stripe-blue' }); + const first = ctx.fillStyle; + ctx.fillStyle = '#00ff00'; + setFillStyle(ctx, { pattern: 'stripe-blue' }); + const second = ctx.fillStyle; + + expect(second).toBe(first); + expect(stripeSource.draw).toHaveBeenCalledTimes(1); + }); + + test('resolves distinct pattern identities to distinct cached patterns', () => { + const otherSource: PatternTileSource = { id: 'stripe-red', tileSize: { width: 8, height: 8 }, draw: jest.fn() }; + registerPatternFill(stripeSource); + registerPatternFill(otherSource); + const ctx = getContext(); + patchCanvasContextForPatternFill(ctx); + + setFillStyle(ctx, { pattern: 'stripe-blue' }); + const blue = ctx.fillStyle; + setFillStyle(ctx, { pattern: 'stripe-red' }); + const red = ctx.fillStyle; + + expect(blue).not.toBe(red); + }); + + test('applies a rotation transform on the pattern object when the source specifies one', () => { + const rotatedSource: PatternTileSource = { + id: 'stripe-rotated', + tileSize: { width: 8, height: 8 }, + draw: jest.fn(), + rotation: 45, + }; + registerPatternFill(rotatedSource); + const ctx = getContext(); + patchCanvasContextForPatternFill(ctx); + + setFillStyle(ctx, { pattern: 'stripe-rotated' }); + + const pattern = ctx.fillStyle as unknown as { setTransform: jest.Mock }; + expect(pattern.setTransform).toHaveBeenCalledTimes(1); + }); + + test('does not apply a transform when the source specifies no rotation', () => { + registerPatternFill(stripeSource); + const ctx = getContext(); + patchCanvasContextForPatternFill(ctx); + + setFillStyle(ctx, { pattern: 'stripe-blue' }); + + const pattern = ctx.fillStyle as unknown as { setTransform: jest.Mock }; + expect(pattern.setTransform).not.toHaveBeenCalled(); + }); + + test('renders the tile at the canvas pixel resolution, not just its logical tile size', () => { + registerPatternFill(stripeSource); + const ctx = getContextWithPixelScale(2); + const createElementSpy = jest.spyOn(document, 'createElement'); + patchCanvasContextForPatternFill(ctx); + + setFillStyle(ctx, { pattern: 'stripe-blue' }); + + const tile = createElementSpy.mock.results.find((r) => r.value.tagName === 'CANVAS') + ?.value as HTMLCanvasElement; + expect(tile.width).toBe(16); + expect(tile.height).toBe(16); + createElementSpy.mockRestore(); + }); + + test('compensates for a high-pixel-density canvas via the pattern transform, even without rotation', () => { + registerPatternFill(stripeSource); + const ctx = getContextWithPixelScale(2); + patchCanvasContextForPatternFill(ctx); + + setFillStyle(ctx, { pattern: 'stripe-blue' }); + + const pattern = ctx.fillStyle as unknown as { setTransform: jest.Mock }; + const transform = pattern.setTransform.mock.calls[0][0]; + expect(transform.a).toBeCloseTo(0.5); + expect(transform.d).toBeCloseTo(0.5); + }); + + test('resolves a structured value with a foreground color to a color-matched CanvasPattern via drawWithColor', () => { + const drawWithColor = jest.fn(); + registerPatternFill({ id: 'colorizable-stripe', tileSize: { width: 8, height: 8 }, draw: jest.fn(), drawWithColor }); + const ctx = getContext(); + patchCanvasContextForPatternFill(ctx); + + setFillStyle(ctx, { pattern: 'colorizable-stripe', foreground: '#2680eb' }); + + expect(ctx.fillStyle).not.toEqual({ pattern: 'colorizable-stripe', foreground: '#2680eb' }); + expect(drawWithColor).toHaveBeenCalledWith(expect.anything(), { width: 8, height: 8 }, '#2680eb'); + }); + + test('falls back to native behavior for a foreground color whose base source has no drawWithColor', () => { + registerPatternFill(stripeSource); + const ctx = getContext(); + patchCanvasContextForPatternFill(ctx); + + ctx.fillStyle = '#0000ff'; + setFillStyle(ctx, { pattern: 'stripe-blue', foreground: '#2680eb' }); + + expect(ctx.fillStyle).toBe('#0000ff'); + }); + + test('falls back to native behavior for an unregistered pattern id', () => { + const ctx = getContext(); + patchCanvasContextForPatternFill(ctx); + + ctx.fillStyle = '#0000ff'; + setFillStyle(ctx, { pattern: 'not-registered' }); + + expect(ctx.fillStyle).toBe('#0000ff'); + }); + + test('is idempotent - patching the same context twice does not double-wrap or reset the cache', () => { + registerPatternFill(stripeSource); + const ctx = getContext(); + patchCanvasContextForPatternFill(ctx); + setFillStyle(ctx, { pattern: 'stripe-blue' }); + const first = ctx.fillStyle; + + patchCanvasContextForPatternFill(ctx); + setFillStyle(ctx, { pattern: 'stripe-blue' }); + const second = ctx.fillStyle; + + expect(second).toBe(first); + expect(stripeSource.draw).toHaveBeenCalledTimes(1); + }); + + test('gives independent caches to different contexts', () => { + registerPatternFill(stripeSource); + const ctxA = getContext(); + const ctxB = getContext(); + patchCanvasContextForPatternFill(ctxA); + patchCanvasContextForPatternFill(ctxB); + + setFillStyle(ctxA, { pattern: 'stripe-blue' }); + setFillStyle(ctxB, { pattern: 'stripe-blue' }); + + expect(ctxA.fillStyle).not.toBe(ctxB.fillStyle); + expect(stripeSource.draw).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/react-spectrum-charts-s2/src/utils/canvasPatternFillUtils.ts b/packages/react-spectrum-charts-s2/src/utils/canvasPatternFillUtils.ts new file mode 100644 index 000000000..e2a045cf4 --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/utils/canvasPatternFillUtils.ts @@ -0,0 +1,145 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { + getPatternFillId, + getPatternFillSource, + isPatternFillValue, + PatternFillValue, + PatternTileSource, +} from './patternFillUtils'; + +type FillStyleValue = string | CanvasGradient | CanvasPattern; +/** The raw value Vega's canvas renderer may assign to fillStyle before our interception resolves it. */ +type InterceptedFillStyleValue = FillStyleValue | PatternFillValue; + +const patchedContexts = new WeakSet(); + +interface NativeFillStyleAccessor { + get: (ctx: CanvasRenderingContext2D) => FillStyleValue; + set: (ctx: CanvasRenderingContext2D, value: FillStyleValue) => void; +} + +/** Serializes a structured pattern-fill value to a cache key; never used as the wire representation. */ +const getPatternCacheKey = (id: string, color?: string): string => (color ? `${id}::${color}` : id); + +const getNativeFillStyleAccessor = (ctx: CanvasRenderingContext2D): NativeFillStyleAccessor => { + let proto: unknown = Object.getPrototypeOf(ctx); + while (proto) { + const descriptor = Object.getOwnPropertyDescriptor(proto, 'fillStyle'); + if (descriptor?.get && descriptor.set) { + const { get, set } = descriptor; + return { get: (c) => get.call(c), set: (c, value) => set.call(c, value) }; + } + proto = Object.getPrototypeOf(proto); + } + throw new Error('canvasPatternFillUtils: could not locate the native fillStyle accessor'); +}; + +// Vega's canvas renderer draws at canvas.width/height (device pixels) while scaling the context so drawing +// commands stay in CSS-pixel units. Reading the ratio directly off the element (rather than assuming +// window.devicePixelRatio) tracks whatever scale factor this specific canvas actually ended up with. +const getCanvasPixelScale = (canvas: HTMLCanvasElement): number => { + const cssWidth = canvas.clientWidth; + return cssWidth > 0 ? canvas.width / cssWidth : 1; +}; + +const buildPatternTransform = (degrees: number, scale: number): DOMMatrix2DInit => { + const radians = (degrees * Math.PI) / 180; + const inverseScale = 1 / scale; + return { + a: Math.cos(radians) * inverseScale, + b: Math.sin(radians) * inverseScale, + c: -Math.sin(radians) * inverseScale, + d: Math.cos(radians) * inverseScale, + e: 0, + f: 0, + }; +}; + +const buildCanvasPattern = (ctx: CanvasRenderingContext2D, source: PatternTileSource): CanvasPattern | undefined => { + const { width, height } = source.tileSize; + const scale = getCanvasPixelScale(ctx.canvas); + + const tile = document.createElement('canvas'); + tile.width = width * scale; + tile.height = height * scale; + const tileCtx = tile.getContext('2d'); + if (!tileCtx) return undefined; + tileCtx.scale(scale, scale); + source.draw(tileCtx, source.tileSize); + + const pattern = ctx.createPattern(tile, 'repeat'); + if (pattern && (source.rotation || scale !== 1)) { + pattern.setTransform(buildPatternTransform(source.rotation ?? 0, scale)); + } + return pattern ?? undefined; +}; + +/** Resolves a structured pattern-fill value's sibling color (see resolvePatternFillGroup) to an ad hoc, color-matched tile source. */ +const getColorMatchedPatternSource = (id: string, color: string): PatternTileSource | undefined => { + const baseSource = getPatternFillSource(id); + if (!baseSource?.drawWithColor) return undefined; + + return { + id: getPatternCacheKey(id, color), + tileSize: baseSource.tileSize, + rotation: baseSource.rotation, + draw: (ctx, size) => baseSource.drawWithColor!(ctx, size, color), + }; +}; + +const resolveFillStyleValue = ( + ctx: CanvasRenderingContext2D, + value: InterceptedFillStyleValue, + cache: Map +): FillStyleValue => { + const patternId = getPatternFillId(value); + if (patternId === undefined) return value as FillStyleValue; + + const foreground = isPatternFillValue(value) ? value.foreground : undefined; + const cacheKey = getPatternCacheKey(patternId, foreground); + + const cached = cache.get(cacheKey); + if (cached) return cached; + + const source = foreground ? getColorMatchedPatternSource(patternId, foreground) : getPatternFillSource(patternId); + if (!source) return value as FillStyleValue; + + const pattern = buildCanvasPattern(ctx, source); + if (!pattern) return value as FillStyleValue; + + cache.set(cacheKey, pattern); + return pattern; +}; + +/** + * Intercepts fillStyle assignment on a canvas context so pattern-fill references resolve to a real, + * per-identity-cached CanvasPattern. Idempotent and safe to call repeatedly for the same context. + * @param ctx + */ +export const patchCanvasContextForPatternFill = (ctx: CanvasRenderingContext2D): void => { + if (patchedContexts.has(ctx)) return; + patchedContexts.add(ctx); + + const cache = new Map(); + const native = getNativeFillStyleAccessor(ctx); + + Object.defineProperty(ctx, 'fillStyle', { + configurable: true, + get(): FillStyleValue { + return native.get(ctx); + }, + set(value: InterceptedFillStyleValue) { + native.set(ctx, resolveFillStyleValue(ctx, value, cache)); + }, + }); +}; diff --git a/packages/react-spectrum-charts-s2/src/utils/defaultPatternFills.ts b/packages/react-spectrum-charts-s2/src/utils/defaultPatternFills.ts new file mode 100644 index 000000000..fe8636384 --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/utils/defaultPatternFills.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { PatternTileSource, registerPatternFill } from './patternFillUtils'; + +const TILE_SIZE = { width: 10, height: 10 }; +const BASE_COLOR = '#ffffff'; +const TEXTURE_COLOR = '#404040'; + +type TileSize = { width: number; height: number }; +type ShapeDraw = (ctx: CanvasRenderingContext2D, size: TileSize, inkColor: string, baseColor: string | null) => void; + +const drawStripe: ShapeDraw = (ctx, { width, height }, inkColor, baseColor) => { + if (baseColor) { + ctx.fillStyle = baseColor; + ctx.fillRect(0, 0, width, height); + } + ctx.fillStyle = inkColor; + ctx.fillRect(0, 0, width, height / 2); +}; + +const drawDots: ShapeDraw = (ctx, { width, height }, inkColor, baseColor) => { + if (baseColor) { + ctx.fillStyle = baseColor; + ctx.fillRect(0, 0, width, height); + } + ctx.fillStyle = inkColor; + ctx.beginPath(); + ctx.arc(width / 2, height / 2, Math.min(width, height) / 4, 0, Math.PI * 2); + ctx.fill(); +}; + +const drawCrosshatch: ShapeDraw = (ctx, { width, height }, inkColor, baseColor) => { + if (baseColor) { + ctx.fillStyle = baseColor; + ctx.fillRect(0, 0, width, height); + } + ctx.strokeStyle = inkColor; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(width / 2, 0); + ctx.lineTo(width / 2, height); + ctx.moveTo(0, height / 2); + ctx.lineTo(width, height / 2); + ctx.stroke(); +}; + +const drawGrid: ShapeDraw = (ctx, { width, height }, inkColor, baseColor) => { + if (baseColor) { + ctx.fillStyle = baseColor; + ctx.fillRect(0, 0, width, height); + } + ctx.strokeStyle = inkColor; + ctx.lineWidth = 1; + ctx.strokeRect(0, 0, width, height); +}; + +const toTileSource = ( + id: string, + shape: ShapeDraw, + rotation?: number +): PatternTileSource => ({ + id, + tileSize: TILE_SIZE, + rotation, + draw: (ctx, size) => shape(ctx, size, TEXTURE_COLOR, BASE_COLOR), + // Transparent base: the shape's ink color is meant to match a sibling series color, so the tile shouldn't + // impose its own background over whatever the mark would otherwise show. + drawWithColor: (ctx, size, color) => shape(ctx, size, color, null), +}); + +// The built-in, colorScheme-independent tile palette used as PATTERN_SCALE's default range - registered once +// at module load. Rotation is expressed as a pattern transform (not baked into the tile), per +// planning/specs/chart/pattern-fill-rendering.json's requirements. Each shape also has a drawWithColor variant, +// used when a `patterns` group pairs a built-in name with a sibling literal color (see resolvePatternFillGroup). +registerPatternFill(toTileSource('diagonal-stripe', drawStripe, 45)); +registerPatternFill(toTileSource('diagonal-stripe-reverse', drawStripe, 135)); +registerPatternFill(toTileSource('horizontal-stripe', drawStripe)); +registerPatternFill(toTileSource('dots', drawDots)); +registerPatternFill(toTileSource('crosshatch', drawCrosshatch)); +registerPatternFill(toTileSource('grid', drawGrid)); diff --git a/packages/react-spectrum-charts-s2/src/utils/index.ts b/packages/react-spectrum-charts-s2/src/utils/index.ts index 69aed7292..eaf231c47 100644 --- a/packages/react-spectrum-charts-s2/src/utils/index.ts +++ b/packages/react-spectrum-charts-s2/src/utils/index.ts @@ -10,5 +10,10 @@ * governing permissions and limitations under the License. */ +// Side-effect only: registers the built-in pattern tile palette before any chart can reference it. +import './defaultPatternFills'; + export * from './utils'; export * from './markClickUtils'; +export * from './patternFillUtils'; +export * from './canvasPatternFillUtils'; diff --git a/packages/react-spectrum-charts-s2/src/utils/patternFillUtils.test.ts b/packages/react-spectrum-charts-s2/src/utils/patternFillUtils.test.ts new file mode 100644 index 000000000..a87be422a --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/utils/patternFillUtils.test.ts @@ -0,0 +1,66 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { Spec } from 'vega'; + +import { + clearPatternFillRegistry, + getPatternFillId, + getPatternFillSource, + PatternTileSource, + registerPatternFill, + specHasPatternFill, +} from './patternFillUtils'; + +afterEach(() => { + clearPatternFillRegistry(); +}); + +describe('getPatternFillId()', () => { + test('extracts the pattern id from a structured pattern-fill value', () => { + expect(getPatternFillId({ pattern: 'stripe-blue' })).toBe('stripe-blue'); + }); + + test('returns undefined for a plain color string', () => { + expect(getPatternFillId('#ff0000')).toBeUndefined(); + }); + + test('returns undefined for a non-pattern-fill value', () => { + expect(getPatternFillId(undefined)).toBeUndefined(); + expect(getPatternFillId({})).toBeUndefined(); + }); +}); + +describe('registerPatternFill() / getPatternFillSource()', () => { + test('returns the registered source by id', () => { + const source: PatternTileSource = { id: 'stripe-blue', tileSize: { width: 8, height: 8 }, draw: jest.fn() }; + registerPatternFill(source); + expect(getPatternFillSource('stripe-blue')).toBe(source); + }); + + test('returns undefined for an id with no registered source', () => { + expect(getPatternFillSource('unregistered')).toBeUndefined(); + }); +}); + +describe('specHasPatternFill()', () => { + test('returns false for a spec with no pattern-fill reference', () => { + const spec = { marks: [{ encode: { enter: { fill: { value: '#ff0000' } } } }] } as unknown as Spec; + expect(specHasPatternFill(spec)).toBe(false); + }); + + test('returns true when a pattern-fill reference appears anywhere in the spec', () => { + const spec = { + marks: [{ encode: { enter: { fill: { value: { pattern: 'stripe-blue' } } } } }], + } as unknown as Spec; + expect(specHasPatternFill(spec)).toBe(true); + }); +}); diff --git a/packages/react-spectrum-charts-s2/src/utils/patternFillUtils.ts b/packages/react-spectrum-charts-s2/src/utils/patternFillUtils.ts new file mode 100644 index 000000000..3d1a593c1 --- /dev/null +++ b/packages/react-spectrum-charts-s2/src/utils/patternFillUtils.ts @@ -0,0 +1,62 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { Spec } from 'vega'; + +import { getPatternFillId, isPatternFillValue } from '@spectrum-charts/utils'; + +export { getPatternFillId, isPatternFillValue }; +export { DEFAULT_PATTERN_FILL_IDS, resolvePatternFillGroup, resolvePatternFillValue } from '@spectrum-charts/utils'; +export type { DefaultPatternFillId, PatternFillValue } from '@spectrum-charts/utils'; + +/** + * A tile drawn once and repeated by the canvas/SVG pattern-fill interception, keyed by a stable identity. + */ +export interface PatternTileSource { + id: string; + tileSize: { width: number; height: number }; + draw: (ctx: CanvasRenderingContext2D, tileSize: { width: number; height: number }) => void; + /** Draws the same shape recolored to match a sibling color, used when a PatternFillValue carries a foreground. */ + drawWithColor?: (ctx: CanvasRenderingContext2D, tileSize: { width: number; height: number }, color: string) => void; + /** Degrees, applied as a transform on the pattern object rather than baked into the tile. */ + rotation?: number; +} + +/** + * Cheaply detects whether a compiled spec references any pattern fill, so interception is only engaged when needed. + * @param spec + * @returns true if the spec contains a pattern-fill reference anywhere + */ +export const specHasPatternFill = (spec: Spec): boolean => JSON.stringify(spec).includes('"pattern":'); + +const registry = new Map(); + +/** + * Registers a pattern tile source so mark encodes can reference it via a { pattern: source.id } value. + * @param source + */ +export const registerPatternFill = (source: PatternTileSource): void => { + registry.set(source.id, source); +}; + +/** + * Looks up a registered pattern tile source by id. + * @param id + * @returns the registered source, or undefined if none is registered under that id + */ +export const getPatternFillSource = (id: string): PatternTileSource | undefined => registry.get(id); + +/** + * Clears all registered pattern tile sources. Intended for test isolation. + */ +export const clearPatternFillRegistry = (): void => { + registry.clear(); +}; diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 48ada3304..7e66244d5 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -11,3 +11,4 @@ */ export * from './utils'; +export * from './patternFillId'; diff --git a/packages/utils/src/patternFillId.test.ts b/packages/utils/src/patternFillId.test.ts new file mode 100644 index 000000000..9959ebd4a --- /dev/null +++ b/packages/utils/src/patternFillId.test.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ +import { + DEFAULT_PATTERN_FILL_IDS, + getPatternFillId, + isPatternFillValue, + resolvePatternFillGroup, + resolvePatternFillValue, +} from './patternFillId'; + +describe('isPatternFillValue() / getPatternFillId()', () => { + test('recognizes a structured pattern-fill value', () => { + expect(isPatternFillValue({ pattern: 'diagonal-stripe' })).toBe(true); + expect(getPatternFillId({ pattern: 'diagonal-stripe' })).toBe('diagonal-stripe'); + }); + + test('returns false/undefined for a plain color string', () => { + expect(isPatternFillValue('#ff0000')).toBe(false); + expect(getPatternFillId('#ff0000')).toBeUndefined(); + }); + + test('returns false/undefined for undefined', () => { + expect(isPatternFillValue(undefined)).toBe(false); + expect(getPatternFillId(undefined)).toBeUndefined(); + }); +}); + +describe('resolvePatternFillValue()', () => { + test('resolves a built-in pattern name to a structured value with no foreground', () => { + expect(resolvePatternFillValue('dots')).toStrictEqual({ pattern: 'dots' }); + }); + + test('passes through a literal value unchanged', () => { + expect(resolvePatternFillValue('#2680eb')).toBe('#2680eb'); + }); +}); + +describe('resolvePatternFillGroup()', () => { + test('colorizes a built-in pattern name using a sibling literal color in the same group', () => { + expect(resolvePatternFillGroup(['dots', '#2680eb'])).toStrictEqual([ + { pattern: 'dots', foreground: '#2680eb' }, + '#2680eb', + ]); + }); + + test('falls back to the fixed neutral tile (no foreground) when no sibling color is present', () => { + expect(resolvePatternFillGroup(['dots', 'grid'])).toStrictEqual([{ pattern: 'dots' }, { pattern: 'grid' }]); + }); + + test('passes through literal colors unchanged regardless of group contents', () => { + expect(resolvePatternFillGroup(['#2680eb', '#ff0000'])).toStrictEqual(['#2680eb', '#ff0000']); + }); +}); + +describe('DEFAULT_PATTERN_FILL_IDS', () => { + test('is a non-empty list of built-in pattern names', () => { + expect(DEFAULT_PATTERN_FILL_IDS.length).toBeGreaterThan(0); + expect(new Set(DEFAULT_PATTERN_FILL_IDS).size).toBe(DEFAULT_PATTERN_FILL_IDS.length); + }); +}); diff --git a/packages/utils/src/patternFillId.ts b/packages/utils/src/patternFillId.ts new file mode 100644 index 000000000..6b557f9f1 --- /dev/null +++ b/packages/utils/src/patternFillId.ts @@ -0,0 +1,80 @@ +/* + * Copyright 2026 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +/** + * The built-in, colorScheme-independent pattern tile palette used as PATTERN_SCALE's default range. + */ +export const DEFAULT_PATTERN_FILL_IDS = [ + 'diagonal-stripe', + 'diagonal-stripe-reverse', + 'horizontal-stripe', + 'dots', + 'crosshatch', + 'grid', +] as const; + +export type DefaultPatternFillId = (typeof DEFAULT_PATTERN_FILL_IDS)[number]; + +const PATTERN_FILL_DISCRIMINANT = 'pattern'; + +/** + * A reference to a registered built-in pattern tile, optionally recolored to match a sibling literal color. + * A plain object literal (like Vega's own `Gradient`) rather than a parsed string, so it can flow through + * a scale range/signal or an encode value unchanged. + */ +export interface PatternFillValue { + pattern: string; + foreground?: string; +} + +/** + * Mirrors Vega's own isGradient(value) => value && value.gradient check. + * @param value + * @returns true if value is a PatternFillValue + */ +export const isPatternFillValue = (value: unknown): value is PatternFillValue => + typeof value === 'object' && value !== null && PATTERN_FILL_DISCRIMINANT in value; + +/** + * Extracts the pattern id from a resolved fill value, if it is a pattern-fill reference. + * @param value + * @returns the pattern id, or undefined if value isn't a pattern-fill reference + */ +export const getPatternFillId = (value: unknown): string | undefined => + isPatternFillValue(value) ? value.pattern : undefined; + +const isBuiltInPatternFillId = (entry: string): boolean => (DEFAULT_PATTERN_FILL_IDS as readonly string[]).includes(entry); + +/** + * Resolves a `patterns` override entry to a fill value: a built-in pattern name resolves to a + * PatternFillValue, anything else passes through unchanged as a literal color value. + * @param entry + * @returns the resolved fill value + */ +export const resolvePatternFillValue = (entry: string): string | PatternFillValue => + isBuiltInPatternFillId(entry) ? { pattern: entry } : entry; + +/** + * Resolves a group of `patterns` entries together (a dual-facet row, or a whole 1D override): each + * built-in pattern name in the group is recolored to match the first literal color found elsewhere in + * the same group, so e.g. ['diagonal-stripe', '#2680eb'] renders the stripe using #2680eb, not a fixed + * neutral tile. Falls back to the fixed neutral tile when no sibling color is present in the group. + * @param group + * @returns the resolved fill values, in the same order + */ +export const resolvePatternFillGroup = (group: string[]): (string | PatternFillValue)[] => { + const siblingColor = group.find((entry) => !isBuiltInPatternFillId(entry)); + return group.map((entry) => { + if (!isBuiltInPatternFillId(entry)) return entry; + return siblingColor ? { pattern: entry, foreground: siblingColor } : { pattern: entry }; + }); +}; diff --git a/packages/vega-spec-builder-s2/src/bar/barSpecBuilder.test.ts b/packages/vega-spec-builder-s2/src/bar/barSpecBuilder.test.ts index 0e3afb039..37fedb70b 100644 --- a/packages/vega-spec-builder-s2/src/bar/barSpecBuilder.test.ts +++ b/packages/vega-spec-builder-s2/src/bar/barSpecBuilder.test.ts @@ -36,6 +36,7 @@ import { LINE_TYPE_SCALE, MARK_ID, OPACITY_SCALE, + PATTERN_SCALE, STACK_ID, TABLE, } from '@spectrum-charts/constants'; @@ -660,6 +661,20 @@ describe('barSpecBuilder', () => { ]); }); + test('should add a pattern facet scale when pattern is a field reference', () => { + expect( + addScales( + [{ name: COLOR_SCALE, type: 'ordinal' }], + { ...defaultBarOptions, pattern: 'period' } + ) + ).toStrictEqual([ + defaultColorScale, + defaultMetricScale, + defaultDimensionScale, + { domain: { data: TABLE, fields: ['period'] }, name: PATTERN_SCALE, type: undefined }, + ]); + }); + test('should add trellis scales', () => { expect( addScales([{ name: COLOR_SCALE, type: 'ordinal' }], { @@ -1330,6 +1345,41 @@ describe('barSpecBuilder', () => { type: 'formula', }); }); + + test('should use pattern as the dodge facet when it is the only facet', () => { + expect( + getDodgeGroupTransform({ ...defaultBarOptions, color: { value: 'categorical-100' }, type: 'dodged', pattern: 'period' }) + ).toStrictEqual({ + as: 'bar0_dodgeGroup', + expr: 'datum.period', + type: 'formula', + }); + }); + + test('should combine pattern with another plain field-based facet as separate dodge facets, same as color+lineType would', () => { + expect( + getDodgeGroupTransform({ ...defaultBarOptions, color: 'region', type: 'dodged', pattern: 'period' }) + ).toStrictEqual({ + as: 'bar0_dodgeGroup', + expr: 'datum.region + "," + datum.period', + type: 'formula', + }); + }); + + test('should stack by pattern within each dodge group when pattern is a dual-facet tuple', () => { + expect( + getDodgeGroupTransform({ + ...defaultBarOptions, + color: { value: 'categorical-100' }, + type: 'dodged', + pattern: ['region', 'period'], + }) + ).toStrictEqual({ + as: 'bar0_dodgeGroup', + expr: 'datum.region', + type: 'formula', + }); + }); }); describe('getRepeatedScale()', () => { diff --git a/packages/vega-spec-builder-s2/src/bar/barSpecBuilder.ts b/packages/vega-spec-builder-s2/src/bar/barSpecBuilder.ts index d26187d73..891a29b9d 100644 --- a/packages/vega-spec-builder-s2/src/bar/barSpecBuilder.ts +++ b/packages/vega-spec-builder-s2/src/bar/barSpecBuilder.ts @@ -23,6 +23,7 @@ import { LINE_TYPE_SCALE, OPACITY_SCALE, PADDING_RATIO, + PATTERN_SCALE, SERIES_ID, STACK_ID, TIME, @@ -101,6 +102,7 @@ export const addBar = produce< name, opacity = { value: 1 }, orientation = 'vertical', + pattern, paddingRatio = PADDING_RATIO, trellisOrientation = 'horizontal', trellisPadding = TRELLIS_PADDING, @@ -136,6 +138,7 @@ export const addBar = produce< metricAxis, name: barName, opacity, + pattern, paddingRatio, trellisOrientation, trellisPadding, @@ -157,7 +160,7 @@ export const addBar = produce< ); // diverging is single-series only: dodged and faceted (multi-row-per-category) bars have no well-defined sign - const hasSeriesFacet = getFacetsFromOptions({ color, lineType, opacity }).facets.length > 0; + const hasSeriesFacet = getFacetsFromOptions({ color, lineType, opacity, pattern }).facets.length > 0; if (diverging && type !== 'dodged' && !hasSeriesFacet) { spec.usermeta = addUserMetaDivergingBarMark( spec.usermeta, @@ -275,8 +278,8 @@ export const getStackIdTransform = (options: BarSpecOptions): FormulaTransform = } as FormulaTransform; }; -const getStackFields = ({ trellis, color, dimension, lineType, opacity, type }: BarSpecOptions): string[] => { - const { facets, secondaryFacets } = getFacetsFromOptions({ color, lineType, opacity }); +const getStackFields = ({ trellis, color, dimension, lineType, opacity, pattern, type }: BarSpecOptions): string[] => { + const { facets, secondaryFacets } = getFacetsFromOptions({ color, lineType, opacity, pattern }); return [ ...(trellis ? [trellis] : []), dimension, @@ -299,8 +302,15 @@ export const getDodgedGroupAggregateData = (options: BarSpecOptions): Data => { }; }; -export const getDodgeGroupTransform = ({ color, lineType, name, opacity, type }: BarSpecOptions): FormulaTransform => { - const { facets, secondaryFacets } = getFacetsFromOptions({ color, lineType, opacity }); +export const getDodgeGroupTransform = ({ + color, + lineType, + name, + opacity, + pattern, + type, +}: BarSpecOptions): FormulaTransform => { + const { facets, secondaryFacets } = getFacetsFromOptions({ color, lineType, opacity, pattern }); return { type: 'formula', as: `${name}_dodgeGroup`, @@ -331,7 +341,7 @@ export const addDualMetricAxisData = (data: Data[], options: BarSpecOptions) => }; export const addScales = produce((scales, options) => { - const { color, lineType, opacity, metricAxis } = options; + const { color, lineType, opacity, pattern, metricAxis } = options; const { metricAxis: axisType } = getOrientationProperties(options.orientation); addMetricScale(scales, getScaleValues(options), axisType); @@ -351,6 +361,7 @@ export const addScales = produce((scales, options) => addFieldToFacetScaleDomain(scales, COLOR_SCALE, color); addFieldToFacetScaleDomain(scales, LINE_TYPE_SCALE, lineType); addFieldToFacetScaleDomain(scales, OPACITY_SCALE, opacity); + addFieldToFacetScaleDomain(scales, PATTERN_SCALE, pattern); addSecondaryScales(scales, options); }); @@ -373,7 +384,7 @@ export const addDimensionScale = ( * @param param1 */ export const addSecondaryScales = (scales: Scale[], options: BarSpecOptions) => { - const { color, lineType, opacity } = options; + const { color, lineType, opacity, pattern } = options; if (isDodgedAndStacked(options)) { [ { @@ -391,6 +402,11 @@ export const addSecondaryScales = (scales: Scale[], options: BarSpecOptions) => scaleName: 'opacities', secondaryScaleName: 'secondaryOpacity', }, + { + value: pattern, + scaleName: 'patterns', + secondaryScaleName: 'secondaryPattern', + }, ].forEach(({ value, scaleName, secondaryScaleName }) => { if (Array.isArray(value) && value.length === 2) { // secondary value scale used for 2D scales diff --git a/packages/vega-spec-builder-s2/src/bar/barUtils.test.ts b/packages/vega-spec-builder-s2/src/bar/barUtils.test.ts index 077530fe0..1411f1db5 100644 --- a/packages/vega-spec-builder-s2/src/bar/barUtils.test.ts +++ b/packages/vega-spec-builder-s2/src/bar/barUtils.test.ts @@ -21,6 +21,7 @@ import { LAST_RSC_SERIES_ID, MARK_ID, PADDING_RATIO, + PATTERN_SCALE, SELECTED_GROUP, SELECTED_ITEM, SERIES_ID, @@ -40,6 +41,7 @@ import { import { getBarDimensionAreaPositionEncodings, getBarDimensionHoverArea, + getBarEnterEncodings, getBarFillEncoding, getBarItemSelectionBackdrop, getBarItemSelectionRing, @@ -50,6 +52,7 @@ import { getDimensionSelectionRing, getDodgedDimensionEncodings, getDodgedGroupMark, + isDodgedAndStacked, getMetricEncodings, getOrientationProperties, getStackedCornerRadiusEncodings, @@ -411,6 +414,40 @@ describe('barUtils', () => { }); }); + describe('isDodgedAndStacked() pattern', () => { + test('is false when pattern is a plain field, same as color', () => { + expect( + isDodgedAndStacked({ ...defaultBarOptions, color: { value: 'categorical-100' }, pattern: 'period' }) + ).toBe(false); + }); + + test('is false when pattern is a plain field combined with an existing plain color facet', () => { + expect(isDodgedAndStacked({ ...defaultBarOptions, color: 'region', pattern: 'period' })).toBe(false); + }); + + test('is true when pattern is a dual-facet tuple, same as color', () => { + expect( + isDodgedAndStacked({ ...defaultBarOptions, color: { value: 'categorical-100' }, pattern: ['region', 'period'] }) + ).toBe(true); + }); + + test('is false when neither pattern nor a dual-facet color/lineType/opacity is set', () => { + expect(isDodgedAndStacked(defaultBarOptions)).toBe(false); + }); + }); + + describe('getBarEnterEncodings() pattern', () => { + test('fill uses the pattern scale when pattern is set', () => { + const encoding = getBarEnterEncodings({ ...defaultBarOptions, pattern: 'period' }); + expect(encoding.fill).toStrictEqual({ scale: PATTERN_SCALE, field: 'period' }); + }); + + test('fill falls back to the normal color encoding when pattern is unset', () => { + const encoding = getBarEnterEncodings(defaultBarOptions); + expect(encoding.fill).toStrictEqual({ scale: COLOR_SCALE, field: DEFAULT_COLOR }); + }); + }); + describe('getStroke()', () => { test('should return production rule with one item in array if there is not a popover', () => { const strokeRule = getStroke(defaultBarOptions); diff --git a/packages/vega-spec-builder-s2/src/bar/barUtils.ts b/packages/vega-spec-builder-s2/src/bar/barUtils.ts index 3984d62e3..27c9ca4d4 100644 --- a/packages/vega-spec-builder-s2/src/bar/barUtils.ts +++ b/packages/vega-spec-builder-s2/src/bar/barUtils.ts @@ -39,6 +39,7 @@ import { getCursor, getMarkOpacity, getOpacityProductionRule, + getPatternProductionRule, getStrokeDashProductionRule, getInspectEncoding, hasPopover, @@ -52,8 +53,8 @@ import { getTrellisProperties, isTrellised } from './trellisedBarUtils'; * checks to see if the bar is faceted in the stacked and dodged dimensions * @param color */ -export const isDodgedAndStacked = ({ color, lineType, opacity }: BarSpecOptions): boolean => { - return [color, lineType, opacity].some((facet) => Array.isArray(facet) && facet.length === 2); +export const isDodgedAndStacked = ({ color, lineType, opacity, pattern }: BarSpecOptions): boolean => { + return [color, lineType, opacity, pattern].some((facet) => Array.isArray(facet) && facet.length === 2); }; /** @@ -291,7 +292,7 @@ export const getBarFillEncoding = (options: BarSpecOptions): ColorValueRef => { }; export const getBarEnterEncodings = (options: BarSpecOptions): EncodeEntry => ({ - fill: getBarFillEncoding(options), + fill: options.pattern ? getPatternProductionRule(options.pattern) : getBarFillEncoding(options), fillOpacity: getOpacityProductionRule(options.opacity), tooltip: getInspectEncoding(options.chartInspects, options.name), }); diff --git a/packages/vega-spec-builder-s2/src/chartSpecBuilder.test.ts b/packages/vega-spec-builder-s2/src/chartSpecBuilder.test.ts index f06439455..18f93479c 100644 --- a/packages/vega-spec-builder-s2/src/chartSpecBuilder.test.ts +++ b/packages/vega-spec-builder-s2/src/chartSpecBuilder.test.ts @@ -36,6 +36,7 @@ import { LINE_WIDTH_SCALE, MARK_ID, OPACITY_SCALE, + PATTERN_SCALE, ROUNDED_SQUARE_PATH, SERIES_ID, SYMBOL_SHAPE_SCALE, @@ -52,11 +53,14 @@ import { getLineWidthScale, getLinearColorScale, getOpacityScale, + getPatternRange, + getPatternScale, getSymbolShapeScale, getSymbolSizeScale, getTwoDimensionalColorScheme, getTwoDimensionalLineTypes, getTwoDimensionalOpacities, + getTwoDimensionalPatterns, } from './chartSpecBuilder'; import { defaultSignals } from './specTestUtils'; import { baseData } from './specUtils'; @@ -237,6 +241,32 @@ describe('Chart spec builder', () => { }); }); + describe('getTwoDimensionalPatterns()', () => { + test('should return the built-in pattern palette if undefined', () => { + expect(getTwoDimensionalPatterns(undefined)).toStrictEqual([ + [{ pattern: 'diagonal-stripe' }], + [{ pattern: 'diagonal-stripe-reverse' }], + [{ pattern: 'horizontal-stripe' }], + [{ pattern: 'dots' }], + [{ pattern: 'crosshatch' }], + [{ pattern: 'grid' }], + ]); + }); + + test('should resolve a 1d array as a single group, colorizing built-in names with a sibling color', () => { + expect(getTwoDimensionalPatterns(['dots', '#2680eb'])).toStrictEqual([ + [{ pattern: 'dots', foreground: '#2680eb' }], + ['#2680eb'], + ]); + }); + + test('should pass through a 2d array, colorizing built-in names with a sibling color in the same row', () => { + expect(getTwoDimensionalPatterns([['dots', '#2680eb']])).toStrictEqual([ + [{ pattern: 'dots', foreground: '#2680eb' }, '#2680eb'], + ]); + }); + }); + describe('getLineTypeScale()', () => { test('should return lineType scale', () => { expect(getLineTypeScale(['solid', 'dashed'])).toStrictEqual({ @@ -295,6 +325,46 @@ describe('Chart spec builder', () => { }); }); + describe('getPatternScale()', () => { + test("should return a scale whose range is a signal reference, since Vega's scale-range parser rejects literal objects", () => { + expect(getPatternScale()).toStrictEqual({ + name: PATTERN_SCALE, + type: 'ordinal', + range: { signal: 'patternRange' }, + domain: { data: 'table', fields: [] }, + }); + }); + }); + + describe('getPatternRange()', () => { + test('should return the built-in pattern palette if no patterns provided', () => { + expect(getPatternRange()).toEqual(expect.arrayContaining([{ pattern: 'dots' }])); + }); + + test('should resolve built-in pattern names, colorized with a sibling literal color, in a patterns override', () => { + expect(getPatternRange(['dots', '#2680eb'])).toStrictEqual([{ pattern: 'dots', foreground: '#2680eb' }, '#2680eb']); + }); + + test('should resolve an S2 color token in a patterns override, same as colors would', () => { + expect(getPatternRange(['dots', 'gray-700'], 'light')).toStrictEqual([ + { pattern: 'dots', foreground: '#505050' }, + '#505050', + ]); + }); + + test('should use only the first pattern of each group if a 2d patterns override is provided', () => { + expect( + getPatternRange([ + ['dots', '#2680eb'], + ['grid', '#ff0000'], + ]) + ).toStrictEqual([ + { pattern: 'dots', foreground: '#2680eb' }, + { pattern: 'grid', foreground: '#ff0000' }, + ]); + }); + }); + describe('getLineWidthScale()', () => { test('should return lineWidth scale with line width pixel values provided', () => { expect(getLineWidthScale([1, 2, 3, 4])).toStrictEqual({ @@ -473,6 +543,28 @@ describe('Chart spec builder', () => { }, { name: 'lineTypes', value: [[[7, 4]]] }, { name: 'opacities', value: [[1]] }, + { + name: 'patterns', + value: [ + [{ pattern: 'diagonal-stripe' }], + [{ pattern: 'diagonal-stripe-reverse' }], + [{ pattern: 'horizontal-stripe' }], + [{ pattern: 'dots' }], + [{ pattern: 'crosshatch' }], + [{ pattern: 'grid' }], + ], + }, + { + name: 'patternRange', + value: [ + { pattern: 'diagonal-stripe' }, + { pattern: 'diagonal-stripe-reverse' }, + { pattern: 'horizontal-stripe' }, + { pattern: 'dots' }, + { pattern: 'crosshatch' }, + { pattern: 'grid' }, + ], + }, ]; const endSignals = defaultSignals; diff --git a/packages/vega-spec-builder-s2/src/chartSpecBuilder.ts b/packages/vega-spec-builder-s2/src/chartSpecBuilder.ts index 86986d547..94489a0b1 100644 --- a/packages/vega-spec-builder-s2/src/chartSpecBuilder.ts +++ b/packages/vega-spec-builder-s2/src/chartSpecBuilder.ts @@ -38,6 +38,7 @@ import { LINE_WIDTH_SCALE, MARK_ID, OPACITY_SCALE, + PATTERN_SCALE, SELECTED_GROUP, SELECTED_ITEM, SELECTED_SERIES, @@ -51,6 +52,7 @@ import { DIRECT_LABEL_FONT_SIZE_L, } from '@spectrum-charts/constants'; import { colorSchemes, getS2ColorValue } from '@spectrum-charts/themes'; +import { DEFAULT_PATTERN_FILL_IDS, PatternFillValue, resolvePatternFillGroup } from '@spectrum-charts/utils'; import { addArea } from './area/areaSpecBuilder'; import { addAxis } from './axis/axisSpecBuilder'; @@ -87,6 +89,7 @@ import { LineTypes, LineWidth, Opacities, + Patterns, ScSpec, SymbolShapes, SymbolSize, @@ -119,6 +122,7 @@ export function buildSpec({ lineWidths = ['M'], marks = [], opacities, + patterns, symbolShapes = ['rounded-square'], symbolSizes = ['XS', 'XL'], title, @@ -142,6 +146,7 @@ export function buildSpec({ lineWidths, marks, opacities, + patterns, symbolShapes, symbolSizes, title, @@ -269,6 +274,7 @@ export const getDefaultSignals = ({ colorScheme, lineTypes, opacities, + patterns, hiddenSeries, highlightedItem, highlightedSeries, @@ -318,6 +324,8 @@ export const getDefaultSignals = ({ getGenericValueSignal('colors', getTwoDimensionalColorScheme(colors, colorScheme)), getGenericValueSignal('lineTypes', getTwoDimensionalLineTypes(lineTypes)), getGenericValueSignal('opacities', getTwoDimensionalOpacities(opacities)), + getGenericValueSignal('patterns', getTwoDimensionalPatterns(patterns, colorScheme)), + getGenericValueSignal(PATTERN_RANGE_SIGNAL, getPatternRange(patterns, colorScheme)), getGenericValueSignal('hiddenSeries', hiddenSeries ?? []), getGenericValueSignal(CONTROLLED_HIGHLIGHTED_ITEM, formattedHighlightedItem), getGenericValueSignal(HIGHLIGHTED_GROUP), @@ -373,6 +381,7 @@ const getDefaultScales = ( getLineTypeScale(lineTypes), getLineWidthScale(lineWidths), getOpacityScale(opacities), + getPatternScale(), getSymbolShapeScale(symbolShapes), getSymbolSizeScale(symbolSizes), getSymbolPathWidthScale(symbolSizes), @@ -457,6 +466,50 @@ export const getOpacityScale = (opacities?: Opacities): OrdinalScale | PointScal }; }; +// Resolves an S2 color token (e.g. 'categorical-100') the same way getColors does, leaving built-in pattern +// names untouched for resolvePatternFillGroup - getS2ColorValue already passes through non-token strings +// (literal hex/rgb) unchanged, so this is safe to apply to every non-pattern-name entry unconditionally. +const resolveS2PatternGroup = (group: string[], colorScheme: ColorScheme): (string | PatternFillValue)[] => + resolvePatternFillGroup( + group.map((entry) => ((DEFAULT_PATTERN_FILL_IDS as readonly string[]).includes(entry) ? entry : getS2ColorValue(entry, colorScheme))) + ); + +// PATTERN_SCALE's range is populated via this signal rather than a literal array: Vega's scale-range parser +// rejects arbitrary objects in a literal range (verified via vega.parse()), the same restriction that applies +// to Gradient objects there - a signal reference is exempt, which is also how the dual-facet 'patterns' scale +// already carries its own structured-value range. +const PATTERN_RANGE_SIGNAL = 'patternRange'; + +export const getPatternRange = ( + patterns: Patterns = [...DEFAULT_PATTERN_FILL_IDS], + colorScheme: ColorScheme = DEFAULT_COLOR_SCHEME +): (string | PatternFillValue)[] => + // if a two dimensional scale was provided, then just grab the first pattern in each and set that as the range + isPatternArray(patterns) + ? resolveS2PatternGroup(patterns, colorScheme) + : patterns.map((patternGroup) => resolveS2PatternGroup(patternGroup, colorScheme)[0]); + +export const getPatternScale = (): OrdinalScale => ({ + name: PATTERN_SCALE, + type: 'ordinal', + range: { signal: PATTERN_RANGE_SIGNAL }, + domain: { data: TABLE, fields: [] }, +}); + +export const getTwoDimensionalPatterns = ( + patterns: Patterns = [...DEFAULT_PATTERN_FILL_IDS], + colorScheme: ColorScheme = DEFAULT_COLOR_SCHEME +): (string | PatternFillValue)[][] => { + if (isPatternArray(patterns)) { + return resolveS2PatternGroup(patterns, colorScheme).map((pattern) => [pattern]); + } + return patterns.map((patternGroup) => resolveS2PatternGroup(patternGroup, colorScheme)); +}; + +export const isPatternArray = (patterns: Patterns): patterns is string[] => { + return !patterns.some((pattern) => Array.isArray(pattern)); +}; + function getColors(colors: Colors, colorScheme: ColorScheme): string[] { if (Array.isArray(colors)) { return colors.map((color: string) => getS2ColorValue(color, colorScheme)); diff --git a/packages/vega-spec-builder-s2/src/legend/legendFacetUtils.test.ts b/packages/vega-spec-builder-s2/src/legend/legendFacetUtils.test.ts index e3535e66c..e3861e2d6 100644 --- a/packages/vega-spec-builder-s2/src/legend/legendFacetUtils.test.ts +++ b/packages/vega-spec-builder-s2/src/legend/legendFacetUtils.test.ts @@ -11,7 +11,14 @@ */ import { Scale } from 'vega'; -import { COLOR_SCALE, DEFAULT_COLOR, LINE_TYPE_SCALE, SYMBOL_SIZE_SCALE, TABLE } from '@spectrum-charts/constants'; +import { + COLOR_SCALE, + DEFAULT_COLOR, + LINE_TYPE_SCALE, + PATTERN_SCALE, + SYMBOL_SIZE_SCALE, + TABLE, +} from '@spectrum-charts/constants'; import { getFacets, getFacetsFromKeys } from './legendFacetUtils'; @@ -37,6 +44,18 @@ describe('getFacets()', () => { expect(ordinalFacets).toHaveLength(1); expect(continuousFacets).toHaveLength(1); }); + + test('should recognize a used pattern scale as an ordinal facet', () => { + const scales: Scale[] = [ + { + name: PATTERN_SCALE, + type: 'ordinal', + domain: { data: TABLE, fields: ['period'] }, + }, + ]; + const { ordinalFacets } = getFacets(scales); + expect(ordinalFacets).toStrictEqual([{ facetType: PATTERN_SCALE, field: 'period' }]); + }); }); describe('getFacetsFromKeys()', () => { diff --git a/packages/vega-spec-builder-s2/src/legend/legendFacetUtils.ts b/packages/vega-spec-builder-s2/src/legend/legendFacetUtils.ts index b2c981fa8..3a5e01312 100644 --- a/packages/vega-spec-builder-s2/src/legend/legendFacetUtils.ts +++ b/packages/vega-spec-builder-s2/src/legend/legendFacetUtils.ts @@ -16,6 +16,7 @@ import { LINEAR_COLOR_SCALE, LINE_TYPE_SCALE, OPACITY_SCALE, + PATTERN_SCALE, SYMBOL_SHAPE_SCALE, SYMBOL_SIZE_SCALE, } from '@spectrum-charts/constants'; @@ -31,9 +32,11 @@ const facetScaleNames = new Set([ LINE_TYPE_SCALE, LINEAR_COLOR_SCALE, OPACITY_SCALE, + PATTERN_SCALE, 'secondaryColor', 'secondaryLineType', 'secondaryOpacity', + 'secondaryPattern', 'secondarySymbolShape', SYMBOL_SHAPE_SCALE, SYMBOL_SIZE_SCALE, diff --git a/packages/vega-spec-builder-s2/src/legend/legendUtils.test.ts b/packages/vega-spec-builder-s2/src/legend/legendUtils.test.ts index f05fbd3a9..902e67848 100644 --- a/packages/vega-spec-builder-s2/src/legend/legendUtils.test.ts +++ b/packages/vega-spec-builder-s2/src/legend/legendUtils.test.ts @@ -17,6 +17,7 @@ import { FADE_FACTOR, FILTERED_TABLE, GROUP_ID, + PATTERN_SCALE, ROUNDED_SQUARE_PATH, SERIES_ID, VISIBILITY_OFF_PATH, @@ -93,6 +94,16 @@ describe('getSymbolEncodings()', () => { expect(hiddenStrokeRule?.test).toContain(FILTERED_TABLE); expect(hiddenStrokeRule?.value).toBe('transparent'); }); + + test('a pattern facet takes precedence over color for the swatch fill', () => { + const encodings = getSymbolEncodings( + [{ facetType: PATTERN_SCALE, field: 'period' }], + defaultLegendOptions + ); + expect(encodings.symbols?.update?.fill).toStrictEqual([ + { signal: `scale('${PATTERN_SCALE}', data('legend0Aggregate')[datum.index].period)` }, + ]); + }); }); describe('getShowHideEncodings()', () => { diff --git a/packages/vega-spec-builder-s2/src/legend/legendUtils.ts b/packages/vega-spec-builder-s2/src/legend/legendUtils.ts index 411ca4c6c..1d5b7f3c8 100644 --- a/packages/vega-spec-builder-s2/src/legend/legendUtils.ts +++ b/packages/vega-spec-builder-s2/src/legend/legendUtils.ts @@ -41,6 +41,7 @@ import { LINE_TYPE_SCALE, LINE_WIDTH_SCALE, OPACITY_SCALE, + PATTERN_SCALE, SELECTED_GROUP, SELECTED_SERIES, SERIES_ID, @@ -289,9 +290,12 @@ export const getSymbolEncodings = (facets: Facet[], options: LegendSpecOptions): name, }), }; - const colorRef = getSymbolFacetEncoding({ facets, facetType: COLOR_SCALE, customValue: color, name }) ?? { - value: spectrum2Colors[colorScheme]['categorical-100'], - }; + const patternRef = getSymbolFacetEncoding({ facets, facetType: PATTERN_SCALE, name }); + const colorRef = + patternRef ?? + getSymbolFacetEncoding({ facets, facetType: COLOR_SCALE, customValue: color, name }) ?? { + value: spectrum2Colors[colorScheme]['categorical-100'], + }; // Hidden entries swap shape to the "eye off" icon, colored to match the legend label text (not the series color). const isHidden = isToggleable || hiddenSeries.length > 0; const hiddenSeriesTest = keys?.length @@ -343,6 +347,7 @@ const getSymbolFacetEncoding = ({ lineType: { scale: 'secondaryLineType', signal: 'lineTypes' }, lineWidth: { scale: 'secondaryLineWidth', signal: 'lineWidths' }, opacity: { scale: 'secondaryOpacity', signal: 'opacities' }, + pattern: { scale: 'secondaryPattern', signal: 'patterns' }, symbolShape: { scale: 'secondarySymbolShape', signal: 'symbolShapes' }, symbolSize: { scale: 'secondarySymbolSize', signal: 'symbolSizes' }, symbolPathWidth: { scale: 'secondarySymbolPathWidth', signal: 'symbolPathWidths' }, diff --git a/packages/vega-spec-builder-s2/src/marks/markUtils.test.ts b/packages/vega-spec-builder-s2/src/marks/markUtils.test.ts index 20a241ada..e9514ef40 100644 --- a/packages/vega-spec-builder-s2/src/marks/markUtils.test.ts +++ b/packages/vega-spec-builder-s2/src/marks/markUtils.test.ts @@ -26,6 +26,7 @@ import { LINE_TYPE_SCALE, LINE_WIDTH_SCALE, OPACITY_SCALE, + PATTERN_SCALE, SELECTED_GROUP, SELECTED_ITEM, SYMBOL_SIZE_SCALE, @@ -43,6 +44,7 @@ import { getLineWidthProductionRule, getMarkOpacity, getOpacityProductionRule, + getPatternProductionRule, getStrokeDashProductionRule, getSymbolSizeProductionRule, getInspectEncoding, @@ -79,6 +81,26 @@ describe('getColorProductionRule', () => { }); }); +describe('getPatternProductionRule', () => { + test('should return scale reference if pattern is a string', () => { + expect(getPatternProductionRule('period')).toStrictEqual({ scale: PATTERN_SCALE, field: 'period' }); + }); + + test('should resolve a built-in pattern name to a structured pattern-fill value for a static value', () => { + expect(getPatternProductionRule({ value: 'dots' })).toStrictEqual({ value: { pattern: 'dots' } }); + }); + + test('should pass through a literal color value unchanged for a static value', () => { + expect(getPatternProductionRule({ value: '#2680eb' })).toStrictEqual({ value: '#2680eb' }); + }); + + test('should return a 2d lookup signal if a dual-facet tuple is provided, same shape as color', () => { + expect(getPatternProductionRule(['region', 'period'])).toStrictEqual({ + signal: "scale('patterns', datum.region)[indexof(domain('secondaryPattern'), datum.period)% length(scale('patterns', datum.region))]", + }); + }); +}); + describe('getLineWidthProductionRule', () => { test('should return 2d lookup signal if array provided', () => { expect(getLineWidthProductionRule([DEFAULT_COLOR, DEFAULT_SECONDARY_COLOR])).toStrictEqual({ diff --git a/packages/vega-spec-builder-s2/src/marks/markUtils.ts b/packages/vega-spec-builder-s2/src/marks/markUtils.ts index 0b3c0aee4..ef1c68224 100644 --- a/packages/vega-spec-builder-s2/src/marks/markUtils.ts +++ b/packages/vega-spec-builder-s2/src/marks/markUtils.ts @@ -37,11 +37,13 @@ import { LINE_TYPE_SCALE, LINE_WIDTH_SCALE, OPACITY_SCALE, + PATTERN_SCALE, SELECTED_GROUP, SELECTED_ITEM, SYMBOL_SIZE_SCALE, } from '@spectrum-charts/constants'; import { getS2ColorValue } from '@spectrum-charts/themes'; +import { resolvePatternFillValue } from '@spectrum-charts/utils'; import { addHoveredItemOpacityRules } from '../chartInspect/chartInspectUtils'; import { LineMarkOptions } from '../line/lineUtils'; @@ -64,6 +66,7 @@ import { LineWidthFacet, MetricRangeOptions, OpacityFacet, + PatternFacet, ProductionRuleTests, ScaleType, ScatterSpecOptions, @@ -185,6 +188,25 @@ export const getColorProductionRule = ( return { value: getS2ColorValue(color.value, colorScheme) }; }; +/** + * Gets the pattern encoding + * @param pattern + * @returns ColorValueRef + */ +export const getPatternProductionRule = (pattern: PatternFacet | DualFacet): ColorValueRef => { + if (Array.isArray(pattern)) { + return { + signal: `scale('patterns', datum.${pattern[0]})[indexof(domain('secondaryPattern'), datum.${pattern[1]})% length(scale('patterns', datum.${pattern[0]}))]`, + }; + } + if (typeof pattern === 'string') { + return { scale: PATTERN_SCALE, field: pattern }; + } + // resolvePatternFillValue may return a structured PatternFillValue, which Vega accepts as a literal encode + // value (unlike a scale range entry) but isn't shaped like ColorValueRef's own Gradient-object variant. + return { value: resolvePatternFillValue(pattern.value) } as unknown as ColorValueRef; +}; + /** * gets the color encoding in a signal string format * @param color diff --git a/packages/vega-spec-builder-s2/src/specUtils.test.ts b/packages/vega-spec-builder-s2/src/specUtils.test.ts index c7b21c788..7282d2a42 100644 --- a/packages/vega-spec-builder-s2/src/specUtils.test.ts +++ b/packages/vega-spec-builder-s2/src/specUtils.test.ts @@ -18,6 +18,7 @@ import { DEFAULT_SECONDARY_COLOR, DEFAULT_TRANSFORMED_TIME_DIMENSION, LINE_TYPE_SCALE, + PATTERN_SCALE, ROUNDED_SQUARE_PATH, TABLE, VISIBILITY_OFF_PATH, @@ -81,6 +82,24 @@ describe('getFacetsFromOptions()', () => { secondaryFacets: [DEFAULT_SECONDARY_COLOR], }); }); + test('should treat a plain pattern field as a primary facet, same as color', () => { + expect(getFacetsFromOptions({ pattern: 'period' })).toStrictEqual({ + facets: ['period'], + secondaryFacets: [], + }); + }); + test('a plain pattern field combined with another primary facet contributes its own primary facet, not a secondary one', () => { + expect(getFacetsFromOptions({ color: DEFAULT_COLOR, pattern: 'period' })).toStrictEqual({ + facets: [DEFAULT_COLOR, 'period'], + secondaryFacets: [], + }); + }); + test('should get secondary facet from a pattern dual-facet tuple, same as color', () => { + expect(getFacetsFromOptions({ pattern: ['region', 'period'] })).toStrictEqual({ + facets: ['region'], + secondaryFacets: ['period'], + }); + }); }); describe('getFacetsFromScales()', () => { @@ -112,6 +131,24 @@ describe('getFacetsFromScales()', () => { expect(getFacetsFromScales()).toStrictEqual([]); }); + test('should include a pattern facet - rscSeriesId (and legend hover matching) depend on this', () => { + const defaultPatternScale: OrdinalScale = { + name: PATTERN_SCALE, + type: 'ordinal', + domain: { data: TABLE, fields: ['browser'] }, + }; + expect(getFacetsFromScales([defaultPatternScale])).toStrictEqual(['browser']); + }); + + test('should include a secondaryPattern facet', () => { + const defaultSecondaryPatternScale: OrdinalScale = { + name: 'secondaryPattern', + type: 'ordinal', + domain: { data: TABLE, fields: ['period'] }, + }; + expect(getFacetsFromScales([defaultSecondaryPatternScale])).toStrictEqual(['period']); + }); + test('should return empty array if no scales have fields', () => { expect(getFacetsFromScales([{ ...defaultColorScale, domain: { data: TABLE, fields: [] } }])).toStrictEqual([]); }); diff --git a/packages/vega-spec-builder-s2/src/specUtils.ts b/packages/vega-spec-builder-s2/src/specUtils.ts index 6f7211e49..50ff2380e 100644 --- a/packages/vega-spec-builder-s2/src/specUtils.ts +++ b/packages/vega-spec-builder-s2/src/specUtils.ts @@ -20,6 +20,7 @@ import { LINE_TYPE_SCALE, MARK_ID, OPACITY_SCALE, + PATTERN_SCALE, ROUNDED_SQUARE_PATH, SENTIMENT_NEGATIVE_PATH, SENTIMENT_NEUTRAL_PATH, @@ -41,6 +42,7 @@ import { LineWidth, NumberFormat, OpacityFacet, + PatternFacet, ScSpec, SymbolSize, SymbolSizeFacet, @@ -48,7 +50,8 @@ import { } from './types'; /** - * gets all the keys that are used to facet by + * gets all the keys that are used to facet by. pattern behaves exactly like color/lineType/opacity: a plain + * field is always a primary (dodge) facet; a [primary, secondary] tuple contributes to both, identically. * @param facetOptions * @returns facets */ @@ -56,22 +59,24 @@ export const getFacetsFromOptions = ({ color, lineType, opacity, + pattern, size, }: { color?: ColorFacet | DualFacet; lineType?: LineTypeFacet | DualFacet; opacity?: OpacityFacet | DualFacet; + pattern?: PatternFacet | DualFacet; size?: SymbolSizeFacet; }): { facets: string[]; secondaryFacets: string[] } => { // get all the keys that we need to facet by // filter out the ones that use static values instead of fields - let facets = [color, lineType, opacity, size] + let facets = [color, lineType, opacity, pattern, size] .map((facet) => (Array.isArray(facet) ? facet[0] : facet)) .filter((facet): facet is string => typeof facet === 'string'); // remove duplicates facets = [...new Set(facets)]; - let secondaryFacets = [color, lineType, opacity] + let secondaryFacets = [color, lineType, opacity, pattern] .map((facet) => (Array.isArray(facet) ? facet[1] : undefined)) .filter((facet): facet is string => typeof facet === 'string'); // remove duplicates @@ -90,9 +95,11 @@ export const getFacetsFromScales = (scales: Scale[] = []): string[] => { COLOR_SCALE, LINE_TYPE_SCALE, OPACITY_SCALE, + PATTERN_SCALE, 'secondaryColor', 'secondaryLineType', 'secondaryOpacity', + 'secondaryPattern', ].reduce((acc, cur) => { const scale = scales.find((scale) => scale.name === cur); if (scale?.domain && 'fields' in scale.domain && scale.domain.fields.length) { diff --git a/packages/vega-spec-builder-s2/src/types/chartSpec.types.ts b/packages/vega-spec-builder-s2/src/types/chartSpec.types.ts index 2642cdfc5..e5a5bff40 100644 --- a/packages/vega-spec-builder-s2/src/types/chartSpec.types.ts +++ b/packages/vega-spec-builder-s2/src/types/chartSpec.types.ts @@ -41,6 +41,8 @@ export type ChartColors = Colors | Colors[]; export type LineTypes = LineType[] | LineType[][]; export type Opacities = number[] | number[][]; export type SymbolShapes = ChartSymbolShape[] | ChartSymbolShape[][]; +/** Each entry is a built-in pattern name (e.g. 'diagonal-stripe') or a literal color value. */ +export type Patterns = string[] | string[][]; export interface ChartHandle { copy: () => Promise; @@ -92,6 +94,8 @@ export interface ChartOptions { lineWidths?: LineWidth[]; /** Opacity scale*/ opacities?: Opacities; + /** Pattern scale. Each entry is a built-in pattern name or a literal color value. Defaults to the built-in pattern palette. */ + patterns?: Patterns; /** Chart title. If the `Title` component is provided as a child, the component will override this prop. */ title?: string; /** Vega spec to be used instead of generating one using the component API. */ diff --git a/packages/vega-spec-builder-s2/src/types/marks/barSpec.types.ts b/packages/vega-spec-builder-s2/src/types/marks/barSpec.types.ts index 186cf1572..8dca0557e 100644 --- a/packages/vega-spec-builder-s2/src/types/marks/barSpec.types.ts +++ b/packages/vega-spec-builder-s2/src/types/marks/barSpec.types.ts @@ -12,7 +12,15 @@ import { ColorScheme, HighlightedItem } from '../chartSpec.types'; import { ChartPopoverOptions } from '../dialogs/chartPopoverSpec.types'; import { ChartInspectOptions } from '../dialogs/chartInspectSpec.types'; -import { ColorFacet, LineTypeFacet, LineWidth, OpacityFacet, Orientation, PartiallyRequired } from '../specUtil.types'; +import { + ColorFacet, + LineTypeFacet, + LineWidth, + OpacityFacet, + Orientation, + PartiallyRequired, + PatternFacet, +} from '../specUtil.types'; import { BarAnnotationOptions } from './supplemental/barAnnotationSpec.types'; import { BarDirectLabelOptions } from './supplemental/barDirectLabelSpec.types'; import { TrendlineOptions } from './supplemental/trendlineSpec.types'; @@ -55,6 +63,13 @@ export interface BarOptions { order?: string; /** The direction of the bars. Defaults to "vertical". */ orientation?: Orientation; + /** + * Pattern name/color or key(s) in the data that are used as the pattern facet - behaves exactly like + * {@link color}: a plain field dodges; a [primary, secondary] tuple dodges by primary and stacks by + * secondary. When set, bar fill uses the pattern scale instead of the color scale - {@link pattern} + * takes precedence over {@link color} for fill only. + */ + pattern?: PatternFacet | DualFacet; /** Opacity or key in the data that is used as the opacity facet */ opacity?: OpacityFacet | DualFacet; /** Sets inner padding (https://vega.github.io/vega/docs/scales/#band) */ diff --git a/packages/vega-spec-builder-s2/src/types/specUtil.types.ts b/packages/vega-spec-builder-s2/src/types/specUtil.types.ts index 56f3274fc..5cfdee1f4 100644 --- a/packages/vega-spec-builder-s2/src/types/specUtil.types.ts +++ b/packages/vega-spec-builder-s2/src/types/specUtil.types.ts @@ -145,6 +145,8 @@ export type OpacityFacet = FacetRef; export type PathWidthFacet = FacetRef; export type SymbolSizeFacet = FacetRef; export type SymbolShapeFacet = FacetRef; +/** A built-in pattern name (e.g. 'diagonal-stripe') or a literal color value, used as a static fill. */ +export type PatternFacet = FacetRef; export type FacetType = | 'color' @@ -152,6 +154,7 @@ export type FacetType = | 'lineType' | 'lineWidth' | 'opacity' + | 'pattern' | 'symbolShape' | 'symbolSize' | 'symbolPathWidth'; @@ -161,6 +164,7 @@ export type SecondaryFacetType = | 'secondaryLineType' | 'secondaryLineWidth' | 'secondaryOpacity' + | 'secondaryPattern' | 'secondarySymbolShape' | 'secondarySymbolSize' | 'secondarySymbolPathWidth'; diff --git a/planning/research/vega-canvas-pattern-fill/vega-canvas-pattern-fill.md b/planning/research/vega-canvas-pattern-fill/vega-canvas-pattern-fill.md new file mode 100644 index 000000000..73f0c08c4 --- /dev/null +++ b/planning/research/vega-canvas-pattern-fill/vega-canvas-pattern-fill.md @@ -0,0 +1,50 @@ +# Native Pattern-Fill Support in Vega — Tracking Notes + +> **Status: Superseded by an existing upstream PR — do not file a new proposal.** +> `vega/vega#4290` ("feat: Pattern Fills (#1372)", opened 2026-07-13 by `dm-p`, open/unmerged as of writing) already implements substantially the same design this document originally proposed, in far more depth. This file now tracks that PR and records where our own implementation experience is (and isn't) relevant feedback for it, rather than pitching a fresh design. + +--- + +## Background + +- `vega/vega#1372` (opened 2018) is the long-running design discussion for pattern/texture fills. It converged years ago on "proposal B": a pattern is an **object-valued fill/stroke in the gradient family** — usable anywhere a color is (mark encode values *and* scale range literals) — mirroring how `Gradient` already works. Proposal A (a `repeat`-flag on the `image` mark) was explicitly rejected by the maintainers because it can't apply to arbitrary marks (e.g. area/rect via a plain color-style fill). +- `vega/vega#4290` is a full implementation of proposal B, submitted by the maintainer of Deneb (a JSON-only Vega consumer embedded in Power BI), migrating a pattern-fill feature that had existed as a Deneb-only hack for years. It is under active review; the maintainer (`hydrosquall`) has asked to scope the first merge down (defer the public JS extensibility registry, keep the spec-side `pattern()` expression function). + +This means the gap our original draft was written to close — no native `Pattern` value type, no canvas-side resolution — already has a concrete, in-flight upstream fix. Filing a second, competing proposal would be redundant and unhelpful to the maintainers. + +--- + +## What #4290 does (verified from the PR body/diff, not assumed) + +- New `vega-pattern` package (TypeScript) with a named-pattern registry, mirroring `scheme()`'s runtime-registration shape (`vega.pattern(name, def)`). +- Four spec-level pattern variants, all nested under a `pattern` key exactly like `Gradient`'s `gradient` key: `name` (registry lookup), `shape` (inline SVG path in tile coordinates), `rule` (analytic angled-line generator), `url` (image tile). +- Common properties: `foreground`, `background`, `strokeWidth`, `origin: "view" | "mark"`, `scale`, `shapeRendering`. +- Usable in mark `fill`/`stroke` encode values **and directly as scale range entries**, mixed with plain color strings — this is the "pattern as a facet/scale" mechanism our own `pattern` prop needed, done natively. +- A `pattern()` expression function (the `gradient()` analog) for composing a pattern from signal expressions — e.g. binding foreground to a separate color scale: `pattern(scale('tex', datum.type), {foreground: scale('color', datum.group)})`. +- Two built-in ordinal schemes: `"patterns"` (texture-only) and `"monochrome"` (greyscale + texture, for print/grayscale-safe redundant encoding). +- Legend swatches render patterns natively, in both renderers. +- Canvas and SVG renderers produce parity output; canvas positioning is **raster-baked** (tile rasterized once, phased via `drawImage`) rather than using `CanvasPattern.setTransform`, specifically to work around `node-canvas`'s lack of a global `DOMMatrix` and broken `repeat-x`/`repeat-y` modes — an environment constraint, not a browser limitation. +- `docs/types.md` gets a `Pattern` reference section structured like the existing `Gradient` entry, plus scale-range and scheme docs. + +--- + +## Where this lines up with (and diverges from) our own implementation + +| Concern | Our implementation | `#4290` | +|---|---|---| +| Value model | String-convention hack (`getPatternFillUrl(id)` strings resolved by intercepting `ctx.fillStyle` from outside Vega) | Native object-valued type in the gradient family — exactly what our draft proposed adding | +| Color-matching a pattern to a sibling color | Runtime string-parsing fallback (`baseId::color` composite id, resolved by a canvas-layer registry lookup) | First-class `foreground`/`background` properties on the pattern object itself, resolved by Vega's own encode/scale pipeline | +| Pattern as a scale range | Ordinal scale range of literal `getPatternFillUrl()` strings (works today only because Vega ordinal ranges are literal arrays) | Same idea, but native — range entries can be pattern objects directly, or one of two built-in schemes | +| Canvas tile positioning | `CanvasPattern.setTransform()` for rotation, DPR read directly off `canvas.width / canvas.clientWidth` | Raster-baked tiles, no `setTransform`, specifically because of `node-canvas` gaps — worth flagging in review that a real-browser canvas has `DOMMatrix` and working `repeat-x`/`repeat-y`, so this constraint may be `node-canvas`-only, not universal (relevant only if headless/`vg2png` output quality is ever in question for browser-rendered use) | +| Dodge+stack / dual-facet composition | Built and tested end-to-end on top of Vega's existing scale/dodge machinery — no Vega changes needed for this part, since it was always a spec-authoring problem, not a rendering one | Not this PR's concern — dual-facet dodge/stack composition happens one layer up (in the spec builder), so `#4290`'s scale-range support is sufficient to keep supporting it once adopted | + +The two open items our four-gaps list from earlier (string→type, real SVG support, generic tile-source API, first-class color-matching) are **all addressed** by `#4290` except "generic tile-source API" — its `shape`/`rule`/`url` variants cover hand-authored and image-based tiles but not an arbitrary `render(context)` callback. That's a reasonable scope cut for a first merge, not a gap worth raising, since embedding executable render callbacks in a JSON spec is a materially different (and JSON-unfriendly) API surface than what Deneb/PowerBI's JSON-only consumers need. + +--- + +## Recommended next steps + +1. **Do not file a new issue or PR.** Track `vega/vega#4290` instead. +2. If/when we want to contribute review feedback, it should draw only on verified, generic implementation experience (DPR-correct tile sizing, color-matched pattern/solid pairing, dual-facet scale composition) — not name our product internally, consistent with how any public GitHub comment would read. +3. Once `#4290` merges, plan a follow-up spec to **replace our string-convention interception layer** (`canvasPatternFillUtils.ts`, `patternFillUtils.ts`, the `packages/utils/src/patternFillId.ts` convention) with direct use of the native `Pattern` value type in scale ranges and encode values — this removes the `fillStyle`-patching mechanism entirely once the underlying Vega version is adopted. +4. No action needed on our own SVG-parity phase (step 4 of the original 6-step plan) purely for upstream-contribution purposes — that motivation is now moot. It may still be worth doing independently if SVG rendering is required before `#4290` lands and is adopted. diff --git a/planning/specs/bar/implemented/pattern-scale.json b/planning/specs/bar/implemented/pattern-scale.json new file mode 100644 index 000000000..c8a8275cc --- /dev/null +++ b/planning/specs/bar/implemented/pattern-scale.json @@ -0,0 +1,166 @@ +{ + "id": "pattern-scale", + "title": "Pattern Scale for Bar (real pattern/patterns props)", + "chartType": "bar", + "kind": "feature", + "variant": "s2", + "status": "implemented", + "lastUpdated": "2026-08-17", + "complexity": { + "score": 5, + "rationale": "The canvas injection mechanism and its cache/DPR/rotation handling already exist and are proven (planning/specs/chart/pattern-fill-rendering.json's canvas phase, validated via its prototype stories, though that spec's own status is still approved pending its SVG phase) - reusing the canvas mechanism itself is precedented. What's new and undecided going in: a facet scale that mixes pattern-reference urls with plain color values in the same range (no existing scale does this), a shared id/url convention that must move to a new package boundary (packages/utils) so both vega-spec-builder-s2 and react-spectrum-charts-s2 can use it without a backwards dependency, and a built-in default tile palette that has to be designed from scratch (no precedent for a non-color facet scale's default range in this codebase). Each of those is a real design decision, not a repeat of an existing pattern, which is why this clears the 5 bar despite reusing the underlying render mechanism." + }, + "summary": "Adds a real `pattern` prop on Bar (mirrors `color`, but scoped to a single field name, not an array) and a chart-level `patterns` override array (mirrors `colors`/`lineTypes`/`opacities`), backed by a new ordinal `PATTERN_SCALE`. `patterns` entries are either a built-in pattern name (resolved to a `url(#rsc-pattern-)` reference via the existing canvas-fill mechanism) or a literal color value, so a single scale's range can mix pattern-filled and solid-filled domain values - the motivating case is a previous/current period comparison where the previous period renders candy-striped and the current period renders as a normal solid fill. Replaces the throwaway CanvasPatternFillPrototype stories' hand-written UNSAFE_vegaSpec approach with a real mark prop that goes through the actual S2 spec builder, so bars get real S2 styling (corner radius, theme-aware base rendering) and a real `` reflecting the pattern/color mix natively.", + "requirements": [ + "`pattern` on Bar behaves exactly like `color` (and `lineType`/`opacity`): a plain field name is always a primary (dodge) facet; a `[primary, secondary]` tuple dodges by primary and stacks by secondary, using the identical mechanism (getFacetsFromOptions, isDodgedAndStacked, addSecondaryScales, a 'patterns'/'secondaryPattern' scale+signal pair) as color's own dual-facet path. There is no context-dependent role-switching - pattern's role never changes based on what other facets are set, mirroring color exactly.", + "`pattern` is mutually exclusive with `color` for the fill encode only: if both are set, `pattern` takes precedence for fill via getBarEnterEncodings; `color` is otherwise unaffected and, if it is itself a field, still contributes its own primary/secondary facet(s) independently.", + "`patterns` on Chart is `string[] | string[][]` (mirrors `Opacities`'s exact shape) that overrides PATTERN_SCALE's range and the 'patterns' two-dimensional signal used by pattern's dual-facet lookup. Each entry is resolved as: if it matches a built-in pattern name, resolve it (see the color-matching requirement below); otherwise resolve it as an S2 color value via getS2ColorValue(entry, colorScheme) - the same resolution colors/getColors already applies, so an S2 token name (e.g. 'categorical-100') resolves to its real theme hex, and a literal hex/rgb value passes through unchanged since it isn't a recognized token key. getPatternScale/getTwoDimensionalPatterns take colorScheme as a parameter for this and are called with it from getDefaultScales/getDefaultSignals.", + "When `patterns` is not provided, PATTERN_SCALE's default range is the built-in pattern palette (a fixed, small set of neutral, colorScheme-independent tile patterns), analogous to the default categorical color palette used by COLOR_SCALE.", + "A built-in pattern name is resolved per-group (resolvePatternFillGroup), not independently: if the same group (a dual-facet row, or a whole 1D patterns array) also contains a literal color, the pattern is recolored to match that color instead of using the fixed neutral tile - e.g. `['diagonal-stripe', '#2680eb']` renders the stripe in #2680eb, not gray. This is a real, deliberate behavior change from an earlier version of this spec (which always resolved to a fixed neutral tile) - the motivating case (a previous/current comparison) requires the pattern to visually match the same color the solid segment uses, not an unrelated neutral texture. The canvas interception resolves this via a composite `baseId::color` id (getColorMatchedPatternFillUrl) and each built-in shape's new drawWithColor variant (transparent base, ink = the given color), falling back to the fixed neutral tile when no sibling color is present in the group.", + "The built-in pattern palette's id/url naming convention (getPatternFillUrl/getPatternFillId/the id prefix) moves to packages/utils so vega-spec-builder-s2 (to build PATTERN_SCALE's default range) and react-spectrum-charts-s2 (canvas registry + interception) can both depend on it without creating a backwards package dependency. react-spectrum-charts-s2 continues to re-export these from its own utils/index.ts so existing imports (including the CanvasPatternFillPrototype stories) don't need to change.", + "The built-in palette's tile-drawing functions (CanvasRenderingContext2D-based, browser-only) stay in react-spectrum-charts-s2, registered once at module load via the existing registerPatternFill() registry from planning/specs/chart/pattern-fill-rendering.json's implementation - no canvas API leaks into vega-spec-builder-s2.", + "PATTERN_SCALE follows the same setScales()/addFieldToFacetScaleDomain() extension pattern as COLOR_SCALE/LINE_TYPE_SCALE/OPACITY_SCALE: only marks that reference `pattern` as a field add a domain entry, and removeUnusedScales() strips PATTERN_SCALE entirely when no mark uses it.", + "Legend must render correctly for a mark using `pattern`: unlike the raw-spec prototype (where a hand-written `legends` block just worked), the real S2 Legend component required three explicit changes - PATTERN_SCALE added to legendFacetUtils.ts's facetScaleNames Set (or it's never recognized as a facet at all), a 'pattern' entry added to legendUtils.ts's secondaryFacetMapping (required for that mapped type's completeness), and the swatch fill resolver in getSymbolEncodings updated to prefer a PATTERN_SCALE facet over COLOR_SCALE, mirroring the mark-level fill precedence.", + "pattern-fill rendering only exists for the canvas renderer (SVG is a separate, not-yet-built phase of the mechanism spec) - a story or consumer using `pattern` must explicitly set `renderer=\"canvas\"` on Chart, since the default renderer is svg.", + "New Storybook stories demonstrate the real prop end to end, replacing what the UNSAFE_vegaSpec prototype stories approximated by hand: a previous/current period comparison using `pattern: ['region', 'period']` (dodges by region, stacks by period, matching color's own dual-facet convention exactly) with `patterns` supplied as a 2D array (one identical [stripe, solid] row per region value, since a dual-facet range varies by the primary by default - this is what makes period consistently control the pattern regardless of region) and a Legend scoped to `keys={['period']}`; and a default-palette story (`pattern` alone, no `patterns` override, no `color` field). Both explicitly set `renderer=\"canvas\"` and use real S2 styling (rounded corners, theme colors) via a real ``." + ], + "edgeCases": [ + { + "case": "`patterns` array is shorter than the field's domain (more categories than palette/override entries).", + "expectedBehavior": "Same behavior as COLOR_SCALE/other facet scales when the override array runs short - Vega's own ordinal scale range-cycling/undefined behavior applies uniformly, no special-casing for pattern." + }, + { + "case": "A `patterns` entry doesn't match a built-in pattern name and isn't a valid color string either (typo).", + "expectedBehavior": "Passed through as a literal value like any other invalid color would be - this prop doesn't add new validation beyond what `colors` already does or doesn't do for bad hex/token strings." + }, + { + "case": "Both `color` (a plain field) and `pattern` (a plain field) are set on the same Bar with type=\"dodged\".", + "expectedBehavior": "`pattern` wins for the fill encode. Both fields independently contribute their own primary/dodge facet, exactly as two plain fields on color and lineType would - the dodge-group key combines both; this does not by itself produce a stack. Use pattern's own dual-facet tuple to dodge-and-stack using pattern alone." + }, + { + "case": "`pattern` is set alone (no other field-based facet) on a type=\"dodged\" Bar.", + "expectedBehavior": "pattern's field becomes the primary (dodge) facet itself, producing a valid (if degenerate, one row per dodge group) dodge-group formula - this was a real crash before the fix (an empty groupby produced an empty formula expr, which Vega's parser rejects), reproduced and confirmed via buildSpec()+vega.parse() outside any browser." + }, + { + "case": "`pattern` is a [primary, secondary] dual-facet tuple.", + "expectedBehavior": "Dodges by primary and stacks by secondary, resolving fill via the same 2D indexed-lookup signal mechanism as color's own dual-facet (scale('patterns', datum.primary)[indexof(domain('secondaryPattern'), datum.secondary) % length(...)]). Because the lookup varies by primary by default, getting secondary (not primary) to consistently control the pattern requires supplying `patterns` as an explicit 2D array with one identical row per primary-domain value - the same requirement color's own dual-facet mechanism would impose for the equivalent 'vary by secondary' case." + }, + { + "case": "`pattern` is used with the `canvas` renderer vs the `svg` renderer (default).", + "expectedBehavior": "SVG support depends on planning/specs/chart/pattern-fill-rendering.json's SVG phase (not yet implemented as of this spec). A chart using `pattern` must explicitly set `renderer=\"canvas\"` - this was a real bug found after implementation (a story omitted it, silently defaulting to svg, rendering blank/fallback fills) and is now its own requirement, not just an edge case to remember." + }, + { + "case": "Hover animation, legend highlight, or controlled highlight interacting with a pattern-filled bar.", + "expectedBehavior": "Same assumption as the mechanism spec's edge case (opacity composites independently of fill, so it should just work) - re-verify specifically for a real Bar+Legend integration (not just the raw-spec prototype) before marking this implemented, since the real Legend's highlight-opacity wiring is more involved than the prototype's raw `legends` block." + }, + { + "case": "S1 (vega-spec-builder / react-spectrum-charts) Bar.", + "expectedBehavior": "Out of scope for this spec (s2-only) - see crossCutting.requiresS1S2Parity." + } + ], + "crossCutting": { + "touchesHoverAnimation": false, + "touchesControlledHighlight": false, + "touchesLegendInteraction": true, + "touchesTooltipOrPopover": false, + "requiresNewSignalOrScale": true, + "requiresS1S2Parity": true, + "notes": "requiresNewSignalOrScale: adds PATTERN_SCALE, a new ordinal facet scale alongside COLOR_SCALE/LINE_TYPE_SCALE/OPACITY_SCALE. touchesLegendInteraction: confirmed true by direct investigation, not just a risk - there are three separate hardcoded facet-scale-name lists in this codebase, and PATTERN_SCALE/secondaryPattern had to be added to all three: legendFacetUtils.ts's facetScaleNames Set (legend facet recognition), legendUtils.ts's secondaryFacetMapping (dual-facet swatch lookup + type completeness), and specUtils.ts's getFacetsFromScales (drives the table's rscSeriesId formula, which legend<->mark hover cross-highlighting depends on - missing this caused hover matching to silently fail for any pattern-only bar, since rscSeriesId never included the pattern field at all). All three were found and fixed by building and testing against the real Legend component and real hover behavior, not by inspection alone. requiresS1S2Parity: s1 has no pattern-fill rendering mechanism at all yet (per the mechanism spec), so this mark-level prop is s2-only until that changes; no immediate port required." + }, + "implementationPlan": [ + { + "file": "packages/constants/constants.ts", + "change": "Add PATTERN_SCALE = 'pattern' alongside COLOR_SCALE/LINE_TYPE_SCALE/OPACITY_SCALE." + }, + { + "file": "packages/utils/src/patternFillId.ts", + "change": "New file: PATTERN_FILL_ID_PREFIX, getPatternFillUrl(id), getPatternFillId(value), and DEFAULT_PATTERN_FILL_IDS - moved/promoted from react-spectrum-charts-s2's patternFillUtils.ts so vega-spec-builder-s2 can use the same convention without depending on react-spectrum-charts-s2. Also added COMPOSITE_PATTERN_SEPARATOR, getColorMatchedPatternFillUrl(baseId, color), and resolvePatternFillGroup(group) for the color-matching requirement above (resolvePatternFillValue kept as-is for the single-value, no-group-context case)." + }, + { + "file": "packages/react-spectrum-charts-s2/src/utils/patternFillUtils.ts", + "change": "Re-export PATTERN_FILL_ID_PREFIX/getPatternFillUrl/getPatternFillId/DEFAULT_PATTERN_FILL_IDS/COMPOSITE_PATTERN_SEPARATOR/getColorMatchedPatternFillUrl/resolvePatternFillGroup from @spectrum-charts/utils instead of defining them locally, keeping PatternTileSource (now with an optional drawWithColor field), the registry (registerPatternFill/getPatternFillSource/clearPatternFillRegistry), and specHasPatternFill unchanged so existing imports (including CanvasPatternFillPrototype.story.tsx) don't break." + }, + { + "file": "packages/react-spectrum-charts-s2/src/utils/defaultPatternFills.ts", + "change": "New file: registers the built-in tile palette via registerPatternFill(), at module load - imported for its side effect from utils/index.ts (see that entry below), not from Chart.tsx as originally planned. Each shape's draw function is now parameterized (ink color, optional base color) so it can produce both the fixed-neutral `draw` (white base, dark ink) and a `drawWithColor` variant (transparent base, ink = the given color) from the same drawing logic." + }, + { + "file": "packages/react-spectrum-charts-s2/src/utils/canvasPatternFillUtils.ts", + "change": "Added getColorMatchedPatternSource: on a cache miss, if the pattern id contains the `::` composite separator, split it into a base id + embedded color, look up the base shape's drawWithColor, and build an ad hoc PatternTileSource from it - resolveFillStyleValue tries this as a fallback after the normal registry lookup." + }, + { + "file": "packages/vega-spec-builder-s2/src/types/specUtil.types.ts", + "change": "Add PatternFacet = FacetRef; add 'pattern' to FacetType and 'secondaryPattern' to SecondaryFacetType (required for legendUtils.ts's mapped-type completeness, even though the secondary/dual-facet path is not implemented for pattern in this pass)." + }, + { + "file": "packages/vega-spec-builder-s2/src/types/marks/barSpec.types.ts", + "change": "Add pattern?: PatternFacet | DualFacet to BarOptions, exactly mirroring color's type; no OptionsWithDefaults entry since absence means no pattern behavior." + }, + { + "file": "packages/vega-spec-builder-s2/src/types/chartSpec.types.ts", + "change": "Add Patterns = string[] | string[][] to ChartOptions, exactly mirroring Opacities's shape (widened from an initial string[]-only version once pattern gained dual-facet support)." + }, + { + "file": "packages/vega-spec-builder-s2/src/chartSpecBuilder.ts", + "change": "Add getPatternScale(patterns) mirroring getLineTypeScale (uses only the first entry of each group for a 2D override); getTwoDimensionalPatterns(patterns) mirroring getTwoDimensionalOpacities exactly; isPatternArray mirroring isNumberArray; register a 'patterns' signal in getDefaultSignals mirroring 'colors'/'lineTypes'/'opacities'. Thread patterns through buildSpec's destructure, the assembled options object, and getDefaultScales's/getDefaultSignals's params." + }, + { + "file": "packages/vega-spec-builder-s2/src/marks/markUtils.ts", + "change": "Add getPatternProductionRule(pattern), mirroring getColorProductionRule's three branches exactly: array (2D indexed-lookup signal against 'patterns'/'secondaryPattern'), string (scale reference), static value." + }, + { + "file": "packages/vega-spec-builder-s2/src/bar/barUtils.ts", + "change": "getBarEnterEncodings's fill checks options.pattern first (via getPatternProductionRule) before falling back to getBarFillEncoding - deliberately NOT changing getBarFillEncoding itself, since getStroke also calls it for the bar's default border color and must stay pattern-free (a CanvasPattern-driving string is not a sensible stroke value under the current canvas interception, which only patches fillStyle)." + }, + { + "file": "packages/vega-spec-builder-s2/src/bar/barSpecBuilder.ts", + "change": "Destructure pattern (bare, no default) in addBar's produce callback and include it in the assembled BarSpecOptions; call addFieldToFacetScaleDomain(scales, PATTERN_SCALE, pattern) in addScales; pass pattern through to getStackFields and getDodgeGroupTransform (both call getFacetsFromOptions with pattern included)." + }, + { + "file": "packages/vega-spec-builder-s2/src/specUtils.ts", + "change": "getFacetsFromOptions accepts an optional pattern param, included in the primary/secondary collection exactly like color/lineType/opacity (a plain field is always primary; array[1] is secondary). An earlier version of this fix special-cased pattern as context-dependent (primary only when no other facet existed) - reverted per direct instruction that pattern must behave exactly like color, with no role-switching. This still fixes the real crash (confirmed via buildSpec()+vega.parse()) where a type=\"dodged\" Bar using pattern alone produced an empty-string dodge-group formula expr that Vega's parser rejected - simply including pattern in the same unconditional list as color was sufficient. Also added PATTERN_SCALE/'secondaryPattern' to getFacetsFromScales's own separate hardcoded facet-scale-name list (used to build the table's rscSeriesId formula) - missing this broke legend<->mark hover cross-highlighting for any pattern-only bar, confirmed via the compiled spec's rscSeriesId formula before/after." + }, + { + "file": "packages/vega-spec-builder-s2/src/legend/legendFacetUtils.ts", + "change": "Add PATTERN_SCALE and 'secondaryPattern' to the facetScaleNames Set, or the real Legend never recognizes a PATTERN_SCALE domain field as a facet at all." + }, + { + "file": "packages/vega-spec-builder-s2/src/legend/legendUtils.ts", + "change": "Add a 'pattern' entry to secondaryFacetMapping (required for type completeness). In getSymbolEncodings, resolve a PATTERN_SCALE facet ref and prefer it over the color facet/customValue for the swatch's fill (and stroke), mirroring the mark-level precedence." + }, + { + "file": "packages/react-spectrum-charts-s2/src/utils/index.ts", + "change": "Side-effect import of defaultPatternFills.ts so the built-in palette registers before any chart can reference it - simpler than threading an explicit import through Chart.tsx, since every chart already transitively imports from ./utils via VegaChart.tsx." + }, + { + "file": "packages/react-spectrum-charts-s2/src/RscChart.tsx", + "change": "Destructure/pass patterns to useSpec." + }, + { + "file": "packages/react-spectrum-charts-s2/src/hooks/useSpec.tsx", + "change": "Accept patterns, pass to rscPropsToSpecBuilderOptions, add to the memo dep array." + }, + { + "file": "packages/react-spectrum-charts-s2/src/components/Bar/Bar.tsx", + "change": "Add pattern to the destructure list (render-null component holding prop defaults/discovery), no default value." + }, + { + "file": "packages/vega-spec-builder-s2/src/bar/barUtils.ts", + "change": "isDodgedAndStacked includes pattern in the same array-of-facets dual-tuple check as color/lineType/opacity - `[color, lineType, opacity, pattern].some(f => Array.isArray(f) && f.length === 2)` - reusing the existing getDodgedAndStackedBarMark structure for a pattern dual-facet tuple exactly as it already does for color's." + }, + { + "file": "packages/vega-spec-builder-s2/src/bar/barSpecBuilder.ts", + "change": "addSecondaryScales adds a pattern entry ({value: pattern, scaleName: 'patterns', secondaryScaleName: 'secondaryPattern'}) to the same forEach array as color/lineType/opacity." + }, + { + "file": "packages/react-spectrum-charts-s2/src/stories/components/Bar/PatternFill.story.tsx", + "change": "New file: PreviousCurrentComparison (pattern: ['region', 'period'], a dual-facet tuple dodging by region and stacking by period, exactly like a color dual-facet tuple; patterns is a 2D array with one row per region - ['#2680eb', 'diagonal-stripe'] for East, ['#e68619', 'diagonal-stripe'] for West - so each region gets its own color (mirroring DodgedStacked's per-OS color family) and the stripe is recolored to match via resolvePatternFillGroup; data lists Current before Previous per region so Current stacks on the bottom and gets range index 0 (solid), Previous index 1 (stripe); no `keys` on Legend, matching DodgedStacked's own unscoped dual-facet legend, which shows the full region x period cross-product) and DefaultPalette (pattern='browser' alone, no patterns override, so the built-in fixed-neutral tiles still apply since there's no sibling color), both explicitly setting renderer=\"canvas\"." + } + ], + "openQuestions": [ + "Legend hover/click highlighting (legendHighlightUtils.ts) and hover-animation deemphasis were not specifically re-verified against a pattern-filled bar+Legend in this pass, beyond confirming the swatch itself renders correctly - the assumption that opacity composites independently of fill (from the mechanism spec) held for the swatch's own fill/stroke encode, but the highlight-driven opacity *rules* applied elsewhere in the legend/mark were not separately exercised.", + "pattern's dual-facet lookup was verified structurally (buildSpec output, vega.parse(), and unit tests on getFacetsFromOptions/isDodgedAndStacked/getDodgeGroupTransform/getPatternProductionRule) but not visually in a browser - the browser check is the user's own next step, not part of this implementation pass. Note the story's `patterns` override needing an explicit 2D array (one identical row per primary-domain value) to get period-consistent behavior is unwieldy for consumers who don't know the primary field's domain size in advance - worth revisiting if this becomes a common pattern.", + "A real Storybook rendering bug was found and fixed after the initial implementation: neither story set renderer=\"canvas\" explicitly, so both silently used the svg default, where pattern-fill isn't implemented. Nothing in buildSpec()/vega.parse() checks would ever catch this, since renderer is a React/VegaChart-level concern, not part of the compiled spec - worth remembering as a class of bug the spec-level verification approach in this pass cannot see." + ], + "relatedIssues": ["pattern-fill-structured-value"] +} diff --git a/planning/specs/bar/pattern-fill-structured-value.json b/planning/specs/bar/pattern-fill-structured-value.json new file mode 100644 index 000000000..84cfc3dc3 --- /dev/null +++ b/planning/specs/bar/pattern-fill-structured-value.json @@ -0,0 +1,92 @@ +{ + "id": "pattern-fill-structured-value", + "title": "Replace composite pattern-fill id string with a structured value type", + "chartType": "bar", + "kind": "feature", + "variant": "s2", + "status": "approved", + "lastUpdated": "2026-08-17", + "complexity": { + "score": 5, + "rationale": "Not a repeat of any precedented edit in this codebase: it requires designing a new discriminated value type (no existing non-Vega-native value type exists here to copy) and verifying, via buildSpec()+vega.parse() rather than assumption, that Vega's own scale-range/expression-indexing/production-rule pipeline actually tolerates an object-valued range entry the same way it tolerates a Gradient-shaped one for the pattern facet's specific dual-facet indexed-lookup expression. Multiple sites (packages/utils's value construction, the canvas interception's cache-key/lookup logic, the spec builder's scale/signal construction, the legend swatch resolver) each need distinct, non-repeated reasoning about how they consume the new shape, not just a mechanical rename." + }, + "summary": "Replaces the pattern-scale feature's (planning/specs/bar/implemented/pattern-scale.json) composite `baseId::color` string convention - used to color-match a built-in pattern tile to a sibling literal color in the same `patterns` group - with a structured, discriminated value object, mirroring how Vega's own `Gradient` type is already a plain object (`{gradient: 'linear', ...}`) usable as a literal scale-range/encode value rather than a parsed string. Removes all string-splitting/separator logic from the canvas interception layer. Motivated by a review of `vega/vega#4290` (an in-flight, unmerged upstream PR adding a native `Pattern` value type with first-class `foreground`/`background` properties): our own string convention was flagged as real, if minor, technical debt independent of that PR's outcome, and this codebase is expected to carry its own pattern-fill mechanism for a long time regardless of when/whether #4290 lands - see planning/research/vega-canvas-pattern-fill/vega-canvas-pattern-fill.md for the upstream context.", + "requirements": [ + "Introduce a structured pattern-fill value type in packages/utils/src/patternFillId.ts (e.g. a plain object with a dedicated discriminant field, analogous to Vega's `isGradient(value) => value && value.gradient` check) that carries a built-in pattern id and an optional color to match, replacing the current `url(...)`-style string returned by getPatternFillUrl/getColorMatchedPatternFillUrl.", + "resolvePatternFillGroup returns this structured value (not a string) whenever it resolves a built-in pattern name - whether or not a sibling color is present in the group. When no sibling color exists, the value's color field is omitted/undefined and canvas resolution falls back to the fixed-neutral tile exactly as it does today.", + "getPatternFillId / resolvePatternFillValue (the single-value, no-group-context entry point) recognize the structured value via direct property access on the discriminant field - no string parsing, no separator, no `indexOf`/`slice`.", + "canvasPatternFillUtils.ts's getColorMatchedPatternSource (and resolveFillStyleValue's lookup/caching) reads `.id` and `.color` directly off the structured value. The `COMPOSITE_PATTERN_SEPARATOR` constant and all associated string-splitting logic are deleted, not just deprecated.", + "PATTERN_SCALE's range and the 'patterns'/'secondaryPattern' generic-value signal accept this structured value as a literal array entry, verified (not assumed) via buildSpec() + vega.parse() that Vega's scale resolution and the pattern facet's dual-facet indexed-lookup expression (scale('patterns', datum.primary)[indexof(domain('secondaryPattern'), datum.secondary) % length(...)]) both pass the object through unchanged to the mark's fill encode and, from there, to the canvas fillStyle assignment.", + "TypeScript types for whatever currently types PATTERN_SCALE's range / the 'patterns' signal's value accept the structured value alongside plain color strings, without an `any` cast - follow whatever precedent already exists for Gradient-shaped range entries in this codebase's own scale types.", + "Legend swatch fill resolution (legendUtils.ts's getSymbolEncodings) is re-verified against a real Legend rendering a pattern facet whose resolved value is the new structured object, not assumed correct from the mark-level fix alone - this mirrors the three-hardcoded-list legend risk that was real (not just theoretical) the first time this feature was built.", + "No change to the public `pattern` (Bar) / `patterns` (Chart) prop surface - this is purely an internal representation change. Existing stories (PatternFill.story.tsx) and their rendered output are unaffected and require no edits.", + "All existing tests asserting the old string shape (patternFillId.test.ts, canvasPatternFillUtils.test.ts, chartSpecBuilder.test.ts, and any others touching PATTERN_SCALE's range or the 'patterns' signal) are updated for the new value shape, not skipped or loosened." + ], + "edgeCases": [ + { + "case": "A `patterns` group entry is a built-in pattern name with no sibling literal color in the same group.", + "expectedBehavior": "Resolves to the structured value with `color` omitted/undefined; canvas resolution uses the fixed-neutral `draw` tile source exactly as before this change - only the color-matched (drawWithColor) path is affected by the new shape." + }, + { + "case": "A `patterns` entry is a literal color string, not a built-in pattern name.", + "expectedBehavior": "Stays a plain string, completely unaffected - only entries that resolve to a built-in pattern id become structured objects; this refactor doesn't touch how literal colors flow through the scale." + }, + { + "case": "The compiled Vega spec is serialized (e.g. UNSAFE_vegaSpec debugging, a snapshot test, or a headless vg2png-style round trip through JSON).", + "expectedBehavior": "The structured value must survive a real JSON round-trip identically to how a Gradient-shaped object already does, since RSC builds specs as plain JS objects (not parsed from JSON text) end-to-end - verify this isn't broken by the new shape, particularly for any test that does deep-equality against a fixture built before this change." + }, + { + "case": "A developer inspects the compiled spec's PATTERN_SCALE range or 'patterns' signal value directly (browser devtools, a debug log) while diagnosing a pattern-fill issue.", + "expectedBehavior": "They now see a structured object instead of a `url(...)`-style string - a legibility/debugging-experience change worth being aware of, not a behavioral edge case, but relevant if any tooling or documentation referenced the old string format." + } + ], + "crossCutting": { + "touchesHoverAnimation": false, + "touchesControlledHighlight": false, + "touchesLegendInteraction": true, + "touchesTooltipOrPopover": false, + "requiresNewSignalOrScale": false, + "requiresS1S2Parity": true, + "notes": "touchesLegendInteraction: the legend swatch fill resolver (legendUtils.ts's getSymbolEncodings) must be re-verified against the new structured value, since the original pattern-scale feature already proved this exact resolver has non-obvious, easy-to-miss failure modes for pattern facets (it required three separate hardcoded-list fixes the first time). requiresNewSignalOrScale is false - this change alters the *value shape* carried by the existing PATTERN_SCALE scale and 'patterns'/'secondaryPattern' signal, it does not add a new scale or signal. requiresS1S2Parity is true only in the same sense the original pattern-scale spec flagged it: s1 has no pattern-fill mechanism at all yet, so there is nothing to port today, but if/when s1 gains pattern-fill support, it should adopt this structured value shape from the start rather than reintroducing the string convention this spec removes." + }, + "implementationPlan": [ + { + "file": "packages/utils/src/patternFillId.ts", + "change": "Replace the string-returning getPatternFillUrl/getColorMatchedPatternFillUrl and the COMPOSITE_PATTERN_SEPARATOR convention with a structured PatternFillValue type (discriminant field + id + optional color) and a type guard (e.g. isPatternFillValue), mirroring Vega's own isGradient(value) pattern. resolvePatternFillGroup and resolvePatternFillValue return/recognize this type instead of a composite string." + }, + { + "file": "packages/react-spectrum-charts-s2/src/utils/patternFillUtils.ts", + "change": "Update re-exports for the new type/guard names from @spectrum-charts/utils; PatternTileSource and the registry (registerPatternFill/getPatternFillSource/clearPatternFillRegistry/specHasPatternFill) are unaffected." + }, + { + "file": "packages/react-spectrum-charts-s2/src/utils/canvasPatternFillUtils.ts", + "change": "getPatternFillId's lookup and getColorMatchedPatternSource read `.id`/`.color` directly off the structured value via the new type guard instead of indexOf/slice string parsing; resolveFillStyleValue's cache key becomes a value derived internally from the object (e.g. a small serialization used only as a Map key, never as the wire representation) rather than the id string itself carrying the color." + }, + { + "file": "packages/vega-spec-builder-s2/src/chartSpecBuilder.ts", + "change": "getPatternScale/getTwoDimensionalPatterns/resolveS2PatternGroup updated for resolvePatternFillGroup's new return type; whatever type currently backs PATTERN_SCALE's OrdinalScale range / the 'patterns' signal's value is widened to accept the structured value alongside string, without an any cast." + }, + { + "file": "packages/vega-spec-builder-s2/src/marks/markUtils.ts", + "change": "getPatternProductionRule's shape is unchanged (still emits a scale/signal reference, not a literal value) - only its type annotations may need widening if they assumed a string-only range." + }, + { + "file": "packages/utils/src/patternFillId.test.ts", + "change": "Update assertions for the new structured value shape in place of the old composite string." + }, + { + "file": "packages/react-spectrum-charts-s2/src/utils/canvasPatternFillUtils.test.ts", + "change": "Update fixtures/assertions for the new value shape; remove any test specifically covering the deleted composite-string-splitting path, replacing it with coverage of the structured-value color-match path." + }, + { + "file": "packages/vega-spec-builder-s2/src/chartSpecBuilder.test.ts", + "change": "Update PATTERN_SCALE range / 'patterns' signal assertions for the new value shape." + } + ], + "openQuestions": [ + "Exact discriminant field name/shape for the structured value (e.g. matching or deliberately diverging from Gradient's own `gradient` key) - not yet decided; pick something that can't collide with a real Gradient object or a plain color string.", + "Whether to name the new value's color field `color` (current internal naming) or `foreground` - the latter would reduce churn if/when this codebase eventually migrates to vega/vega#4290's native Pattern type (which uses `foreground`/`background`), at the cost of diverging from this codebase's current `patterns`/color-matching naming. Worth deciding deliberately rather than defaulting.", + "Whether the CanvasPatternFillPrototype.story.tsx prototype (from planning/specs/chart/pattern-fill-rendering.json, predates the real pattern-scale prop) depends on the old string convention directly via UNSAFE_vegaSpec - check before deleting the string-returning functions entirely, since that story is explicitly kept in place per prior direction and must keep working or be updated in the same PR." + ], + "relatedIssues": ["pattern-scale"] +} diff --git a/planning/specs/chart/pattern-fill-rendering.json b/planning/specs/chart/pattern-fill-rendering.json new file mode 100644 index 000000000..1bc881367 --- /dev/null +++ b/planning/specs/chart/pattern-fill-rendering.json @@ -0,0 +1,90 @@ +{ + "id": "pattern-fill-rendering", + "title": "Pattern-Fill Rendering for Canvas and SVG Renderers", + "chartType": "chart", + "kind": "feature", + "variant": "s2", + "status": "approved", + "lastUpdated": "2026-08-15", + "complexity": { + "score": 5, + "rationale": "Neither renderer has any existing pattern-fill support to build on - a prior prototype covering both was discarded and no longer exists in source, so this is from-scratch work in two genuinely different rendering pipelines (canvas 2D context interception vs. SVG DOM/defs manipulation), each requiring its own non-precedented interception technique. Real unknowns remain (the shared tile/image value shape, device-pixel-ratio handling per renderer, cache lifetime per renderer) that could change the shape of either implementation, and the hard constraint of not modifying vega package internals rules out the simplest fix (a native Gradient-like value type) for both phases covered by this spec." + }, + "summary": "Neither Vega's canvas renderer nor its SVG renderer currently supports rendering a pattern-reference fill (e.g. a candy-stripe/hatch tile used in place of a solid color) for any mark in this codebase - the only prior implementation was a discarded prototype that never landed in source. This spec covers building genuine pattern-fill rendering support for both renderers entirely at the RSC layer, without modifying the vega/vega-scenegraph/vega-embed package internals, so the feature ships on the stock upstream Vega dependency. A shared, renderer-agnostic tile/image value shape is defined once and consumed identically by a canvas interception path and an SVG interception path, so canvas and SVG output are visually equivalent for the same encoding. A later, separate initiative (not covered by this spec) may prototype and propose a native Vega-side implementation of the same mechanism once this RSC-layer version is built and validated.", + "requirements": [ + "A fill value that resolves to a pattern-reference (a shared, renderer-agnostic tile/image source - not a plain color or gradient) must render as a real repeating pattern under the canvas renderer, visually equivalent to the same encoding rendered under the SVG renderer.", + "The mechanism must not require modifying the vega, vega-scenegraph, vega-embed, or any other Vega package's internals - RSC depends only on the stock, unmodified upstream Vega packages and achieves pattern rendering purely through interception/wrapping at the RSC layer, for both renderers.", + "Canvas: injection works by intercepting the point where a pattern-reference value is assigned as the canvas context's active fill, so no Vega dataflow or rendering code path needs to change.", + "SVG: injection works by detecting pattern-reference fill values on the rendered SVG output and supplying the corresponding definition into the chart's own , so no change to Vega's SVG renderer is required.", + "Fill values that are not pattern references (plain colors, gradients) must continue to pass through both interception points completely unmodified.", + "A given pattern identity (e.g. a specific series color) must resolve to a single, cached pattern object per renderer (a CanvasPattern for canvas; a single shared def for SVG) reused across draw calls/elements within the same chart instance, rather than rebuilding the underlying tile on every mark drawn.", + "Each interception must be applied at most once per chart instance (per canvas element/context for canvas; per rendered SVG root for SVG) and safe to invoke repeatedly across re-renders/resizes without double-wrapping or leaking state from a previous instance.", + "Any rotation/orientation applied to the pattern must be expressed as a transform on the pattern object itself (CanvasPattern.setTransform for canvas; the patternTransform attribute for SVG) rather than baked into the source tile/image, so tile generation stays a single reusable source of truth shared by both renderers, independent of orientation.", + "The tile/pattern-source representation (the generic tile-function/image value shape) is defined once, internal to RSC, and consumed identically by both the canvas and SVG interception paths, so canvas and SVG can never drift out of visual parity and so the representation could later be handed to a native Vega implementation largely as-is (see openQuestions).", + "Each interception should only engage for charts whose compiled spec actually contains a pattern-reference fill, so charts that don't use the feature incur no overhead in either renderer." + ], + "edgeCases": [ + { + "case": "Multiple series/marks on the same chart resolve to different pattern-reference identities.", + "expectedBehavior": "Each distinct pattern identity gets its own cached pattern object per renderer; the cache key must be based on the resolved identity (e.g. color), not a single global cache entry, or later series would incorrectly reuse an earlier series' pattern." + }, + { + "case": "A dense chart (many marks) shares only a handful of distinct pattern identities.", + "expectedBehavior": "Pattern lookup/creation must not become a per-draw-call or per-element bottleneck in either renderer - the common case of a cache hit should be cheap relative to the cost of drawing/painting the mark itself." + }, + { + "case": "The chart's view is destroyed and recreated (resize triggering a new canvas element or SVG root, or a full chart remount).", + "expectedBehavior": "A new canvas element/context or SVG root gets independent interception and its own fresh cache; state tied to a previous, now-detached canvas or SVG root must not be reused or leak into the new one." + }, + { + "case": "The chart renders at a non-1 device pixel ratio (high-DPI display).", + "expectedBehavior": "Canvas pattern tiles must repeat and align at the correct on-screen scale, tracking whatever DPR scaling the canvas renderer already applies to the drawing context. SVG patterns are resolution-independent by nature but must still align to the same visual tile size/spacing as the canvas version at any DPR." + }, + { + "case": "colorScheme (light/dark) or theme changes, altering the resolved color a pattern-reference encodes.", + "expectedBehavior": "A changed resolved color must produce a new pattern (and cache entry) for that identity in both renderers, rather than reusing a stale, previously cached pattern built from the old color." + }, + { + "case": "A mark's fill is driven by a per-row data field (e.g. a color override column) rather than a single static value for the whole mark.", + "expectedBehavior": "Each interception must resolve and cache per the actual per-row value at assignment/render time, not assume one fill value applies to the whole mark/context for the life of the chart, in either renderer." + }, + { + "case": "A pattern-filled mark participates in hover animation, legend highlight, or externally controlled highlight, all of which drive opacity changes.", + "expectedBehavior": "Opacity/highlight encoding continues to composite over the resolved pattern via the existing globalAlpha (canvas) / fill-opacity (SVG) mechanism, unaffected by either interception. This is expected to work automatically since opacity and fill are set independently in both renderers, but must be explicitly verified during the phase 3 and phase 5 review/testing passes rather than assumed." + }, + { + "case": "Renderer switch (canvas vs SVG) for the same chart/story, e.g. via a renderer prop or Storybook control.", + "expectedBehavior": "The same pattern-reference encoding must produce visually equivalent output regardless of which renderer is active, since both interception paths consume the same shared tile/image value shape." + }, + { + "case": "S1 (vega-spec-builder / react-spectrum-charts) canvas and SVG rendering.", + "expectedBehavior": "Out of scope for this spec (s2-only), but the interception strategy for both renderers should be portable if s1 ever needs the same capability - see crossCutting.requiresS1S2Parity." + } + ], + "crossCutting": { + "touchesHoverAnimation": false, + "touchesControlledHighlight": false, + "touchesLegendInteraction": false, + "touchesTooltipOrPopover": false, + "requiresNewSignalOrScale": false, + "requiresS1S2Parity": true, + "notes": "requiresS1S2Parity: s1's react-spectrum-charts / vega-spec-builder also supports canvas and SVG renderers for bar and other marks. No s1 consumer currently needs pattern-reference fills, so no immediate port is required, but if one is added later it will need the same dual-renderer interception strategy implemented against s1's own rendering entry points. The four interaction-related flags are expected to stay false because opacity/highlight and fill are set independently in both renderers (canvas globalAlpha, SVG fill-opacity), but this assumption is called out explicitly in edgeCases and must be verified during phase 3/5 testing rather than trusted blindly - if verification finds an actual interaction, these flags and this spec must be updated before status moves to implemented." + }, + "implementationPlan": [ + { + "file": "packages/react-spectrum-charts-s2/src/VegaChart.tsx", + "change": "Strategy only: after a view mounts (and on any subsequent view recreation), if the compiled spec contains a pattern-reference fill anywhere, locate the chart's own canvas element (canvas renderer) or SVG root (SVG renderer) and engage the corresponding interception strategy against it. No specific line-level implementation is prescribed here - this entry only records where the responsibility of 'detect + wire up per renderer' belongs. Phase 2 (this spec's initial implementation pass) covers the canvas path; phase 4 covers the SVG path, added to this same file's detection/wiring logic." + }, + { + "file": "packages/react-spectrum-charts-s2/src/utils/ (exact filenames TBD at implementation time)", + "change": "Strategy only: owns the shared tile/pattern-source value shape (the generic tile-function/image representation), plus two renderer-specific interception modules that both consume it - one recognizing a pattern-reference fill value at canvas fillStyle-assignment time (resolving/caching the corresponding CanvasPattern, tile generation + orientation transform) and one detecting pattern-reference fill values in rendered SVG output and injecting/caching the corresponding def (with patternTransform for orientation) into the chart's . No specific API shape or code is prescribed here; the prior prototype exploring this space was intentionally discarded so this gets a from-scratch implementation against the strategy above, built in two phases (canvas first, SVG second) per the plan agreed with the requester." + } + ], + "openQuestions": [ + "What exact shape should the shared tile/image pattern-source API take (a function returning a drawable, a pre-rendered image, or declarative tile-drawing instructions)? This needs to be settled during the canvas implementation phase, since both the SVG phase and any later native-Vega proposal build on whatever is chosen here.", + "How should device-pixel-ratio scaling be handled - for canvas tile sizing/alignment, and for keeping the SVG pattern's visual tile size in sync with the canvas version at any DPR - so neither renderer's output is blurry, misaligned, or out of parity with the other after a resize?", + "What is the right cache lifetime/scope per renderer - tied to the canvas element / SVG root, the view instance, or something longer-lived - given views can be destroyed and recreated on resize?", + "Does opacity/highlight compositing (hover animation, legend interaction, controlled highlight) actually continue to work transparently over pattern fills in both renderers once implemented, or does phase 3/5 testing surface a real interaction that needs explicit handling and a crossCutting flag update?", + "Once this RSC-layer implementation is built and validated (phases 1-5), what should the phase 6 native-Vega prototype/proposal actually change in vega-scenegraph (a Pattern.js sibling to Gradient.js, consumed by both SVGRenderer/SVGStringRenderer and the canvas color.js/gradient.js path) - this is intentionally left undesigned until the RSC-layer version proves out the tile/image value shape." + ] +}