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

     1  // Copyright 2021 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 { axisLeft, axisTop, scaleLinear, select as d3Select } from 'd3';
    16  import { css, html, PropertyValues } from 'lit';
    17  import { customElement } from 'lit/decorators.js';
    18  import { autorun, computed, makeObservable, observable, reaction } from 'mobx';
    19  
    20  import { MobxExtLitElement } from '@/generic_libs/components/lit_mobx_ext';
    21  import { consumer, createContextLink } from '@/generic_libs/tools/lit_context';
    22  
    23  export interface Coordinate {
    24    x: number;
    25    y: number;
    26  }
    27  
    28  export const [provideCoord, consumeCoord] = createContextLink<Coordinate>();
    29  
    30  /**
    31   * An element that let users zoom in and view the individual pixel of an image.
    32   */
    33  @customElement('milo-pixel-viewer')
    34  @consumer
    35  export class PixelViewerElement extends MobxExtLitElement {
    36    @observable.ref label = '';
    37    @observable.ref imgUrl!: string;
    38    @consumeCoord() @observable coord: Coordinate = { x: 0, y: 0 };
    39    @observable.ref pixelSize = 10;
    40  
    41    private canvas = document.createElement('canvas');
    42    @observable.ref img: HTMLImageElement | null = null;
    43    @observable.ref private loadedImgUrl = '';
    44    private ctx = this.canvas.getContext('2d')!;
    45    private resizeObserver!: ResizeObserver;
    46  
    47    // Prefix 'r' means range. Maps to pixels in the SVG of the pixel viewer.
    48    @observable.ref private rWidth = 0;
    49    @observable.ref private rHeight = 0;
    50  
    51    // Prefix 'd' means domain. Maps to pixels in the cropped source image.
    52    @observable.ref private dWidth = 0;
    53    @observable.ref private dHeight = 0;
    54    @observable.ref private dMiddleX = 0;
    55    @observable.ref private dMiddleY = 0;
    56  
    57    @computed private get coordColor() {
    58      // The updated image was not loaded yet. Return the default value.
    59      if (this.loadedImgUrl !== this.img?.src) {
    60        return [0, 0, 0, 0];
    61      }
    62      return this.ctx.getImageData(this.coord.x, this.coord.y, 1, 1).data;
    63    }
    64    @computed private get color() {
    65      const [r, g, b, a] = this.coordColor;
    66      return `rgba(${r}, ${g}, ${b}, ${(a / 255).toFixed(2)})`;
    67    }
    68    @computed private get labelColor() {
    69      const [r, g, b, a] = this.coordColor;
    70      return ((r + g + b) / 3) * (a / 255) > 127 ? 'black' : 'white';
    71    }
    72  
    73    constructor() {
    74      super();
    75      makeObservable(this);
    76    }
    77  
    78    connectedCallback() {
    79      super.connectedCallback();
    80  
    81      const drawImg = () => {
    82        if (!this.img) {
    83          return;
    84        }
    85        this.ctx.imageSmoothingEnabled = false;
    86        this.canvas.width = this.img.width;
    87        this.canvas.height = this.img.height;
    88        this.ctx.drawImage(this.img, 0, 0, this.img.width, this.img.height);
    89        this.loadedImgUrl = this.img.src;
    90      };
    91  
    92      // Load the new image.
    93      this.addDisposer(
    94        reaction(
    95          () => this.imgUrl,
    96          () => {
    97            if (this.img) {
    98              this.img.removeEventListener('load', drawImg);
    99              this.ctx.clearRect(0, 0, this.img.width, this.img.height);
   100            }
   101  
   102            this.img = new Image();
   103            this.img.crossOrigin = 'anonymous';
   104            this.img.src = this.imgUrl;
   105            this.img.addEventListener('load', drawImg, { once: true });
   106          },
   107          { fireImmediately: true },
   108        ),
   109      );
   110    }
   111  
   112    protected firstUpdated(changeProperties: PropertyValues) {
   113      super.firstUpdated(changeProperties);
   114  
   115      const svgEle = this.shadowRoot!.querySelector('svg')!;
   116  
   117      // Sync width and height.
   118      this.resizeObserver = new ResizeObserver(() => {
   119        const rect = svgEle.getBoundingClientRect();
   120        this.dWidth = Math.floor(rect.width / this.pixelSize);
   121        this.dHeight = Math.floor(rect.height / this.pixelSize);
   122        this.rWidth = this.dWidth * this.pixelSize;
   123        this.rHeight = this.dHeight * this.pixelSize;
   124        this.dMiddleX = Math.floor(this.dWidth / 2);
   125        this.dMiddleY = Math.floor(this.dHeight / 2);
   126      });
   127      this.resizeObserver.observe(svgEle);
   128      this.addDisposer(() => this.resizeObserver.disconnect());
   129  
   130      // Draw zoomed-in image.
   131      this.addDisposer(
   132        autorun(() => {
   133          const xScale = scaleLinear()
   134            .range([0, this.rWidth])
   135            .domain([0, this.dWidth]);
   136          const yScale = scaleLinear()
   137            .range([0, this.rHeight])
   138            .domain([0, this.dHeight]);
   139  
   140          // Draw grid.
   141          const svg = d3Select(svgEle);
   142          const gridGroup = svg.select('#grid');
   143          gridGroup.selectChildren().remove();
   144          const vGridLines = axisTop(xScale)
   145            .ticks(this.dWidth)
   146            .tickSize(-this.rHeight)
   147            .tickFormat(() => '');
   148          gridGroup.append('g').call(vGridLines);
   149          const hGridLines = axisLeft(yScale)
   150            .ticks(this.dHeight)
   151            .tickSize(-this.rWidth)
   152            .tickFormat(() => '');
   153          gridGroup.append('g').call(hGridLines);
   154        }),
   155      );
   156    }
   157  
   158    protected render() {
   159      return html`
   160        <div id="root">
   161          <div
   162            id="label-area"
   163            style="color: ${this.labelColor}; background-color: ${this.color};"
   164          >
   165            ${this.label} (${this.coord.x}, ${this.coord.y}) ${this.color}
   166          </div>
   167          <svg
   168            viewBox="0 0 ${this.rWidth} ${this.rHeight}"
   169            preserveAspectRatio="xMinYMin slice"
   170          >
   171            <g
   172              transform="
   173                  scale(${this.pixelSize})
   174                  translate(${this.dMiddleX}, ${this.dMiddleY})
   175                  translate(${-this.coord.x}, ${-this.coord.y})
   176                "
   177            >
   178              <image
   179                href=${this.imgUrl}
   180                image-rendering="optimizeQuality"
   181              ></image>
   182            </g>
   183            <g id="grid"></g>
   184            <g id="focus">
   185              <rect
   186                x=${this.dMiddleX * this.pixelSize}
   187                y=${this.dMiddleY * this.pixelSize}
   188                width=${this.pixelSize}
   189                height=${this.pixelSize}
   190              ></rect>
   191            </g>
   192          </svg>
   193        </div>
   194      `;
   195    }
   196  
   197    static styles = css`
   198      :host {
   199        width: 500px;
   200        height: 500px;
   201        overflow: hidden;
   202      }
   203  
   204      #root {
   205        display: grid;
   206        grid-template-rows: auto 1fr;
   207        grid-gap: 5px;
   208        width: 100%;
   209        height: 100%;
   210      }
   211  
   212      #label-area {
   213        height: 18px;
   214        padding: 2px;
   215        text-align: center;
   216      }
   217  
   218      svg {
   219        width: 100%;
   220        height: 100%;
   221      }
   222  
   223      image {
   224        image-rendering: pixelated;
   225      }
   226  
   227      #grid * {
   228        stroke: rgba(0, 0, 0, 0.05);
   229      }
   230  
   231      #focus * {
   232        stroke: rgba(0, 0, 0, 0.2);
   233        stroke-width: 3;
   234        fill: transparent;
   235      }
   236    `;
   237  }