pixel-buffer-diff
Advanced tools
+2
-2
@@ -11,4 +11,4 @@ export declare type Result = { | ||
| }; | ||
| export declare const diffImageDatas: (baseline: ImageData, candidate: ImageData, diff: ImageData, options?: Options) => Result; | ||
| export declare const diff: (baseline8: Uint8Array | Uint8ClampedArray, candidate8: Uint8Array | Uint8ClampedArray, diff8: Uint8Array | Uint8ClampedArray, width: number, height: number, options?: Options) => Result; | ||
| export declare const diffImageDatas: (baseline: ImageData, candidate: ImageData, diff: ImageData, options: Options) => Result; | ||
| export declare const diff: (baseline8: Uint8Array | Uint8ClampedArray, candidate8: Uint8Array | Uint8ClampedArray, diff8: Uint8Array | Uint8ClampedArray, width: number, height: number, options: Options) => Result; | ||
| //# sourceMappingURL=index.d.ts.map |
+1
-1
@@ -1,1 +0,1 @@ | ||
| Object.defineProperty(exports,"__esModule",{value:!0}),exports.diff=exports.diffImageDatas=void 0;const t={threshold:.03,cumulatedThreshold:.5,enableMinimap:!1};exports.diffImageDatas=(e,a,i,n=t)=>{if(void 0===n.threshold||void 0===n.cumulatedThreshold||void 0===n.enableMinimap)throw new Error("Invalid options");const{width:h,height:r}=e,o=h*r,d=e.data,f=a.data,l=i.data,s=d.length,c=f.length,m=l.length;if(h!==a.width||r!==a.height||4*o!==s||s!==c)throw new Error("Different baseline and candidate ImageData dimensions");const w=i.width/h,u=m/s;if(i.height!==r||w!==u||1!==w&&3!==w)throw new Error("Invalid diff ImageData dimensions");const g=m===s?1:2,M=d.buffer,p=f.buffer,b=l.buffer,x=new Uint32Array(M,0,s>>2),y=new Uint32Array(p,0,s>>2),D=new Uint32Array(b,0,m>>2),v=n.threshold*n.threshold*35215;let A=0,I=0,U=0,E=0,T=0,_=0,j=0,q=0;const C=Math.ceil(Math.sqrt(o)/128),O=s/C&-4;for(let t=0;t<C;t++)q+=(.299*(d[A]+f[A])+.587*(d[A+1]+f[A+1])+.114*(d[A+2]+f[A+2]))/C/2,A+=O;const P=q<128,k=P?1057016832:1056964863,z=P?1056964863:1057016832;A=0;const B=Math.ceil(r/256),F=Math.ceil(h/256),G=new Uint8ClampedArray(F*B),H=2===g?h:0,J=2*H+h,K=Math.max(h,r),L=Math.max(F,B),N=new Uint32Array(K);let Q=0;for(let t=0;t<L;t++)N.fill(t,Q,Math.min(Q+256,K)),Q+=256;for(let t=0;t<r;t++){const e=N[t]*F;H>0&&(D.set(new Uint32Array(M,A,h),U),U+=h,D.set(new Uint32Array(p,A,h),U+h));let a=4034073399*(4034073399^t);for(let t=0;t<h;t++,U++,I++,A+=4,a++){if(x[I]===y[I])continue;const i=f[A]-d[A],n=f[A+1]-d[A+1],h=f[A+2]-d[A+2],r=.29889531*i+.58662247*n+.11448223*h,o=.59597799*i-.2741761*n-.32180189*h,l=.21147017*i-.52261711*n+.31114694*h;if(r*r*.5053+o*o*.299+l*l*.1957>v){G[e+N[t]]++,E++;const i=Math.abs(r);j+=i,D[U]=(r>0?k:z)+(Math.min(192,8*i)<<24),0===T&&(_=a),T+=a}}U+=H}if(T-=_,j/=256,n.enableMinimap)for(let t=0;t<F*B;t++){if(G[t]>0){const e=t/F|0,a=256*(t%F),i=Math.min(a+256,h),n=256*e,o=Math.min(n+256,r);U=a+n*J+H;const d=J-i+a;for(let t=n;t<o;t++){for(let t=a;t<i;t++)D[U++]|=1082064896;U+=d}}}return j>n.cumulatedThreshold?{diff:E,cumulatedDiff:j,hash:T}:{diff:0,cumulatedDiff:0,hash:0}};exports.diff=(e,a,i,n,h,r=t)=>(0,exports.diffImageDatas)({width:n,height:h,data:e},{width:n,height:h,data:a},{width:n*i.length/e.length,height:h,data:i},r); | ||
| Object.defineProperty(exports,"__esModule",{value:!0}),exports.diff=exports.diffImageDatas=void 0;exports.diffImageDatas=(t,e,a,i)=>{void 0===i.threshold&&(i.threshold=.3),void 0===i.cumulatedThreshold&&(i.cumulatedThreshold=.5),void 0===i.enableMinimap&&(i.enableMinimap=!1);const{width:n,height:h}=t,r=n*h,d=t.data,o=e.data,f=a.data,l=d.length,s=o.length,c=f.length;if(n!==e.width||h!==e.height||4*r!==l||l!==s)throw new Error("Different baseline and candidate ImageData dimensions");const m=a.width/n,u=c/l;if(a.height!==h||m!==u||1!==m&&3!==m)throw new Error("Invalid diff ImageData dimensions");const w=c===l?1:2,g=d.buffer,M=o.buffer,p=f.buffer,b=new Uint32Array(g,0,l>>2),x=new Uint32Array(M,0,l>>2),y=new Uint32Array(p,0,c>>2),D=i.threshold*i.threshold*35215;let A=0,U=0,v=0,I=0,T=0,E=0,_=0,j=0;const q=Math.ceil(Math.sqrt(r)/128),C=l/q&-4;for(let t=0;t<q;t++)j+=(.299*(d[A]+o[A])+.587*(d[A+1]+o[A+1])+.114*(d[A+2]+o[A+2]))/q/2,A+=C;const O=j<128,P=O?1057016832:1056964863,k=O?1056964863:1057016832;A=0;const z=Math.ceil(h/256),B=Math.ceil(n/256),F=new Uint8ClampedArray(B*z),G=2===w?n:0,H=2*G+n,J=Math.max(n,h),K=Math.max(B,z),L=new Uint32Array(J);let N=0;for(let t=0;t<K;t++)L.fill(t,N,Math.min(N+256,J)),N+=256;for(let t=0;t<h;t++){const e=L[t]*B;G>0&&(y.set(new Uint32Array(g,A,n),v),v+=n,y.set(new Uint32Array(M,A,n),v+n));let a=4034073399*(4034073399^t);for(let t=0;t<n;t++,v++,U++,A+=4,a++){if(b[U]===x[U])continue;const i=o[A]-d[A],n=o[A+1]-d[A+1],h=o[A+2]-d[A+2],r=.29889531*i+.58662247*n+.11448223*h,f=.59597799*i-.2741761*n-.32180189*h,l=.21147017*i-.52261711*n+.31114694*h;if(r*r*.5053+f*f*.299+l*l*.1957>D){F[e+L[t]]++,I++;const i=Math.abs(r);_+=i,y[v]=(r>0?P:k)+(Math.min(192,8*i)<<24),0===T&&(E=a),T+=a}}v+=G}if(T-=E,_/=256,i.enableMinimap)for(let t=0;t<B*z;t++){if(F[t]>0){const e=t/B|0,a=256*(t%B),i=Math.min(a+256,n),r=256*e,d=Math.min(r+256,h);v=a+r*H+G;const o=H-i+a;for(let t=r;t<d;t++){for(let t=a;t<i;t++)y[v++]|=1082064896;v+=o}}}return _>i.cumulatedThreshold?{diff:I,cumulatedDiff:_,hash:T}:{diff:0,cumulatedDiff:0,hash:0}};exports.diff=(t,e,a,i,n,h)=>(0,exports.diffImageDatas)({width:i,height:n,data:t},{width:i,height:n,data:e},{width:i*a.length/t.length,height:n,data:a},h); |
+1
-1
@@ -23,3 +23,3 @@ { | ||
| ], | ||
| "version": "1.3.0", | ||
| "version": "1.3.1", | ||
| "scripts": { | ||
@@ -26,0 +26,0 @@ "build": "tsc && terser ./dist/index.js -c -m --module -o ./dist/index.js" |
+16
-19
@@ -81,20 +81,2 @@ # **Pixel-buffer-diff** aka **Pbd** | ||
| ### The `Result` type | ||
| The `Result` type defines the properties resulting from diffing two pixel buffers. | ||
| ```typescript | ||
| type Result = { | ||
| diff: number; | ||
| cumulatedDiff: number; | ||
| hash: number | ||
| }; | ||
| ``` | ||
| * `diff` a number showing the number of pixels that exceeded the `threshold` | ||
| * `hash` a numeric hash representing the pixel change between the two images. This hash allows to de-duplicate changes across multiple images to only show unique changes in your visual regression report and approval workflow. | ||
| * `cumulatedDiff` a number representing the cumulated difference of every pixel change in the two images. This can used to discard changes that only effect subtle differences like anti-aliasing pixels. | ||
| These properties are all set to `0` if the two images are within the cumulatedThreshold. | ||
| ### The `diff` method | ||
@@ -138,7 +120,22 @@ | ||
| The `diff` and `diffImageDatas` methods mutate the diff pixel buffer they receive as argument and return an object with the following properties: | ||
| The `diff` and `diffImageDatas` methods mutate the diff pixel buffer they receive as argument and return a `Result` object. | ||
| ### The `Result` type | ||
| The `Result` type defines the properties resulting from diffing two pixel buffers. | ||
| ```typescript | ||
| type Result = { | ||
| diff: number; | ||
| cumulatedDiff: number; | ||
| hash: number | ||
| }; | ||
| ``` | ||
| * `diff` a number showing the number of pixels that exceeded the `threshold` | ||
| * `hash` a numeric hash representing the pixel change between the two images. This hash allows to de-duplicate changes across multiple images to only show unique changes in your visual regression report and approval workflow. | ||
| * `cumulatedDiff` a number representing the cumulated difference of every pixel change in the two images. This can used to discard changes that only effect subtle differences like anti-aliasing pixels. | ||
| These properties are all set to `0` if the two images are within the cumulatedThreshold. | ||
| ## Example usage | ||
@@ -145,0 +142,0 @@ |
| <!DOCTYPE html> | ||
| <html> | ||
| <head> | ||
| <meta charset="utf-8" /> | ||
| <title>Noise 🕵️♀️ Visual Regression Report</title> | ||
| <style> | ||
| html, body { | ||
| margin: 0; | ||
| background-color: #fff; | ||
| color: #000; | ||
| font-family: sans-serif; | ||
| overscroll-behavior: none; | ||
| } | ||
| * { | ||
| box-sizing: border-box; | ||
| } | ||
| body { | ||
| display: flex; | ||
| width: 100vw; | ||
| height: 100vh; | ||
| user-select: none; | ||
| } | ||
| main { | ||
| padding: 0; | ||
| height: 100vh; | ||
| min-width: 50%; | ||
| display: flex; | ||
| flex-direction: column; | ||
| box-shadow: 0 0 1rem #0008; | ||
| z-index: 1; | ||
| border: 1rem solid transparent; | ||
| transition: border-color .5s; | ||
| position: relative; | ||
| overflow: hidden; | ||
| } | ||
| main:before { | ||
| content: "🕵️♀️"; | ||
| position:absolute; | ||
| right: 0; | ||
| bottom: 0; | ||
| transform: translate(.4ex, .4ex) rotate(-.1rad); | ||
| font-size: 33vmin; | ||
| opacity: .1; | ||
| } | ||
| main.approved { | ||
| border-color: #0c3; | ||
| } | ||
| #filter { | ||
| padding: 1rem; | ||
| background: #ddd; | ||
| border: 0; | ||
| } | ||
| main > * { | ||
| margin: .5rem 1rem; | ||
| white-space: pre-line; | ||
| } | ||
| code { | ||
| background: #000; | ||
| color: #fff; | ||
| padding: .5ex; | ||
| } | ||
| #instructions { | ||
| user-select: none; | ||
| opacity: .75; | ||
| } | ||
| nav { | ||
| display: flex; | ||
| flex-grow: 1; | ||
| align-items: center; | ||
| min-height: 4rem; | ||
| font: 1.5rem monospace; | ||
| } | ||
| nav > * { | ||
| text-align: center; | ||
| padding: 1rem; | ||
| cursor: pointer; | ||
| } | ||
| nav > *.disabled { | ||
| opacity: .25; | ||
| } | ||
| #summary { | ||
| flex-grow: 1; | ||
| } | ||
| #summary::first-letter { | ||
| font-size: 4em; | ||
| } | ||
| #matches { | ||
| flex-grow: 1; | ||
| flex-shrink: 1; | ||
| height: 100%; | ||
| padding: 1ex; | ||
| align-self: baseline; | ||
| word-break: break-all; | ||
| font-family: monospace; | ||
| overflow: auto; | ||
| user-select: text; | ||
| } | ||
| #view { | ||
| background: radial-gradient(circle, #fff4, #0004); | ||
| min-width: 50%; | ||
| flex-grow: 1; | ||
| height: 100vh; | ||
| } | ||
| #progress { | ||
| height: 1rem; | ||
| image-rendering: pixelated; | ||
| } | ||
| </style> | ||
| </head> | ||
| <body> | ||
| <main><h1><code id="overview"></code> visual regressions in <!--between <span id="drop-baseline" class="drop-name">baseline</span> and--><code id="drop-candidate" class="drop-name">user/nelson/sbc5</code></h1> | ||
| <!-- <h2 id="overview">? screenshots ➜ ? uniques changes in total</h2>--> | ||
| <canvas id="progress"></canvas> | ||
| <input id="filter" type="text" placeholder="filter the screenshots"/> | ||
| <nav><span id="nav_prev" disabled>🡸</span><tt id="summary" tabindex="0"></tt><span id="nav_next">🡺</span></nav> | ||
| <div id="matches"></div> | ||
| <div id="instructions">Use the <b>INPUT</b> above to filter the images | ||
| <b>🡸</b> and <b>🡺</b> to navigate between matching images | ||
| <b>🡹</b> and <b>🡻</b> to change approval rating with | ||
| <b>SPACE</b> to change view mode | ||
| <b>Click and drag</b> to pan the image use the <b>mouse wheel</b> to zoom in/out | ||
| <b>Double click</b> the image to reset the zoom level</div> | ||
| </main> | ||
| <canvas id="view"></canvas> | ||
| </body> | ||
| <script> | ||
| const xhr = new XMLHttpRequest(); | ||
| xhr.open("GET", "report.json"); | ||
| xhr.onload = (e) => { | ||
| const report = JSON.parse(e.target.responseText); | ||
| const changed = report.changed.sort((a, b)=>(b.hash-a.hash) || (b.diff-a.diff)); //.filter(e=>e.cummulatedDelta>.5); | ||
| const progress = document.querySelector("canvas#progress"); | ||
| const progressContext = progress.getContext("2d"); | ||
| progress.width = changed.length; | ||
| progress.height = 1; | ||
| progressContext.imageSmoothingEnabled = false; | ||
| const view = document.querySelector("canvas#view"); | ||
| const viewContext = view.getContext("2d"); | ||
| const modes = ["👥opacity", "🏄♀️swipe"]; | ||
| const colors = {"rejected": "#f00", "approved": "#0d3"}; | ||
| const viewState = { | ||
| index: 0, | ||
| mode: modes[0], | ||
| totalChanged: changed.length, | ||
| totalUniques: 7, //uniquesTotal, | ||
| img: undefined, | ||
| zx: 64, | ||
| zy: 64, | ||
| rzx: 0, | ||
| rzy: 0, | ||
| x01: .5, | ||
| y01: .5, | ||
| zoomLevel: 1, | ||
| zoomLevelMin: .5, | ||
| }; | ||
| // Create checkboard pattern to show behind the diff | ||
| view.width = 32; | ||
| view.height = 32; | ||
| viewContext.fillStyle = "#707070" | ||
| viewContext.fillRect(0,0,16,16); | ||
| viewContext.fillRect(16,16,16,16); | ||
| viewContext.fillStyle = "#909090" | ||
| viewContext.fillRect(16,0,16,16); | ||
| viewContext.fillRect(0,16,16,16); | ||
| const checkboardPattern = viewContext.createPattern(view, "repeat"); | ||
| const updateView = () => { | ||
| viewState.prevRzx = viewState.rzx; | ||
| viewState.prevRzy = viewState.rzy; | ||
| requestAnimationFrame(updateView); | ||
| if (!viewState.img || !viewState.changed || !viewState.img.complete) { | ||
| return; | ||
| } | ||
| viewState.changed = false; | ||
| const cbb = view.getBoundingClientRect(); | ||
| view.width = cbb.width; | ||
| view.height = cbb.height; | ||
| const { img, naturalWidth, naturalHeight } = viewState; | ||
| if (naturalWidth * naturalHeight === 0) { | ||
| console.log({update:"failed-image-is-zero", viewState}); | ||
| return; | ||
| } | ||
| const imgWidth = naturalWidth / 3; | ||
| const imgHeight = naturalHeight; | ||
| const zl = viewState.zoomLevel; | ||
| const x = Math.max(0, Math.min( 1, .5 + (viewState.x01-.5) * 2)) | ||
| const xSmooth = x * x * (3 - 2 * x); | ||
| const y = Math.max(0, Math.min( 1, .5 + (viewState.y01-.5) * 2)); | ||
| const ySmooth = (y * y * (3 - 2 * y)) ** 16; | ||
| viewContext.fillStyle = "#808080"; | ||
| viewContext.fillRect(0,0,view.width,view.height); | ||
| viewContext.save(); | ||
| viewContext.scale(zl, zl); | ||
| viewContext.imageSmoothingEnabled = zl<1; | ||
| viewContext.translate(viewState.zx, viewState.zy); | ||
| viewContext.fillStyle = "#808080"; | ||
| viewContext.fillRect(0,0,imgWidth,imgHeight); | ||
| viewContext.fillStyle = checkboardPattern; | ||
| viewContext.fillRect(0,0,imgWidth,imgHeight); | ||
| viewContext.fillStyle = "#808080"; | ||
| viewContext.clearRect(0,0,imgWidth,imgHeight); | ||
| viewContext.fillStyle = "#000"; | ||
| if (viewState.mode === modes[0]) { | ||
| // Baseline | ||
| viewContext.globalAlpha = (1 - ySmooth); | ||
| viewContext.drawImage(img,0,0,imgWidth,imgHeight ,0,0,imgWidth,imgHeight); | ||
| // Candidate | ||
| viewContext.globalAlpha = xSmooth * ( 1- ySmooth); | ||
| viewContext.drawImage(img,imgWidth*2,0,imgWidth,imgHeight ,0,0,imgWidth,imgHeight); | ||
| } else if (viewState.mode === modes[1] ) { | ||
| // Baseline | ||
| viewContext.globalAlpha = ( 1- ySmooth); | ||
| const x01 = Math.max(0, Math.min(1, viewState.rzx / imgWidth)); | ||
| viewContext.drawImage(img,0,0,x01*imgWidth,imgHeight ,0,0,x01*imgWidth,imgHeight); | ||
| viewContext.drawImage(img,imgWidth*(2+x01),0,imgWidth*(1-x01),imgHeight ,imgWidth*x01,0,imgWidth*(1-x01),imgHeight); | ||
| viewContext.fillRect(imgWidth * x01,0, .5 / zl, imgHeight); | ||
| } | ||
| // Diff | ||
| viewContext.globalAlpha = ySmooth; | ||
| viewContext.drawImage(img,imgWidth,0,imgWidth,imgHeight ,0,0,imgWidth,imgHeight); | ||
| // Text on the edges | ||
| viewContext.restore(); | ||
| viewContext.fillStyle = colors[viewState.match.rating]; | ||
| viewContext.globalAlpha = .5; | ||
| const fontSize = Math.min(view.width, view.height)/48; | ||
| viewContext.fillRect(0,0,view.width,fontSize); | ||
| viewContext.fillRect(0,view.height-fontSize,view.width,fontSize); | ||
| viewContext.fillRect(0,fontSize,fontSize,view.height-fontSize*2); | ||
| viewContext.fillRect(view.width-fontSize,fontSize,fontSize,view.height-fontSize*2); | ||
| viewContext.globalAlpha = 1; | ||
| viewContext.font = `900 ${fontSize}px monospace`; | ||
| //viewContext.globalCompositeOperation = "difference"; | ||
| viewContext.fillStyle = "#fff"; | ||
| viewContext.textAlign = "center"; | ||
| viewContext.textBaseline = "middle"; | ||
| viewContext.fillText("DIFFERENCE", view.width / 2, view.height - fontSize/2); | ||
| viewContext.save(); | ||
| viewContext.translate(fontSize/2, view.height / 2); | ||
| viewContext.rotate(-Math.PI/2); | ||
| viewContext.fillText("BASELINE", 0, 0); | ||
| viewContext.restore(); | ||
| viewContext.save(); | ||
| viewContext.translate(view.width - fontSize/2, view.height / 2); | ||
| viewContext.rotate(-Math.PI/2); | ||
| viewContext.fillText("CANDIDATE", 0, 0); | ||
| viewContext.restore(); | ||
| }; | ||
| view.onwheel = e => { | ||
| const x = e.offsetX; | ||
| const y = e.offsetY; | ||
| const rzxBefore = x / viewState.zoomLevel - viewState.zx; | ||
| const rzyBefore = y / viewState.zoomLevel - viewState.zy; | ||
| viewState.zoomLevel = Math.min(16, Math.max(viewState.zoomLevelMin, viewState.zoomLevel - e.deltaY / 32)); | ||
| viewState.rzx = x / viewState.zoomLevel - viewState.zx; | ||
| viewState.rzy = y / viewState.zoomLevel - viewState.zy; | ||
| viewState.zx += viewState.rzx - rzxBefore; | ||
| viewState.zy += viewState.rzy - rzyBefore; | ||
| viewState.changed = true; | ||
| } | ||
| view.onmousedown = e => { | ||
| viewState.mouseDown = true; | ||
| } | ||
| view.onmouseup = view.onmouseleave = e => { | ||
| viewState.mouseDown = false; | ||
| } | ||
| view.onmousemove = e => { | ||
| const x = e.offsetX; | ||
| const y = e.offsetY; | ||
| if (viewState.mouseDown || e.ctrlKey) { | ||
| viewState.rzx = x / viewState.zoomLevel - viewState.zx; | ||
| viewState.rzy = y / viewState.zoomLevel - viewState.zy; | ||
| viewState.zx += viewState.rzx - viewState.prevRzx; | ||
| viewState.zy += viewState.rzy - viewState.prevRzy; | ||
| } else { | ||
| viewState.x01 = x / view.width; | ||
| viewState.y01 = y / view.height; | ||
| } | ||
| viewState.rzx = x / viewState.zoomLevel - viewState.zx; | ||
| viewState.rzy = y / viewState.zoomLevel - viewState.zy; | ||
| viewState.changed = true; | ||
| } | ||
| const resetSize = e => { | ||
| if (e && e.type == "resize") { | ||
| const cbb = view.getBoundingClientRect(); | ||
| view.width = cbb.width; | ||
| view.height = cbb.height; | ||
| } | ||
| viewState.naturalWidth = viewState.img.naturalWidth; | ||
| viewState.naturalHeight = viewState.img.naturalHeight; | ||
| const imgWidth = viewState.naturalWidth / 3; | ||
| const imgHeight = viewState.naturalHeight; | ||
| const vw = view.width - 32 * 2; | ||
| const vh = view.height - 32 * 2; | ||
| viewState.zoomLevelMin = Math.min(vw / imgWidth, vh / imgHeight); | ||
| viewState.zoomLevel = viewState.zoomLevelMin; | ||
| viewState.zx = (view.width - imgWidth * viewState.zoomLevel) / 2; | ||
| viewState.zy = (view.height - imgHeight * viewState.zoomLevel) / 2; | ||
| viewState.changed = true; | ||
| } | ||
| onresize = resetSize; | ||
| view.ondblclick = resetSize; | ||
| viewState.img = new Image(); | ||
| viewState.img.onload = resetSize; | ||
| // options: InputEvent | { direction?: number } | ||
| const applyFilter = (options = {direction: 0}) => { | ||
| const value = filter.value.toLowerCase(); | ||
| let total = 0; | ||
| let unique = 0; | ||
| let prevHash = NaN; | ||
| let prevMatchHash = NaN; | ||
| let uniqueMatch = 0; | ||
| let rating = undefined; | ||
| const matchesList = []; | ||
| // ? | ||
| for (let i=0; i<changed.length; i++) { | ||
| const item = changed[i]; | ||
| item.matches = item.path.toLowerCase().includes(value); | ||
| if (item.matches) { | ||
| item.isDuplicate = item.hash == prevHash; | ||
| prevHash = item.hash; | ||
| if (!item.isDuplicate) { | ||
| rating = item.rating || "rejected"; | ||
| matchesList.push([]); | ||
| unique++; | ||
| } | ||
| item.rating = rating; | ||
| matchesList[matchesList.length - 1].push(item); | ||
| total++; | ||
| } | ||
| } | ||
| const direction = options?.direction || 0; | ||
| viewState.index = Math.max(0, Math.min(matchesList.length - 1, viewState.index + direction)); | ||
| const matchList = matchesList[viewState.index]; | ||
| overview.textContent = viewState.totalUniques; | ||
| const match = matchList && matchList[0]; | ||
| if (match) { | ||
| nav_prev.classList.toggle("disabled", viewState.index === 0); | ||
| nav_next.classList.toggle("disabled", viewState.index === unique - 1); | ||
| summary.textContent = `${viewState.mode} view match #${viewState.index + 1} of ${unique}\nΔ ${match.diff} partial and ${match.cummulatedDelta} full pixels`; | ||
| matches.textContent = `${matchList.length} copies\n`+ matchList.map((str, index) => `${index!=1?"\u00a0":"•"} ${str.path}`).join("\n"); | ||
| requestAnimationFrame(() => { | ||
| if (viewState.img.getAttribute("src") !== match.path) { | ||
| viewState.img.src = match.path; | ||
| } | ||
| }); | ||
| } else { | ||
| nav_prev.classList.add("disabled"); | ||
| nav_next.classList.add("disabled"); | ||
| summary.textContent = `No matches`; | ||
| matches.textContent = `N/A`; | ||
| } | ||
| // Update progress bar | ||
| progress.height = 1; | ||
| const matchHash = match?.hash; | ||
| let zebra = false; | ||
| prevHash = NaN; | ||
| let allApproved = true; | ||
| for (let i=0;i<changed.length;i++) { | ||
| const item = changed[i]; | ||
| progressContext.fillStyle = colors[item.rating]; | ||
| if (allApproved && item.rating === "rejected") { | ||
| allApproved = false; | ||
| } | ||
| if (item.hash !== prevHash) { | ||
| prevHash = item.hash; | ||
| zebra = !zebra; | ||
| } | ||
| const value = zebra ? 1 : .8; | ||
| progressContext.globalAlpha = item.hash === matchHash ? 1 : value * (item.matches ? .5 : .1); | ||
| progressContext.fillRect(i,0,1,1); | ||
| } | ||
| document.querySelector("main").classList.toggle("approved", allApproved); | ||
| viewState.match = match; | ||
| viewState.changed = true; | ||
| } | ||
| const updateRating = rating => { | ||
| const hash = viewState.match.hash; | ||
| for (let i=0; i<changed.length; i++) { | ||
| if (changed[i].hash === hash) { | ||
| changed[i].rating = rating; | ||
| } | ||
| } | ||
| applyFilter(); | ||
| } | ||
| oninput = applyFilter; | ||
| onkeyup = e => { | ||
| if (document.activeElement === filter) { | ||
| return; | ||
| } | ||
| const code = e.code; | ||
| if (code === "ArrowLeft") { | ||
| applyFilter({direction: e.shiftKey ? -10 : -1}); | ||
| } else if (code === "ArrowRight") { | ||
| applyFilter({direction: e.shiftKey ? 10 : 1}); | ||
| } else if (code === "ArrowUp") { | ||
| updateRating("approved"); | ||
| } else if (code === "ArrowDown") { | ||
| updateRating("rejected"); | ||
| } else if (code === "Space") { | ||
| const index = modes.indexOf(viewState.mode) || 0; | ||
| viewState.mode = modes[(index + 1) % modes.length]; | ||
| applyFilter(); | ||
| } | ||
| }; | ||
| updateView(); | ||
| applyFilter({}); | ||
| }; | ||
| xhr.send(); | ||
| </script> | ||
| </html> |
14260
-51.02%5
-16.67%182
-1.62%