blob: fc619b4c5fa2c2dbee4b3338a7ab916d0f701ed4 [file] [log] [blame]
/**
* @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);
}
});