From 2d1a94420af25d636eaf0baf6fd0afdc2ebc56dd Mon Sep 17 00:00:00 2001 From: Google AI Edge Date: Sat, 5 Oct 2024 15:42:27 -0700 Subject: [PATCH] Add support for synchronizing navigation across split panes. - Allow users to sync navigation by node id, or upload a data json file to specify node id mapping. When user selects one node in one side of the pane, the node with mapped id will be automatically selected in another pane. - Allow visualizer component user to pass mapping data through visualizer config. PiperOrigin-RevId: 682738216 --- .../components/home_page/home_page.ng.html | 3 +- src/ui/src/components/home_page/home_page.ts | 19 +- .../src/components/visualizer/app_service.ts | 2 +- .../visualizer/common/sync_navigation.ts | 57 ++++++ .../src/components/visualizer/common/task.ts | 27 +++ .../src/components/visualizer/common/types.ts | 14 ++ .../visualizer/common/visualizer_config.ts | 4 + .../visualizer/common/worker_events.ts | 2 + .../visualizer/model_graph_visualizer.ts | 14 ++ .../node_data_provider_extension_service.ts | 5 +- .../visualizer/node_styler_service.ts | 27 ++- .../visualizer/split_panes_container.ng.html | 12 +- .../visualizer/split_panes_container.scss | 28 +++ .../visualizer/split_panes_container.ts | 11 +- .../visualizer/sync_navigation_button.ng.html | 83 ++++++++ .../visualizer/sync_navigation_button.scss | 190 ++++++++++++++++++ .../visualizer/sync_navigation_button.ts | 190 ++++++++++++++++++ .../visualizer/sync_navigation_service.ts | 132 ++++++++++++ .../components/visualizer/webgl_renderer.ts | 139 ++++++++----- .../components/visualizer/worker/worker.ts | 1 + src/ui/src/services/url_service.ts | 14 ++ src/ui/src/theme/theme.scss | 1 + 22 files changed, 902 insertions(+), 73 deletions(-) create mode 100644 src/ui/src/components/visualizer/common/sync_navigation.ts create mode 100644 src/ui/src/components/visualizer/common/task.ts create mode 100644 src/ui/src/components/visualizer/sync_navigation_button.ng.html create mode 100644 src/ui/src/components/visualizer/sync_navigation_button.scss create mode 100644 src/ui/src/components/visualizer/sync_navigation_button.ts create mode 100644 src/ui/src/components/visualizer/sync_navigation_service.ts diff --git a/src/ui/src/components/home_page/home_page.ng.html b/src/ui/src/components/home_page/home_page.ng.html index ee271ae3..0c58dcaf 100644 --- a/src/ui/src/components/home_page/home_page.ng.html +++ b/src/ui/src/components/home_page/home_page.ng.html @@ -107,7 +107,8 @@ (titleClicked)="handleClickTitle()" (modelGraphProcessed)="handleModelGraphProcessed($event)" (uiStateChanged)="handleUiStateChanged($event)" - (remoteNodeDataPathsChanged)="handleRemoteNodeDataPathsChanged($event)"> + (remoteNodeDataPathsChanged)="handleRemoteNodeDataPathsChanged($event)" + (syncNavigationModeChanged)="handleSyncNavigationModeChanged($event)">
diff --git a/src/ui/src/components/home_page/home_page.ts b/src/ui/src/components/home_page/home_page.ts index 102032cf..9c334ff8 100644 --- a/src/ui/src/components/home_page/home_page.ts +++ b/src/ui/src/components/home_page/home_page.ts @@ -58,7 +58,10 @@ import {ModelSourceInput} from '../model_source_input/model_source_input'; import {OpenInNewTabButton} from '../open_in_new_tab_button/open_in_new_tab_button'; import {OpenSourceLibsDialog} from '../open_source_libs_dialog/open_source_libs_dialog'; import {SettingsDialog} from '../settings_dialog/settings_dialog'; -import {ModelGraphProcessedEvent} from '../visualizer/common/types'; +import { + ModelGraphProcessedEvent, + SyncNavigationModeChangedEvent, +} from '../visualizer/common/types'; import {VisualizerConfig} from '../visualizer/common/visualizer_config'; import {VisualizerUiState} from '../visualizer/common/visualizer_ui_state'; import {Logo} from '../visualizer/logo'; @@ -111,6 +114,7 @@ export class HomePage implements AfterViewInit { benchmark = false; remoteNodeDataPaths: string[] = []; remoteNodeDataTargetModels: string[] = []; + syncNavigation?: SyncNavigationModeChangedEvent; hasUploadedModels = signal(false); shareButtonTooltip: Signal = signal(''); @@ -162,6 +166,9 @@ export class HomePage implements AfterViewInit { // Remote node data paths encoded in the url. this.remoteNodeDataPaths = this.urlService.getNodeDataSources(); this.remoteNodeDataTargetModels = this.urlService.getNodeDataTargets(); + + // Sync navigation. + this.syncNavigation = this.urlService.getSyncNavigation(); } ngAfterViewInit() { @@ -276,12 +283,22 @@ export class HomePage implements AfterViewInit { ); this.remoteProcessedNodeDataTargetModels.add(modelName); } + + if (this.syncNavigation) { + this.modelGraphVisualizer?.syncNavigationService.loadSyncNavigationDataFromEvent( + this.syncNavigation, + ); + } } handleRemoteNodeDataPathsChanged(paths: string[]) { this.urlService.setNodeDataSources(paths); } + handleSyncNavigationModeChanged(event: SyncNavigationModeChangedEvent) { + this.urlService.setSyncNavigation(event); + } + handleClickShowThirdPartyLibraries() { this.dialog.open(OpenSourceLibsDialog, {}); } diff --git a/src/ui/src/components/visualizer/app_service.ts b/src/ui/src/components/visualizer/app_service.ts index 4bc24044..a57ca882 100644 --- a/src/ui/src/components/visualizer/app_service.ts +++ b/src/ui/src/components/visualizer/app_service.ts @@ -106,7 +106,7 @@ export class AppService { readonly doubleClickedNode = signal(undefined); - testMode: boolean = false; + testMode = false; private groupNodeChildrenCountThresholdFromUrl: string | null = null; diff --git a/src/ui/src/components/visualizer/common/sync_navigation.ts b/src/ui/src/components/visualizer/common/sync_navigation.ts new file mode 100644 index 00000000..d9ac981f --- /dev/null +++ b/src/ui/src/components/visualizer/common/sync_navigation.ts @@ -0,0 +1,57 @@ +/** + * @license + * Copyright 2024 The Model Explorer Authors. All Rights Reserved. + * + * 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 {TaskData, TaskType} from './task'; + +/** The data for navigation syncing. */ +export interface SyncNavigationData extends TaskData { + type: TaskType.SYNC_NAVIGATION; + + mapping: SyncNavigationMapping; +} + +/** + * The mapping for navigation syncing, from node id from one side to node id + * from another side. + */ +export type SyncNavigationMapping = Record; + +/** The mode of navigation syncing. */ +export enum SyncNavigationMode { + DISABLED = 'disabled', + MATCH_NODE_ID = 'match_node_id', + VISUALIZER_CONFIG = 'visualizer_config', + UPLOAD_MAPPING_FROM_COMPUTER = 'from_computer', + LOAD_MAPPING_FROM_CNS = 'from_cns', +} + +/** The labels for sync navigation modes. */ +export const SYNC_NAVIGATION_MODE_LABELS = { + [SyncNavigationMode.DISABLED]: 'Disabled', + [SyncNavigationMode.MATCH_NODE_ID]: 'Match node id', + [SyncNavigationMode.UPLOAD_MAPPING_FROM_COMPUTER]: + 'Upload mapping from computer', + [SyncNavigationMode.LOAD_MAPPING_FROM_CNS]: 'Load mapping from CNS', + [SyncNavigationMode.VISUALIZER_CONFIG]: 'From Visualizer Config', +}; + +/** Information about the source of navigation. */ +export interface NavigationSourceInfo { + paneIndex: number; + nodeId: string; +} diff --git a/src/ui/src/components/visualizer/common/task.ts b/src/ui/src/components/visualizer/common/task.ts new file mode 100644 index 00000000..ec2bcbd1 --- /dev/null +++ b/src/ui/src/components/visualizer/common/task.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2024 The Model Explorer Authors. All Rights Reserved. + * + * 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. + * ============================================================================== + */ + +/** The base data for a task. */ +export declare interface TaskData { + type: TaskType; +} + +/** The type of a task. */ +export enum TaskType { + SYNC_NAVIGATION = 'sync_navigation', +} diff --git a/src/ui/src/components/visualizer/common/types.ts b/src/ui/src/components/visualizer/common/types.ts index 377c1267..cb1f9c32 100644 --- a/src/ui/src/components/visualizer/common/types.ts +++ b/src/ui/src/components/visualizer/common/types.ts @@ -17,6 +17,7 @@ */ import {GroupNode, ModelGraph, ModelNode} from './model_graph'; +import {SyncNavigationMode} from './sync_navigation'; /** A type for key-value pairs. */ export type KeyValuePairs = Record; @@ -170,6 +171,7 @@ export interface SelectedNodeInfo { rendererId: string; isGroupNode: boolean; noNodeShake?: boolean; + triggerNavigationSync?: boolean; } /** Info about a node to locate. */ @@ -658,3 +660,15 @@ export declare interface SerializedStyle { id: NodeStyleId; value: string; } + +/** Response from reading a file. */ +export declare interface ReadFileResp { + content: string; +} + +/** Event for sync navigation mode change. */ +export declare interface SyncNavigationModeChangedEvent { + mode: SyncNavigationMode; + // Used when mode is LOAD_MAPPING_FROM_CNS. + cnsPath?: string; +} diff --git a/src/ui/src/components/visualizer/common/visualizer_config.ts b/src/ui/src/components/visualizer/common/visualizer_config.ts index adee7268..db47abd7 100644 --- a/src/ui/src/components/visualizer/common/visualizer_config.ts +++ b/src/ui/src/components/visualizer/common/visualizer_config.ts @@ -16,6 +16,7 @@ * ============================================================================== */ +import {SyncNavigationData} from './sync_navigation'; import {NodeStylerRule, RendererType} from './types'; /** Configs for the visualizer. */ @@ -59,6 +60,9 @@ export declare interface VisualizerConfig { /** The default node styler rules. */ nodeStylerRules?: NodeStylerRule[]; + /** The data for navigation syncing. */ + syncNavigationData?: SyncNavigationData; + /** * Default graph renderer. * diff --git a/src/ui/src/components/visualizer/common/worker_events.ts b/src/ui/src/components/visualizer/common/worker_events.ts index c55acac4..2a6a390a 100644 --- a/src/ui/src/components/visualizer/common/worker_events.ts +++ b/src/ui/src/components/visualizer/common/worker_events.ts @@ -118,6 +118,7 @@ export declare interface RelayoutGraphRequest extends WorkerEventBase { clearAllExpandStates?: boolean; forRestoringSnapshotAfterTogglingFlattenLayers?: boolean; nodeStylerQueries?: NodeStylerRule[]; + triggerNavigationSync?: boolean; } /** The response for re-laying out the whole graph. */ @@ -130,6 +131,7 @@ export declare interface RelayoutGraphResponse extends WorkerEventBase { rectToZoomFit?: Rect; forRestoringSnapshotAfterTogglingFlattenLayers?: boolean; targetDeepestGroupNodeIdsToExpand?: string[]; + triggerNavigationSync?: boolean; } /** The request for locating a node. */ diff --git a/src/ui/src/components/visualizer/model_graph_visualizer.ts b/src/ui/src/components/visualizer/model_graph_visualizer.ts index aa5b9c0f..d86c71d6 100644 --- a/src/ui/src/components/visualizer/model_graph_visualizer.ts +++ b/src/ui/src/components/visualizer/model_graph_visualizer.ts @@ -45,6 +45,7 @@ import { NodeDataProviderData, NodeDataProviderGraphData, NodeInfo, + SyncNavigationModeChangedEvent, } from './common/types'; import {genUid, inInputElement, isOpNode} from './common/utils'; import {type VisualizerConfig} from './common/visualizer_config'; @@ -53,6 +54,7 @@ import {ExtensionService} from './extension_service'; import {NodeDataProviderExtensionService} from './node_data_provider_extension_service'; import {NodeStylerService} from './node_styler_service'; import {SplitPanesContainer} from './split_panes_container'; +import {SyncNavigationService} from './sync_navigation_service'; import {ThreejsService} from './threejs_service'; import {TitleBar} from './title_bar'; import {UiStateService} from './ui_state_service'; @@ -70,6 +72,7 @@ import {WorkerService} from './worker_service'; ExtensionService, NodeDataProviderExtensionService, NodeStylerService, + SyncNavigationService, UiStateService, WorkerService, ], @@ -109,6 +112,10 @@ export class ModelGraphVisualizer implements OnInit, OnDestroy, OnChanges { /** Triggered when a remote node data paths are updated. */ @Output() readonly remoteNodeDataPathsChanged = new EventEmitter(); + /** Triggered when the sync navigation mode is changed. */ + @Output() readonly syncNavigationModeChanged = + new EventEmitter(); + /** Triggered when the selected node is changed. */ @Output() readonly selectedNodeChanged = new EventEmitter(); @@ -140,6 +147,7 @@ export class ModelGraphVisualizer implements OnInit, OnDestroy, OnChanges { private readonly uiStateService: UiStateService, private readonly nodeDataProviderExtensionService: NodeDataProviderExtensionService, private readonly nodeStylerService: NodeStylerService, + readonly syncNavigationService: SyncNavigationService, ) { effect(() => { @@ -201,6 +209,12 @@ export class ModelGraphVisualizer implements OnInit, OnDestroy, OnChanges { this.modelGraphProcessed.next(event); }); + this.syncNavigationService.syncNavigationModeChanged$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((event) => { + this.syncNavigationModeChanged.next(event); + }); + this.initThreejs(); } diff --git a/src/ui/src/components/visualizer/node_data_provider_extension_service.ts b/src/ui/src/components/visualizer/node_data_provider_extension_service.ts index 2fa7d6e5..ca15a1de 100644 --- a/src/ui/src/components/visualizer/node_data_provider_extension_service.ts +++ b/src/ui/src/components/visualizer/node_data_provider_extension_service.ts @@ -28,6 +28,7 @@ import { NodeDataProviderData, NodeDataProviderResultProcessedData, NodeDataProviderRunData, + ReadFileResp, ThresholdItem, } from './common/types'; import {genUid, isOpNode} from './common/utils'; @@ -44,10 +45,6 @@ interface ProcessedGradientItem { textColor?: Rgb; } -declare interface ReadFileResp { - content: string; -} - declare interface ExternalReadFileResp { content: string; error?: string; diff --git a/src/ui/src/components/visualizer/node_styler_service.ts b/src/ui/src/components/visualizer/node_styler_service.ts index 8d5e014a..0f79e76f 100644 --- a/src/ui/src/components/visualizer/node_styler_service.ts +++ b/src/ui/src/components/visualizer/node_styler_service.ts @@ -110,23 +110,20 @@ export class NodeStylerService { private readonly appService: AppService, private readonly localStorageService: LocalStorageService, ) { - effect( - () => { - const rules = this.rules(); + effect(() => { + const rules = this.rules(); - if (!this.appService.testMode) { - // Save rules to local storage on changes. - this.localStorageService.setItem( - LOCAL_STORAGE_KEY_NODE_STYLER_RULES, - JSON.stringify(rules), - ); - } + if (!this.appService.testMode) { + // Save rules to local storage on changes. + this.localStorageService.setItem( + LOCAL_STORAGE_KEY_NODE_STYLER_RULES, + JSON.stringify(rules), + ); + } - // Compute matched nodes. - this.computeMatchedNodes(rules); - }, - {allowSignalWrites: true}, - ); + // Compute matched nodes. + this.computeMatchedNodes(rules); + }); // Load rules from local storage in non-test mode. if (!this.appService.testMode) { diff --git a/src/ui/src/components/visualizer/split_panes_container.ng.html b/src/ui/src/components/visualizer/split_panes_container.ng.html index d3861008..516ab161 100644 --- a/src/ui/src/components/visualizer/split_panes_container.ng.html +++ b/src/ui/src/components/visualizer/split_panes_container.ng.html @@ -75,7 +75,9 @@
} -
+
{{getPaneTitle(pane)}}
@@ -103,4 +105,12 @@ (mousedown)="handleMouseDownResizer($event, panesContainer)">
+ + + @if (hasSplitPane && allPanesLoaded()) { +
+ +
+ }
diff --git a/src/ui/src/components/visualizer/split_panes_container.scss b/src/ui/src/components/visualizer/split_panes_container.scss index 28dfcedc..633f3351 100644 --- a/src/ui/src/components/visualizer/split_panes_container.scss +++ b/src/ui/src/components/visualizer/split_panes_container.scss @@ -41,6 +41,14 @@ cursor: pointer; flex-shrink: 0; + &.extra-left-padding { + padding-left: 36px; + } + + &.extra-right-padding { + padding-right: 36px; + } + .buttons-container { display: flex; align-items: center; @@ -65,6 +73,13 @@ width: 18px; } } + + .divider { + width: 1px; + height: 12px; + background-color: #999; + margin: 0 4px 0 12px; + } } split-pane { @@ -82,6 +97,10 @@ .pane-title-container { background-color: #ea8600; color: white; + + .divider { + background-color: white; + } } } @@ -193,6 +212,15 @@ border-left: 1px solid #999; } } + + .sync-navigation-container { + position: absolute; + transform: translate(-22px, 0); + top: 0px; + height: 24px; + // Over resizer. + z-index: 250; + } } ::ng-deep .model-explorer-processing-tasks-container { diff --git a/src/ui/src/components/visualizer/split_panes_container.ts b/src/ui/src/components/visualizer/split_panes_container.ts index 02c90607..24daf804 100644 --- a/src/ui/src/components/visualizer/split_panes_container.ts +++ b/src/ui/src/components/visualizer/split_panes_container.ts @@ -16,16 +16,18 @@ * ============================================================================== */ -import {animate, state, style, transition, trigger} from '@angular/animations'; +import {animate, style, transition, trigger} from '@angular/animations'; import {CommonModule} from '@angular/common'; import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, + computed, effect, ElementRef, QueryList, + Signal, ViewChild, ViewChildren, } from '@angular/core'; @@ -48,6 +50,7 @@ import { import {GraphPanel} from './graph_panel'; import {InfoPanel} from './info_panel'; import {SplitPane} from './split_pane'; +import {SyncNavigationButton} from './sync_navigation_button'; import {WorkerService} from './worker_service'; interface ProcessingTask { @@ -69,6 +72,7 @@ interface ProcessingTask { MatProgressSpinnerModule, MatTooltipModule, SplitPane, + SyncNavigationButton, ], templateUrl: './split_panes_container.ng.html', styleUrls: ['./split_panes_container.scss'], @@ -90,6 +94,7 @@ export class SplitPanesContainer implements AfterViewInit { @ViewChildren('splitPane') splitPanes = new QueryList(); readonly processingTasks: Record = {}; + readonly allPanesLoaded: Signal; resizingSplitPane = false; curLeftWidthFraction = 1; @@ -103,6 +108,10 @@ export class SplitPanesContainer implements AfterViewInit { private readonly workerService: WorkerService, ) { this.panes = this.appService.panes; + this.allPanesLoaded = computed(() => + this.panes().every((pane) => pane.modelGraph != null), + ); + effect(() => { const panes = this.panes(); if (panes.length >= 1) { diff --git a/src/ui/src/components/visualizer/sync_navigation_button.ng.html b/src/ui/src/components/visualizer/sync_navigation_button.ng.html new file mode 100644 index 00000000..ad32f9cd --- /dev/null +++ b/src/ui/src/components/visualizer/sync_navigation_button.ng.html @@ -0,0 +1,83 @@ + + +
+
+ + {{syncIcon()}} + +
Sync
+
+
+ + +
+ Synchronize the node selection across two panes by the given node id mapping. +
+
+ + +
+ + @for (mode of allSyncModes; track mode) { +
+
+ + {{getModeLabel(mode)}} + + @switch (mode) { + @case (SyncNavigationMode.UPLOAD_MAPPING_FROM_COMPUTER) { + + +
+ {{uploadedFileName}} +
+ } + } +
+
+ } +
+
\ No newline at end of file diff --git a/src/ui/src/components/visualizer/sync_navigation_button.scss b/src/ui/src/components/visualizer/sync_navigation_button.scss new file mode 100644 index 00000000..a84e9b6d --- /dev/null +++ b/src/ui/src/components/visualizer/sync_navigation_button.scss @@ -0,0 +1,190 @@ +/** + * @license + * Copyright 2024 The Model Explorer Authors. All Rights Reserved. + * + * 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. + * ============================================================================== + */ + +$sync_highlight_color: #004fb8; + +@keyframes rotating { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + +.container { + height: 100%; + display: flex; + align-items: center; + font-size: 11px; + cursor: pointer; + color: #777; + padding: 0 5px; + background-color: white; + border-radius: 99px; + border: 1px solid #ccc; + box-sizing: border-box; + + .content { + display: flex; + align-items: center; + justify-content: center; + opacity: 0.8; + + &:hover { + opacity: 1; + } + } + + &.enabled { + background-color: $sync_highlight_color; + color: white; + + mat-icon { + color: white; + } + } + + mat-icon { + font-size: 18px; + height: 18px; + width: 18px; + + &.loading { + animation: rotating 2s linear infinite; + } + } +} + +::ng-deep .model-explorer-sync-navigation-dropdown { + font-size: 12px; + background-color: white; + display: flex; + flex-direction: column; + padding-bottom: 12px; + + .section-label { + padding: 8px 12px; + margin-bottom: 8px; + font-size: 11px; + background: #f1f1f1; + font-weight: 500; + text-transform: uppercase; + display: flex; + align-items: center; + justify-content: space-between; + + .right { + display: flex; + align-items: center; + gap: 4px; + + .icon-container { + display: flex; + cursor: pointer; + opacity: 0.8; + + &:hover { + opacity: 1; + } + } + + mat-icon { + font-size: 18px; + height: 18px; + width: 18px; + color: #999; + } + } + } + + .section { + padding-right: 16px; + } + + mat-radio-button { + cursor: pointer; + + &.cns { + margin-top: 8px; + } + + > div[mat-internal-form-field] { + height: 24px; + } + + div:has(> input[type='radio']) { + transform: scale(0.7); + margin-right: -8px; + } + + label { + letter-spacing: normal; + cursor: pointer; + font-size: 12px; + font-family: 'Google Sans Text', 'Google Sans', Arial, Helvetica, + sans-serif; + } + } + + .select-container { + display: flex; + flex-direction: column; + } + + .upload-mapping-button { + margin: 2px 0 0 36px; + width: 90px; + height: 30px; + /* stylelint-disable-next-line declaration-no-important -- override MDC */ + font-size: 12px !important; + /* stylelint-disable-next-line declaration-no-important -- override MDC */ + letter-spacing: normal !important; + + &.cns { + margin-top: 4px; + } + + ::ng-deep .mat-mdc-button-touch-target { + display: none; + } + } + + .upload-mapping-input { + display: none; + } + + .uploaded-file-name { + margin-left: 36px; + color: #999; + line-break: anywhere; + line-height: 14px; + } + + textarea { + height: 48px; + box-sizing: border-box; + margin: 4px 0 0 36px; + resize: none; + border-radius: 3px; + font-family: 'Google Sans Text', 'Google Sans', Arial, Helvetica, sans-serif; + font-size: 11px; + padding: 2px; + line-break: anywhere; + } +} diff --git a/src/ui/src/components/visualizer/sync_navigation_button.ts b/src/ui/src/components/visualizer/sync_navigation_button.ts new file mode 100644 index 00000000..7ae99888 --- /dev/null +++ b/src/ui/src/components/visualizer/sync_navigation_button.ts @@ -0,0 +1,190 @@ +/** + * @license + * Copyright 2024 The Model Explorer Authors. All Rights Reserved. + * + * 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 {OverlaySizeConfig} from '@angular/cdk/overlay'; +import {CommonModule} from '@angular/common'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + ViewChild, + computed, + inject, +} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +import {MatProgressSpinnerModule} from '@angular/material/progress-spinner'; +import {MatRadioModule} from '@angular/material/radio'; +import {MatSnackBar} from '@angular/material/snack-bar'; +import {MatTooltipModule} from '@angular/material/tooltip'; + +import {Bubble} from '../bubble/bubble'; +import {BubbleClick} from '../bubble/bubble_click'; + +import {AppService} from './app_service'; +import { + SYNC_NAVIGATION_MODE_LABELS, + SyncNavigationMode, +} from './common/sync_navigation'; +import {LocalStorageService} from './local_storage_service'; +import {SyncNavigationService} from './sync_navigation_service'; + +/** The button to manage sync navigation. */ +@Component({ + standalone: true, + selector: 'sync-navigation-button', + imports: [ + Bubble, + BubbleClick, + CommonModule, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule, + MatRadioModule, + MatTooltipModule, + ], + templateUrl: 'sync_navigation_button.ng.html', + styleUrls: ['./sync_navigation_button.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class SyncNavigationButton { + @ViewChild(BubbleClick) dropdown?: BubbleClick; + + private readonly appService = inject(AppService); + private readonly changeDetectorRef = inject(ChangeDetectorRef); + private readonly localStorageService = inject(LocalStorageService); + private readonly syncNavigationService = inject(SyncNavigationService); + private readonly snackBar = inject(MatSnackBar); + + readonly SyncNavigationMode = SyncNavigationMode; + readonly allSyncModes: SyncNavigationMode[]; + readonly syncMode = this.syncNavigationService.mode; + readonly syncEnabled = computed(() => { + return this.syncMode() !== SyncNavigationMode.DISABLED; + }); + readonly syncIcon = computed(() => + this.syncMode() === SyncNavigationMode.DISABLED && + !this.syncNavigationService.loadingFromCns() + ? 'sync_disabled' + : 'sync', + ); + readonly loadingFromCns = this.syncNavigationService.loadingFromCns; + + readonly helpPopupSize: OverlaySizeConfig = { + minWidth: 0, + minHeight: 0, + }; + + readonly dropdownSize: OverlaySizeConfig = { + minWidth: 0, + minHeight: 0, + maxHeight: 500, + }; + + uploadedFileName = ''; + + constructor() { + + // Populate sync modes. + // + // Show "component input" mode only if there is sync navigation data passed + // through visualizer config.. + const syncNavigationDataFromVisConfig = + this.appService.config()?.syncNavigationData; + this.allSyncModes = syncNavigationDataFromVisConfig + ? [ + SyncNavigationMode.DISABLED, + SyncNavigationMode.MATCH_NODE_ID, + SyncNavigationMode.VISUALIZER_CONFIG, + SyncNavigationMode.UPLOAD_MAPPING_FROM_COMPUTER, + SyncNavigationMode.LOAD_MAPPING_FROM_CNS, + ] + : [ + SyncNavigationMode.DISABLED, + SyncNavigationMode.MATCH_NODE_ID, + SyncNavigationMode.UPLOAD_MAPPING_FROM_COMPUTER, + SyncNavigationMode.LOAD_MAPPING_FROM_CNS, + ]; + // If there is sync navigation data passed through visualizer config, set + // the sync navigation data for the "visualizer config" mode and select the + // mode by default. + if (syncNavigationDataFromVisConfig) { + this.syncNavigationService.mode.set(SyncNavigationMode.VISUALIZER_CONFIG); + this.syncNavigationService.updateSyncNavigationData( + SyncNavigationMode.VISUALIZER_CONFIG, + syncNavigationDataFromVisConfig, + ); + } + } + + setSyncMode(mode: SyncNavigationMode) { + this.syncNavigationService.mode.set(mode); + + switch (mode) { + case SyncNavigationMode.DISABLED: + case SyncNavigationMode.MATCH_NODE_ID: + this.syncNavigationService.syncNavigationModeChanged$.next({ + mode, + }); + break; + default: + break; + } + } + + getModeLabel(mode: SyncNavigationMode): string { + return SYNC_NAVIGATION_MODE_LABELS[mode]; + } + + handleClickUpload(input: HTMLInputElement) { + this.syncNavigationService.mode.set( + SyncNavigationMode.UPLOAD_MAPPING_FROM_COMPUTER, + ); + input.click(); + } + + handleUploadedFileChanged(input: HTMLInputElement) { + const files = input.files; + if (!files || files.length === 0) { + return; + } + const file = files[0]; + this.uploadedFileName = ''; + + const fileReader = new FileReader(); + fileReader.onload = (event) => { + const error = this.syncNavigationService.processJsonData( + event.target?.result as string, + SyncNavigationMode.UPLOAD_MAPPING_FROM_COMPUTER, + ); + if (!error) { + this.uploadedFileName = file.name; + this.changeDetectorRef.markForCheck(); + } + }; + fileReader.readAsText(file); + } + + private showError(message: string) { + console.error(message); + this.snackBar.open(message, 'Dismiss', { + duration: 5000, + }); + } +} diff --git a/src/ui/src/components/visualizer/sync_navigation_service.ts b/src/ui/src/components/visualizer/sync_navigation_service.ts new file mode 100644 index 00000000..b3d66fa2 --- /dev/null +++ b/src/ui/src/components/visualizer/sync_navigation_service.ts @@ -0,0 +1,132 @@ +/** + * @license + * Copyright 2024 The Model Explorer Authors. All Rights Reserved. + * + * 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 { + NavigationSourceInfo, + SyncNavigationData, + SyncNavigationMode, +} from './common/sync_navigation'; +import {ReadFileResp, SyncNavigationModeChangedEvent} from './common/types'; + +import {Injectable, signal} from '@angular/core'; +import {Subject} from 'rxjs'; + +declare interface ProcessedSyncNavigationData extends SyncNavigationData { + inversedMapping: Record; +} + +/** A service for split pane sync navigation related tasks. */ +@Injectable() +export class SyncNavigationService { + readonly mode = signal(SyncNavigationMode.DISABLED); + readonly navigationSourceChanged$ = new Subject(); + readonly loadingFromCns = signal(false); + + // Used for notifying mode change to other components. + readonly syncNavigationModeChanged$ = + new Subject(); + + private savedProcessedSyncNavigationData: Record< + string, + ProcessedSyncNavigationData + > = {}; + + updateNavigationSource(info: NavigationSourceInfo) { + if (this.mode() === SyncNavigationMode.DISABLED) { + return; + } + this.navigationSourceChanged$.next(info); + } + + updateSyncNavigationData(mode: SyncNavigationMode, data: SyncNavigationData) { + // Generate inversed mapping. + const processedData: ProcessedSyncNavigationData = { + ...data, + inversedMapping: {}, + }; + for (const key of Object.keys(data.mapping)) { + processedData.inversedMapping[data.mapping[key]] = key; + } + + // Save it. + this.savedProcessedSyncNavigationData[mode] = processedData; + } + + getMappedNodeId(paneIndex: number, nodeId: string): string { + const mode = this.mode(); + const curSyncNavigationData: ProcessedSyncNavigationData | undefined = + this.savedProcessedSyncNavigationData[mode]; + + switch (mode) { + case SyncNavigationMode.MATCH_NODE_ID: { + return nodeId; + } + case SyncNavigationMode.VISUALIZER_CONFIG: + case SyncNavigationMode.UPLOAD_MAPPING_FROM_COMPUTER: + case SyncNavigationMode.LOAD_MAPPING_FROM_CNS: { + // Get mapped node id from mapping. + // Fallback to the original node id if not found. + const mapping = curSyncNavigationData?.mapping ?? {}; + const inversedMapping = curSyncNavigationData?.inversedMapping ?? {}; + return mapping[nodeId] ?? inversedMapping[nodeId] ?? nodeId; + } + default: + return nodeId; + } + } + + async loadFromCns(path: string): Promise { + // Call API to read file content. + this.loadingFromCns.set(true); + const url = `/read_file?path=${path}`; + const resp = await fetch(url); + if (!resp.ok) { + return `Failed to load JSON file "${path}"`; + } + + // Parse response. + const json = JSON.parse( + (await resp.text()).replace(")]}'\n", ''), + ) as ReadFileResp; + + const error = this.processJsonData( + json.content, + SyncNavigationMode.LOAD_MAPPING_FROM_CNS, + ); + + this.loadingFromCns.set(false); + + return error; + } + + async loadSyncNavigationDataFromEvent(event: SyncNavigationModeChangedEvent) { + + // Set mode. + this.mode.set(event.mode); + } + + processJsonData(str: string, mode: SyncNavigationMode): string { + try { + const data = JSON.parse(str) as SyncNavigationData; + this.updateSyncNavigationData(mode, data); + } catch (e) { + return `Failed to parse JSON file. ${e}`; + } + return ''; + } +} diff --git a/src/ui/src/components/visualizer/webgl_renderer.ts b/src/ui/src/components/visualizer/webgl_renderer.ts index d9b6e8de..d380e359 100644 --- a/src/ui/src/components/visualizer/webgl_renderer.ts +++ b/src/ui/src/components/visualizer/webgl_renderer.ts @@ -105,6 +105,7 @@ import {NodeDataProviderExtensionService} from './node_data_provider_extension_s import {NodeStylerService} from './node_styler_service'; import {SplitPaneService} from './split_pane_service'; import {SubgraphSelectionService} from './subgraph_selection_service'; +import {SyncNavigationService} from './sync_navigation_service'; import {ThreejsService} from './threejs_service'; import {UiStateService} from './ui_state_service'; import {WebglEdges} from './webgl_edges'; @@ -398,6 +399,7 @@ export class WebglRenderer implements OnInit, OnDestroy { workerEvent.rectToZoomFit, workerEvent.forRestoringSnapshotAfterTogglingFlattenLayers, workerEvent.targetDeepestGroupNodeIdsToExpand, + workerEvent.triggerNavigationSync, ); } break; @@ -440,6 +442,7 @@ export class WebglRenderer implements OnInit, OnDestroy { private readonly snackBar: MatSnackBar, private readonly splitPaneService: SplitPaneService, private readonly subgraphSelectionService: SubgraphSelectionService, + private readonly syncNavigationService: SyncNavigationService, private readonly uiStateService: UiStateService, private readonly viewContainerRef: ViewContainerRef, private readonly webglRendererAttrsTableService: WebglRendererAttrsTableService, @@ -484,45 +487,39 @@ export class WebglRenderer implements OnInit, OnDestroy { }); // Handle changes for node to locate. - effect( - () => { - const nodeInfoToLocate = this.appService.curToLocateNodeInfo(); - if (nodeInfoToLocate?.rendererId !== this.rendererId) { - return; - } + effect(() => { + const nodeInfoToLocate = this.appService.curToLocateNodeInfo(); + if (nodeInfoToLocate?.rendererId !== this.rendererId) { + return; + } - if (nodeInfoToLocate) { - this.sendLocateNodeRequest( - nodeInfoToLocate.nodeId, - nodeInfoToLocate.rendererId, - nodeInfoToLocate.noNodeShake, - nodeInfoToLocate.select, - ); - } - this.appService.curToLocateNodeInfo.set(undefined); - }, - {allowSignalWrites: true}, - ); + if (nodeInfoToLocate) { + this.sendLocateNodeRequest( + nodeInfoToLocate.nodeId, + nodeInfoToLocate.rendererId, + nodeInfoToLocate.noNodeShake, + nodeInfoToLocate.select, + ); + } + this.appService.curToLocateNodeInfo.set(undefined); + }); // Handle changes for node to reveal - effect( - () => { - const pane = this.appService.getPaneById(this.paneId); - if (!pane || !pane.modelGraph) { - return; - } + effect(() => { + const pane = this.appService.getPaneById(this.paneId); + if (!pane || !pane.modelGraph) { + return; + } - const nodeIdToReveal = pane.nodeIdToReveal; - if (!nodeIdToReveal) { - return; - } - const success = this.revealNode(nodeIdToReveal); - if (success) { - this.appService.setNodeToReveal(this.paneId, undefined); - } - }, - {allowSignalWrites: true}, - ); + const nodeIdToReveal = pane.nodeIdToReveal; + if (!nodeIdToReveal) { + return; + } + const success = this.revealNode(nodeIdToReveal); + if (success) { + this.appService.setNodeToReveal(this.paneId, undefined); + } + }); effect(() => { const runs = this.nodeDataProviderExtensionService.getRunsForModelGraph( @@ -585,7 +582,9 @@ export class WebglRenderer implements OnInit, OnDestroy { return; } - this.selectedNodeId = info?.nodeId || ''; + const selectedNodeId = info?.nodeId || ''; + const selectedNodeChanged = this.selectedNodeId !== selectedNodeId; + this.selectedNodeId = selectedNodeId; if (this.tracing) { if ( @@ -604,6 +603,14 @@ export class WebglRenderer implements OnInit, OnDestroy { this.webglRendererIdenticalLayerService.updateIdenticalLayerIndicators(); this.updateNodesStyles(); this.webglRendererThreejsService.render(); + + // Trigger a navigation sync request (if enabled). + if (selectedNodeChanged && info.triggerNavigationSync) { + this.syncNavigationService.updateNavigationSource({ + paneIndex: this.appService.getPaneIndexById(this.paneId) || 0, + nodeId: this.selectedNodeId, + }); + } }); // Handle "download as png". @@ -674,6 +681,26 @@ export class WebglRenderer implements OnInit, OnDestroy { this.updateNodesStyles(); this.webglRendererThreejsService.render(); }); + + // Handle navigation sync. + this.syncNavigationService.navigationSourceChanged$ + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((data) => { + if (!data) { + return; + } + + if (data.paneIndex !== this.appService.getPaneIndexById(this.paneId)) { + const mappedNodeId = this.syncNavigationService.getMappedNodeId( + data.paneIndex, + data.nodeId, + ); + const mappedNode = this.curModelGraph.nodesById[mappedNodeId]; + if (mappedNode && mappedNode.id !== this.selectedNodeId) { + this.revealNode(mappedNodeId, false); + } + } + }); } ngOnInit() { @@ -730,6 +757,7 @@ export class WebglRenderer implements OnInit, OnDestroy { true, snapshot.showOnNodeItemTypes, true, + false, ); pane.snapshotToRestore = undefined; } else { @@ -784,13 +812,18 @@ export class WebglRenderer implements OnInit, OnDestroy { deepestExpandedGroupNodeIds = groupNodeIds; } if ( - paneState.selectedNodeId != '' || + paneState.selectedNodeId !== '' || deepestExpandedGroupNodeIds.length > 0 ) { this.sendRelayoutGraphRequest( paneState.selectedNodeId, deepestExpandedGroupNodeIds, true, + undefined, + false, + undefined, + false, + false, ); } else { initGraphFn(); @@ -1022,10 +1055,8 @@ export class WebglRenderer implements OnInit, OnDestroy { return; } this.handleSelectNode(this.hoveredNodeId); - this.handleToggleExpandCollapse( - this.curModelGraph.nodesById[this.hoveredNodeId], - all, - ); + const node = this.curModelGraph.nodesById[this.hoveredNodeId] as GroupNode; + this.handleToggleExpandCollapse(node, all); } handleClickExpandAll(nodeId?: string) { @@ -1161,16 +1192,16 @@ export class WebglRenderer implements OnInit, OnDestroy { // Expand/collapse node on double click. Alt key controls whether to do it // for all sub layers. if (this.selectedNodeId !== '' && !shiftDown) { + const node = this.curModelGraph.nodesById[ + this.selectedNodeId + ] as GroupNode; this.appService.updateDoubleClickedNode( this.selectedNodeId, this.curModelGraph.id, this.curModelGraph.collectionLabel || '', - this.curModelGraph.nodesById[this.selectedNodeId], - ); - this.handleToggleExpandCollapse( - this.curModelGraph.nodesById[this.selectedNodeId], - altDown, + node, ); + this.handleToggleExpandCollapse(node, altDown); } } @@ -1287,6 +1318,7 @@ export class WebglRenderer implements OnInit, OnDestroy { clearAllExpandStates = false, showOnNodeItemTypes?: Record, forRestoringSnapshotAfterTogglingFlattenLayers?: boolean, + triggerNavigationSync = true, ) { this.showBusySpinnerWithDelay(); @@ -1302,6 +1334,7 @@ export class WebglRenderer implements OnInit, OnDestroy { rectToZoomFit, clearAllExpandStates, forRestoringSnapshotAfterTogglingFlattenLayers, + triggerNavigationSync, }; this.workerService.worker.postMessage(req); } @@ -1544,7 +1577,7 @@ export class WebglRenderer implements OnInit, OnDestroy { return this.webglRendererThreejsService.fps; } - private handleSelectNode(nodeId: string) { + private handleSelectNode(nodeId: string, triggerNavigationSync = true) { this.appService.selectNode(this.paneId, { nodeId, rendererId: this.rendererId, @@ -1552,6 +1585,7 @@ export class WebglRenderer implements OnInit, OnDestroy { nodeId === '' ? false : isGroupNode(this.curModelGraph.nodesById[nodeId]), + triggerNavigationSync, }); } @@ -1626,6 +1660,7 @@ export class WebglRenderer implements OnInit, OnDestroy { rectToZoomFit?: Rect, forRestoringSnapshotAfterTogglingFlattenLayers?: boolean, targetDeepestGroupNodeIdsToExpand?: string[], + triggerNavigationSync?: boolean, ) { this.updateCurModelGraph(modelGraph); this.updateNodesAndEdgesToRender(); @@ -1662,7 +1697,7 @@ export class WebglRenderer implements OnInit, OnDestroy { // Select node. if (this.selectedNodeId !== selectedNodeId) { - this.handleSelectNode(selectedNodeId || ''); + this.handleSelectNode(selectedNodeId || '', triggerNavigationSync); } if (!this.inPopup) { @@ -2762,7 +2797,7 @@ export class WebglRenderer implements OnInit, OnDestroy { this.changeDetectorRef.detectChanges(); } - private revealNode(nodeId: string): boolean { + private revealNode(nodeId: string, triggerNavigationSync = true): boolean { const node = this.curModelGraph.nodesById[nodeId]; if (!node) { return false; @@ -2770,6 +2805,12 @@ export class WebglRenderer implements OnInit, OnDestroy { this.sendRelayoutGraphRequest( nodeId, node.nsParentId ? [node.nsParentId] : [], + false, + undefined, + false, + undefined, + false, + triggerNavigationSync, ); return true; } diff --git a/src/ui/src/components/visualizer/worker/worker.ts b/src/ui/src/components/visualizer/worker/worker.ts index 8029bf82..ac78beec 100644 --- a/src/ui/src/components/visualizer/worker/worker.ts +++ b/src/ui/src/components/visualizer/worker/worker.ts @@ -161,6 +161,7 @@ self.addEventListener('message', (event: Event) => { workerEvent.forRestoringSnapshotAfterTogglingFlattenLayers, targetDeepestGroupNodeIdsToExpand: workerEvent.targetDeepestGroupNodeIdsToExpand, + triggerNavigationSync: workerEvent.triggerNavigationSync, }; postMessage(resp); break; diff --git a/src/ui/src/services/url_service.ts b/src/ui/src/services/url_service.ts index 85330a70..b3d8313f 100644 --- a/src/ui/src/services/url_service.ts +++ b/src/ui/src/services/url_service.ts @@ -19,6 +19,7 @@ import {Injectable} from '@angular/core'; import {Params, Router} from '@angular/router'; +import {SyncNavigationModeChangedEvent} from '../components/visualizer/common/types'; import {VisualizerUiState} from '../components/visualizer/common/visualizer_ui_state'; /** All URL query parameter keys. */ @@ -43,6 +44,7 @@ declare interface OldEncodedUrlData { declare interface EncodedUrlData { models: ModelSource[]; nodeData?: string[]; + sync?: SyncNavigationModeChangedEvent; // Target model names (e.g. model.tflite) that each of the `nodeData` above // is applied to. nodeDataTargets?: string[]; @@ -65,6 +67,7 @@ export declare interface ModelSource { export class UrlService { private models: ModelSource[] = []; private nodeData?: string[] = []; + private syncNavigation?: SyncNavigationModeChangedEvent; private nodeDataTargets?: string[] = []; private uiState?: VisualizerUiState; private prevQueryParamStr = ''; @@ -107,6 +110,15 @@ export class UrlService { this.updateUrl(); } + getSyncNavigation(): SyncNavigationModeChangedEvent | undefined { + return this.syncNavigation; + } + + setSyncNavigation(syncNavigation: SyncNavigationModeChangedEvent) { + this.syncNavigation = syncNavigation; + this.updateUrl(); + } + getNodeDataTargets(): string[] { return this.nodeDataTargets || []; } @@ -125,6 +137,7 @@ export class UrlService { nodeData: this.nodeData, nodeDataTargets: this.nodeDataTargets, uiState: this.uiState, + sync: this.syncNavigation, }; queryParams[QueryParamKey.DATA] = JSON.stringify(data); queryParams[QueryParamKey.RENDERER] = this.renderer; @@ -194,6 +207,7 @@ export class UrlService { this.models = decodedData.models; this.uiState = decodedData.uiState; this.nodeData = decodedData.nodeData; + this.syncNavigation = decodedData.sync; this.nodeDataTargets = decodedData.nodeDataTargets; } diff --git a/src/ui/src/theme/theme.scss b/src/ui/src/theme/theme.scss index 3c22ece9..660ef403 100644 --- a/src/ui/src/theme/theme.scss +++ b/src/ui/src/theme/theme.scss @@ -106,6 +106,7 @@ $blue-palette: ( @include mat.list-theme($theme); @include mat.menu-theme($theme); @include mat.progress-spinner-theme($theme); + @include mat.radio-theme($theme); @include mat.select-theme($theme); @include mat.sidenav-theme($theme); @include mat.slide-toggle-theme($theme);