@@ -889,3 +889,230 @@ export function generateNotFoundSVG(
889889 </g>
890890</svg>` ;
891891}
892+ export function generateVersusSVG (
893+ stats1 : StreakStats ,
894+ stats2 : StreakStats ,
895+ params : BadgeParams ,
896+ calendar1 : ContributionCalendar ,
897+ calendar2 : ContributionCalendar
898+ ) : string {
899+ if ( params . autoTheme )
900+ return generateAutoThemeVersusSVG ( stats1 , stats2 , params , calendar1 , calendar2 ) ;
901+
902+ const safeUser1 = escapeXML ( params . user || 'User 1' ) ;
903+ const safeUser2 = escapeXML ( params . versus || 'User 2' ) ;
904+ const bg = `#${ sanitizeHexColor ( params . bg , '0d1117' ) } ` ;
905+ const accent = `#${ sanitizeHexColor ( params . accent , '00ffaa' ) } ` ;
906+ const text = `#${ sanitizeHexColor ( params . text , 'ffffff' ) } ` ;
907+
908+ const sanitizedFont = sanitizeFont ( params . font ) ;
909+ const predefinedFont = getFontFromMap ( sanitizedFont ) ;
910+ const isPredefinedFont = Boolean ( predefinedFont ) ;
911+ const selectedFont = isPredefinedFont
912+ ? predefinedFont
913+ : sanitizedFont
914+ ? `"${ sanitizedFont } ", sans-serif`
915+ : null ;
916+ const statsFont = selectedFont || '"Space Grotesk", sans-serif' ;
917+ const googleFontUrlPart =
918+ sanitizedFont && ! isPredefinedFont ? sanitizeGoogleFontUrl ( sanitizedFont ) : null ;
919+ const googleFontsImport = googleFontUrlPart
920+ ? `@import url('https://fonts.googleapis.com/css2?family=${ googleFontUrlPart } &display=swap');`
921+ : '' ;
922+
923+ const sf = getSizeScale ( params . size ) ;
924+ const radius = sanitizeRadius ( params . radius , 8 ) * sf ;
925+ const labels = getLabels ( params . lang ) ;
926+
927+ const singleW = Math . round ( SVG_WIDTH * sf ) ;
928+ const W = singleW * 2 ;
929+ const H = Math . round ( SVG_HEIGHT * sf ) ;
930+
931+ const towerData1 = scaleTowerData (
932+ computeTowers ( calendar1 , params . scale , stats1 . todayDate , params . mode ) ,
933+ sf
934+ ) ;
935+ const towerData2 = scaleTowerData (
936+ computeTowers ( calendar2 , params . scale , stats2 . todayDate , params . mode ) ,
937+ sf
938+ ) ;
939+
940+ const towers1 = renderTowers ( towerData1 , accent , text , sf ) ;
941+ const towers2 = renderTowers ( towerData2 , accent , text , sf ) ;
942+
943+ const s = createScaler ( sf ) ;
944+ const unit = params . mode === 'loc' ? 'lines of code' : 'total contributions' ;
945+
946+ return `
947+ <svg xmlns="http://www.w3.org/2000/svg" width="${ W } " height="${ H } " viewBox="0 0 ${ W } ${ H } " fill="none" role="img">
948+ <title>CommitPulse Versus Stats: ${ safeUser1 } vs ${ safeUser2 } </title>
949+ <desc>${ safeUser1 } has ${ stats1 . totalContributions } ${ unit } . ${ safeUser2 } has ${ stats2 . totalContributions } ${ unit } .</desc>
950+ ${ renderDefs ( sf ) }
951+ ${ renderStyle ( selectedFont , statsFont , googleFontsImport , text , accent , sf ) }
952+ <rect width="${ W } " height="${ H } " rx="${ radius } " fill="${ params . hideBackground ? 'transparent' : bg } " />
953+
954+ <!-- User 1 (Left) -->
955+ <g transform="translate(0, 0)">
956+ <g transform="translate(0, ${ Math . round ( 20 * sf ) } )">${ towers1 } </g>
957+ ${ renderIsometricLabels ( calendar1 , params , text , sf ) }
958+ ${ renderFooter ( stats1 , params , labels , safeUser1 , accent , sf ) }
959+ </g>
960+
961+ <!-- User 2 (Right) -->
962+ <g transform="translate(${ singleW } , 0)">
963+ <g transform="translate(0, ${ Math . round ( 20 * sf ) } )">${ towers2 } </g>
964+ ${ renderIsometricLabels ( calendar2 , params , text , sf ) }
965+ ${ renderFooter ( stats2 , params , labels , safeUser2 , accent , sf ) }
966+ </g>
967+
968+ <!-- Divider Line -->
969+ <line x1="${ singleW } " y1="${ s ( 40 ) } " x2="${ singleW } " y2="${ H - s ( 40 ) } " stroke="${ text } " stroke-opacity="0.2" stroke-width="2" stroke-dasharray="4 4" />
970+
971+ <!-- VS Badge -->
972+ <g transform="translate(${ singleW } , ${ H / 2 } )">
973+ <circle cx="0" cy="0" r="${ s ( 24 ) } " fill="${ bg } " stroke="${ accent } " stroke-width="2" />
974+ <text x="0" y="${ s ( 6 ) } " text-anchor="middle" font-family="${ statsFont } " fill="${ accent } " font-size="${ s ( 16 ) } " font-weight="bold">VS</text>
975+ </g>
976+ </svg>` ;
977+ }
978+
979+ function generateAutoThemeVersusSVG (
980+ stats1 : StreakStats ,
981+ stats2 : StreakStats ,
982+ params : BadgeParams ,
983+ calendar1 : ContributionCalendar ,
984+ calendar2 : ContributionCalendar
985+ ) : string {
986+ const light = AUTO_THEME_LIGHT ;
987+ const dark = AUTO_THEME_DARK ;
988+ const safeUser1 = escapeXML ( params . user || 'User 1' ) ;
989+ const safeUser2 = escapeXML ( params . versus || 'User 2' ) ;
990+ const sanitizedFont = sanitizeFont ( params . font ) ;
991+ const selectedFont = sanitizedFont
992+ ? getFontFromMap ( sanitizedFont ) || `"${ sanitizedFont } ", sans-serif`
993+ : null ;
994+ const statsFont = selectedFont || '"Space Grotesk", sans-serif' ;
995+ const sf = getSizeScale ( params . size ) ;
996+ const radius = sanitizeRadius ( params . radius , 8 ) * sf ;
997+ const labels = getLabels ( params . lang ) ;
998+
999+ const singleW = Math . round ( SVG_WIDTH * sf ) ;
1000+ const W = singleW * 2 ;
1001+ const H = Math . round ( SVG_HEIGHT * sf ) ;
1002+
1003+ const towerData1 = scaleTowerData (
1004+ computeTowers ( calendar1 , params . scale , stats1 . todayDate , params . mode ) ,
1005+ sf
1006+ ) ;
1007+ const towerData2 = scaleTowerData (
1008+ computeTowers ( calendar2 , params . scale , stats2 . todayDate , params . mode ) ,
1009+ sf
1010+ ) ;
1011+
1012+ let towers1 = '' ;
1013+ for ( const t of towerData1 ) {
1014+ const fillClass = t . isGhost ? 'cp-text-fill' : 'cp-accent-fill' ;
1015+ const strokeColor = t . isGhost ? 'var(--cp-text)' : 'var(--cp-accent)' ;
1016+ const delay = ( ( t . row + t . col ) * 0.015 ) . toFixed ( 3 ) ;
1017+ towers1 += `
1018+ <g transform="translate(${ t . x } , ${ t . y } )">
1019+ <g class="cp-tower" style="animation-delay: ${ delay } s;">
1020+ ${ t . isTodayWithCommits ? '<animate attributeName="opacity" values="1;0.4;1" dur="1.5s" repeatCount="indefinite" />' : '' }
1021+ <title>${ escapeXML ( t . tooltip ) } </title>
1022+ <path d="M0 ${ 10 - t . h } L0 10 L-16 0 L-16 ${ - t . h } Z" class="${ fillClass } " fill-opacity="${ t . faceOpacity . left } " stroke="${ strokeColor } " stroke-opacity="${ t . strokeOpacity } " stroke-width="${ t . strokeWidth } " />
1023+ <path d="M0 ${ 10 - t . h } L0 10 L16 0 L16 ${ - t . h } Z" class="${ fillClass } " fill-opacity="${ t . faceOpacity . right } " stroke="${ strokeColor } " stroke-opacity="${ t . strokeOpacity } " stroke-width="${ t . strokeWidth } " />
1024+ <path d="M0 ${ - t . h } L16 ${ 10 - t . h } L0 ${ 20 - t . h } L-16 ${ 10 - t . h } Z" class="${ fillClass } " fill-opacity="${ t . faceOpacity . top } " stroke="${ strokeColor } " stroke-opacity="${ t . strokeOpacity } " stroke-width="${ t . strokeWidth } " />
1025+ ${ t . contributionCount > 5 ? `<path d="M0 ${ - t . h } L16 ${ 10 - t . h } L0 ${ 20 - t . h } L-16 ${ 10 - t . h } Z" fill="white" fill-opacity="0.2" />` : '' }
1026+ </g>
1027+ </g>` ;
1028+ if ( t . contributionCount >= 10 )
1029+ towers1 += generateParticles ( t . x , t . y , t . h , t . contributionCount , sf , true ) ;
1030+ }
1031+
1032+ let towers2 = '' ;
1033+ for ( const t of towerData2 ) {
1034+ const fillClass = t . isGhost ? 'cp-text-fill' : 'cp-accent-fill' ;
1035+ const strokeColor = t . isGhost ? 'var(--cp-text)' : 'var(--cp-accent)' ;
1036+ const delay = ( ( t . row + t . col ) * 0.015 ) . toFixed ( 3 ) ;
1037+ towers2 += `
1038+ <g transform="translate(${ t . x } , ${ t . y } )">
1039+ <g class="cp-tower" style="animation-delay: ${ delay } s;">
1040+ ${ t . isTodayWithCommits ? '<animate attributeName="opacity" values="1;0.4;1" dur="1.5s" repeatCount="indefinite" />' : '' }
1041+ <title>${ escapeXML ( t . tooltip ) } </title>
1042+ <path d="M0 ${ 10 - t . h } L0 10 L-16 0 L-16 ${ - t . h } Z" class="${ fillClass } " fill-opacity="${ t . faceOpacity . left } " stroke="${ strokeColor } " stroke-opacity="${ t . strokeOpacity } " stroke-width="${ t . strokeWidth } " />
1043+ <path d="M0 ${ 10 - t . h } L0 10 L16 0 L16 ${ - t . h } Z" class="${ fillClass } " fill-opacity="${ t . faceOpacity . right } " stroke="${ strokeColor } " stroke-opacity="${ t . strokeOpacity } " stroke-width="${ t . strokeWidth } " />
1044+ <path d="M0 ${ - t . h } L16 ${ 10 - t . h } L0 ${ 20 - t . h } L-16 ${ 10 - t . h } Z" class="${ fillClass } " fill-opacity="${ t . faceOpacity . top } " stroke="${ strokeColor } " stroke-opacity="${ t . strokeOpacity } " stroke-width="${ t . strokeWidth } " />
1045+ ${ t . contributionCount > 5 ? `<path d="M0 ${ - t . h } L16 ${ 10 - t . h } L0 ${ 20 - t . h } L-16 ${ 10 - t . h } Z" fill="white" fill-opacity="0.2" />` : '' }
1046+ </g>
1047+ </g>` ;
1048+ if ( t . contributionCount >= 10 )
1049+ towers2 += generateParticles ( t . x , t . y , t . h , t . contributionCount , sf , true ) ;
1050+ }
1051+
1052+ const s = createScaler ( sf ) ;
1053+ const fs = ( n : number ) : number => Math . round ( n * sf * 10 ) / 10 ;
1054+ const unit = params . mode === 'loc' ? 'lines of code' : 'total contributions' ;
1055+
1056+ return `
1057+ <svg xmlns="http://www.w3.org/2000/svg" width="${ W } " height="${ H } " viewBox="0 0 ${ W } ${ H } " fill="none" role="img">
1058+ <title>CommitPulse Versus Stats: ${ safeUser1 } vs ${ safeUser2 } </title>
1059+ <desc>${ safeUser1 } has ${ stats1 . totalContributions } ${ unit } . ${ safeUser2 } has ${ stats2 . totalContributions } ${ unit } .</desc>
1060+ ${ renderDefs ( sf ) }
1061+
1062+ <style>
1063+ @import url('https://fonts.googleapis.com/css2?family=Fira+Code&family=JetBrains+Mono&family=Roboto&family=Syncopate:wght@400;700&family=Space+Grotesk:wght@400;500;600;700&display=swap');
1064+ :root { --cp-bg: #${ light . bg } ; --cp-text: #${ light . text } ; --cp-accent: #${ light . accent } ; }
1065+ @media (prefers-color-scheme: dark) { :root { --cp-bg: #${ dark . bg } ; --cp-text: #${ dark . text } ; --cp-accent: #${ dark . accent } ; } }
1066+ .cp-bg-fill { fill: var(--cp-bg); } .cp-text-fill { fill: var(--cp-text); color: var(--cp-text); } .cp-accent-fill { fill: var(--cp-accent); color: var(--cp-accent); }
1067+ ${ TOWER_ANIMATION_CSS }
1068+ .scan-line {
1069+ animation: scan-sweep var(--scan-speed, 8s) linear infinite;
1070+ transform-box: fill-box;
1071+ transform-origin: center;
1072+ }
1073+ @keyframes scan-sweep {
1074+ from { transform: translateY(var(--scan-start, ${ s ( 20 ) } px)); }
1075+ to { transform: translateY(var(--scan-end, ${ s ( 260 ) } px)); }
1076+ }
1077+ .title { font-family: ${ selectedFont || '"Syncopate", sans-serif' } ; fill: var(--cp-text); font-size: ${ fs ( 18 ) } px; letter-spacing: ${ fs ( 6 ) } px; font-weight: 400; opacity: 0.8; }
1078+ .stats { font-family: ${ statsFont } ; fill: var(--cp-text); font-size: ${ fs ( 42 ) } px; font-weight: 500; letter-spacing: 0; }
1079+ .total-val { font-family: ${ statsFont } ; fill: var(--cp-accent); font-size: ${ fs ( 24 ) } px; font-weight: 500; }
1080+ .label { font-family: "Roboto", sans-serif; fill: var(--cp-accent); font-size: ${ fs ( 11 ) } px; font-weight: 400; letter-spacing: ${ fs ( 2 ) } px; opacity: 0.7; }
1081+ .isometric-label { font-family: ${ selectedFont || '"Roboto", sans-serif' } ; font-size: ${ fs ( 10 ) } px; font-weight: 400; letter-spacing: 1px; fill-opacity: 0.6; }
1082+
1083+ @media (prefers-reduced-motion: reduce) {
1084+ .heat-particles { display: none; }
1085+ .scan-line {
1086+ animation: none !important;
1087+ transition: none !important;
1088+ transform: translateY(var(--scan-start, ${ s ( 20 ) } px)) !important;
1089+ }
1090+ }
1091+ </style>
1092+
1093+ <rect width="${ W } " height="${ H } " rx="${ radius } " class="${ params . hideBackground ? '' : 'cp-bg-fill' } " fill="${ params . hideBackground ? 'transparent' : '' } " />
1094+
1095+ <!-- User 1 (Left) -->
1096+ <g transform="translate(0, 0)">
1097+ <g transform="translate(0, ${ Math . round ( 20 * sf ) } )">${ towers1 } </g>
1098+ ${ renderIsometricLabels ( calendar1 , params , '' , sf ) }
1099+ ${ renderFooter ( stats1 , params , labels , safeUser1 , '' , sf ) }
1100+ </g>
1101+
1102+ <!-- User 2 (Right) -->
1103+ <g transform="translate(${ singleW } , 0)">
1104+ <g transform="translate(0, ${ Math . round ( 20 * sf ) } )">${ towers2 } </g>
1105+ ${ renderIsometricLabels ( calendar2 , params , '' , sf ) }
1106+ ${ renderFooter ( stats2 , params , labels , safeUser2 , '' , sf ) }
1107+ </g>
1108+
1109+ <!-- Divider Line -->
1110+ <line x1="${ singleW } " y1="${ s ( 40 ) } " x2="${ singleW } " y2="${ H - s ( 40 ) } " stroke="var(--cp-text)" stroke-opacity="0.2" stroke-width="2" stroke-dasharray="4 4" />
1111+
1112+ <!-- VS Badge -->
1113+ <g transform="translate(${ singleW } , ${ H / 2 } )">
1114+ <circle cx="0" cy="0" r="${ s ( 24 ) } " class="cp-bg-fill" stroke="var(--cp-accent)" stroke-width="2" />
1115+ <text x="0" y="${ s ( 6 ) } " text-anchor="middle" font-family="${ statsFont } " class="cp-accent-fill" font-size="${ s ( 16 ) } " font-weight="bold">VS</text>
1116+ </g>
1117+ </svg>` ;
1118+ }
0 commit comments