renderdiff: add magnification glass of rendering (#9265)

- Show magnifying glass when diffing between two images
- Add checkbox to enable or disable feature
This commit is contained in:
Powei Feng
2025-09-26 17:19:26 -07:00
committed by GitHub
parent d8e8aafad7
commit 0fa3de5ac7
2 changed files with 455 additions and 7 deletions

View File

@@ -53,10 +53,27 @@ class ExpandedPassedResult extends LitElement {
render() {
if (this.content) {
const url = getGoldenUrl(this.content);
return html`<tiff-viewer fileurl="${url}"></tiff-viewer>`;
return html`
<div>
<div style="margin-bottom: 10px;">
<label>
<input type="checkbox" id="magnifierToggle" checked @change="${this._onMagnifierToggle}">
Enable Magnifier
</label>
</div>
<tiff-viewer fileurl="${url}" magnifier-enabled id="passedViewer"></tiff-viewer>
</div>
`;
}
return html``;
}
_onMagnifierToggle(ev) {
const viewer = this.shadowRoot.getElementById('passedViewer');
if (viewer) {
viewer.magnifierEnabled = ev.target.checked;
}
}
}
customElements.define('expanded-passed-result', ExpandedPassedResult);
@@ -97,6 +114,7 @@ class ExpandedComparisonResult extends LitElement {
leftViewType: { type: String },
rightViewType: { type: String },
disableDropdowns: { type: Boolean },
magnifierEnabled: { type: Boolean },
};
}
@@ -116,6 +134,12 @@ class ExpandedComparisonResult extends LitElement {
margin-top: 22px;
margin-bottom: 5px;
}
.diff-container {
position: relative;
}
.viewer-container {
position: relative;
}
.selector {
margin: 8px 0;
}
@@ -132,6 +156,10 @@ class ExpandedComparisonResult extends LitElement {
this.leftViewType = 'golden';
this.rightViewType = 'golden';
this.disableDropdowns = false;
this.magnifierEnabled = true;
this.diffCanvasRect = null;
this.originalDiffImageData = null;
this.globalMousePosition = null;
this.addEventListener(
'image-loaded',
@@ -156,7 +184,9 @@ class ExpandedComparisonResult extends LitElement {
<div>${current.name}</div>
<tiff-viewer id="viewer-${name}" class="viewer"
name="${current.name}"
fileurl="${url}"></tiff-viewer>
fileurl="${url}"
?magnifier-enabled="${this.magnifierEnabled}"
disable-mouse-handlers></tiff-viewer>
</div>
`;
}
@@ -233,6 +263,8 @@ class ExpandedComparisonResult extends LitElement {
if (diff.result == RES_DIFFERENT_PIXELS) {
this.diffResult = diff;
this.showDiff = true;
// Reset original diff data when new diff is computed
this.originalDiffImageData = null;
}
}
@@ -263,6 +295,228 @@ class ExpandedComparisonResult extends LitElement {
// Create the ImageData from the modified copy.
const imgData = new ImageData(diffImgCopy, diffResult.dim.width, diffResult.dim.height);
diffCtx.putImageData(imgData, 0, 0);
// Store both original and current diff image data for magnifier
if (!this.originalDiffImageData) {
this.originalDiffImageData = new ImageData(diffResult.diffImg.slice(), diffResult.dim.width, diffResult.dim.height);
}
this.currentDiffImageData = imgData;
}
_onDiffMouseEnter(event) {
if (!this.showDiff || !this.currentDiffImageData) return;
this.diffCanvasRect = event.target.getBoundingClientRect();
}
_onGlobalMouseLeave(event) {
// Hide all magnifiers when mouse leaves the viewer container
const leftViewer = this.shadowRoot.querySelector('#viewer-left');
const rightViewer = this.shadowRoot.querySelector('#viewer-right');
const diffMagnifier = this.shadowRoot.getElementById('diffMagnifier');
if (leftViewer) {
const leftMagnifier = leftViewer.shadowRoot?.getElementById('magnifier');
if (leftMagnifier) leftMagnifier.hide();
}
if (rightViewer) {
const rightMagnifier = rightViewer.shadowRoot?.getElementById('magnifier');
if (rightMagnifier) rightMagnifier.hide();
}
if (diffMagnifier) diffMagnifier.hide();
}
_onGlobalMouseMove(event) {
this.globalMousePosition = { clientX: event.clientX, clientY: event.clientY };
// Update all three magnifiers
this._updateLeftMagnifier(event);
this._updateRightMagnifier(event);
this._updateDiffMagnifier(event);
}
_updateLeftMagnifier(event) {
if (!this.magnifierEnabled) return;
const leftViewer = this.shadowRoot.querySelector('#viewer-left');
if (!leftViewer) return;
const canvas = leftViewer.shadowRoot?.querySelector('canvas');
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const leftMagnifier = leftViewer.shadowRoot?.getElementById('magnifier');
if (!leftMagnifier) return;
// Get image data from the viewer
const imageData = leftViewer.imageData;
if (!imageData) {
leftMagnifier.hide();
return;
}
// Check if mouse is over any of the three views (left, right, or diff)
if (!this._isMouseOverAnyView(event)) {
leftMagnifier.hide();
return;
}
// Calculate the equivalent position on the left image
const { imageX, imageY, mouseX, mouseY } = this._calculateEquivalentPosition(event, rect, imageData);
// Position magnifier relative to the canvas within the left viewer
leftMagnifier.updateMagnifier(imageData, imageX, imageY, mouseX, mouseY, rect);
}
_updateRightMagnifier(event) {
if (!this.magnifierEnabled) return;
const rightViewer = this.shadowRoot.querySelector('#viewer-right');
if (!rightViewer) return;
const canvas = rightViewer.shadowRoot?.querySelector('canvas');
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const rightMagnifier = rightViewer.shadowRoot?.getElementById('magnifier');
if (!rightMagnifier) return;
// Get image data from the viewer
const imageData = rightViewer.imageData;
if (!imageData) {
rightMagnifier.hide();
return;
}
// Check if mouse is over any of the three views (left, right, or diff)
if (!this._isMouseOverAnyView(event)) {
rightMagnifier.hide();
return;
}
// Calculate the equivalent position on the right image
const { imageX, imageY, mouseX, mouseY } = this._calculateEquivalentPosition(event, rect, imageData);
// Position magnifier relative to the canvas within the right viewer
rightMagnifier.updateMagnifier(imageData, imageX, imageY, mouseX, mouseY, rect);
}
_updateDiffMagnifier(event) {
if (!this.magnifierEnabled || !this.showDiff || !this.currentDiffImageData || !this.diffCanvasRect) return;
const rect = this.diffCanvasRect;
const diffMagnifier = this.shadowRoot.getElementById('diffMagnifier');
if (!diffMagnifier) return;
// Check if mouse is over any of the three views (left, right, or diff)
if (!this._isMouseOverAnyView(event)) {
diffMagnifier.hide();
return;
}
// Calculate the equivalent position on the diff image
const { imageX, imageY, mouseX, mouseY } = this._calculateEquivalentPosition(event, rect, this.currentDiffImageData);
// Position magnifier relative to the diff canvas
diffMagnifier.updateMagnifier(this.currentDiffImageData, imageX, imageY, mouseX, mouseY, rect, 8, this.originalDiffImageData);
}
_isMouseOverElement(event, rect) {
return event.clientX >= rect.left &&
event.clientX <= rect.right &&
event.clientY >= rect.top &&
event.clientY <= rect.bottom;
}
_isMouseOverAnyView(event) {
// Check if mouse is over left viewer
const leftViewer = this.shadowRoot.querySelector('#viewer-left');
if (leftViewer) {
const leftCanvas = leftViewer.shadowRoot?.querySelector('canvas');
if (leftCanvas && this._isMouseOverElement(event, leftCanvas.getBoundingClientRect())) {
return true;
}
}
// Check if mouse is over right viewer
const rightViewer = this.shadowRoot.querySelector('#viewer-right');
if (rightViewer) {
const rightCanvas = rightViewer.shadowRoot?.querySelector('canvas');
if (rightCanvas && this._isMouseOverElement(event, rightCanvas.getBoundingClientRect())) {
return true;
}
}
// Check if mouse is over diff canvas
if (this.showDiff && this.diffCanvasRect) {
if (this._isMouseOverElement(event, this.diffCanvasRect)) {
return true;
}
}
return false;
}
_calculateEquivalentPosition(event, targetRect, targetImageData) {
// Find which view the mouse is actually over and calculate the equivalent position
const leftViewer = this.shadowRoot.querySelector('#viewer-left');
const rightViewer = this.shadowRoot.querySelector('#viewer-right');
let sourceRect = null;
let sourceImageData = null;
// Determine source view
if (leftViewer) {
const leftCanvas = leftViewer.shadowRoot?.querySelector('canvas');
if (leftCanvas && this._isMouseOverElement(event, leftCanvas.getBoundingClientRect())) {
sourceRect = leftCanvas.getBoundingClientRect();
sourceImageData = leftViewer.imageData;
}
}
if (!sourceRect && rightViewer) {
const rightCanvas = rightViewer.shadowRoot?.querySelector('canvas');
if (rightCanvas && this._isMouseOverElement(event, rightCanvas.getBoundingClientRect())) {
sourceRect = rightCanvas.getBoundingClientRect();
sourceImageData = rightViewer.imageData;
}
}
if (!sourceRect && this.showDiff && this.diffCanvasRect) {
if (this._isMouseOverElement(event, this.diffCanvasRect)) {
sourceRect = this.diffCanvasRect;
sourceImageData = this.currentDiffImageData;
}
}
if (!sourceRect || !sourceImageData) {
// Fallback to target rect
sourceRect = targetRect;
sourceImageData = targetImageData;
}
// Calculate relative position in source
const sourceMouseX = event.clientX - sourceRect.left;
const sourceMouseY = event.clientY - sourceRect.top;
const sourceScaleX = sourceImageData.width / sourceRect.width;
const sourceScaleY = sourceImageData.height / sourceRect.height;
const sourceImageX = Math.floor(sourceMouseX * sourceScaleX);
const sourceImageY = Math.floor(sourceMouseY * sourceScaleY);
// Convert to target coordinates
const targetScaleX = targetImageData.width / targetRect.width;
const targetScaleY = targetImageData.height / targetRect.height;
const targetMouseX = sourceImageX / targetScaleX;
const targetMouseY = sourceImageY / targetScaleY;
return {
imageX: sourceImageX,
imageY: sourceImageY,
mouseX: targetMouseX,
mouseY: targetMouseY
};
}
render() {
@@ -274,22 +528,34 @@ class ExpandedComparisonResult extends LitElement {
const v1 = this._viewer("left", this.tests, this.left, this.leftViewType);
const v2 = this._viewer("right", this.tests, this.right, this.rightViewType);
const onMultiplierChange = (ev) => {
const multiplierValue = this.shadowRoot.querySelector('#multiplierValue');
multiplierValue.textContent = ev.target.value;
this._updateDiffCanvas(this.diffResult, ev.target.value);
};
const onMagnifierToggle = (ev) => {
this.magnifierEnabled = ev.target.checked;
};
return html`
<div class="main-container">
<div class="viewer-container">
<div class="viewer-container" @mousemove="${this._onGlobalMouseMove}" @mouseleave="${this._onGlobalMouseLeave}">
${v1}
<div style="flex: 1; ${diffStyle}">
<canvas id="diffCanvas"></canvas>
<div style="flex: 1; ${diffStyle}" class="diff-container">
<canvas id="diffCanvas" @mouseenter="${this._onDiffMouseEnter}"></canvas>
<image-magnifier id="diffMagnifier"></image-magnifier>
</div>
${v2}
</div>
<div class="control" style="${diffStyle}">
<div>
<div>Difference Multiplier</div>
<div>Difference Multiplier: <span id="multiplierValue">1</span></div>
<input type="range" min="1" max="100" value="1" id="diffMultiplier" @input=${onMultiplierChange}>
</div>
<div style="margin-top: 10px;">
<label>
<input type="checkbox" .checked="${this.magnifierEnabled}" @change=${onMagnifierToggle}>
Enable Magnifier
</label>
</div>
</div>
</div>
`;

View File

@@ -14,11 +14,154 @@
import { LitElement, html, css } from "https://cdn.jsdelivr.net/gh/lit/dist@3/all/lit-all.min.js";
class ImageMagnifier extends LitElement {
static styles = css`
:host {
position: absolute;
pointer-events: none;
z-index: 1000;
display: none;
}
:host([visible]) {
display: block;
}
.magnifier {
width: 150px;
height: 150px;
border: 2px solid #000;
border-radius: 75px;
background: white;
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
position: relative;
}
.magnifier-canvas {
width: 100%;
height: 100%;
border-radius: 73px;
}
.pixel-info {
position: absolute;
top: -65px;
left: 0;
background: rgba(0,0,0,0.8);
color: white;
padding: 4px 8px;
border-radius: 4px;
font-family: monospace;
font-size: 11px;
white-space: pre-line;
max-width: 250px;
}
`;
static properties = {
visible: {type: Boolean, reflect: true},
};
constructor() {
super();
this.visible = false;
}
render() {
return html`
<div class="magnifier">
<canvas class="magnifier-canvas" id="magnifierCanvas"></canvas>
<div class="pixel-info" id="pixelInfo"></div>
</div>
`;
}
updateMagnifier(imageData, imageX, imageY, mouseX, mouseY, canvasRect, zoomFactor = 8, originalImageData = null) {
if (!imageData) return;
if (imageX < 0 || imageX >= imageData.width || imageY < 0 || imageY >= imageData.height) {
this.visible = false;
return;
}
const pixelIndex = (imageY * imageData.width + imageX) * 4;
const r = imageData.data[pixelIndex];
const g = imageData.data[pixelIndex + 1];
const b = imageData.data[pixelIndex + 2];
const a = imageData.data[pixelIndex + 3];
let pixelInfoText = `RGBA(${r}, ${g}, ${b}, ${a})\n@ (${imageX}, ${imageY})`;
// If original image data is provided, show the unmultiplied values too
if (originalImageData) {
const origR = originalImageData.data[pixelIndex];
const origG = originalImageData.data[pixelIndex + 1];
const origB = originalImageData.data[pixelIndex + 2];
const origA = originalImageData.data[pixelIndex + 3];
pixelInfoText = `Diff: RGBA(${origR}, ${origG}, ${origB}, ${origA})\nDisplay: RGBA(${r}, ${g}, ${b}, ${a})\n@ (${imageX}, ${imageY})`;
}
const magnifierSize = 150;
const sourceSize = magnifierSize / zoomFactor;
const halfSource = sourceSize / 2;
const sourceX = Math.max(0, Math.min(imageData.width - sourceSize, imageX - halfSource));
const sourceY = Math.max(0, Math.min(imageData.height - sourceSize, imageY - halfSource));
const magnifierCanvas = this.shadowRoot.getElementById('magnifierCanvas');
const pixelInfo = this.shadowRoot.getElementById('pixelInfo');
magnifierCanvas.width = magnifierSize;
magnifierCanvas.height = magnifierSize;
const magnifierCtx = magnifierCanvas.getContext('2d');
magnifierCtx.imageSmoothingEnabled = false;
const tempCanvas = document.createElement('canvas');
tempCanvas.width = imageData.width;
tempCanvas.height = imageData.height;
const tempCtx = tempCanvas.getContext('2d');
tempCtx.putImageData(imageData, 0, 0);
magnifierCtx.drawImage(
tempCanvas,
sourceX, sourceY, sourceSize, sourceSize,
0, 0, magnifierSize, magnifierSize
);
const centerX = magnifierSize / 2;
const centerY = magnifierSize / 2;
magnifierCtx.strokeStyle = 'red';
magnifierCtx.lineWidth = 1;
magnifierCtx.beginPath();
magnifierCtx.moveTo(centerX - 5, centerY);
magnifierCtx.lineTo(centerX + 5, centerY);
magnifierCtx.moveTo(centerX, centerY - 5);
magnifierCtx.lineTo(centerX, centerY + 5);
magnifierCtx.stroke();
// Position relative to the TiffViewer container
this.style.left = (mouseX - magnifierSize / 2) + 'px';
this.style.top = (mouseY - magnifierSize / 2) + 'px';
pixelInfo.textContent = pixelInfoText;
this.visible = true;
}
hide() {
this.visible = false;
}
}
customElements.define('image-magnifier', ImageMagnifier);
// Generated by Gemini with some modifications
export class TiffViewer extends LitElement {
static styles = css`
:host {
display: block;
position: relative;
}
canvas {
border: 1px solid #ccc;
@@ -31,6 +174,8 @@ export class TiffViewer extends LitElement {
fileurl: {type: String, attribute: 'fileurl'},
name: {type: String, attribute: 'name'},
failedToFetch: {type: Boolean },
magnifierEnabled: {type: Boolean, attribute: 'magnifier-enabled'},
disableMouseHandlers: {type: Boolean, attribute: 'disable-mouse-handlers'},
};
constructor() {
@@ -38,13 +183,20 @@ export class TiffViewer extends LitElement {
this.fileurl = null;
this.name = null;
this.failedToFetch = false;
this.magnifierEnabled = false;
this.disableMouseHandlers = false;
this.imageData = null;
this.canvasRect = null;
}
render() {
if (this.failedToFetch) {
return html``;
}
return html`<canvas id="tiffCanvas"></canvas>`;
return html`
<canvas id="tiffCanvas" @mousemove="${this._onMouseMove}" @mouseenter="${this._onMouseEnter}" @mouseleave="${this._onMouseLeave}"></canvas>
<image-magnifier id="magnifier"></image-magnifier>
`;
}
updated(props) {
@@ -112,6 +264,8 @@ export class TiffViewer extends LitElement {
const imageData = new ImageData(new Uint8ClampedArray(rgba), firstImage.width, firstImage.height);
ctx.putImageData(imageData, 0, 0);
this.imageData = imageData;
this.dispatchEvent(new CustomEvent('image-loaded', {
bubbles: true,
composed: true,
@@ -133,6 +287,34 @@ export class TiffViewer extends LitElement {
ctx.clearRect(0, 0, canvas.width, canvas.height);
}
}
_onMouseEnter(event) {
if (this.disableMouseHandlers || !this.magnifierEnabled || !this.imageData) return;
this.canvasRect = event.target.getBoundingClientRect();
}
_onMouseLeave(event) {
if (this.disableMouseHandlers || !this.magnifierEnabled) return;
const magnifier = this.shadowRoot.getElementById('magnifier');
magnifier.hide();
}
_onMouseMove(event) {
if (this.disableMouseHandlers || !this.magnifierEnabled || !this.imageData || !this.canvasRect) return;
const rect = this.canvasRect;
const scaleX = this.imageData.width / rect.width;
const scaleY = this.imageData.height / rect.height;
const mouseX = event.clientX - rect.left;
const mouseY = event.clientY - rect.top;
const imageX = Math.floor(mouseX * scaleX);
const imageY = Math.floor(mouseY * scaleY);
const magnifier = this.shadowRoot.getElementById('magnifier');
magnifier.updateMagnifier(this.imageData, imageX, imageY, event.clientX, event.clientY, rect);
}
}
customElements.define('tiff-viewer', TiffViewer);