diff --git a/app/components/Chart/PatternSlot.vue b/app/components/Chart/PatternSlot.vue new file mode 100644 index 0000000000..ed0ae0a1fe --- /dev/null +++ b/app/components/Chart/PatternSlot.vue @@ -0,0 +1,32 @@ + + + + + diff --git a/app/components/Compare/FacetBarChart.vue b/app/components/Compare/FacetBarChart.vue index 1e28d23db2..4b0322fd32 100644 --- a/app/components/Compare/FacetBarChart.vue +++ b/app/components/Compare/FacetBarChart.vue @@ -3,7 +3,9 @@ import { ref, computed } from 'vue' import { VueUiHorizontalBar } from 'vue-data-ui/vue-ui-horizontal-bar' import type { VueUiHorizontalBarConfig, VueUiHorizontalBarDatasetItem } from 'vue-data-ui' import { getFrameworkColor, isListedFramework } from '~/utils/frameworks' +import { createChartPatternSlotMarkup } from '~/utils/charts' import { drawSmallNpmxLogoAndTaglineWatermark } from '~/composables/useChartWatermark' + import { loadFile, insertLineBreaks, @@ -213,13 +215,40 @@ const config = computed(() => { backdropFilter: false, backgroundColor: 'transparent', customFormat: ({ datapoint }) => { - const name = datapoint?.name?.replace(/\n/g, '') + const name = datapoint?.name?.replace(/\n/g, '') ?? '' + const safeSeriesIndex = (datapoint?.absoluteIndex as number) ?? 0 + const patternId = `tooltip-pattern-${safeSeriesIndex}` + const usePattern = safeSeriesIndex !== 0 + + const patternMarkup = usePattern + ? createChartPatternSlotMarkup({ + id: patternId, + seed: safeSeriesIndex, + foregroundColor: colors.value.bg!, + fallbackColor: 'transparent', + maxSize: 24, + minSize: 16, + }) + : '' + + const markerMarkup = usePattern + ? ` + + + ` + : ` + + ` + return ` - - + + + ${patternMarkup} + + ${markerMarkup} @@ -230,7 +259,7 @@ const config = computed(() => { - ` + ` }, }, }, @@ -243,8 +272,19 @@ const config = computed(() => { + + + + - + CSV + PNG + SVG + = readonly [T, ...T[]] + +/** + * Generates a deterministic 32-bit unsigned integer hash from a string. + * + * This function is based on the FNV-1a hashing algorithm. It is used to + * transform any string input into a stable numeric seed suitable for + * deterministic pseudo-random number generation. + * + * The same input string will always produce the same output number. + * + * @param value - The input string to hash. + * @returns A 32-bit unsigned integer hash. + */ +export function createSeedNumber(value: string): number { + let hashValue = 2166136261 + for (let index = 0; index < value.length; index += 1) { + hashValue ^= value.charCodeAt(index) + hashValue = Math.imul(hashValue, 16777619) + } + return hashValue >>> 0 +} + +/** + * Creates a deterministic pseudo-random number generator (PRNG) based on a numeric seed. + * + * This function implements a fast, non-cryptographic PRNG similar to Mulberry32. + * It produces a reproducible sequence of numbers in the range [0, 1), meaning + * the same seed will always generate the same sequence. + * + * The returned function maintains internal state and should be called repeatedly + * to obtain successive pseudo-random values. + * + * @param seedNumber - 32 bit integer seed + * @returns A function that returns a pseudo rand number between 0 (inclusive) and 1 (exclusive). + * + * @example + * const random = createDeterministicRandomGenerator(12345) + * const a = random() // always the same for seed 12345 + * const b = random() + */ +function createDeterministicRandomGenerator(seedNumber: number): () => number { + // Ensure the seed is treated as an unsigned 32 bit int + let state = seedNumber >>> 0 + + return function generateRandomNumber(): number { + // Advance internal state using a constant + state += 0x6d2b79f5 + let intermediateValue = state + + // First mixing step: + // - XOR with a right shifted version of itself + // - Multiply with a derived value to further scramble bits + intermediateValue = Math.imul( + intermediateValue ^ (intermediateValue >>> 15), + intermediateValue | 1, + ) + + // Second mixing step: + // - Combine current value with another transformed version of itself + // - Multiply again to increase entropy and spread bits + intermediateValue ^= + intermediateValue + + Math.imul(intermediateValue ^ (intermediateValue >>> 7), intermediateValue | 61) + + // Final step: + // - Final XOR with shifted value for additional scrambling + // - Convert to unsigned 32 bit int + // - Normalize to a float in range 0 to 1 + return ((intermediateValue ^ (intermediateValue >>> 14)) >>> 0) / 4294967296 + } +} + +function pickValue(values: NonEmptyReadonlyArray, generateRandomNumber: () => number): T { + const selectedIndex = Math.floor(generateRandomNumber() * values.length) + const selectedValue = values[selectedIndex] + if (selectedValue === undefined) { + throw new Error('pickValue requires a non-empty array') + } + return selectedValue +} + +function escapeSvgAttribute(value: string): string { + return value + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(//g, '>') +} + +function createLineElement( + x1: number, + y1: number, + x2: number, + y2: number, + stroke: string, + strokeWidth: number, + opacity: number, +): string { + const safeStroke = escapeSvgAttribute(stroke) + return `` +} + +function createCircleElement( + centerX: number, + centerY: number, + radius: number, + fill: string, + opacity: number, +): string { + const safeFill = escapeSvgAttribute(fill) + return `` +} + +function createPathElement( + pathData: string, + fill: string, + stroke: string, + strokeWidth: number, + opacity: number, +): string { + const safeFill = escapeSvgAttribute(fill) + const safeStroke = escapeSvgAttribute(stroke) + return `` +} + +function toNonEmptyReadonlyArray(values: readonly T[]): NonEmptyReadonlyArray { + if (values.length === 0) { + throw new Error('Expected a non-empty array') + } + + return values as NonEmptyReadonlyArray +} + +export function createSeededSvgPattern( + seed: string | number, + options?: SeededSvgPatternOptions, +): SeededSvgPatternResult { + const normalizedSeed = String(seed) + const foregroundColor = options?.foregroundColor ?? '#111111' + const backgroundColor = options?.backgroundColor ?? 'transparent' + const minimumSize = options?.minimumSize ?? 8 + const maximumSize = options?.maximumSize ?? 20 + + if ( + !Number.isFinite(minimumSize) || + !Number.isFinite(maximumSize) || + minimumSize <= 0 || + maximumSize <= 0 || + minimumSize > maximumSize + ) { + throw new RangeError( + 'minimumSize and maximumSize must be finite, positive, and minimumSize must not exceed maximumSize', + ) + } + + const seedNumber = createSeedNumber(normalizedSeed) + const generateRandomNumber = createDeterministicRandomGenerator(seedNumber) + + const patternType = pickValue( + [ + 'diagonalLines', + 'verticalLines', + 'horizontalLines', + 'crosshatch', + 'dots', + 'grid', + 'zigzag', + ] as const, + generateRandomNumber, + ) + + const availableSizes: number[] = [] + for (let size = minimumSize; size <= maximumSize; size += 2) { + availableSizes.push(size) + } + + const tileSize = pickValue(toNonEmptyReadonlyArray(availableSizes), generateRandomNumber) + const gap = pickValue([2, 3, 4, 5, 6] as const, generateRandomNumber) + const strokeWidth = pickValue([1, 1.25, 1.5, 1.75, 2] as const, generateRandomNumber) + const opacity = pickValue([0.7, 0.8, 0.9, 1] as const, generateRandomNumber) + const rotation = pickValue([0, 15, 30, 45, 60, 75, 90, 120, 135] as const, generateRandomNumber) + + let contentMarkup = '' + + switch (patternType) { + case 'diagonalLines': { + contentMarkup = [ + createLineElement( + -tileSize, + tileSize, + tileSize, + -tileSize, + foregroundColor, + strokeWidth, + opacity, + ), + createLineElement(0, tileSize, tileSize, 0, foregroundColor, strokeWidth, opacity), + createLineElement(0, tileSize * 2, tileSize * 2, 0, foregroundColor, strokeWidth, opacity), + ].join('') + break + } + + case 'verticalLines': { + const positions = [0, gap + strokeWidth, (gap + strokeWidth) * 2] + contentMarkup = positions + .map(x => createLineElement(x, 0, x, tileSize, foregroundColor, strokeWidth, opacity)) + .join('') + break + } + + case 'horizontalLines': { + const positions = [0, gap + strokeWidth, (gap + strokeWidth) * 2] + contentMarkup = positions + .map(y => createLineElement(0, y, tileSize, y, foregroundColor, strokeWidth, opacity)) + .join('') + break + } + + case 'crosshatch': { + contentMarkup = [ + createLineElement( + 0, + tileSize / 2, + tileSize, + tileSize / 2, + foregroundColor, + strokeWidth, + opacity, + ), + createLineElement( + tileSize / 2, + 0, + tileSize / 2, + tileSize, + foregroundColor, + strokeWidth, + opacity, + ), + createLineElement(0, 0, tileSize, tileSize, foregroundColor, strokeWidth * 0.75, opacity), + createLineElement(tileSize, 0, 0, tileSize, foregroundColor, strokeWidth * 0.75, opacity), + ].join('') + break + } + + case 'dots': { + const radius = Math.max(1, tileSize / 12) + contentMarkup = [ + createCircleElement(tileSize / 4, tileSize / 4, radius, foregroundColor, opacity), + createCircleElement((tileSize * 3) / 4, tileSize / 4, radius, foregroundColor, opacity), + createCircleElement(tileSize / 4, (tileSize * 3) / 4, radius, foregroundColor, opacity), + createCircleElement( + (tileSize * 3) / 4, + (tileSize * 3) / 4, + radius, + foregroundColor, + opacity, + ), + ].join('') + break + } + + case 'grid': { + contentMarkup = [ + createLineElement(0, 0, tileSize, 0, foregroundColor, strokeWidth, opacity), + createLineElement(0, 0, 0, tileSize, foregroundColor, strokeWidth, opacity), + createLineElement( + 0, + tileSize / 2, + tileSize, + tileSize / 2, + foregroundColor, + strokeWidth * 0.8, + opacity, + ), + createLineElement( + tileSize / 2, + 0, + tileSize / 2, + tileSize, + foregroundColor, + strokeWidth * 0.8, + opacity, + ), + ].join('') + break + } + + case 'zigzag': { + const midPoint = tileSize / 2 + const pathData = `M 0 ${midPoint} L ${tileSize / 4} 0 L ${tileSize / 2} ${midPoint} L ${(tileSize * 3) / 4} ${tileSize} L ${tileSize} ${midPoint}` + contentMarkup = createPathElement(pathData, 'none', foregroundColor, strokeWidth, opacity) + break + } + } + + if (backgroundColor !== 'transparent') { + const safeBackgroundColor = escapeSvgAttribute(backgroundColor) + contentMarkup = `${contentMarkup}` + } + + return { + width: tileSize, + height: tileSize, + rotation, + patternType, + contentMarkup, + } +} + +export type ChartPatternSlotProps = { + id: string + seed: string | number + color?: string + foregroundColor: string + fallbackColor: string + maxSize: number + minSize: number +} + +// Equivalent of the PatternSlot.vue component, to be used inside tooltip.customFormat in chart configs +export function createChartPatternSlotMarkup({ + id, + seed, + color, + foregroundColor, + fallbackColor, + maxSize, + minSize, +}: ChartPatternSlotProps) { + const pattern = createSeededSvgPattern(seed, { + foregroundColor, + backgroundColor: color ?? fallbackColor, + minimumSize: minSize, + maximumSize: maxSize, + }) + + return ` + + ${pattern.contentMarkup} + + ` +} diff --git a/test/nuxt/a11y.spec.ts b/test/nuxt/a11y.spec.ts index 72e3c3bedd..1cd5155c79 100644 --- a/test/nuxt/a11y.spec.ts +++ b/test/nuxt/a11y.spec.ts @@ -136,6 +136,7 @@ import { ButtonBase, LinkBase, CallToAction, + ChartPatternSlot, CodeDirectoryListing, CodeFileTree, CodeMobileTreeDrawer, @@ -2149,6 +2150,23 @@ describe('component accessibility audits', () => { }) }) + describe('ChartPatternSlot', () => { + it('should have no accessibility violations', async () => { + const component = await mountSuspended(ChartPatternSlot, { + props: { + id: 'perennius', + seed: 1, + foregroundColor: 'black', + fallbackColor: 'transparent', + maxSize: 24, + minSize: 16, + }, + }) + const results = await runAxe(component) + expect(results.violations).toEqual([]) + }) + }) + describe('CopyToClipboardButton', () => { it('should have no accessibility violations in default state', async () => { const component = await mountSuspended(CopyToClipboardButton, { diff --git a/test/unit/app/utils/charts.spec.ts b/test/unit/app/utils/charts.spec.ts index 72c3d6c998..6a82d7e82a 100644 --- a/test/unit/app/utils/charts.spec.ts +++ b/test/unit/app/utils/charts.spec.ts @@ -15,6 +15,9 @@ import { sanitise, insertLineBreaks, applyEllipsis, + createSeedNumber, + createSeededSvgPattern, + createChartPatternSlotMarkup, type TrendLineConfig, type TrendLineDataset, type VersionsBarConfig, @@ -1399,3 +1402,252 @@ describe('applyEllipsis', () => { expect(applyEllipsis('you need to touch grass', 13)).toBe('you need to t...') }) }) + +describe('createSeedNumber', () => { + it('returns the same hash for the same input', () => { + expect(createSeedNumber('react')).toBe(createSeedNumber('react')) + expect(createSeedNumber('vue')).toBe(createSeedNumber('vue')) + }) + + it('returns different hashes for different inputs', () => { + expect(createSeedNumber('react')).not.toBe(createSeedNumber('vue')) + expect(createSeedNumber('svelte')).not.toBe(createSeedNumber('solid')) + }) + + it('returns a 32 bit unsigned integer', () => { + const result = createSeedNumber('react') + expect(Number.isInteger(result)).toBe(true) + expect(result).toBeGreaterThanOrEqual(0) + expect(result).toBeLessThanOrEqual(4294967295) + }) + + it('handles an empty string', () => { + const result = createSeedNumber('') + expect(Number.isInteger(result)).toBe(true) + expect(result).toBeGreaterThanOrEqual(0) + expect(result).toBeLessThanOrEqual(4294967295) + }) + + it('is case sensitive', () => { + expect(createSeedNumber('react')).not.toBe(createSeedNumber('React')) + }) +}) + +describe('createSeededSvgPattern', () => { + it('returns deterministic output for the same seed', () => { + const first = createSeededSvgPattern('react') + const second = createSeededSvgPattern('react') + expect(first).toEqual(second) + }) + + it('returns different output for different seeds', () => { + const first = createSeededSvgPattern('react') + const second = createSeededSvgPattern('vue') + expect(second).not.toEqual(first) + }) + + it('returns a valid pattern object shape', () => { + const result = createSeededSvgPattern('react') + expect(typeof result.width).toBe('number') + expect(typeof result.height).toBe('number') + expect(typeof result.rotation).toBe('number') + expect(typeof result.patternType).toBe('string') + expect(typeof result.contentMarkup).toBe('string') + }) + + it('uses default options when none are provided', () => { + const result = createSeededSvgPattern('react') + expect(result.width).toBeGreaterThanOrEqual(8) + expect(result.width).toBeLessThanOrEqual(20) + expect(result.height).toBe(result.width) + expect(result.contentMarkup.length).toBeGreaterThan(0) + }) + + it('uses the provided foreground and background colors', () => { + const result = createSeededSvgPattern('react', { + foregroundColor: '#ff0000', + backgroundColor: '#00ff00', + }) + expect(result.contentMarkup).toContain('#ff0000') + expect(result.contentMarkup).toContain('#00ff00') + expect(result.contentMarkup).toContain(' { + const result = createSeededSvgPattern('react', { + backgroundColor: 'transparent', + }) + expect(result.contentMarkup).not.toContain(' { + const result = createSeededSvgPattern('react', { + minimumSize: 10, + maximumSize: 16, + }) + expect(result.width).toBeGreaterThanOrEqual(10) + expect(result.width).toBeLessThanOrEqual(16) + expect(result.height).toBe(result.width) + }) + + it('always returns one of the supported pattern types', () => { + const allowedPatternTypes = [ + 'diagonalLines', + 'verticalLines', + 'horizontalLines', + 'crosshatch', + 'dots', + 'grid', + 'zigzag', + ] + const result = createSeededSvgPattern('react') + expect(allowedPatternTypes).toContain(result.patternType) + }) + + it('returns a supported rotation value', () => { + const allowedRotations = [0, 15, 30, 45, 60, 75, 90, 120, 135] + const result = createSeededSvgPattern('react') + expect(allowedRotations).toContain(result.rotation) + }) + + it('returns svg markup matching the selected pattern type', () => { + const seeds = [ + 'react', + 'vue', + 'svelte', + 'solid', + 'angular', + 'ember', + 'preact', + 'lit', + 'alpine', + 'nuxt', + 'next', + 'astro', + 'qwik', + 'backbone', + ] + + const expectedTagByPatternType: Record< + ReturnType['patternType'], + string + > = { + diagonalLines: ' { + const result = createSeededSvgPattern(12345) + expect(typeof result.width).toBe('number') + expect(typeof result.contentMarkup).toBe('string') + expect(result.contentMarkup.length).toBeGreaterThan(0) + }) + + it('returns deterministic output for equivalent numeric and string seeds', () => { + const numericSeedResult = createSeededSvgPattern(12345) + const stringSeedResult = createSeededSvgPattern('12345') + expect(numericSeedResult).toEqual(stringSeedResult) + }) +}) + +describe('createChartPatternSlotMarkup', () => { + it('returns a pattern element with the provided id', () => { + const result = createChartPatternSlotMarkup({ + id: 'pattern-1', + seed: 7, + color: '#ff0000', + foregroundColor: '#ffffff', + fallbackColor: 'transparent', + maxSize: 24, + minSize: 16, + }) + + expect(result).toContain('') + }) + + it('includes width, height, rotation, and content markup from the generated pattern', () => { + const generatedPattern = createSeededSvgPattern(1, { + foregroundColor: '#000', + backgroundColor: 'transparent', + minimumSize: 16, + maximumSize: 24, + }) + + const result = createChartPatternSlotMarkup({ + id: 'pattern-1', + seed: 1, + foregroundColor: '#000', + fallbackColor: 'transparent', + maxSize: 24, + minSize: 16, + }) + + expect(result).toContain(`width="${generatedPattern.width}"`) + expect(result).toContain(`height="${generatedPattern.height}"`) + expect(result).toContain(`patternTransform="rotate(${generatedPattern.rotation})"`) + expect(result).toContain(generatedPattern.contentMarkup) + }) + + it('is deterministic for the same inputs', () => { + const first = createChartPatternSlotMarkup({ + id: 'pattern-stable', + seed: 'nuxt', + color: '#00ff00', + foregroundColor: '#000000', + fallbackColor: 'transparent', + maxSize: 40, + minSize: 10, + }) + + const second = createChartPatternSlotMarkup({ + id: 'pattern-stable', + seed: 'nuxt', + color: '#00ff00', + foregroundColor: '#000000', + fallbackColor: 'transparent', + maxSize: 40, + minSize: 10, + }) + + expect(first).toBe(second) + }) + + it('changes when the id changes', () => { + const first = createChartPatternSlotMarkup({ + id: 'pattern-a', + seed: 1, + color: '#00ff00', + foregroundColor: '#000000', + fallbackColor: 'transparent', + maxSize: 40, + minSize: 10, + }) + + const second = createChartPatternSlotMarkup({ + id: 'pattern-b', + seed: 2, + color: '#00ff00', + foregroundColor: '#000000', + fallbackColor: 'transparent', + maxSize: 40, + minSize: 10, + }) + + expect(first).not.toBe(second) + }) +})