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);