/* * Copyright (C) 2022 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { Component, ElementRef, EventEmitter, HostListener, Inject, Input, OnDestroy, OnInit, Output, SimpleChange, SimpleChanges, } from '@angular/core'; import {CanColor} from '@angular/material/core'; import {MatIconRegistry} from '@angular/material/icon'; import {MatSelectChange} from '@angular/material/select'; import {DomSanitizer} from '@angular/platform-browser'; import {assertDefined} from 'common/assert_utils'; import {PersistentStore} from 'common/persistent_store'; import {UrlUtils} from 'common/url_utils'; import {Analytics} from 'logging/analytics'; import {TRACE_INFO} from 'trace/trace_info'; import {TraceType} from 'trace/trace_type'; import {DisplayIdentifier} from 'viewers/common/display_identifier'; import {UserOptions} from 'viewers/common/user_options'; import {RectDblClickDetail, ViewerEvents} from 'viewers/common/viewer_events'; import {UiRect} from 'viewers/components/rects/types2d'; import {iconDividerStyle} from 'viewers/components/styles/icon_divider.styles'; import {multlineTooltip} from 'viewers/components/styles/tooltip.styles'; import {viewerCardInnerStyle} from 'viewers/components/styles/viewer_card.styles'; import {Canvas} from './canvas'; import {Mapper3D} from './mapper3d'; import {Distance2D, ShadingMode} from './types3d'; @Component({ selector: 'rects-view', template: `
rotate_90_degrees_ccw format_letter_spacing
{{groupLabel}}: {{ name }}
`, styles: [ ` .view-header { display: flex; flex-direction: column; } .mat-title { padding-top: 8px; } .right-btn-container { display: flex; align-items: center; } .right-btn-container .mat-slider-horizontal { min-width: 64px !important; } .icon-divider { height: 50%; } .slider-container { padding: 0 5px; display: flex; align-items: center; } .slider-icon { min-width: 18px; width: 18px; height: 18px; line-height: 18px; font-size: 18px; } .filter-controls { justify-content: space-between; } .block-filter-controls { display: flex; flex-direction: row; align-items: baseline; } .displays-section { display: flex; flex-direction: row; align-items: center; width: fit-content; flex-wrap: nowrap; } .displays-select { font-size: 14px; background-color: var(--disabled-color); border-radius: 4px; height: 24px; margin-left: 5px; } .rects-content { height: 100%; display: flex; flex-direction: column; padding: 0px 12px; } .canvas-container { height: 100%; width: 100%; position: relative; } .large-rects-canvas { position: absolute; top: 0; left: 0; width: 100%; height: 100%; cursor: pointer; } .large-rects-labels { position: absolute; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; } .mini-rects-canvas { cursor: pointer; width: 30%; height: 30%; top: 16px; display: block; position: absolute; z-index: 1000; } `, multlineTooltip, iconDividerStyle, viewerCardInnerStyle, ], }) export class RectsComponent implements OnInit, OnDestroy { Analytics = Analytics; ViewerEvents = ViewerEvents; @Input() title = 'title'; @Input() zoomFactor = 1; @Input() store?: PersistentStore; @Input() rects: UiRect[] = []; @Input() miniRects: UiRect[] | undefined; @Input() displays: DisplayIdentifier[] = []; @Input() highlightedItem = ''; @Input() groupLabel = 'Displays'; @Input() isStackBased = false; @Input() shadingModes: ShadingMode[] = [ShadingMode.GRADIENT]; @Input() userOptions: UserOptions = {}; @Input() dependencies: TraceType[] = []; @Output() collapseButtonClicked = new EventEmitter(); private internalRects: UiRect[] = []; private internalMiniRects?: UiRect[]; private storeKeyZSpacingFactor = ''; private storeKeyShadingMode = ''; private displayNames: string[] = []; private internalDisplays: DisplayIdentifier[] = []; private internalHighlightedItem = ''; private currentDisplay: DisplayIdentifier | undefined; private mapper3d: Mapper3D; private largeRectsCanvas?: Canvas; private miniRectsCanvas?: Canvas; private resizeObserver: ResizeObserver; private largeRectsCanvasElement?: HTMLCanvasElement; private miniRectsCanvasElement?: HTMLCanvasElement; private largeRectsLabelsElement?: HTMLElement; private mouseMoveListener = (event: MouseEvent) => this.onMouseMove(event); private mouseUpListener = (event: MouseEvent) => this.onMouseUp(event); private static readonly ZOOM_SCROLL_RATIO = 0.3; constructor( @Inject(ElementRef) private elementRef: ElementRef, @Inject(MatIconRegistry) private matIconRegistry: MatIconRegistry, @Inject(DomSanitizer) private domSanitizer: DomSanitizer, ) { this.mapper3d = new Mapper3D(); this.resizeObserver = new ResizeObserver((entries) => { this.drawLargeRectsAndLabels(); }); this.matIconRegistry.addSvgIcon( 'cube_full_shade', this.domSanitizer.bypassSecurityTrustResourceUrl( UrlUtils.getRootUrl() + 'cube_full_shade.svg', ), ); this.matIconRegistry.addSvgIcon( 'cube_partial_shade', this.domSanitizer.bypassSecurityTrustResourceUrl( UrlUtils.getRootUrl() + 'cube_partial_shade.svg', ), ); } ngOnInit() { this.mapper3d.setAllowedShadingModes(this.shadingModes); const canvasContainer: HTMLElement = this.elementRef.nativeElement.querySelector('.canvas-container'); this.resizeObserver.observe(canvasContainer); const isDarkMode = () => this.store?.get('dark-mode') === 'true'; this.largeRectsCanvasElement = canvasContainer.querySelector( '.large-rects-canvas', )! as HTMLCanvasElement; this.largeRectsLabelsElement = assertDefined( canvasContainer.querySelector('.large-rects-labels'), ) as HTMLElement; this.largeRectsCanvas = new Canvas( this.largeRectsCanvasElement, this.largeRectsLabelsElement, isDarkMode, ); this.largeRectsCanvasElement.addEventListener('mousedown', (event) => this.onCanvasMouseDown(event), ); if (this.store) { this.updateControlsFromStore(); } this.currentDisplay = this.internalDisplays.length > 0 ? this.getFirstDisplayWithRectsOrFirstDisplay(this.internalDisplays) : undefined; this.mapper3d.increaseZoomFactor(this.zoomFactor - 1); this.drawLargeRectsAndLabels(); this.miniRectsCanvasElement = canvasContainer.querySelector( '.mini-rects-canvas', )! as HTMLCanvasElement; this.miniRectsCanvas = new Canvas( this.miniRectsCanvasElement, undefined, isDarkMode, ); if (this.miniRects && this.miniRects.length > 0) { this.drawMiniRects(); } } blurTab() { (document.activeElement as HTMLElement).blur(); } ngOnChanges(simpleChanges: SimpleChanges) { if (simpleChanges['highlightedItem']) { this.internalHighlightedItem = simpleChanges['highlightedItem'].currentValue; this.mapper3d.setHighlightedRectId(this.internalHighlightedItem); if (!(simpleChanges['rects'] || simpleChanges['displays'])) { this.drawLargeRectsAndLabels(); } } let displayChange = false; if (simpleChanges['displays']) { const curr: DisplayIdentifier[] = simpleChanges['displays'].currentValue; const prev: DisplayIdentifier[] | null = simpleChanges['displays'].previousValue; displayChange = curr.length > 0 && !curr.every((d, index) => d.displayId === prev?.at(index)?.displayId); } if (simpleChanges['rects']) { this.internalRects = simpleChanges['rects'].currentValue; if (!displayChange) { this.drawLargeRectsAndLabels(); } } if (displayChange) { this.onDisplaysChange(simpleChanges['displays']); } if (simpleChanges['miniRects']) { this.internalMiniRects = simpleChanges['miniRects'].currentValue; this.drawMiniRects(); } } ngOnDestroy() { this.resizeObserver?.disconnect(); } onDisplaysChange(change: SimpleChange) { const displays = change.currentValue; this.internalDisplays = displays; this.displayNames = this.internalDisplays.map((d) => d.name); if (displays.length === 0) { return; } if (change.firstChange) { this.updateCurrentDisplay( this.getFirstDisplayWithRectsOrFirstDisplay(this.internalDisplays), ); return; } const curr = this.internalDisplays.find( (display) => display.displayId === this.currentDisplay?.displayId, ); if (curr) { this.updateCurrentDisplay(curr); return; } const displaysWithCurrentGroupId = this.internalDisplays.filter( (display) => display.groupId === this.mapper3d.getCurrentGroupId(), ); if (displaysWithCurrentGroupId.length === 0) { this.updateCurrentDisplay( this.getFirstDisplayWithRectsOrFirstDisplay(this.internalDisplays), ); return; } this.updateCurrentDisplay( this.getFirstDisplayWithRectsOrFirstDisplay(displaysWithCurrentGroupId), ); return; } updateControlsFromStore() { this.storeKeyZSpacingFactor = `rectsView.${this.title}.zSpacingFactor`; this.storeKeyShadingMode = `rectsView.${this.title}.shadingMode`; const storedZSpacingFactor = assertDefined(this.store).get( this.storeKeyZSpacingFactor, ); if (storedZSpacingFactor !== undefined) { this.mapper3d.setZSpacingFactor(Number(storedZSpacingFactor)); } const storedShadingMode = assertDefined(this.store).get( this.storeKeyShadingMode, ); if ( storedShadingMode !== undefined && this.shadingModes.includes(storedShadingMode as ShadingMode) ) { this.mapper3d.setShadingMode(storedShadingMode as ShadingMode); } } onSeparationSliderChange(factor: number) { Analytics.Navigation.logRectSettingsChanged( 'z spacing', factor, TRACE_INFO[this.dependencies[0]].name, ); this.store?.add(this.storeKeyZSpacingFactor, `${factor}`); this.mapper3d.setZSpacingFactor(factor); this.drawLargeRectsAndLabels(); } onRotationSliderChange(factor: number) { this.mapper3d.setCameraRotationFactor(factor); this.drawLargeRectsAndLabels(); } resetCamera() { Analytics.Navigation.logZoom('reset', 'rects'); this.mapper3d.resetCamera(); this.drawLargeRectsAndLabels(); } @HostListener('wheel', ['$event']) onScroll(event: WheelEvent) { if ((event.target as HTMLElement).className === 'large-rects-canvas') { if (event.deltaY > 0) { Analytics.Navigation.logZoom('scroll', 'rects', 'out'); this.doZoomOut(RectsComponent.ZOOM_SCROLL_RATIO); } else { Analytics.Navigation.logZoom('scroll', 'rects', 'in'); this.doZoomIn(RectsComponent.ZOOM_SCROLL_RATIO); } } } onCanvasMouseDown(event: MouseEvent) { document.addEventListener('mousemove', this.mouseMoveListener); document.addEventListener('mouseup', this.mouseUpListener); } onMouseMove(event: MouseEvent) { const distance = new Distance2D(event.movementX, event.movementY); this.mapper3d.addPanScreenDistance(distance); this.drawLargeRectsAndLabels(); } onMouseUp(event: MouseEvent) { document.removeEventListener('mousemove', this.mouseMoveListener); document.removeEventListener('mouseup', this.mouseUpListener); } onZoomInClick() { Analytics.Navigation.logZoom('button', 'rects', 'in'); this.doZoomIn(); } onZoomOutClick() { Analytics.Navigation.logZoom('button', 'rects', 'out'); this.doZoomOut(); } onDisplayChange(event: MatSelectChange) { const displayName = event.value; const display = assertDefined( this.internalDisplays.find((d) => d.name === displayName), ); this.updateCurrentDisplay(display); const viewerEvent = new CustomEvent(ViewerEvents.RectGroupIdChange, { bubbles: true, detail: {groupId: display.groupId}, }); this.elementRef.nativeElement.dispatchEvent(viewerEvent); } onRectClick(event: MouseEvent) { event.preventDefault(); const id = this.findClickedRectId(event); if (id !== undefined) { this.notifyHighlightedItem(id); } } onRectDblClick(event: MouseEvent) { event.preventDefault(); const clickedRectId = this.findClickedRectId(event); if (clickedRectId === undefined) { return; } this.elementRef.nativeElement.dispatchEvent( new CustomEvent(ViewerEvents.RectsDblClick, { bubbles: true, detail: new RectDblClickDetail(clickedRectId), }), ); } onMiniRectDblClick(event: MouseEvent) { event.preventDefault(); this.elementRef.nativeElement.dispatchEvent( new CustomEvent(ViewerEvents.MiniRectsDblClick, {bubbles: true}), ); } getZSpacingFactor(): number { return this.mapper3d.getZSpacingFactor(); } getShadingMode(): ShadingMode { return this.mapper3d.getShadingMode(); } onShadingModeButtonClicked() { this.mapper3d.updateShadingMode(); const newMode = this.mapper3d.getShadingMode(); Analytics.Navigation.logRectSettingsChanged( 'shading mode', newMode, TRACE_INFO[this.dependencies[0]].name, ); this.store?.add(this.storeKeyShadingMode, newMode); this.drawLargeRectsAndLabels(); } onInteractionStart(components: CanColor[]) { components.forEach((c) => (c.color = 'primary')); } onInteractionEnd(components: CanColor[]) { components.forEach((c) => (c.color = 'accent')); } private getFirstDisplayWithRectsOrFirstDisplay( displays: DisplayIdentifier[], ): DisplayIdentifier { return ( displays.find((display) => this.internalRects.some( (rect) => !rect.isDisplay && rect.groupId === display.groupId, ), ) ?? assertDefined(displays.at(0)) ); } private updateCurrentDisplay(display: DisplayIdentifier) { this.currentDisplay = display; this.mapper3d.setCurrentGroupId(display.groupId); this.drawLargeRectsAndLabels(); } private findClickedRectId(event: MouseEvent): string | undefined { const canvas = event.target as Element; const canvasOffset = canvas.getBoundingClientRect(); const x = ((event.clientX - canvasOffset.left) / canvas.clientWidth) * 2 - 1; const y = -((event.clientY - canvasOffset.top) / canvas.clientHeight) * 2 + 1; const z = 0; return this.largeRectsCanvas?.getClickedRectId(x, y, z); } private doZoomIn(ratio = 1) { this.mapper3d.increaseZoomFactor(ratio); this.drawLargeRectsAndLabels(); } private doZoomOut(ratio = 1) { this.mapper3d.decreaseZoomFactor(ratio); this.drawLargeRectsAndLabels(); } private drawLargeRectsAndLabels() { // TODO(b/258593034): Re-create scene only when input rects change. With the other input events // (rotation, spacing, ...) we can just update the camera and/or update the mesh positions. // We'd probably need to get rid of the intermediate layer (Scene3D, Rect3D, ... types) and // work directly with three.js's meshes. this.mapper3d.setRects(this.internalRects); this.largeRectsCanvas?.draw(this.mapper3d.computeScene()); } private drawMiniRects() { // TODO(b/258593034): Re-create scene only when input rects change. With the other input events // (rotation, spacing, ...) we can just update the camera and/or update the mesh positions. // We'd probably need to get rid of the intermediate layer (Scene3D, Rect3D, ... types) and // work directly with three.js's meshes. if (this.internalMiniRects) { const largeRectShadingMode = this.mapper3d.getShadingMode(); const largeRectGroupId = this.mapper3d.getCurrentGroupId(); const largeRectZSpacing = this.mapper3d.getZSpacingFactor(); const largeRectCameraRotation = this.mapper3d.getCameraRotationFactor(); this.mapper3d.setShadingMode(ShadingMode.GRADIENT); this.mapper3d.setCurrentGroupId(this.internalMiniRects[0]?.groupId); this.mapper3d.resetToOrthogonalState(); this.mapper3d.setRects(this.internalMiniRects); this.mapper3d.decreaseZoomFactor(this.zoomFactor - 1); this.miniRectsCanvas?.draw(this.mapper3d.computeScene()); this.mapper3d.increaseZoomFactor(this.zoomFactor - 1); // Mapper internally sets these values to 100%. They need to be reset afterwards if (this.miniRectsCanvasElement) { this.miniRectsCanvasElement.style.width = '25%'; this.miniRectsCanvasElement.style.height = '25%'; } this.mapper3d.setShadingMode(largeRectShadingMode); this.mapper3d.setCurrentGroupId(largeRectGroupId); this.mapper3d.setZSpacingFactor(largeRectZSpacing); this.mapper3d.setCameraRotationFactor(largeRectCameraRotation); } } private notifyHighlightedItem(id: string) { const event: CustomEvent = new CustomEvent( ViewerEvents.HighlightedIdChange, { bubbles: true, detail: {id}, }, ); this.elementRef.nativeElement.dispatchEvent(event); } }