| /** |
| * @module module/multi-zoom-sk |
| * @description <h2><code>multi-zoom-sk</code></h2> |
| * |
| * The multi-zoom-sk element shows a zoomed-in comparison between two images (with a rendered diff). |
| * It supports many keybindings for navigation of the image, as well as clicking on the image |
| * thumbnails to navigate around. |
| * |
| * @event sources-loaded when the left, the right, and the diff image sources have been loaded. |
| * |
| * It should typically be wrapped in a dialog tag. |
| */ |
| import { define } from 'elements-sk/define'; |
| import { html } from 'lit-html'; |
| import { $$ } from 'common-sk/modules/dom'; |
| import { ElementSk } from '../../../infra-sk/modules/ElementSk'; |
| |
| import 'elements-sk/checkbox-sk'; |
| |
| const previewCanvasSize = 128; |
| const zoomedCanvasSize = 500; |
| const leftImageIdx = 0; |
| const diffImageIdx = 1; |
| const rightImageIdx = 2; |
| |
| const template = (ele) => html` |
| <div class=container> |
| <div class=preview_and_zoomed>${previewsAndZoomCanvas(ele)}</div> |
| <div class=stats_and_nav>${statsAndNavigation(ele)}</div> |
| </div>`; |
| |
| const previewsAndZoomCanvas = (ele) => html` |
| <div class=previews_and_toggles> |
| ${thumbnailAndToggle(ele, leftImageIdx)} |
| ${thumbnailAndToggle(ele, diffImageIdx)} |
| ${thumbnailAndToggle(ele, rightImageIdx)} |
| </div> |
| <div class=zoomed_view> |
| <canvas class=zoomed width=${zoomedCanvasSize} height=${zoomedCanvasSize}></canvas> |
| </div> |
| <!-- This scratch canvas is not displayed, but is used to get the pixel data from the |
| loaded images--> |
| <canvas class=scratch></canvas> |
| `; |
| |
| // thumbnailAndToggle dynamically creates an img, a canvas and a checkbox-sk with classes "idx_N" |
| // We chose classes instead of ids because on the off-chance there are multiple of these elements |
| // on the page, it is best to not have duplicate ids. The image and canvas will be used to have the |
| // thumbnail and the crosshair over it. The crosshair shows the user where in the image they are |
| // zoomed into. The checkbox is used to select which images should be toggled through in the zoomed |
| // element. |
| const thumbnailAndToggle = (ele, idx) => { |
| const label = ele._labels[idx]; |
| |
| return html` |
| <figure class=preview> |
| <img class="thumbnail idx_${idx}" src=${ele._srcs[idx]} alt=${label} |
| @load=${() => ele._imageLoaded(idx)}> |
| <canvas class="crosshair idx_${idx}" width=${previewCanvasSize} height=${previewCanvasSize} |
| @click=${(e) => ele._previewCanvasClicked(e, idx)}></canvas> |
| <figcaption> |
| <checkbox-sk label=${label} class="displayed for_spacing"></checkbox-sk> |
| <checkbox-sk label=${label} class="idx_${idx} ${idx === ele._zoomedIndex ? 'displayed' : ''}" |
| ?checked=${ele._cycleThrough[idx]} @click=${(e) => ele._cycleBoxClicked(e, idx)}> |
| </checkbox-sk> |
| </figcaption> |
| </figure>`; |
| }; |
| |
| const statsAndNavigation = (ele) => html` |
| <table class=stats> |
| <tr> |
| <td class=label>Coordinate</td> |
| <td class="coord value">(${ele._x}, ${ele._y})</td> |
| </tr> |
| <tr> |
| <td class=label>Left Pixel</td> |
| <td class="left value">${ele._currentColor(leftImageIdx)}</td> |
| </tr> |
| <tr> |
| <td class=label>Diff</td> |
| <td class="diff value">${ele._currentDiff()}</td> |
| </tr> |
| <tr> |
| <td class=label>Right Pixel</td> |
| <td class="right value">${ele._currentColor(rightImageIdx)}</td> |
| </tr> |
| </table> |
| <!-- TODO(kjlubick) Here would be a good place for reading any trace comments and putting pixel |
| specific ones here.--> |
| ${sizeWarning(ele)} |
| ${nthPixelDiff(ele)} |
| <table class=navigation> |
| <tr><th colspan=2>Navigation</td></tr> |
| <tr><td class=label>H</td><td>Left</td></tr> |
| <tr><td class=label>J</td><td>Down</td></tr> |
| <tr><td class=label>K</td><td>Up</td></tr> |
| <tr><td class=label>L</td><td>Right</td></tr> |
| <tr><td class=label>A</td><td>Zoom Out</td></tr> |
| <tr><td class=label>Z</td><td>Zoom In</td></tr> |
| <tr><td class=label>U</td><td>Jump To Next Largest Diff</td></tr> |
| <tr><td class=label>Y</td><td>Jump To Prev. Largest Diff</td></tr> |
| <tr><td class=label>M</td><td>Manual Toggle</td></tr> |
| <tr><td class=label>G</td><td>Hide/Show Grid</td></tr> |
| </table> |
| `; |
| |
| const sizeWarning = (ele) => { |
| const leftData = ele._loadedImageData[leftImageIdx]; |
| const rightData = ele._loadedImageData[rightImageIdx]; |
| if (!leftData || !rightData) { |
| return ''; |
| } |
| if (leftData.width === rightData.width && leftData.height === rightData.height) { |
| return ''; |
| } |
| return html` |
| <div class=size_warning> |
| Images are different sizes - only pixels in overlapping area will be compared. |
| </div>`; |
| }; |
| |
| // If the pixel diffs between the two images have been calculated and sorted, look up to see if |
| // the given pixel is in the list. If so, display where the diff is in the ordering. |
| const nthPixelDiff = (ele) => { |
| if (!ele._cachedDiffs || !ele._cachedDiffs.length) { |
| return ''; |
| } |
| const endings = ['st', 'nd', 'rd']; // for 1st, 2nd, 3rd |
| const total = ele._cachedDiffs.length; |
| for (let i = 0; i < total; i++) { |
| const d = ele._cachedDiffs[i]; |
| if (d.x === ele._x && d.y === ele._y) { |
| // Update our current diff index so that if the user navigates (using |
| // J/H/K/L) from the 3rd to the 12th biggest pixel and hits U, they |
| // go to the 13th biggest diff, not the 4th. |
| ele._cachedDiffIdx = i; |
| const e = endings[i] || 'th'; |
| return html`<div class=nth_diff>${i + 1}${e} biggest pixel diff (out of ${total})</div>`; |
| } |
| } |
| return html`<div class=nth_diff>No difference on this pixel</div>`; |
| }; |
| |
| // min and max are inclusive. |
| const clamp = (n, min, max) => { |
| if (n < min) { |
| return min; |
| } |
| if (n > max) { |
| return max; |
| } |
| return n; |
| }; |
| |
| const getRGBA = (imgData, X, Y) => { |
| const offset = (Y * imgData.width + X) * 4; |
| return [ |
| imgData.data[offset], |
| imgData.data[offset + 1], |
| imgData.data[offset + 2], |
| imgData.data[offset + 3], |
| ]; |
| }; |
| |
| // colorHex returns a hex representation of a given color pixel as a string. |
| function colorHex(r, g, b, a) { |
| const toHex = (i) => i.toString(16).toUpperCase().padStart(2, '0'); |
| |
| return `#${toHex(r)}${toHex(g)}${toHex(b)}${toHex(a)}`; |
| } |
| |
| // colorDist returns the distance of a color from (0, 0, 0, 0) using a |
| // crude square distance per channel. |
| const colorDist = (r, g, b, a) => r * r + g * g + b * b + a * a; |
| |
| // Compute how much we scaled down, if at all. Either we had to scale down because the width |
| // was too big, the height was too big, or no scaling was done. |
| const scaleOf = (originalWidth, originalHeight) => Math.min(previewCanvasSize / originalWidth, |
| previewCanvasSize / originalHeight, 1); |
| |
| define('multi-zoom-sk', class extends ElementSk { |
| constructor() { |
| super(template); |
| |
| // _x and _y are in the native image coordinates; that is, they are not scaled. |
| this._x = 0; |
| this._y = 0; |
| this._srcs = ['', '', '']; |
| this._labels = ['', 'Diff', '']; |
| |
| // We save the image data from all 3 images after it loads here - this lets us access the |
| // pixel data quickly. |
| this._loadedImageData = [null, null, null]; |
| // How many times are we zoomed in. We default to 8x. |
| this._zoomLevel = 8; |
| // The index of the image we should be zoomed in. -1 is a sentinel value for none. |
| this._zoomedIndex = 0; |
| this._showGrid = false; |
| // If we are cycling through a subset of the images. |
| this._cyclingView = true; |
| // Default to rotating through left and right image (i.e. index 0 and index 2). |
| this._cycleThrough = [true, false, true]; |
| // Used by the u/y key presses to go through pixels that are different between the right and |
| // left image. |
| this._cachedDiffs = []; |
| this._cachedDiffIdx = -1; |
| |
| this._keyEventHandler = (e) => this._keyPressed(e); |
| } |
| |
| connectedCallback() { |
| super.connectedCallback(); |
| this._render(); |
| // This assumes that there is only one multi-zoom-sk rendered on the page at a time (if there |
| // are multiple, they may all respond to keypresses at once). |
| document.addEventListener('keydown', this._keyEventHandler); |
| // Every 1 second (1000 ms), go to the next image that the user has checked the box for, if |
| // there is one. |
| const maybeCycleZoomedImage = () => { |
| if (!this._cyclingView) { |
| return; // The user must have manually started to cycle images, thus we stop. |
| } |
| // Unless our element has been removed from the DOM, reschedule another check. |
| if (this._connected) { |
| setTimeout(maybeCycleZoomedImage, 1000); |
| } else { |
| return; |
| } |
| this._nextZoomedImage(); |
| }; |
| |
| setTimeout(maybeCycleZoomedImage, 1000); |
| } |
| |
| disconnectedCallback() { |
| super.disconnectedCallback(); |
| document.removeEventListener('keydown', this._keyEventHandler); |
| // Free up heavy resources. This may not be necessary, but it makes sure we aren't erroneously |
| // holding onto them. |
| this._cachedDiffs = []; |
| this._loadedImageData = [null, null, null]; |
| } |
| |
| /** |
| * @prop details {object} an object with strings leftImageSrc, rightImageSrc, diffImageSrc, |
| * leftLabel, and rightLabel. These control what to draw and compare. |
| */ |
| set details(obj) { |
| this._srcs[leftImageIdx] = obj.leftImageSrc || ''; |
| this._srcs[diffImageIdx] = obj.diffImageSrc || ''; |
| this._srcs[rightImageIdx] = obj.rightImageSrc || ''; |
| this._labels[leftImageIdx] = obj.leftLabel || ''; |
| this._labels[rightImageIdx] = obj.rightLabel || ''; |
| // Clear the cache of differences. We'll need to recompute them when the images load. |
| this._cachedDiffs = []; |
| this._cachedDiffIdx = -1; |
| this._render(); |
| } |
| |
| _buildPixelDiffCache() { |
| const leftData = this._loadedImageData[leftImageIdx]; |
| const rightData = this._loadedImageData[rightImageIdx]; |
| // find all the diffs and sort them biggest diff to smallest diff. |
| const width = Math.min(leftData.width, rightData.width); |
| const height = Math.min(leftData.height, rightData.height); |
| |
| this._cachedDiffs = []; |
| for (let x = 0; x < width; x++) { |
| for (let y = 0; y < height; y++) { |
| const [leftR, leftG, leftB, leftA] = getRGBA(leftData, x, y); |
| const [rightR, rightG, rightB, rightA] = getRGBA(rightData, x, y); |
| const dist = colorDist(leftR - rightR, leftG - rightG, leftB - rightB, |
| leftA - rightA); |
| if (!dist) { |
| // No difference in pixels - no need to add |
| // it to our list of "different pixels" |
| continue; |
| } |
| this._cachedDiffs.push({ |
| x: x, |
| y: y, |
| diff: dist, |
| }); |
| } |
| } |
| this._cachedDiffs.sort((a, b) => { |
| // First sort diffs high to low, so biggest ones are first |
| const d = b.diff - a.diff; |
| if (d) { |
| return d; |
| } |
| // prioritize up and to the left for tie breaks. |
| if (b.x !== a.x) { |
| return a.x - b.x; |
| } |
| return a.y - b.y; |
| }); |
| } |
| |
| _currentColor(imgIdx) { |
| const imgData = this._loadedImageData[imgIdx]; |
| if (!imgData) { |
| return ''; |
| } |
| if (this._x >= imgData.width || this._y >= imgData.height) { |
| return 'out of bounds'; |
| } |
| const [r, g, b, a] = getRGBA(imgData, this._x, this._y); |
| return `rgba(${r}, ${g}, ${b}, ${a}) ${colorHex(r, g, b, a)}`; |
| } |
| |
| _currentDiff() { |
| const leftData = this._loadedImageData[leftImageIdx]; |
| const rightData = this._loadedImageData[rightImageIdx]; |
| if (!leftData || !rightData) { |
| return ''; |
| } |
| if (this._x >= leftData.width || this._y >= leftData.height |
| || this._x >= rightData.width || this._y >= rightData.height) { |
| return 'n/a'; |
| } |
| const [leftR, leftG, leftB, leftA] = getRGBA(leftData, this._x, this._y); |
| const [rightR, rightG, rightB, rightA] = getRGBA(rightData, this._x, this._y); |
| return `rgba(${Math.abs(leftR - rightR)}, ${Math.abs(leftG - rightG)}, ` |
| + `${Math.abs(leftB - rightB)}, ${Math.abs(leftA - rightA)})`; |
| } |
| |
| _cycleBoxClicked(e, imgIdx) { |
| // Prevents duplicate click events from happening; without this, we see two click events in |
| // rapid succession, which leads to the toggle cancelling itself out. |
| e.preventDefault(); |
| e.stopPropagation(); |
| this._cycleThrough[imgIdx] = !this._cycleThrough[imgIdx]; |
| // Nothing was selected previously, so snap to the new selection. |
| if (this._zoomedIndex < 0 && this._cycleThrough[imgIdx]) { |
| this._zoomedIndex = imgIdx; |
| } |
| this._render(); |
| } |
| |
| _drawCrosshairsOnPreview(imgIdx) { |
| const imgData = this._loadedImageData[imgIdx]; |
| if (!imgData) { |
| return; |
| } |
| const canvas = this._getCrosshairCanvas(imgIdx); |
| const ctx = canvas.getContext('2d'); |
| const scale = scaleOf(imgData.width, imgData.height); |
| ctx.clearRect(0, 0, previewCanvasSize, previewCanvasSize); |
| ctx.strokeStyle = 'red'; |
| ctx.lineWidth = 1; |
| // As specified in the docs, clearRect only works if the next path drawn starts with |
| // beginPath(); Otherwise, the old path is drawn again, which means we have multiple |
| // crosshairs at once. |
| ctx.beginPath(); |
| ctx.moveTo(this._x * scale, 0); |
| ctx.lineTo(this._x * scale, previewCanvasSize); |
| ctx.moveTo(0, this._y * scale); |
| ctx.lineTo(previewCanvasSize, this._y * scale); |
| ctx.stroke(); |
| } |
| |
| // Draws the currently selected image on the big canvas, zoomed in according to the set level. |
| _drawZoomedView(imgIdx) { |
| if (this._loadedImageData[imgIdx]) { |
| const canvas = $$('canvas.zoomed', this); |
| const img = this._getImage(imgIdx); |
| const ctx = canvas.getContext('2d'); |
| ctx.fillStyle = '#CCC'; // Grey background for outside the bounds of the image. |
| ctx.fillRect(0, 0, zoomedCanvasSize, zoomedCanvasSize); |
| ctx.imageSmoothingEnabled = false; |
| // The offset from zoomedCanvasSize / 2 is to center the image. |
| const x = zoomedCanvasSize / 2 - (this._x * this._zoomLevel); |
| const y = zoomedCanvasSize / 2 - (this._y * this._zoomLevel); |
| const w = img.naturalWidth * this._zoomLevel; |
| const h = img.naturalHeight * this._zoomLevel; |
| // Draw a white backdrop for our image, in case there are transparent pixels |
| ctx.fillStyle = '#FFF'; |
| ctx.fillRect(x, y, w, h); |
| // Draw the image. The canvas will clip any pixels not on the screen for us. |
| ctx.drawImage(img, x, y, w, h); |
| |
| // The grid is essentially pointless when not zoomed in, so don't bother drawing it then. |
| if (this._showGrid && this._zoomLevel >= 4) { |
| ctx.beginPath(); |
| ctx.strokeStyle = '#FFF'; |
| ctx.lineWidth = 1; |
| // This modular arithmetic lines up the grid with the central pixel. The offset by 0.5 |
| // makes sure we draw all within one pixel, not spread between two pixels. |
| // https://stackoverflow.com/a/10003573 |
| let x = ((zoomedCanvasSize / 2) % this._zoomLevel) - 0.5; |
| for (; x < zoomedCanvasSize; x += this._zoomLevel) { |
| ctx.moveTo(x, 0); |
| ctx.lineTo(x, zoomedCanvasSize); |
| } |
| let y = ((zoomedCanvasSize / 2) % this._zoomLevel) - 0.5; |
| for (; y < zoomedCanvasSize; y += this._zoomLevel) { |
| ctx.moveTo(0, y); |
| ctx.lineTo(zoomedCanvasSize, y); |
| } |
| ctx.stroke(); |
| } |
| |
| // Draw the box showing the selected pixel (the center of the screen). |
| ctx.strokeStyle = '#000'; |
| ctx.lineWidth = 1; |
| // Offset by 0.5 to make sure draw a clean 1px-wide black line, not a 2px-wide grey line. |
| // See above for more details. |
| const corner = zoomedCanvasSize / 2 - 0.5; |
| ctx.strokeRect(corner, corner, this._zoomLevel, this._zoomLevel); |
| } |
| } |
| |
| _getCrosshairCanvas(imgIdx) { |
| return $$(`canvas.idx_${imgIdx}`, this); |
| } |
| |
| _getImage(imgIdx) { |
| return $$(`img.idx_${imgIdx}`, this); |
| } |
| |
| _imageLoaded(imgIdx) { |
| // To get the image data for the image that just loaded, we draw it into our scratch canvas and |
| // then read it back. |
| const img = this._getImage(imgIdx); |
| const scratchCanvas = $$('canvas.scratch', this); |
| scratchCanvas.width = img.naturalWidth; |
| scratchCanvas.height = img.naturalHeight; |
| |
| const ctx = scratchCanvas.getContext('2d'); |
| ctx.clearRect(0, 0, img.naturalWidth, img.naturalHeight); |
| ctx.drawImage(img, 0, 0); |
| this._loadedImageData[imgIdx] = ctx.getImageData(0, 0, img.naturalWidth, img.naturalHeight); |
| |
| this._render(); |
| if (this._loadedImageData[leftImageIdx] && this._loadedImageData[diffImageIdx] |
| && this._loadedImageData[rightImageIdx]) { |
| this.dispatchEvent(new CustomEvent('sources-loaded', { bubbles: true })); |
| } |
| } |
| |
| _keyPressed(e) { |
| // Advice taken from https://medium.com/@uistephen/keyboardevent-key-for-cross-browser-key-press-check-61dbad0a067a |
| const zoomData = this._loadedImageData[this._zoomedIndex]; |
| const key = e.key || e.keyCode; |
| switch (key) { |
| case 'z': case 90: // Zoom in |
| this._zoomLevel = clamp(this._zoomLevel * 2, 1, 128); |
| this._render(); |
| break; |
| case 'a': case 65: // Zoom out |
| this._zoomLevel = clamp(this._zoomLevel / 2, 1, 128); |
| this._render(); |
| break; |
| case 'j': case 74: // Go down |
| if (zoomData) { |
| this._y = clamp(this._y + 1, 0, zoomData.height - 1); |
| this._render(); |
| } |
| break; |
| case 'k': case 75: // Go up |
| if (zoomData) { |
| this._y = clamp(this._y - 1, 0, zoomData.height - 1); |
| this._render(); |
| } |
| break; |
| case 'l': case 76: // Move right |
| if (zoomData) { |
| this._x = clamp(this._x + 1, 0, zoomData.width - 1); |
| this._render(); |
| } |
| break; |
| case 'h': case 72: // Move left |
| if (zoomData) { |
| this._x = clamp(this._x - 1, 0, zoomData.width - 1); |
| this._render(); |
| } |
| break; |
| case 'm': case 77: // Manually cycle to next image. |
| this._cyclingView = false; |
| this._nextZoomedImage(); |
| break; |
| case 'g': case 71: // Toggle the grid. |
| this._showGrid = !this._showGrid; |
| this._render(); |
| break; |
| case 'u': case 85: // move to next largest pixel diff. |
| this._moveToNextLargestDiff(false); |
| break; |
| case 'y': case 89: // move to previous next largest pixel diff. |
| this._moveToNextLargestDiff(true); |
| break; |
| default: |
| return; |
| } |
| // If we captured the key event, stop it from propagating. |
| e.stopPropagation(); |
| } |
| |
| _moveToNextLargestDiff(backwards) { |
| const leftData = this._loadedImageData[leftImageIdx]; |
| const rightData = this._loadedImageData[rightImageIdx]; |
| if (!leftData || !rightData) { |
| return; |
| } |
| if (!this._cachedDiffs || !this._cachedDiffs.length) { |
| this._buildPixelDiffCache(); |
| } |
| |
| if (backwards) { |
| this._cachedDiffIdx = clamp(this._cachedDiffIdx - 1, 0, this._cachedDiffs.length - 1); |
| } else { |
| this._cachedDiffIdx = clamp(this._cachedDiffIdx + 1, 0, this._cachedDiffs.length - 1); |
| } |
| |
| const diff = this._cachedDiffs[this._cachedDiffIdx]; |
| if (!diff) { |
| // Perhaps there are no diffs? |
| return; |
| } |
| this._x = diff.x; |
| this._y = diff.y; |
| this._render(); |
| } |
| |
| _nextZoomedImage() { |
| // Cycle through our 3 possible images looking for the first one that is selected to be rotated |
| // through. |
| for (let i = 0; i < 3; i++) { |
| this._zoomedIndex = (this._zoomedIndex + 1) % 3; |
| if (this._cycleThrough[this._zoomedIndex]) { |
| this._render(); |
| return; // we've found a selected index. |
| } |
| } |
| // None of the 3 indices are valid, set to -1 to mean "don't show anything" |
| this._zoomedIndex = -1; |
| } |
| |
| _previewCanvasClicked(e, imgIdx) { |
| const imgData = this._loadedImageData[imgIdx]; |
| if (!imgData) { |
| return; |
| } |
| const scale = scaleOf(imgData.width, imgData.height); |
| // offsetX and offsetY are in scaled coordinates; by dividing by scale and then rounding them, |
| // we convert them approximately onto the unscaled coordinates. |
| const x = Math.round(e.offsetX / scale); |
| const y = Math.round(e.offsetY / scale); |
| |
| this._x = clamp(x, 0, imgData.width - 1); |
| this._y = clamp(y, 0, imgData.height - 1); |
| this._render(); |
| } |
| |
| _render() { |
| super._render(); |
| // HTML elements are in place, draw on our canvases now. |
| for (let imgIdx = 0; imgIdx < 3; imgIdx++) { |
| this._drawCrosshairsOnPreview(imgIdx); |
| } |
| this._drawZoomedView(this._zoomedIndex); |
| } |
| }); |