github.com/web-platform-tests/wpt.fyi@v0.0.0-20240530210107-70cf978996f1/webapp/components/reftest-analyzer.js (about) 1 /** 2 * Copyright 2018 The WPT Dashboard Project. All rights reserved. 3 * Use of this source code is governed by a BSD-style license that can be 4 * found in the LICENSE file. 5 */ 6 7 import { PolymerElement, html } from '../node_modules/@polymer/polymer/polymer-element.js'; 8 import '../node_modules/@polymer/polymer/lib/elements/dom-if.js'; 9 import '../node_modules/@polymer/polymer/lib/elements/dom-repeat.js'; 10 import '../node_modules/@polymer/paper-checkbox/paper-checkbox.js'; 11 import '../node_modules/@polymer/paper-radio-button/paper-radio-button.js'; 12 import '../node_modules/@polymer/paper-radio-group/paper-radio-group.js'; 13 import '../node_modules/@polymer/paper-spinner/paper-spinner-lite.js'; 14 import '../node_modules/@polymer/paper-tooltip/paper-tooltip.js'; 15 import { LoadingState } from './loading-state.js'; 16 17 const nsSVG = 'http://www.w3.org/2000/svg'; 18 const nsXLINK = 'http://www.w3.org/1999/xlink'; 19 const blankFill = 'white'; 20 21 class ReftestAnalyzer extends LoadingState(PolymerElement) { 22 static get template() { 23 return html` 24 <style> 25 :host { 26 display: flex; 27 flex-direction: row; 28 } 29 #zoom svg { 30 height: 250px; 31 width: 250px; 32 margin: 10px 0; 33 border: 1px solid; 34 } 35 #zoom #info { 36 width: 280px; 37 } 38 #display { 39 position: relative; 40 height: 600px; 41 width: 800px; 42 } 43 #display svg, 44 #display img { 45 position: absolute; 46 left: 0; 47 top: 0; 48 } 49 #error-message { 50 position: absolute; 51 display: none; 52 width: 800px; 53 } 54 #source { 55 min-width: 800px; 56 } 57 #source.before #after, 58 #source.after #before { 59 display: none; 60 } 61 #options { 62 display: flex; 63 justify-content: space-between; 64 align-items: center; 65 padding: 8px; 66 } 67 </style> 68 69 <div id="zoom"> 70 <svg xmlns="http://www.w3.org/2000/svg" shape-rendering="optimizeSpeed"> 71 <g id="zoomed"> 72 <rect width="250" height="250" fill="white"/> 73 </g> 74 </svg> 75 76 <div id="info"> 77 <strong>Pixel at:</strong> [[curX]], [[curY]] <br> 78 <strong>Actual:</strong> [[getRGB(canvasBefore, curX, curY)]] <br> 79 <strong>Expected:</strong> [[getRGB(canvasAfter, curX, curY)]] <br> 80 <p> 81 The grid above is a zoomed-in view of the 5×5 pixels around your cursor. 82 When actual and expected pixels are different, the upper-left half shows the 83 actual and the lower-right half shows the expected. 84 </p> 85 <strong>maxDifference:</strong> [[maxDifference]] <br> 86 <strong>totalPixels:</strong> [[totalPixels]] 87 <p> 88 Any suggestions? 89 <a href="https://github.com/web-platform-tests/wpt.fyi/issues/new?template=screenshots.md&projects=web-platform-tests/wpt.fyi/9" target="_blank">File an issue!</a> 90 </p> 91 <button onclick="window.history.back()">Go back</button> 92 </div> 93 </div> 94 95 <div id="source" class$="[[selectedImage]]"> 96 <div id="options"> 97 <paper-radio-group selected="{{selectedImage}}"> 98 <paper-radio-button name="before">Actual screenshot</paper-radio-button> 99 <paper-radio-button name="after">Expected screenshot</paper-radio-button> 100 </paper-radio-group> 101 <paper-checkbox id="diff-button" checked="{{showDiff}}">Highlight diff</paper-checkbox> 102 <paper-tooltip for="diff-button"> 103 Apply a semi-transparent mask over the selected image, and highlight 104 the areas where two images differ with a solid 1px red border. 105 </paper-tooltip> 106 <paper-spinner-lite active="[[isLoading]]" class="blue"></paper-spinner-lite> 107 </div> 108 109 110 <p id="error-message"> 111 Failed to load images. Some historical runs (before 2019-04-01) and 112 some runners did not have complete screenshots. Please file an issue using the link on the 113 left if you think something is wrong. 114 </p> 115 116 <div id="display"> 117 <img id="before" onmousemove="[[zoom]]" crossorigin="anonymous" on-error="showError" /> 118 <img id="after" onmousemove="[[zoom]]" crossorigin="anonymous" on-error="showError" /> 119 120 <template is="dom-if" if="[[showDiff]]"> 121 <svg id="diff-layer" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"> 122 <defs> 123 <filter id="diff-filter" x="0" y="0"> 124 <feImage id="different-pixels" result="pixels" /> 125 126 <!-- Border by 1px, remove the original, color red. --> 127 <feMorphology result="bordered" in="pixels" operator="dilate" radius="1" /> 128 <feComposite result="border" in="bordered" in2="pixels" operator="out" /> 129 <feFlood result="red" flood-color="#f00" /> 130 <feComposite result="highlight" in="red" in2="border" operator="in" /> 131 132 <feFlood id="shadow" result="shadow" flood-color="#fff" flood-opacity="0.8" /> 133 <feBlend in="shadow" in2="highlight" mode="multiply" /> 134 </filter> 135 </defs> 136 <rect onmousemove="[[zoom]]" filter="url(#diff-filter)" /> 137 </svg> 138 </template> 139 </div> 140 </div> 141 `; 142 } 143 144 static get is() { 145 return 'reftest-analyzer'; 146 } 147 148 static get properties() { 149 return { 150 curX: Number, 151 curY: Number, 152 before: { 153 type: String, 154 value: '', 155 }, 156 after: { 157 type: String, 158 value: '', 159 }, 160 selectedImage: { 161 type: String, 162 value: 'before', 163 }, 164 zoomedSVGPaths: Array, // 2D array of the paths. 165 canvasBefore: Object, 166 canvasAfter: Object, 167 diff: String, // data:image URL. 168 totalPixels: Number, 169 maxDifference: Number, 170 showDiff: { 171 type: Boolean, 172 value: true, 173 } 174 }; 175 } 176 177 constructor() { 178 super(); 179 this.zoom = this.handleZoom.bind(this); 180 } 181 182 ready() { 183 super.ready(); 184 this._createMethodObserver('computeDiff(canvasBefore, canvasAfter)'); 185 186 // Set the img srcs manually so that we can promisify them being loaded. 187 const imagePromises = ['before', 'after'].map(prop => new Promise((resolve, reject) => { 188 if (!this[prop]) { 189 throw new Error(`${prop} is empty`); 190 } 191 const img = this.shadowRoot.querySelector(`#${prop}`); 192 img.onload = resolve; 193 img.onerror = reject; 194 img.src = this[prop]; 195 })); 196 this.load( 197 Promise.all(imagePromises).then(async() => { 198 await this.setupZoomSVG(); 199 await this.setupCanvases(); 200 }) 201 ); 202 } 203 204 async setupCanvases() { 205 this.canvasBefore = await this.makeCanvas('before'); 206 this.canvasAfter = await this.makeCanvas('after'); 207 } 208 209 async makeCanvas(image) { 210 const img = this.shadowRoot.querySelector(`#${image}`); 211 if (!img.width) { 212 await new Promise(resolve => img.onload = img.onerror = resolve); 213 } 214 var canvas = document.createElement('canvas'); 215 canvas.width = img.width; 216 canvas.height = img.height; 217 canvas.getContext('2d').drawImage(img, 0, 0, img.width, img.height); 218 return canvas; 219 } 220 221 get sourceImage() { 222 return this.shadowRoot && this.shadowRoot.querySelector('#source svg image'); 223 } 224 225 async setupZoomSVG() { 226 const zoomed = this.shadowRoot.querySelector('#zoomed'); 227 const pathsBefore = [], pathsAfter = []; 228 for (const before of [true, false]) { 229 const paths = before ? pathsBefore : pathsAfter; 230 for (let x = 0; x < 5; x++) { 231 paths.push([]); 232 for (let y = 0; y < 5; y++) { 233 const path = document.createElementNS(nsSVG, 'path'); 234 const offsetX = x * 50 + 1; 235 const offsetY = y * 50 + 1; 236 if (before) { 237 path.setAttribute('d', `M${offsetX},${offsetY} H${offsetX + 48} L${offsetX},${offsetY + 48} V${offsetY}`); 238 } else { 239 path.setAttribute('d', `M${offsetX + 48},${offsetY} V${offsetY + 48} H${offsetX} L${offsetX + 48},${offsetY}`); 240 } 241 path.setAttribute('fill', blankFill); 242 paths[x].push(zoomed.appendChild(path)); 243 } 244 } 245 } 246 this.pathsBefore = pathsBefore; 247 this.pathsAfter = pathsAfter; 248 } 249 250 getRGB(canvas, x, y) { 251 if (!canvas || x === undefined || y === undefined) { 252 return; 253 } 254 const ctx = canvas.getContext('2d'); 255 const p = ctx.getImageData(x, y, 1, 1).data; 256 return `RGB(${p[0]}, ${p[1]}, ${p[2]})`; 257 } 258 259 computeDiff(canvasBefore, canvasAfter) { 260 if (!canvasBefore || !canvasAfter) { 261 return; 262 } 263 return this.load(new Promise(resolve => { 264 const before = this.shadowRoot.querySelector('#before'); 265 const after = this.shadowRoot.querySelector('#after'); 266 267 const beforeCtx = canvasBefore.getContext('2d'); 268 const afterCtx = canvasAfter.getContext('2d'); 269 270 const out = document.createElement('canvas'); 271 out.width = Math.max(before.width, after.width); 272 out.height = Math.max(before.height, after.height); 273 const outCtx = out.getContext('2d'); 274 275 const beforePixels = beforeCtx.getImageData(0, 0, out.width, out.height); 276 const afterPixels = afterCtx.getImageData(0, 0, out.width, out.height); 277 let totalPixels = 0; 278 let maxDifference = 0; 279 for (let i = 0; i < out.width * out.height; i++) { 280 let thisPixelDifferent = false; 281 for (let j = i * 4; j < i * 4 + 4; j++) { 282 if (beforePixels.data[j] !== afterPixels.data[j]) { 283 maxDifference = Math.max(maxDifference, Math.abs(beforePixels.data[j] - afterPixels.data[j])); 284 if (!thisPixelDifferent) { 285 thisPixelDifferent = true; 286 ++totalPixels; 287 const x = i % out.width; 288 const y = Math.floor(i / out.width); 289 outCtx.fillRect(x, y, 1, 1); 290 } 291 } 292 } 293 } 294 this.diff = out.toDataURL('image/png'); 295 this.totalPixels = totalPixels; 296 this.maxDifference = maxDifference; 297 const display = this.shadowRoot.querySelector('#different-pixels'); 298 display.setAttribute('width', out.width); 299 display.setAttribute('height', out.height); 300 display.setAttributeNS(nsXLINK, 'xlink:href', this.diff); 301 const svg = this.shadowRoot.querySelector('#diff-layer'); 302 svg.setAttribute('width', out.width); 303 svg.setAttribute('height', out.height); 304 const rect = this.shadowRoot.querySelector('#diff-layer rect'); 305 rect.setAttribute('width', out.width); 306 rect.setAttribute('height', out.height); 307 resolve(); 308 })); 309 } 310 311 handleZoom(e) { 312 if (!this.canvasAfter || !this.canvasBefore) { 313 return; 314 } 315 const c = e.target.getBoundingClientRect(); 316 // (x, y) is the current position on the image. 317 this.curX = e.clientX - c.left; 318 this.curY = e.clientY - c.top; 319 320 for (const before of [true, false]) { 321 const canvas = before ? this.canvasBefore : this.canvasAfter; 322 const paths = before ? this.pathsBefore : this.pathsAfter; 323 const ctx = canvas.getContext('2d'); 324 // We extract a 5x5 square around (x, y): (x-2, y-2) .. (x+2, y+2). 325 const dx = this.curX - 2; 326 const dy = this.curY - 2; 327 for (let i = 0; i < 5; i++) { 328 for (let j = 0; j < 5; j++) { 329 if (dx + i < 0 || dx + i >= canvas.width || dy + j < 0 || dy + j >= canvas.height) { 330 paths[i][j].fill = blankFill; 331 } else { 332 const p = ctx.getImageData(dx+i, dy+j, 1, 1).data; 333 const [r,g,b] = p; 334 const a = p[3]/255; 335 paths[i][j].setAttribute('fill', `rgba(${r},${g},${b},${a})`); 336 } 337 } 338 } 339 } 340 } 341 342 showError() { 343 this.shadowRoot.querySelector('#display').style.display = 'none'; 344 this.shadowRoot.querySelector('#error-message').style.display = 'block'; 345 } 346 } 347 window.customElements.define(ReftestAnalyzer.is, ReftestAnalyzer);