Find the cost of adding an npm package to your app's bundle size teardown.kelinci.dev
14
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: avoid color collisions

Mary 3eae2d49 2236ac9e

+120 -9
+120 -9
src/components/package-dependencies.tsx
··· 44 44 }, 45 45 }; 46 46 47 - /** colors for the size breakdown bar segments */ 47 + /** colors for the size breakdown bar segments (Tailwind 500 palette) */ 48 48 const SEGMENT_COLORS = [ 49 + tw`bg-[#ef4444]`, // red 49 50 tw`bg-[#f97316]`, // orange 50 51 tw`bg-[#eab308]`, // yellow 52 + tw`bg-[#84cc16]`, // lime 51 53 tw`bg-[#22c55e]`, // green 54 + tw`bg-[#10b981]`, // emerald 52 55 tw`bg-[#06b6d4]`, // cyan 56 + tw`bg-[#0ea5e9]`, // sky 53 57 tw`bg-[#3b82f6]`, // blue 58 + tw`bg-[#6366f1]`, // indigo 54 59 tw`bg-[#8b5cf6]`, // violet 60 + tw`bg-[#d946ef]`, // fuchsia 55 61 tw`bg-[#ec4899]`, // pink 56 - tw`bg-[#f43f5e]`, // rose 57 62 ]; 58 63 59 64 // #endregion 60 65 61 66 // #region size breakdown bar 62 67 68 + /** 69 + * cyrb53 hash - fast 53-bit hash with good distribution. 70 + * @see https://github.com/bryc/code/blob/master/jshash/experimental/cyrb53.js 71 + */ 72 + function cyrb53(str: string, seed = 0): number { 73 + let h1 = 0xdeadbeef ^ seed; 74 + let h2 = 0x41c6ce57 ^ seed; 75 + for (let i = 0; i < str.length; i++) { 76 + const ch = str.charCodeAt(i); 77 + h1 = Math.imul(h1 ^ ch, 2654435761); 78 + h2 = Math.imul(h2 ^ ch, 1597334677); 79 + } 80 + h1 = Math.imul(h1 ^ (h1 >>> 16), 2246822507); 81 + h1 ^= Math.imul(h2 ^ (h2 >>> 13), 3266489909); 82 + h2 = Math.imul(h2 ^ (h2 >>> 16), 2246822507); 83 + h2 ^= Math.imul(h1 ^ (h1 >>> 13), 3266489909); 84 + return 4294967296 * (2097151 & h2) + (h1 >>> 0); 85 + } 86 + 63 87 /** derives a consistent color index from a package name */ 64 - function getColorIndex(name: string): number { 65 - let hash = 0; 66 - for (let i = 0; i < name.length; i++) { 67 - hash = (hash * 31 + name.charCodeAt(i)) | 0; 88 + function getCanonicalColorIndex(name: string): number { 89 + return cyrb53(name) % SEGMENT_COLORS.length; 90 + } 91 + 92 + /** threshold above which we use greedy instead of DP (for performance) */ 93 + const COLOR_RESOLUTION_DP_LIMIT = 2000; 94 + 95 + /** 96 + * resolves adjacent color collisions with globally minimal adjustments. 97 + * uses dynamic programming to find the assignment that changes the fewest segments 98 + * from their canonical (hash-based) colors while ensuring no adjacent segments share a color. 99 + * falls back to greedy for very large inputs. 100 + */ 101 + function resolveColorCollisions(canonicalIndices: number[]): number[] { 102 + const numSegments = canonicalIndices.length; 103 + const numColors = SEGMENT_COLORS.length; 104 + 105 + if (numSegments === 0) { 106 + return []; 107 + } 108 + if (numSegments === 1) { 109 + return [canonicalIndices[0]]; 110 + } 111 + 112 + // fall back to greedy for very large inputs 113 + if (numSegments > COLOR_RESOLUTION_DP_LIMIT) { 114 + const resolved: number[] = [canonicalIndices[0]]; 115 + for (let i = 1; i < numSegments; i++) { 116 + const canonical = canonicalIndices[i]; 117 + resolved.push(canonical === resolved[i - 1] ? (canonical + 1) % numColors : canonical); 118 + } 119 + return resolved; 120 + } 121 + 122 + // dp[c] = min cost to reach current position with color c 123 + let prev = Array.from({ length: numColors }, () => 0); 124 + let curr = Array.from({ length: numColors }, () => 0); 125 + 126 + // parent[i][c] = color of position i that led to optimal assignment at position i+1 with color c 127 + const parent: number[][] = []; 128 + 129 + // base case: position 0 130 + for (let c = 0; c < numColors; c++) { 131 + prev[c] = c === canonicalIndices[0] ? 0 : 1; 68 132 } 69 - return Math.abs(hash) % SEGMENT_COLORS.length; 133 + 134 + // fill DP table 135 + for (let i = 1; i < numSegments; i++) { 136 + const canonical = canonicalIndices[i]; 137 + const parentRow = Array.from({ length: numColors }, () => 0); 138 + 139 + for (let c = 0; c < numColors; c++) { 140 + const cost = c === canonical ? 0 : 1; 141 + 142 + // find best previous color that isn't c 143 + let bestPrevCost = Infinity; 144 + let bestPrevColor = 0; 145 + for (let cp = 0; cp < numColors; cp++) { 146 + if (cp !== c && prev[cp] < bestPrevCost) { 147 + bestPrevCost = prev[cp]; 148 + bestPrevColor = cp; 149 + } 150 + } 151 + 152 + curr[c] = cost + bestPrevCost; 153 + parentRow[c] = bestPrevColor; 154 + } 155 + 156 + parent.push(parentRow); 157 + [prev, curr] = [curr, prev]; 158 + } 159 + 160 + // find best final color 161 + let bestFinal = 0; 162 + for (let c = 1; c < numColors; c++) { 163 + if (prev[c] < prev[bestFinal]) { 164 + bestFinal = c; 165 + } 166 + } 167 + 168 + // backtrack to build result (build reversed, then flip) 169 + let color = bestFinal; 170 + const reversed = [color]; 171 + for (let i = parent.length - 1; i >= 0; i--) { 172 + color = parent[i][color]; 173 + reversed.push(color); 174 + } 175 + return reversed.reverse(); 70 176 } 71 177 72 178 interface SizeBreakdownBarProps { ··· 78 184 const segments = createMemo(() => { 79 185 // sort by size descending for the bar 80 186 const sorted = [...props.packages].sort((a, b) => b.size - a.size); 81 - return sorted.map((pkg) => ({ 187 + 188 + // compute canonical colors, then resolve adjacent collisions 189 + const canonicalIndices = sorted.map((pkg) => getCanonicalColorIndex(pkg.name)); 190 + const resolvedIndices = resolveColorCollisions(canonicalIndices); 191 + 192 + return sorted.map((pkg, i) => ({ 82 193 pkg, 83 194 percent: (pkg.size / props.installSize) * 100, 84 - color: SEGMENT_COLORS[getColorIndex(pkg.name)], 195 + color: SEGMENT_COLORS[resolvedIndices[i]], 85 196 })); 86 197 }); 87 198