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 }