Skip to content

Commit b12729f

Browse files
committed
feat: add chart utilities to create seeded patterns
1 parent 230b7c7 commit b12729f

File tree

2 files changed

+502
-0
lines changed

2 files changed

+502
-0
lines changed

app/utils/charts.ts

Lines changed: 338 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -720,3 +720,341 @@ export function applyEllipsis(text: string, maxLength = 45) {
720720
}
721721
return text.slice(0, maxLength) + '...'
722722
}
723+
724+
// a11y pattern generation
725+
export type SvgPatternType =
726+
| 'diagonalLines'
727+
| 'verticalLines'
728+
| 'horizontalLines'
729+
| 'crosshatch'
730+
| 'dots'
731+
| 'grid'
732+
| 'zigzag'
733+
734+
export type SeededSvgPatternOptions = {
735+
foregroundColor?: string
736+
backgroundColor?: string
737+
minimumSize?: number
738+
maximumSize?: number
739+
}
740+
741+
export type SeededSvgPatternResult = {
742+
width: number
743+
height: number
744+
rotation: number
745+
patternType: SvgPatternType
746+
contentMarkup: string
747+
}
748+
749+
type NonEmptyReadonlyArray<T> = readonly [T, ...T[]]
750+
751+
/**
752+
* Generates a deterministic 32-bit unsigned integer hash from a string.
753+
*
754+
* This function is based on the FNV-1a hashing algorithm. It is used to
755+
* transform any string input into a stable numeric seed suitable for
756+
* deterministic pseudo-random number generation.
757+
*
758+
* The same input string will always produce the same output number.
759+
*
760+
* @param value - The input string to hash.
761+
* @returns A 32-bit unsigned integer hash.
762+
*/
763+
export function createSeedNumber(value: string): number {
764+
let hashValue = 2166136261
765+
for (let index = 0; index < value.length; index += 1) {
766+
hashValue ^= value.charCodeAt(index)
767+
hashValue = Math.imul(hashValue, 16777619)
768+
}
769+
return hashValue >>> 0
770+
}
771+
772+
/**
773+
* Creates a deterministic pseudo-random number generator (PRNG) based on a numeric seed.
774+
*
775+
* This function implements a fast, non-cryptographic PRNG similar to Mulberry32.
776+
* It produces a reproducible sequence of numbers in the range [0, 1), meaning
777+
* the same seed will always generate the same sequence.
778+
*
779+
* The returned function maintains internal state and should be called repeatedly
780+
* to obtain successive pseudo-random values.
781+
*
782+
* @param seedNumber - 32 bit integer seed
783+
* @returns A function that returns a pseudo rand number between 0 (inclusive) and 1 (exclusive).
784+
*
785+
* @example
786+
* const random = createDeterministicRandomGenerator(12345)
787+
* const a = random() // always the same for seed 12345
788+
* const b = random()
789+
*/
790+
function createDeterministicRandomGenerator(seedNumber: number): () => number {
791+
// Ensure the seed is treated as an unsigned 32 bit int
792+
let state = seedNumber >>> 0
793+
794+
return function generateRandomNumber(): number {
795+
// Advance internal state using a constant
796+
state += 0x6d2b79f5
797+
let intermediateValue = state
798+
799+
// First mixing step:
800+
// - XOR with a right shifted version of itself
801+
// - Multiply with a derived value to further scramble bits
802+
intermediateValue = Math.imul(
803+
intermediateValue ^ (intermediateValue >>> 15),
804+
intermediateValue | 1,
805+
)
806+
807+
// Second mixing step:
808+
// - Combine current value with another transformed version of itself
809+
// - Multiply again to increase entropy and spread bits
810+
intermediateValue ^= intermediateValue + Math.imul(
811+
intermediateValue ^ (intermediateValue >>> 7),
812+
intermediateValue | 61,
813+
)
814+
815+
// Final step:
816+
// - Final XOR with shifted value for additional scrambling
817+
// - Convert to unsigned 32 bit int
818+
// - Normalize to a float in range 0 to 1
819+
return ((intermediateValue ^ (intermediateValue >>> 14)) >>> 0) / 4294967296
820+
}
821+
}
822+
823+
function pickValue<T>(
824+
values: NonEmptyReadonlyArray<T>,
825+
generateRandomNumber: () => number,
826+
): T {
827+
const selectedIndex = Math.floor(generateRandomNumber() * values.length)
828+
const selectedValue = values[selectedIndex]
829+
if (selectedValue === undefined) {
830+
throw new Error('pickValue requires a non-empty array')
831+
}
832+
return selectedValue
833+
}
834+
835+
function createLineElement(
836+
x1: number,
837+
y1: number,
838+
x2: number,
839+
y2: number,
840+
stroke: string,
841+
strokeWidth: number,
842+
opacity: number,
843+
): string {
844+
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" stroke="${stroke}" stroke-width="${strokeWidth}" opacity="${opacity}" shape-rendering="crispEdges" stroke-linecap="round" stroke-linejoin="round" />`
845+
}
846+
847+
function createCircleElement(
848+
centerX: number,
849+
centerY: number,
850+
radius: number,
851+
fill: string,
852+
opacity: number,
853+
): string {
854+
return `<circle cx="${centerX}" cy="${centerY}" r="${radius}" fill="${fill}" opacity="${opacity}" />`
855+
}
856+
857+
function createPathElement(
858+
pathData: string,
859+
fill: string,
860+
stroke: string,
861+
strokeWidth: number,
862+
opacity: number,
863+
): string {
864+
return `<path d="${pathData}" fill="${fill}" stroke="${stroke}" stroke-width="${strokeWidth}" opacity="${opacity}" stroke-linecap="round" stroke-linejoin="round" />`
865+
}
866+
867+
function toNonEmptyReadonlyArray<T>(values: readonly T[]): NonEmptyReadonlyArray<T> {
868+
if (values.length === 0) {
869+
throw new Error('Expected a non-empty array')
870+
}
871+
872+
return values as NonEmptyReadonlyArray<T>
873+
}
874+
875+
export function createSeededSvgPattern(
876+
seed: string | number,
877+
options?: SeededSvgPatternOptions,
878+
): SeededSvgPatternResult {
879+
const normalizedSeed = String(seed)
880+
const foregroundColor = options?.foregroundColor ?? '#111111'
881+
const backgroundColor = options?.backgroundColor ?? 'transparent'
882+
const minimumSize = options?.minimumSize ?? 8
883+
const maximumSize = options?.maximumSize ?? 20
884+
885+
const seedNumber = createSeedNumber(normalizedSeed)
886+
const generateRandomNumber = createDeterministicRandomGenerator(seedNumber)
887+
888+
const patternType = pickValue(
889+
[
890+
'diagonalLines',
891+
'verticalLines',
892+
'horizontalLines',
893+
'crosshatch',
894+
'dots',
895+
'grid',
896+
'zigzag',
897+
] as const,
898+
generateRandomNumber,
899+
)
900+
901+
const availableSizes: number[] = []
902+
for (let size = minimumSize; size <= maximumSize; size += 2) {
903+
availableSizes.push(size)
904+
}
905+
906+
const tileSize = pickValue(toNonEmptyReadonlyArray(availableSizes), generateRandomNumber)
907+
const gap = pickValue([2, 3, 4, 5, 6] as const, generateRandomNumber)
908+
const strokeWidth = pickValue([1, 1.25, 1.5, 1.75, 2] as const, generateRandomNumber)
909+
const opacity = pickValue([0.7, 0.8, 0.9, 1] as const, generateRandomNumber)
910+
const rotation = pickValue([0, 15, 30, 45, 60, 75, 90, 120, 135] as const, generateRandomNumber)
911+
912+
let contentMarkup = ''
913+
914+
switch (patternType) {
915+
case 'diagonalLines': {
916+
contentMarkup = [
917+
createLineElement(
918+
-tileSize,
919+
tileSize,
920+
tileSize,
921+
-tileSize,
922+
foregroundColor,
923+
strokeWidth,
924+
opacity,
925+
),
926+
createLineElement(0, tileSize, tileSize, 0, foregroundColor, strokeWidth, opacity),
927+
createLineElement(
928+
0,
929+
tileSize * 2,
930+
tileSize * 2,
931+
0,
932+
foregroundColor,
933+
strokeWidth,
934+
opacity,
935+
),
936+
].join('')
937+
break
938+
}
939+
940+
case 'verticalLines': {
941+
const positions = [0, gap + strokeWidth, (gap + strokeWidth) * 2]
942+
contentMarkup = positions
943+
.map(x => createLineElement(x, 0, x, tileSize, foregroundColor, strokeWidth, opacity))
944+
.join('')
945+
break
946+
}
947+
948+
case 'horizontalLines': {
949+
const positions = [0, gap + strokeWidth, (gap + strokeWidth) * 2]
950+
contentMarkup = positions
951+
.map(y => createLineElement(0, y, tileSize, y, foregroundColor, strokeWidth, opacity))
952+
.join('')
953+
break
954+
}
955+
956+
case 'crosshatch': {
957+
contentMarkup = [
958+
createLineElement(
959+
0,
960+
tileSize / 2,
961+
tileSize,
962+
tileSize / 2,
963+
foregroundColor,
964+
strokeWidth,
965+
opacity,
966+
),
967+
createLineElement(
968+
tileSize / 2,
969+
0,
970+
tileSize / 2,
971+
tileSize,
972+
foregroundColor,
973+
strokeWidth,
974+
opacity,
975+
),
976+
createLineElement(
977+
0,
978+
0,
979+
tileSize,
980+
tileSize,
981+
foregroundColor,
982+
strokeWidth * 0.75,
983+
opacity,
984+
),
985+
createLineElement(
986+
tileSize,
987+
0,
988+
0,
989+
tileSize,
990+
foregroundColor,
991+
strokeWidth * 0.75,
992+
opacity,
993+
),
994+
].join('')
995+
break
996+
}
997+
998+
case 'dots': {
999+
const radius = Math.max(1, tileSize / 12)
1000+
contentMarkup = [
1001+
createCircleElement(tileSize / 4, tileSize / 4, radius, foregroundColor, opacity),
1002+
createCircleElement((tileSize * 3) / 4, tileSize / 4, radius, foregroundColor, opacity),
1003+
createCircleElement(tileSize / 4, (tileSize * 3) / 4, radius, foregroundColor, opacity),
1004+
createCircleElement(
1005+
(tileSize * 3) / 4,
1006+
(tileSize * 3) / 4,
1007+
radius,
1008+
foregroundColor,
1009+
opacity,
1010+
),
1011+
].join('')
1012+
break
1013+
}
1014+
1015+
case 'grid': {
1016+
contentMarkup = [
1017+
createLineElement(0, 0, tileSize, 0, foregroundColor, strokeWidth, opacity),
1018+
createLineElement(0, 0, 0, tileSize, foregroundColor, strokeWidth, opacity),
1019+
createLineElement(
1020+
0,
1021+
tileSize / 2,
1022+
tileSize,
1023+
tileSize / 2,
1024+
foregroundColor,
1025+
strokeWidth * 0.8,
1026+
opacity,
1027+
),
1028+
createLineElement(
1029+
tileSize / 2,
1030+
0,
1031+
tileSize / 2,
1032+
tileSize,
1033+
foregroundColor,
1034+
strokeWidth * 0.8,
1035+
opacity,
1036+
),
1037+
].join('')
1038+
break
1039+
}
1040+
1041+
case 'zigzag': {
1042+
const midPoint = tileSize / 2
1043+
const pathData = `M 0 ${midPoint} L ${tileSize / 4} 0 L ${tileSize / 2} ${midPoint} L ${(tileSize * 3) / 4} ${tileSize} L ${tileSize} ${midPoint}`
1044+
contentMarkup = createPathElement(pathData, 'none', foregroundColor, strokeWidth, opacity)
1045+
break
1046+
}
1047+
}
1048+
1049+
if (backgroundColor !== 'transparent') {
1050+
contentMarkup = `<rect x="0" y="0" width="${tileSize}" height="${tileSize}" fill="${backgroundColor}" />${contentMarkup}`
1051+
}
1052+
1053+
return {
1054+
width: tileSize,
1055+
height: tileSize,
1056+
rotation,
1057+
patternType,
1058+
contentMarkup,
1059+
}
1060+
}

0 commit comments

Comments
 (0)