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 }