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&times;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);