@@ -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