@syngrisi/syngrisi
Version:
Syngrisi - Visual Testing Tool
1,071 lines (937 loc) • 36.2 kB
text/typescript
import { fabric } from 'fabric';
import { SimpleView } from '@index/components/Tests/Table/Checks/CheckDetails/Canvas/simpleView';
import { SideToSideView } from '@index/components/Tests/Table/Checks/CheckDetails/Canvas/sideToSideView';
import { lockImage } from '@index/components/Tests/Table/Checks/CheckDetails/Canvas/helpers';
import { errorMsg, successMsg } from '@shared/utils/utils';
import config from '@config';
import { log } from '@shared/utils/Logger';
import { highlightDiff, mergeNearbyGroups } from '@index/components/Tests/Table/Checks/CheckDetails/Toolbar/highlightDiff';
import { RCAOverlay, RCAOverlayCallbacks } from './rcaOverlay';
import { DOMNode, DOMChange } from '@shared/interfaces/IRCA';
/* eslint-disable dot-notation,no-underscore-dangle */
interface IRectParams {
name: any;
fill: any;
stroke: any;
strokeWidth: any;
top: any;
left: any;
width: any;
height: any;
}
interface Props {
canvasElementWidth: number;
canvasElementHeight: number;
canvasId: string;
// url: string
actual: any;
expectedImage: any;
actualImage: any;
diffImage: any;
}
export class MainView {
canvasElementWidth: number;
canvasElementHeight: number;
sliderView: SideToSideView;
canvas: fabric.Canvas;
actualImage: any;
currentMode: any;
defaultMode: string;
currentView: string;
actualView: SimpleView;
expectedView: SimpleView;
diffView: SimpleView;
expectedImage: any;
diffImage: any;
// Bounding region overlay state
boundingOverlayEnabled: boolean = false;
// RCA (Root Cause Analysis) overlay
private rcaOverlay: RCAOverlay | null = null;
private rcaCallbacks: RCAOverlayCallbacks = {};
// Region state for dirty checking
snapshotRegions: string = '';
constructor(
{
canvasElementWidth,
canvasElementHeight,
canvasId,
actual,
expectedImage,
actualImage,
diffImage,
}: Props,
) {
fabric.Object.prototype.objectCaching = false;
// init properties
this.canvasElementWidth = canvasElementWidth;
this.canvasElementHeight = canvasElementHeight;
this.actualImage = lockImage(actualImage);
this.expectedImage = lockImage(expectedImage);
this.diffImage = diffImage ? lockImage(diffImage) : null;
this.canvas = new fabric.Canvas(canvasId, {
width: this.canvasElementWidth,
height: this.canvasElementHeight,
preserveObjectStacking: true,
uniformScaling: false,
});
// this._currentView = 'actual';
// this.expectedCanvasViewportAreaSize = MainView.calculateExpectedCanvasViewportAreaSize();
this.defaultMode = '';
// @ts-ignore - Expose mainView instance for E2E tests
window.mainView = this;
this.currentView = this.diffImage ? 'diff' : 'actual';
if (actual) {
this.sliderView = new SideToSideView(
{
mainView: this,
},
);
}
// events
this.selectionEvents();
this.zoomEvents();
this.panEvents();
this.initBoundingOverlay();
this.boundingRegionEvents();
// Initialize RCA overlay
this.rcaOverlay = new RCAOverlay(this.canvas, this.rcaCallbacks);
// views
this.expectedView = new SimpleView(this, 'expected');
this.actualView = new SimpleView(this, 'actual');
this.diffView = new SimpleView(this, 'diff');
this[`${this.currentView}View`].render();
// this.sideToSideView.render()
}
/**
* Update images without recreating canvas - used for navigation optimization
*/
// Concurrency control
private currentUpdateId: number = 0;
/**
* Update images without recreating canvas - used for navigation optimization
*/
/**
* Update images without recreating canvas - used for navigation optimization
*/
async updateImages({
expectedImage,
actualImage,
diffImage,
actual,
}: {
expectedImage: fabric.Image;
actualImage: fabric.Image;
diffImage: fabric.Image | null;
actual: any;
}): Promise<void> {
// Generate new update ID
this.currentUpdateId++;
const updateId = this.currentUpdateId;
// 1. Destroy old views but keep canvas
await this.destroyAllViews();
// Check if a new update has started while we were clearing
if (updateId !== this.currentUpdateId) {
log.debug(`[MainView] updateImages aborted after destroy (stale updateId: ${updateId}, current: ${this.currentUpdateId})`);
return;
}
// 2. Update image references
// Guard against disposed state
if (!this.canvas) return;
this.actualImage = lockImage(actualImage);
this.expectedImage = lockImage(expectedImage);
this.diffImage = diffImage ? lockImage(diffImage) : null;
// 3. Recreate views with new images
try {
if (actual) {
this.sliderView = new SideToSideView({ mainView: this });
}
this.expectedView = new SimpleView(this, 'expected');
this.actualView = new SimpleView(this, 'actual');
this.diffView = new SimpleView(this, 'diff');
// 4. Render current view
// Check updateId again before rendering
if (updateId !== this.currentUpdateId) {
log.debug(`[MainView] updateImages aborted before render (stale updateId: ${updateId}, current: ${this.currentUpdateId})`);
await this.destroyAllViews(); // Clean up partial state
return;
}
if ((this as any)[`${this.currentView}View`]) {
(this as any)[`${this.currentView}View`].render();
}
// 5. Clear regions (will be reloaded by useEffect)
if (updateId === this.currentUpdateId) {
this.removeAllRegions();
}
} catch (e) {
log.error('[MainView] Error during updateImages:', e);
// Attempt cleanup if something failed
await this.destroyAllViews();
}
}
/**
* Check if canvas dimensions need to be updated
*/
needsCanvasResize(newWidth: number, newHeight: number): boolean {
return this.canvasElementWidth !== newWidth ||
this.canvasElementHeight !== newHeight;
}
/**
* Resize canvas (only when viewport changes)
*/
resizeCanvas(newWidth: number, newHeight: number): void {
this.canvasElementWidth = newWidth;
this.canvasElementHeight = newHeight;
this.canvas.setDimensions({
width: newWidth,
height: newHeight,
});
this.zoomToFit();
this.canvas.renderAll();
}
/**
* Zoom and pan to fit the image within the canvas
*/
zoomToFit(): void {
if (!this.canvas || !this.actualImage) return;
const imgWidth = this.actualImage.width || 0;
const imgHeight = this.actualImage.height || 0;
if (imgWidth === 0 || imgHeight === 0) return;
const canvasWidth = this.canvas.width || 0;
const canvasHeight = this.canvas.height || 0;
// Calculate scale to fit with some padding
const padding = 20;
const availableWidth = canvasWidth - padding * 2;
const availableHeight = canvasHeight - padding * 2;
const scaleX = availableWidth / imgWidth;
const scaleY = availableHeight / imgHeight;
const scale = Math.min(scaleX, scaleY);
// Set zoom
this.canvas.setZoom(scale);
// Center
const vpt = this.canvas.viewportTransform;
if (vpt) {
vpt[0] = scale;
vpt[3] = scale;
vpt[4] = (canvasWidth - imgWidth * scale) / 2;
vpt[5] = (canvasHeight - imgHeight * scale) / 2;
}
this.updateRCATransform();
}
/*
this is the area from the left top canvas corner till the end of the viewport
┌──────┬─────────────┐
│ │xxxxxxxx │
│ │xxxxxxxx │
│ │xxxxxxxx │
│ │xxxxxxxx │
│ │ │
│ │ │
│ │ the area │
│ │ │
│ │ │
└──────┴─────────────┘
*/
static calculateExpectedCanvasViewportAreaSize() {
const viewportWidth = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
const viewportHeight = Math.max(document.documentElement.clientHeight || 0, window.innerHeight || 0);
const canvasDimensions = document.getElementById('snapshoot')!
.getBoundingClientRect();
return {
width: Number(viewportWidth - canvasDimensions.x),
height: Number(viewportHeight - canvasDimensions.y),
};
}
zoomEvents() {
this.canvas.on('mouse:wheel', (opt: any) => {
if (!opt.e.ctrlKey) return;
const delta = opt.e.deltaY;
let zoomVal = this.canvas.getZoom();
zoomVal *= 0.999 ** delta;
if (zoomVal > 9) zoomVal = 9;
if (zoomVal < 0.01) zoomVal = 0.01;
this.canvas.zoomToPoint({
x: opt.e.offsetX,
y: opt.e.offsetY,
}, zoomVal);
// Update RCA overlay transform
this.updateRCATransform();
// setZoomPercent(() => zoomVal * 100);
document.dispatchEvent(new Event('zoom'));
opt.e.preventDefault();
opt.e.stopPropagation();
});
}
panEvents() {
this.canvas.on(
'mouse:move', (e) => {
// log.debug(e.e.buttons);
if ((e.e.buttons === 4)) {
this.canvas.setCursor('grab');
const mEvent = e.e;
const delta = new fabric.Point(mEvent.movementX, mEvent.movementY);
this.canvas.relativePan(delta);
this.canvas.renderAll();
// Update RCA overlay transform
this.updateRCATransform();
}
},
);
this.canvas.on('mouse:wheel', (opt) => {
if (opt.e.ctrlKey) return;
const delta = new fabric.Point(-opt.e.deltaX / 2, -opt.e.deltaY / 2);
this.canvas.relativePan(delta);
// this.canvas.dispatchEvent(new Event('pan'));
this.canvas.fire('pan', opt);
this.canvas.renderAll();
// Update RCA overlay transform
this.updateRCATransform();
opt.e.preventDefault();
opt.e.stopPropagation();
});
}
selectionEvents() {
// disable rotation point for selections
this.canvas.on('selection:created', (e) => {
const activeSelection: any = e.target;
if (!activeSelection?._objects?.length || (activeSelection?._objects?.length < 2)) return;
activeSelection.hasControls = false;
this.canvas.renderAll();
});
// fired e.g. when you select one object first,
// then add another via shift+click
this.canvas.on('selection:updated', (e) => {
const activeSelection: any = e.target;
if (!activeSelection?._objects?.length || (activeSelection?._objects?.length < 2)) return;
if (activeSelection.hasControls) {
activeSelection.hasControls = false;
}
});
}
/**
* Initialize bounding overlay rendering in the after:render event
*/
initBoundingOverlay() {
this.canvas.on('after:render', () => this.renderBoundingOverlay());
}
/**
* Render semi-transparent overlay with a cutout for the bounding region
* Uses evenodd fill rule to create the "hole" effect
*/
renderBoundingOverlay() {
if (!this.boundingOverlayEnabled) return;
const boundRect = this.canvas.getObjects()
.find((obj) => obj.name === 'bound_rect') as fabric.Rect;
if (!boundRect) return;
const ctx = this.canvas.getContext();
const vpt = this.canvas.viewportTransform;
ctx.save();
ctx.beginPath();
// Outer rectangle (entire canvas)
ctx.rect(0, 0, this.canvas.width!, this.canvas.height!);
// Apply viewport transform for correct zoom/pan handling
ctx.transform(vpt![0], vpt![1], vpt![2], vpt![3], vpt![4], vpt![5]);
// Inner rectangle (cutout) - the bounding region area
ctx.rect(
boundRect.left!,
boundRect.top!,
boundRect.getScaledWidth(),
boundRect.getScaledHeight(),
);
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
ctx.fill('evenodd');
ctx.restore();
}
/**
* Update bounding overlay state based on presence of bound_rect
*/
updateBoundingOverlay() {
const hasBoundRect = this.canvas.getObjects()
.some((obj) => obj.name === 'bound_rect');
this.boundingOverlayEnabled = hasBoundRect;
this.canvas.requestRenderAll();
}
/**
* Subscribe to bounding region events for real-time overlay updates
*/
boundingRegionEvents() {
// Update overlay during move/resize for real-time feedback
this.canvas.on('object:moving', (e) => {
if (e.target?.name === 'bound_rect') {
this.canvas.requestRenderAll();
}
});
this.canvas.on('object:scaling', (e) => {
if (e.target?.name === 'bound_rect') {
this.canvas.requestRenderAll();
}
});
this.canvas.on('object:modified', (e) => {
if (e.target?.name === 'bound_rect') {
this.canvas.requestRenderAll();
}
});
}
// get objects() {
// return this.canvas.getObjects();
// }
async destroyAllViews() {
this.expectedView.destroy();
this.actualView.destroy();
this.diffView.destroy();
await this.sliderView.destroy();
}
async switchView(view: string) {
const previousView = this.currentView;
const simpleViews = ['actual', 'expected', 'diff'];
const shouldPreserveViewport = simpleViews.includes(previousView) && simpleViews.includes(view);
const viewportTransform = shouldPreserveViewport && this.canvas.viewportTransform
? [...this.canvas.viewportTransform]
: null;
this.currentView = view;
await this.destroyAllViews();
this.sliderView = new SideToSideView(
{
mainView: this,
},
);
await this[`${view}View`].render();
if (viewportTransform) {
this.canvas.setViewportTransform(viewportTransform);
this.updateRCATransform();
this.canvas.requestRenderAll();
document.dispatchEvent(new Event('zoom'));
}
}
panToCanvasWidthCenter(imageName: string) {
const image = this[imageName as keyof this] as fabric.Image | null;
if (!image) return;
this.canvas.absolutePan(new fabric.Point(0, 0));
const delta = new fabric.Point(
((this.canvas.width / 2)
- ((image.width * this.canvas.getZoom()) / 2)
),
0,
);
this.canvas.relativePan(delta);
}
removeActiveIgnoreRegions() {
const els = this.canvas.getActiveObjects()
.filter((x) => x.name === 'ignore_rect');
this.canvas.discardActiveObject()
.renderAll();
if (els.length === 0) {
// eslint-disable-next-line no-undef,no-alert
alert('there is no active regions for removing');
return;
}
els.forEach((el) => {
this.canvas.remove(el);
});
this.canvas.renderAll();
}
addRect(params: IRectParams) {
// eslint-disable-next-line no-param-reassign
params.name = params.name ? params.name : 'default_rect';
let lastLeft = null;
let lastTop = null;
let width = null;
let height = null;
if ((this.getLastRegion() !== undefined) && (this.getLastRegion().name === 'ignore_rect')) {
lastLeft = this.getLastRegion().left || 50;
lastTop = this.getLastRegion().top;
width = this.getLastRegion()
.getScaledWidth();
height = this.getLastRegion()
.getScaledHeight();
}
// if last elements fit in current viewport create new region near this region
const top = (lastTop > document.documentElement.scrollTop
&& lastTop < document.documentElement.scrollTop + window.innerHeight)
? lastTop + 20
: document.documentElement.scrollTop + 50;
const left = (lastLeft < (this.canvas.width - 80)) ? lastLeft + 20 : lastLeft - 50;
return new fabric.Rect({
left: params.left || left,
top: params.top || top,
fill: params.fill || 'blue',
width: params.width || width || 200,
height: params.height || height || 100,
strokeWidth: params.strokeWidth || 2,
// stroke: params.stroke || 'rgba(100,200,200,0.5)',
stroke: params.stroke || 'black',
opacity: 0.5,
name: params.name,
// uniformScaling: true,
strokeUniform: true,
noScaleCache: false,
cornerSize: 9,
transparentCorners: false,
cornerColor: 'rgb(26, 115, 232)',
cornerStrokeColor: 'rgb(255, 255, 255)',
});
}
addIgnoreRegion(params) {
// @ts-ignore - Always sync window.mainView for E2E tests
window.mainView = this;
Object.assign(params, { fill: 'MediumVioletRed' });
const r = this.addRect(params);
// Explicitly set name property - fabric.js might not set it from constructor options
if (params.name && !r.name) {
r.name = params.name;
}
r.setControlsVisibility({
bl: true,
br: true,
tl: true,
tr: true,
mt: true,
mb: true,
mtr: false,
});
this.canvas.add(r);
r.bringToFront();
// become selected
if (params.noSelect) {
return;
}
this.canvas.setActiveObject(r);
}
/**
* Create ignore regions automatically from diff areas
* @param padding - pixels to add around each diff region (default: 5)
* @param mergeDistance - merge regions within this distance in pixels (default: 15)
* @returns number of regions created
*/
async createAutoIgnoreRegions(padding: number = 5, mergeDistance: number = 15): Promise<number> {
if (!this.diffImage) {
log.warn('[MainView] Cannot create auto regions: no diff image');
return 0;
}
try {
// Get diff groups without animation
const { groups: rawGroups } = await highlightDiff(this, null, null, { skipAnimation: true });
if (rawGroups.length === 0) {
log.debug('[MainView] No diff regions found');
return 0;
}
// Merge nearby groups to reduce number of small regions
const groups = mergeNearbyGroups(rawGroups, mergeDistance);
log.debug(`[MainView] Creating ${groups.length} auto ignore regions (merged from ${rawGroups.length} raw groups, mergeDistance: ${mergeDistance}px)`);
// Create ignore region for each diff group
// eslint-disable-next-line no-restricted-syntax
for (const group of groups) {
const regionParams = {
left: Math.max(0, group.minX - padding),
top: Math.max(0, group.minY - padding),
width: (group.maxX - group.minX) + padding * 2,
height: (group.maxY - group.minY) + padding * 2,
name: 'ignore_rect',
strokeWidth: 0,
noSelect: true, // Don't select each region as we add it
};
this.addIgnoreRegion(regionParams);
}
this.canvas.renderAll();
return groups.length;
} catch (e) {
log.error('[MainView] Failed to create auto regions:', e);
errorMsg({ error: 'Failed to create auto ignore regions' });
return 0;
}
}
addBoundingRegion(name) {
// Check if bound_rect already exists
const existingBoundRect = this.canvas.getObjects()
.find((obj) => obj.name === 'bound_rect');
if (existingBoundRect) {
log.warn('[MainView] Bound region already exists, skipping creation');
return;
}
const params = {
name,
fill: 'rgba(0,0,0,0)',
stroke: 'green',
strokeWidth: 3,
top: 1,
left: 1,
width: this.expectedImage.getScaledWidth() - 10,
height: this.expectedImage.getScaledHeight() - 10,
};
const r = this.addRect(params);
this.canvas.add(r);
r.bringToFront();
this.updateBoundingOverlay();
}
removeAllRegions() {
const regions = this.allRects;
regions.forEach((region) => {
this.canvas.remove(region);
});
this.updateBoundingOverlay();
}
get allRects() {
return this.canvas.getObjects()
.filter((r) => (r.name === 'ignore_rect') || (r.name === 'bound_rect'));
}
/**
* Check if there are any ignore or bound regions
*/
hasRegions(): boolean {
return this.allRects.length > 0;
}
/**
* Check if current regions differ from the last saved state
*/
isDirty(): boolean {
const currentData = this.getRegionsData();
const currentSnapshot = JSON.stringify(currentData);
// If we have never loaded regions (snapshotRegions is empty), and we have regions now, it's dirty
if (!this.snapshotRegions && this.hasRegions()) return true;
// If we have no regions now, and snapshot was empty, it's clean
if (!this.snapshotRegions && !this.hasRegions()) return false;
return this.snapshotRegions !== currentSnapshot;
}
/**
* Save regions and update snapshot
*/
async saveRegions(baselineId: string): Promise<boolean> {
const regionsData = this.getRegionsData();
const success = await MainView.sendRegions(baselineId, regionsData);
if (success) {
this.snapshotRegions = JSON.stringify(regionsData);
return true;
}
return false;
}
getLastRegion() {
return this.canvas.item(this.canvas.getObjects().length - 1);
}
/**
* 1. collect data about all rects
* 2. convert the data to resemble.js format
* 3. return json string
* @deprecated Use getRegionsData() instead
*/
getRectData() {
const rects = this.allRects;
const data = [];
const coef = parseFloat(this.coef);
rects.forEach((reg) => {
const right = reg.left + reg.getScaledWidth();
const bottom = reg.top + reg.getScaledHeight();
if (coef) {
data.push({
name: reg.name,
top: reg.top * coef,
left: reg.left * coef,
bottom: bottom * coef,
right: right * coef,
});
}
});
return JSON.stringify(data);
}
/**
* Collect data about all regions, separated by type
* @returns {{ ignoreRegions: string, boundRegions: string }} JSON strings for each region type
*/
getRegionsData(): { ignoreRegions: string, boundRegions: string } {
const rects = this.allRects;
const ignoreRegions: any[] = [];
const boundRegions: any[] = [];
const coef = parseFloat(this.coef);
rects.forEach((reg) => {
const right = reg.left + reg.getScaledWidth();
const bottom = reg.top + reg.getScaledHeight();
if (coef) {
const regionData = {
top: reg.top * coef,
left: reg.left * coef,
bottom: bottom * coef,
right: right * coef,
};
if (reg.name === 'ignore_rect') {
ignoreRegions.push(regionData);
} else if (reg.name === 'bound_rect') {
boundRegions.push(regionData);
}
}
});
return {
ignoreRegions: JSON.stringify(ignoreRegions),
boundRegions: JSON.stringify(boundRegions),
};
}
get coef() {
return this.expectedImage.height / this.expectedImage.getScaledHeight();
}
/**
* @deprecated Use sendRegions() instead
*/
static async sendIgnoreRegions(id: string, regionsData) {
try {
const response = await fetch(`${config.baseUri}/v1/baselines/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id, ignoreRegions: regionsData }),
});
const text = await response.text();
if (response.status === 200) {
log.debug(`Successful send baseline ignored regions, id: '${id}' resp: '${text}'`);
successMsg({ message: 'ignored regions was saved' });
// MainView.showToaster('ignored regions was saved');
return;
}
log.error(`Cannot set baseline ignored regions , status: '${response.status}', resp: '${text}'`);
errorMsg({ error: 'Cannot set baseline ignored regions' });
// MainView.showToaster('Cannot set baseline ignored regions', 'Error');
} catch (e: unknown) {
log.error(`Cannot set baseline ignored regions: ${errorMsg(e)}`);
errorMsg({ error: 'Cannot set baseline ignored regions' });
// MainView.showToaster('Cannot set baseline ignored regions', 'Error');
}
}
/**
* Send both ignore and bound regions to the server
*/
/**
* Send both ignore and bound regions to the server
*/
static async sendRegions(id: string, regionsData: { ignoreRegions: string, boundRegions: string }): Promise<boolean> {
try {
const response = await fetch(`${config.baseUri}/v1/baselines/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id,
ignoreRegions: regionsData.ignoreRegions,
boundRegions: regionsData.boundRegions,
}),
});
const text = await response.text();
if (response.status === 200) {
log.debug(`Successful send baseline regions, id: '${id}' resp: '${text}'`);
successMsg({ message: 'Regions saved' });
return true;
}
log.error(`Cannot set baseline regions, status: '${response.status}', resp: '${text}'`);
errorMsg({ error: 'Cannot set baseline regions' });
return false;
} catch (e: unknown) {
log.error(`Cannot set baseline regions: ${errorMsg(e)}`);
errorMsg({ error: 'Cannot set baseline regions' });
return false;
}
}
/**
* convert json to fabric.js format
* @param {string} regions JSON string that contain data about regions in resemble.js format
* @returns {object} region data in fabric.js format
*/
convertRegionsDataFromServer(regions) {
const data = [];
const coef = parseFloat(this.coef);
regions
.forEach((reg) => {
const width = reg.right - reg.left;
const height = reg.bottom - reg.top;
if (coef) {
data.push({
name: reg.name,
top: reg.top / coef,
left: reg.left / coef,
width: width / coef,
height: height / coef,
});
}
});
return data;
}
drawRegions(data) {
// log.debug({ data });
if (!data || data === 'undefined') {
return;
// log.error('The regions data is empty')
}
const regs = this.convertRegionsDataFromServer(JSON.parse(data));
// log.debug('converted:', regs.length, regs);
const classThis = this;
regs.forEach((regParams) => {
// eslint-disable-next-line no-param-reassign
regParams['noSelect'] = true;
// eslint-disable-next-line no-param-reassign
regParams['name'] = 'ignore_rect';
classThis.addIgnoreRegion(regParams);
});
}
static async getRegionsData(baselineId: string, prefetchedBaseline?: any) {
try {
if (prefetchedBaseline) {
return prefetchedBaseline;
}
if (!baselineId) {
// log.debug('Cannot get regions, baseline id is empty');
return [];
}
const url = `${config.baseUri}/v1/baselines?filter={"_id":"${baselineId}"}`;
// log.debug({ url });
const response = await fetch(url);
const text = await response.text();
if (response.status === 200) {
log.debug(`Successfully got ignored regions, id: '${baselineId}' resp: '${text}'`);
return JSON.parse(text).results[0];
}
if (response.status === 202) {
log.debug('No regions');
return [];
}
log.error(`Cannot get baseline ignored regions , status: '${response.status}', resp: '${text}'`);
// MainView.showToaster('Cannot get baseline ignored regions', 'Error');
errorMsg({ error: 'Cannot get baseline ignored regions' });
} catch (e) {
log.error(`Cannot get baseline ignored regions: ${errorMsg(e)}`);
// MainView.showToaster('Cannot get baseline ignored regions', 'Error');
errorMsg({ error: 'Cannot get baseline ignored regions' });
}
return null;
}
async getSnapshotIgnoreRegionsDataAndDrawRegions(id: string, prefetchedBaseline?: any) {
this.removeAllRegions();
const regionData = await MainView.getRegionsData(id, prefetchedBaseline);
if (regionData) {
this.drawRegions(regionData.ignoreRegions);
this.drawBoundRegions(regionData.boundRegions);
this.updateBoundingOverlay();
}
this.snapshotRegions = JSON.stringify(this.getRegionsData());
}
/**
* Draw bound regions on the canvas
* @param data JSON string with bound region data
*/
drawBoundRegions(data: string) {
if (!data || data === 'undefined' || data === '[]') {
return;
}
const regs = this.convertRegionsDataFromServer(JSON.parse(data));
regs.forEach((regParams) => {
const params = {
name: 'bound_rect',
fill: 'rgba(0,0,0,0)',
stroke: 'green',
strokeWidth: 3,
top: regParams.top,
left: regParams.left,
width: regParams.width,
height: regParams.height,
};
const r = this.addRect(params);
this.canvas.add(r);
r.bringToFront();
});
this.updateBoundingOverlay();
}
// ==================== RCA (Root Cause Analysis) Methods ====================
/**
* Set callbacks for RCA overlay events
*/
setRCACallbacks(callbacks: RCAOverlayCallbacks): void {
this.rcaCallbacks = callbacks;
// Recreate overlay with new callbacks
if (this.rcaOverlay) {
const wasEnabled = this.rcaOverlay.isEnabled();
this.rcaOverlay.disable();
this.rcaOverlay = new RCAOverlay(this.canvas, callbacks);
if (wasEnabled) {
log.debug('[MainView] RCA callbacks updated, overlay was enabled - need to re-enable');
}
}
}
/**
* Enable RCA overlay with DOM data and changes
*/
enableRCAOverlay(
actualDom: DOMNode | null,
baselineDom: DOMNode | null,
changes: DOMChange[],
showWireframe: boolean = false
): void {
if (!this.rcaOverlay) {
log.warn('[MainView] RCA overlay not initialized');
return;
}
// Get the actual image position and scale on canvas
const image = this.actualImage || this.expectedImage;
const imageLeft = image?.left || 0;
const imageTop = image?.top || 0;
const imageScaleX = image?.scaleX || 1;
const imageScaleY = image?.scaleY || 1;
// Use image scale (not canvas zoom) for coordinate transform
// DOM coordinates are relative to original image size
const scale = imageScaleX; // Assuming uniform scale
log.debug('[MainView] Enabling RCA overlay', {
hasActualDom: !!actualDom,
hasBaselineDom: !!baselineDom,
changesCount: changes.length,
scale,
offsetX: imageLeft,
offsetY: imageTop,
imageScaleX,
imageScaleY,
showWireframe,
});
this.rcaOverlay.enable(actualDom, baselineDom, changes, scale, imageLeft, imageTop, showWireframe);
}
/**
* Toggle RCA wireframe visibility
*/
toggleRCAWireframe(visible: boolean): void {
if (this.rcaOverlay) {
this.rcaOverlay.toggleWireframe(visible);
}
}
/**
* Disable RCA overlay
*/
disableRCAOverlay(): void {
if (this.rcaOverlay) {
log.debug('[MainView] Disabling RCA overlay');
this.rcaOverlay.disable();
}
}
/**
* Update RCA overlay transform when zoom/pan changes
*/
private updateRCATransform(): void {
if (this.rcaOverlay?.isEnabled()) {
// Get the actual image position and scale on canvas
const image = this.actualImage || this.expectedImage;
const imageLeft = image?.left || 0;
const imageTop = image?.top || 0;
const imageScaleX = image?.scaleX || 1;
this.rcaOverlay.updateImageTransform(imageScaleX, imageLeft, imageTop);
}
}
/**
* Highlight a specific DOM change on the canvas
*/
highlightRCAChange(change: DOMChange | null): void {
if (this.rcaOverlay?.isEnabled()) {
this.rcaOverlay.highlightChange(change);
}
}
/**
* Highlight a specific DOM node on the canvas
*/
highlightRCANode(node: DOMNode | null): void {
if (this.rcaOverlay?.isEnabled()) {
this.rcaOverlay.highlightNode(node);
}
}
/**
* Check if RCA overlay is currently enabled
*/
isRCAEnabled(): boolean {
return this.rcaOverlay?.isEnabled() ?? false;
}
}