UNPKG

@syngrisi/syngrisi

Version:
1,071 lines (937 loc) 36.2 kB
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; } }