go.chromium.org/luci@v0.0.0-20240309015107-7cdc2e660f33/milo/ui/src/common/components/image_diff_viewer.ts (about)

     1  // Copyright 2020 The LUCI Authors.
     2  //
     3  // Licensed under the Apache License, Version 2.0 (the "License");
     4  // you may not use this file except in compliance with the License.
     5  // You may obtain a copy of the License at
     6  //
     7  //      http://www.apache.org/licenses/LICENSE-2.0
     8  //
     9  // Unless required by applicable law or agreed to in writing, software
    10  // distributed under the License is distributed on an "AS IS" BASIS,
    11  // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
    12  // See the License for the specific language governing permissions and
    13  // limitations under the License.
    14  
    15  import { css, html } from 'lit';
    16  import { customElement } from 'lit/decorators.js';
    17  import { styleMap } from 'lit/directives/style-map.js';
    18  import { computed, makeObservable, observable, reaction } from 'mobx';
    19  
    20  import '@/generic_libs/components/hotkey';
    21  import '@/generic_libs/components/pixel_viewer';
    22  import { Artifact } from '@/common/services/resultdb';
    23  import { commonStyles } from '@/common/styles/stylesheets';
    24  import { getRawArtifactURLPath } from '@/common/tools/url_utils';
    25  import { MobxExtLitElement } from '@/generic_libs/components/lit_mobx_ext';
    26  import {
    27    Coordinate,
    28    provideCoord,
    29  } from '@/generic_libs/components/pixel_viewer';
    30  import { provider } from '@/generic_libs/tools/lit_context';
    31  
    32  const enum ViewOption {
    33    Expected,
    34    Actual,
    35    Diff,
    36    Animated,
    37    SideBySide,
    38  }
    39  
    40  const VIEW_OPTION_CLASS_MAP = Object.freeze({
    41    [ViewOption.Expected]: 'expected',
    42    [ViewOption.Actual]: 'actual',
    43    [ViewOption.Diff]: 'diff',
    44    [ViewOption.Animated]: 'animated',
    45    [ViewOption.SideBySide]: 'side-by-side',
    46  });
    47  
    48  /**
    49   * Renders an image diff artifact set, including expected image, actual image
    50   * and image diff.
    51   */
    52  // TODO(weiweilin): improve error handling.
    53  @customElement('milo-image-diff-viewer')
    54  @provider
    55  export class ImageDiffViewerElement extends MobxExtLitElement {
    56    @observable.ref expected!: Artifact;
    57    @observable.ref actual!: Artifact;
    58    @observable.ref diff!: Artifact;
    59  
    60    @observable.ref private showPixelViewers = false;
    61    @observable.ref @provideCoord() coord: Coordinate = { x: 0, y: 0 };
    62  
    63    @computed private get expectedImgUrl() {
    64      return getRawArtifactURLPath(this.expected.name);
    65    }
    66    @computed private get actualImgUrl() {
    67      return getRawArtifactURLPath(this.actual.name);
    68    }
    69    @computed private get diffImgUrl() {
    70      return getRawArtifactURLPath(this.diff.name);
    71    }
    72    @observable.ref private viewOption = ViewOption.Animated;
    73  
    74    private readonly updateCoord = (e: MouseEvent) => {
    75      if (!this.showPixelViewers) {
    76        return;
    77      }
    78  
    79      const rect = (e.target as HTMLImageElement).getBoundingClientRect();
    80      const x = Math.max(Math.round(e.clientX - rect.left), 0);
    81      const y = Math.max(Math.round(e.clientY - rect.top), 0);
    82      this.coord = { x, y };
    83    };
    84  
    85    constructor() {
    86      super();
    87      makeObservable(this);
    88    }
    89  
    90    connectedCallback() {
    91      super.connectedCallback();
    92      const hidePixelViewers = () => (this.showPixelViewers = false);
    93      window.addEventListener('click', hidePixelViewers);
    94      this.addDisposer(() =>
    95        window.removeEventListener('click', hidePixelViewers),
    96      );
    97      this.addDisposer(
    98        reaction(
    99          () => this.coord,
   100          (coord) => {
   101            // Emulate @property() update.
   102            this.updated(new Map([['coord', coord]]));
   103          },
   104          { fireImmediately: true },
   105        ),
   106      );
   107    }
   108  
   109    protected render() {
   110      return html`
   111        <div
   112          id="pixel-viewers"
   113          style=${styleMap({ display: this.showPixelViewers ? '' : 'none' })}
   114        >
   115          <milo-hotkey
   116            id="close-viewers-instruction"
   117            .key=${'esc'}
   118            .handler=${() => (this.showPixelViewers = false)}
   119            @click=${() => (this.showPixelViewers = false)}
   120          >
   121            Click again or press ESC to close the pixel viewers.
   122          </milo-hotkey>
   123          <div id="pixel-viewer-grid">
   124            <milo-pixel-viewer
   125              .label=${'expected:'}
   126              .imgUrl=${this.expectedImgUrl}
   127            ></milo-pixel-viewer>
   128            <milo-pixel-viewer
   129              .label=${'actual:'}
   130              .imgUrl=${this.actualImgUrl}
   131            ></milo-pixel-viewer>
   132            <milo-pixel-viewer
   133              .label=${'diff:'}
   134              .imgUrl=${this.diffImgUrl}
   135            ></milo-pixel-viewer>
   136          </div>
   137        </div>
   138        <div id="options">
   139          <input
   140            type="radio"
   141            name="view-option"
   142            id="expected"
   143            @change=${() => (this.viewOption = ViewOption.Expected)}
   144            ?checked=${this.viewOption === ViewOption.Expected}
   145          />
   146          <label for="expected">Expected</label>
   147          <input
   148            type="radio"
   149            name="view-option"
   150            id="actual"
   151            @change=${() => (this.viewOption = ViewOption.Actual)}
   152            ?checked=${this.viewOption === ViewOption.Actual}
   153          />
   154          <label for="actual">Actual</label>
   155          <input
   156            type="radio"
   157            name="view-option"
   158            id="diff"
   159            @change=${() => (this.viewOption = ViewOption.Diff)}
   160            ?checked=${this.viewOption === ViewOption.Diff}
   161          />
   162          <label for="diff">Diff</label>
   163          <input
   164            type="radio"
   165            name="view-option"
   166            id="animated"
   167            @change=${() => (this.viewOption = ViewOption.Animated)}
   168            ?checked=${this.viewOption === ViewOption.Animated}
   169          />
   170          <label for="animated">Animated</label>
   171          <input
   172            type="radio"
   173            name="view-option"
   174            id="side-by-side"
   175            @change=${() => (this.viewOption = ViewOption.SideBySide)}
   176            ?checked=${this.viewOption === ViewOption.SideBySide}
   177          />
   178          <label for="side-by-side">Side by side</label>
   179        </div>
   180        <div id="content" class=${VIEW_OPTION_CLASS_MAP[this.viewOption]}>
   181          ${this.renderImage('expected-image', 'expected', this.expectedImgUrl)}
   182          ${this.renderImage('actual-image', 'actual', this.actualImgUrl)}
   183          ${this.renderImage('diff-image', 'diff', this.diffImgUrl)}
   184        </div>
   185      `;
   186    }
   187  
   188    private renderImage(id: string, label: string, url: string) {
   189      return html`
   190        <div id=${id} class="image">
   191          <div>
   192            ${label} (view raw <a href=${url} target="_blank">here</a> or click on
   193            the image to zoom in.)
   194          </div>
   195          <img
   196            src=${url}
   197            @mousemove=${this.updateCoord}
   198            @click=${(e: MouseEvent) => {
   199              e.stopPropagation();
   200              this.showPixelViewers = !this.showPixelViewers;
   201              this.updateCoord(e);
   202            }}
   203          />
   204        </div>
   205      `;
   206    }
   207  
   208    static styles = [
   209      commonStyles,
   210      css`
   211        :host {
   212          display: block;
   213          overflow: hidden;
   214        }
   215  
   216        #pixel-viewers {
   217          width: 100%;
   218          position: fixed;
   219          left: 0;
   220          top: 0;
   221          z-index: 999;
   222          background-color: var(--dark-background-color);
   223        }
   224        #close-viewers-instruction {
   225          color: white;
   226          padding: 5px;
   227        }
   228        #pixel-viewer-grid {
   229          display: grid;
   230          grid-template-columns: 1fr 1fr 1fr;
   231          grid-gap: 5px;
   232          padding: 5px;
   233          height: 300px;
   234          width: 100%;
   235        }
   236        #pixel-viewer-grid > * {
   237          width: 100%;
   238          height: 100%;
   239        }
   240  
   241        #options {
   242          margin: 5px;
   243        }
   244        #options > label {
   245          margin-right: 5px;
   246        }
   247        .raw-link:not(:last-child):after {
   248          content: ',';
   249        }
   250  
   251        #content {
   252          white-space: nowrap;
   253          overflow-x: auto;
   254          margin: 15px;
   255          position: relative;
   256          top: 0;
   257          left: 0;
   258        }
   259        .image {
   260          display: none;
   261        }
   262  
   263        .expected #expected-image {
   264          display: block;
   265        }
   266        .actual #actual-image {
   267          display: block;
   268        }
   269        .diff #diff-image {
   270          display: block;
   271        }
   272  
   273        .animated .image {
   274          animation-name: blink;
   275          animation-duration: 2s;
   276          animation-timing-function: steps(1);
   277          animation-iteration-count: infinite;
   278        }
   279        .animated #expected-image {
   280          display: block;
   281          position: absolute;
   282          animation-delay: -1s;
   283        }
   284        .animated #actual-image {
   285          display: block;
   286          position: static;
   287          animation-direction: normal;
   288        }
   289        @keyframes blink {
   290          0% {
   291            opacity: 1;
   292          }
   293          50% {
   294            opacity: 0;
   295          }
   296        }
   297  
   298        .side-by-side .image {
   299          display: inline-block;
   300        }
   301      `,
   302    ];
   303  }