This is an automated email from the ASF dual-hosted git repository.
mcgilman pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new b11ac844953 NIFI-15819: Adding the navigation card and birdseye
control to the co… (#11202)
b11ac844953 is described below
commit b11ac84495332cf7fd65ce1e8d9282723a0651b9
Author: Matt Gilman <[email protected]>
AuthorDate: Mon May 11 19:46:01 2026 -0400
NIFI-15819: Adding the navigation card and birdseye control to the co…
(#11202)
* NIFI-15819: Adding the navigation card and birdseye control to the
connector canvas graph controls panel.
* NIFI-15819: Addressing review feedback ensuring that appropriate birdseye
colors are used.
* NIFI-15819: Ensuring the user has permissions to the processor/label
before attempting to extract its color.
---
.../connector-canvas.component.html | 16 +-
.../connector-canvas.component.spec.ts | 267 ++++++++++-
.../connector-canvas/connector-canvas.component.ts | 94 +++-
.../connector-graph-controls.component.html | 16 +-
.../connector-graph-controls.component.spec.ts | 18 +-
.../connector-graph-controls.component.ts | 22 +-
.../common/birdseye/birdseye.component.html} | 10 +-
.../app/ui/common/birdseye/birdseye.component.scss | 55 +++
.../ui/common/birdseye/birdseye.component.spec.ts | 420 +++++++++++++++++
.../app/ui/common/birdseye/birdseye.component.ts | 496 +++++++++++++++++++++
.../src/app/ui/common/birdseye/birdseye.types.ts | 54 +++
.../app/ui/common/canvas/canvas.component.spec.ts | 44 +-
.../src/app/ui/common/canvas/canvas.component.ts | 31 +-
.../canvas-navigation-control.component.html | 97 ++++
.../canvas-navigation-control.component.scss} | 16 -
.../canvas-navigation-control.component.spec.ts | 237 ++++++++++
.../canvas-navigation-control.component.ts | 100 +++++
17 files changed, 1935 insertions(+), 58 deletions(-)
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.html
index c68df8b00ab..0477e70fa0d 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.html
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.html
@@ -29,7 +29,21 @@
<div class="relative flex-1 min-h-0">
<mat-sidenav-container class="h-full">
<mat-sidenav disableClose="true" mode="side"
[opened]="graphControlsOpen" position="start">
- <connector-graph-controls
[connectorEntity]="connectorEntity()" [entitySaving]="entitySaving()">
+ <connector-graph-controls
+ [connectorEntity]="connectorEntity()"
+ [entitySaving]="entitySaving()"
+ [birdseyeComponents]="birdseyeComponents()"
+ [birdseyeTransform]="birdseyeTransform()"
+ [canvasDimensions]="canvasDimensions()"
+ [canNavigateToParent]="canNavigateToParent"
+ (viewportChange)="onBirdseyeViewportChange($event)"
+ (birdseyeDragStart)="onBirdseyeDragStart()"
+ (birdseyeDragEnd)="onBirdseyeDragEnd()"
+ (zoomIn)="onNavigationZoomIn()"
+ (zoomOut)="onNavigationZoomOut()"
+ (zoomFit)="onNavigationZoomFit()"
+ (zoomActual)="onNavigationZoomActual()"
+ (leaveGroup)="onNavigationLeaveGroup()">
</connector-graph-controls>
</mat-sidenav>
<mat-sidenav-content>
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.spec.ts
index a6e49cfe617..d28f81a04e0 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.spec.ts
@@ -21,8 +21,10 @@ import { ComponentFixture, TestBed, fakeAsync, tick } from
'@angular/core/testin
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { provideRouter, Router } from '@angular/router';
import { MatDialog, MatDialogModule } from '@angular/material/dialog';
+import { provideMockActions } from '@ngrx/effects/testing';
+import { Action } from '@ngrx/store';
import { MockStore, provideMockStore } from '@ngrx/store/testing';
-import { firstValueFrom } from 'rxjs';
+import { ReplaySubject, firstValueFrom } from 'rxjs';
import { take } from 'rxjs/operators';
import { ComponentType, selectRouteParams, selectUrl } from '@nifi/shared';
@@ -49,6 +51,7 @@ import {
enterProcessGroup,
leaveProcessGroup,
loadConnectorFlow,
+ loadConnectorFlowSuccess,
navigateToControllerService,
navigateToControllerServices,
navigateToProvenanceForComponent,
@@ -94,6 +97,15 @@ class MockReusableCanvasComponent {
contextMenuOpened = output<unknown>();
centerOnSelection = vi.fn();
centerOnComponent = vi.fn();
+ getBirdseyeComponentData = vi.fn().mockReturnValue([]);
+ getCanvasDimensions = vi.fn().mockReturnValue({ width: 1024, height: 768
});
+ onZoomIn = vi.fn();
+ onZoomOut = vi.fn();
+ onZoomFit = vi.fn();
+ onZoomActual = vi.fn();
+ setViewportPosition = vi.fn();
+ birdseyeDragStart = vi.fn();
+ birdseyeDragEnd = vi.fn();
}
@Component({
@@ -140,6 +152,18 @@ class MockConnectorCanvasFooterComponent {
class MockConnectorGraphControls {
connectorEntity = input<unknown>(null);
entitySaving = input<boolean>(false);
+ birdseyeComponents = input<unknown[]>([]);
+ birdseyeTransform = input<unknown>({ translate: { x: 0, y: 0 }, scale: 1
});
+ canvasDimensions = input<unknown>({ width: 0, height: 0 });
+ canNavigateToParent = input<boolean>(false);
+ viewportChange = output<{ x: number; y: number }>();
+ birdseyeDragStart = output<void>();
+ birdseyeDragEnd = output<void>();
+ zoomIn = output<void>();
+ zoomOut = output<void>();
+ zoomFit = output<void>();
+ zoomActual = output<void>();
+ leaveGroup = output<void>();
}
@Component({
@@ -241,13 +265,17 @@ function createMockStorage() {
};
}
+let actions$: ReplaySubject<Action>;
+
function configureConnectorCanvasTestBed(options: SetupOptions = {}, storage?:
ReturnType<typeof createMockStorage>) {
TestBed.resetTestingModule();
const storageMock = storage ?? createMockStorage();
+ actions$ = new ReplaySubject<Action>(1);
TestBed.configureTestingModule({
imports: [ConnectorCanvasComponent, NoopAnimationsModule,
MatDialogModule],
providers: [
provideRouter([]),
+ provideMockActions(() => actions$),
provideMockStore({ initialState: {}, selectors:
buildMockSelectors(options) }),
{ provide: Storage, useValue: storageMock }
]
@@ -299,6 +327,21 @@ function dispatchWindowKeydown(key: string, modifiers: {
ctrlKey?: boolean; meta
window.dispatchEvent(event);
}
+function getCanvasMock(fixture: ComponentFixture<ConnectorCanvasComponent>):
MockReusableCanvasComponent {
+ return fixture.debugElement.query((el) => el.name === 'reusable-canvas')
+ .componentInstance as MockReusableCanvasComponent;
+}
+
+// viewChild.required(CanvasComponent) cannot resolve to the test mock because
the mock has a
+// different class identity than the real CanvasComponent. Stubbing the signal
lets the SUT's
+// birdseye / navigation handlers call into the mock directly.
+function attachCanvasMock(component: ConnectorCanvasComponent, mock:
MockReusableCanvasComponent): void {
+ Object.defineProperty(component, 'canvasComponent', {
+ configurable: true,
+ value: () => mock
+ });
+}
+
describe('ConnectorCanvasComponent', () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -346,7 +389,7 @@ describe('ConnectorCanvasComponent', () => {
// Should only dispatch setConfiguration, NOT loadConnectorFlow
const loadFlowDispatches = dispatchSpy.mock.calls.filter(
- (call) => (call[0] as { type: string }).type ===
loadConnectorFlow.type
+ (call: unknown[]) => (call[0] as { type: string }).type ===
loadConnectorFlow.type
);
expect(loadFlowDispatches).toHaveLength(0);
}));
@@ -444,7 +487,7 @@ describe('ConnectorCanvasComponent', () => {
component.leaveGroupAction();
const leaveDispatches = dispatchSpy.mock.calls.filter(
- (call) => (call[0] as { type: string }).type ===
leaveProcessGroup.type
+ (call: unknown[]) => (call[0] as { type: string }).type ===
leaveProcessGroup.type
);
expect(leaveDispatches).toHaveLength(0);
}));
@@ -479,7 +522,7 @@ describe('ConnectorCanvasComponent', () => {
dispatchWindowKeydown('Escape');
const leaveDispatches = dispatchSpy.mock.calls.filter(
- (call) => (call[0] as { type: string }).type ===
leaveProcessGroup.type
+ (call: unknown[]) => (call[0] as { type: string }).type ===
leaveProcessGroup.type
);
expect(leaveDispatches).toHaveLength(0);
@@ -514,7 +557,7 @@ describe('ConnectorCanvasComponent', () => {
dispatchWindowKeydown('r', { ctrlKey: true });
const loadFlowDispatches = dispatchSpy.mock.calls.filter(
- (call) => (call[0] as { type: string }).type ===
loadConnectorFlow.type
+ (call: unknown[]) => (call[0] as { type: string }).type ===
loadConnectorFlow.type
);
expect(loadFlowDispatches).toHaveLength(0);
@@ -537,7 +580,7 @@ describe('ConnectorCanvasComponent', () => {
searchEl.dispatchEvent(escapeEvent);
const leaveDispatches = dispatchSpy.mock.calls.filter(
- (call) => (call[0] as { type: string }).type ===
leaveProcessGroup.type
+ (call: unknown[]) => (call[0] as { type: string }).type ===
leaveProcessGroup.type
);
expect(leaveDispatches).toHaveLength(0);
@@ -546,7 +589,7 @@ describe('ConnectorCanvasComponent', () => {
searchEl.dispatchEvent(refreshEvent);
const loadFlowDispatches = dispatchSpy.mock.calls.filter(
- (call) => (call[0] as { type: string }).type ===
loadConnectorFlow.type
+ (call: unknown[]) => (call[0] as { type: string }).type ===
loadConnectorFlow.type
);
expect(loadFlowDispatches).toHaveLength(0);
@@ -568,7 +611,9 @@ describe('ConnectorCanvasComponent', () => {
inputEl.dispatchEvent(escapeEvent);
expect(
- dispatchSpy.mock.calls.filter((call) => (call[0] as { type:
string }).type === leaveProcessGroup.type)
+ dispatchSpy.mock.calls.filter(
+ (call: unknown[]) => (call[0] as { type: string }).type
=== leaveProcessGroup.type
+ )
).toHaveLength(0);
dispatchSpy.mockClear();
@@ -576,7 +621,9 @@ describe('ConnectorCanvasComponent', () => {
inputEl.dispatchEvent(refreshEvent);
expect(
- dispatchSpy.mock.calls.filter((call) => (call[0] as { type:
string }).type === loadConnectorFlow.type)
+ dispatchSpy.mock.calls.filter(
+ (call: unknown[]) => (call[0] as { type: string }).type
=== loadConnectorFlow.type
+ )
).toHaveLength(0);
document.body.removeChild(inputEl);
@@ -664,6 +711,7 @@ describe('ConnectorCanvasComponent', () => {
});
fixture.detectChanges();
tick();
+ attachCanvasMock(component, getCanvasMock(fixture));
dispatchSpy.mockClear();
component.onCanvasInitialized();
@@ -675,6 +723,7 @@ describe('ConnectorCanvasComponent', () => {
const { fixture, component, dispatchSpy } = setup();
fixture.detectChanges();
tick();
+ attachCanvasMock(component, getCanvasMock(fixture));
dispatchSpy.mockClear();
component.onCanvasInitialized();
@@ -1875,4 +1924,204 @@ describe('ConnectorCanvasComponent', () => {
expect(canvasDebugEl.componentInstance.menuProvider()).toBeTruthy();
}));
});
+
+ describe('Navigation control / birdseye wiring', () => {
+ function getGraphControlsMock(fixture:
ComponentFixture<ConnectorCanvasComponent>): MockConnectorGraphControls {
+ return fixture.debugElement.query((el) => el.name ===
'connector-graph-controls')
+ .componentInstance as MockConnectorGraphControls;
+ }
+
+ it('should seed birdseyeComponents and canvasDimensions from the
canvas on initialization', fakeAsync(() => {
+ const { fixture, component } = setup();
+ fixture.detectChanges();
+ tick();
+
+ const canvas = getCanvasMock(fixture);
+ attachCanvasMock(component, canvas);
+ const seededComponents = [
+ {
+ id: 'p1',
+ type: ComponentType.Processor,
+ position: { x: 1, y: 2 },
+ dimensions: { width: 10, height: 20 }
+ }
+ ];
+ canvas.getBirdseyeComponentData.mockReturnValue(seededComponents);
+ canvas.getCanvasDimensions.mockReturnValue({ width: 1024, height:
768 });
+
+ component.onCanvasInitialized();
+
+ expect(canvas.getBirdseyeComponentData).toHaveBeenCalled();
+ expect(canvas.getCanvasDimensions).toHaveBeenCalled();
+ expect(component.birdseyeComponents()).toEqual(seededComponents);
+ expect(component.canvasDimensions()).toEqual({ width: 1024,
height: 768 });
+ }));
+
+ it('should update birdseyeTransform when the canvas emits
transformChange', fakeAsync(() => {
+ const { fixture, component } = setup();
+ fixture.detectChanges();
+ tick();
+
+ const transform = { translate: { x: -100, y: -50 }, scale: 0.5 };
+ component.onTransformChange(transform);
+
+ expect(component.birdseyeTransform()).toEqual(transform);
+ }));
+
+ it('should refresh birdseye components when loadConnectorFlowSuccess
is dispatched', async () => {
+ const { fixture, component } = setup();
+ fixture.detectChanges();
+
+ const canvas = getCanvasMock(fixture);
+ attachCanvasMock(component, canvas);
+ component.onCanvasInitialized();
+
+ const refreshedComponents = [
+ {
+ id: 'p2',
+ type: ComponentType.Processor,
+ position: { x: 5, y: 6 },
+ dimensions: { width: 30, height: 40 }
+ }
+ ];
+
canvas.getBirdseyeComponentData.mockReturnValue(refreshedComponents);
+
+ actions$.next(
+ loadConnectorFlowSuccess({
+ connectorId: DEFAULT_CONNECTOR_ID,
+ processGroupId: DEFAULT_PROCESS_GROUP_ID,
+ parentProcessGroupId: null,
+ breadcrumb: null,
+ labels: [],
+ funnels: [],
+ inputPorts: [],
+ outputPorts: [],
+ remoteProcessGroups: [],
+ processGroups: [],
+ processors: [],
+ connections: []
+ })
+ );
+
+ // queueMicrotask defers the refresh; await a microtask to let it
run
+ await Promise.resolve();
+
+
expect(component.birdseyeComponents()).toEqual(refreshedComponents);
+ });
+
+ it('should delegate zoom buttons to the canvas', fakeAsync(() => {
+ const { fixture, component } = setup();
+ fixture.detectChanges();
+ tick();
+
+ const canvas = getCanvasMock(fixture);
+ attachCanvasMock(component, canvas);
+
+ component.onNavigationZoomIn();
+ component.onNavigationZoomOut();
+ component.onNavigationZoomFit();
+ component.onNavigationZoomActual();
+
+ expect(canvas.onZoomIn).toHaveBeenCalledTimes(1);
+ expect(canvas.onZoomOut).toHaveBeenCalledTimes(1);
+ expect(canvas.onZoomFit).toHaveBeenCalledTimes(1);
+ expect(canvas.onZoomActual).toHaveBeenCalledTimes(1);
+ }));
+
+ it('should delegate leave group via leaveGroupAction', fakeAsync(() =>
{
+ const { fixture, component, dispatchSpy } = setup({
parentProcessGroupId: 'parent-pg' });
+ fixture.detectChanges();
+ tick();
+ dispatchSpy.mockClear();
+
+ component.onNavigationLeaveGroup();
+
+ expect(dispatchSpy).toHaveBeenCalledWith(leaveProcessGroup());
+ }));
+
+ it('should delegate birdseye viewport change to the canvas',
fakeAsync(() => {
+ const { fixture, component } = setup();
+ fixture.detectChanges();
+ tick();
+
+ const canvas = getCanvasMock(fixture);
+ attachCanvasMock(component, canvas);
+
+ component.onBirdseyeViewportChange({ x: 42, y: 84 });
+
+ expect(canvas.setViewportPosition).toHaveBeenCalledWith(42, 84,
false);
+ }));
+
+ it('should delegate birdseye drag start/end to the canvas',
fakeAsync(() => {
+ const { fixture, component } = setup();
+ fixture.detectChanges();
+ tick();
+
+ const canvas = getCanvasMock(fixture);
+ attachCanvasMock(component, canvas);
+
+ component.onBirdseyeDragStart();
+ component.onBirdseyeDragEnd();
+
+ expect(canvas.birdseyeDragStart).toHaveBeenCalledTimes(1);
+ expect(canvas.birdseyeDragEnd).toHaveBeenCalledTimes(1);
+ }));
+
+ it('should refresh canvasDimensions when the window resizes after
canvas init', fakeAsync(() => {
+ const { fixture, component } = setup();
+ fixture.detectChanges();
+ tick();
+
+ const canvas = getCanvasMock(fixture);
+ attachCanvasMock(component, canvas);
+ component.onCanvasInitialized();
+ canvas.getCanvasDimensions.mockReturnValue({ width: 1600, height:
900 });
+
+ component.handleWindowResize();
+
+ expect(component.canvasDimensions()).toEqual({ width: 1600,
height: 900 });
+ }));
+
+ it('should not call into the canvas on resize before initialization',
fakeAsync(() => {
+ const { fixture, component } = setup();
+ fixture.detectChanges();
+ tick();
+
+ const canvas = getCanvasMock(fixture);
+ canvas.getCanvasDimensions.mockClear();
+
+ component.handleWindowResize();
+
+ expect(canvas.getCanvasDimensions).not.toHaveBeenCalled();
+ }));
+
+ it('should pass birdseye signals through to connector-graph-controls',
fakeAsync(() => {
+ const { fixture, component } = setup({ parentProcessGroupId:
'parent-pg' });
+ fixture.detectChanges();
+ tick();
+
+ const canvas = getCanvasMock(fixture);
+ attachCanvasMock(component, canvas);
+ const seeded = [
+ {
+ id: 'p1',
+ type: ComponentType.Processor,
+ position: { x: 0, y: 0 },
+ dimensions: { width: 10, height: 10 }
+ }
+ ];
+ canvas.getBirdseyeComponentData.mockReturnValue(seeded);
+ canvas.getCanvasDimensions.mockReturnValue({ width: 800, height:
600 });
+ component.onCanvasInitialized();
+ component.onTransformChange({ translate: { x: -10, y: -20 },
scale: 0.75 });
+ fixture.detectChanges();
+
+ const graphControls = getGraphControlsMock(fixture);
+
+ expect(graphControls.birdseyeComponents()).toEqual(seeded);
+ expect(graphControls.birdseyeTransform()).toEqual({ translate: {
x: -10, y: -20 }, scale: 0.75 });
+ expect(graphControls.canvasDimensions()).toEqual({ width: 800,
height: 600 });
+ expect(graphControls.canNavigateToParent()).toBe(true);
+ }));
+ });
});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.ts
index bcc1a9f57ab..84e762ea1ee 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/connector-canvas.component.ts
@@ -15,13 +15,24 @@
* limitations under the License.
*/
-import { Component, computed, OnDestroy, OnInit, DestroyRef, inject,
HostListener, viewChild } from '@angular/core';
+import {
+ Component,
+ computed,
+ OnDestroy,
+ OnInit,
+ DestroyRef,
+ inject,
+ HostListener,
+ signal,
+ viewChild
+} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Router } from '@angular/router';
import { MatButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatSidenav, MatSidenavContainer, MatSidenavContent } from
'@angular/material/sidenav';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+import { Actions, ofType } from '@ngrx/effects';
import { Store } from '@ngrx/store';
import {
canOperateConnector,
@@ -38,7 +49,8 @@ import { selectCurrentUser } from
'../../../../state/current-user/current-user.s
import { CanvasConfiguration } from '../../../../state/canvas-ui';
import { setConfiguration } from
'../../../../state/canvas-ui/canvas-ui.actions';
import { CanvasComponent } from
'../../../../ui/common/canvas/canvas.component';
-import { ContextMenuContext } from '../../../../ui/common/canvas/canvas.types';
+import { BirdseyeComponentData, BirdseyeTransform } from
'../../../../ui/common/birdseye/birdseye.types';
+import { ContextMenuContext, Dimension, Position } from
'../../../../ui/common/canvas/canvas.types';
import {
ContextMenuDefinition,
ContextMenuDefinitionProvider,
@@ -80,6 +92,7 @@ import { getComponentStateAndOpenDialog } from
'../../../../state/component-stat
})
export class ConnectorCanvasComponent implements OnInit, OnDestroy {
private store = inject(Store<NiFiState>);
+ private actions$ = inject(Actions);
private destroyRef = inject(DestroyRef);
private router = inject(Router);
private dialog = inject(MatDialog);
@@ -97,6 +110,12 @@ export class ConnectorCanvasComponent implements OnInit,
OnDestroy {
skipTransform =
this.store.selectSignal(ConnectorCanvasSelectors.selectSkipTransform);
graphControlsOpen = true;
+ birdseyeComponents = signal<BirdseyeComponentData[]>([]);
+ birdseyeTransform = signal<BirdseyeTransform>({ translate: { x: 0, y: 0 },
scale: 1 });
+ canvasDimensions = signal<Dimension>({ width: 0, height: 0 });
+
+ private canvasReady = false;
+
connectorEntity = this.store.selectSignal(selectConnectorCanvasEntity);
entitySaving = this.store.selectSignal(selectConnectorCanvasEntitySaving);
@@ -360,6 +379,18 @@ export class ConnectorCanvasComponent implements OnInit,
OnDestroy {
.subscribe((parentProcessGroupId) => {
this.canNavigateToParent = parentProcessGroupId != null;
});
+
+ // Refresh birdseye geometry whenever a flow load completes (initial
load or refresh).
+ // Without this, the minimap would only reflect the data captured
during the very first
+ // canvas initialization and would go stale after subsequent refreshes
/ process-group
+ // navigations performed against an already-mounted canvas.
+ this.actions$
+ .pipe(ofType(ConnectorCanvasActions.loadConnectorFlowSuccess),
takeUntilDestroyed(this.destroyRef))
+ .subscribe(() => {
+ // Defer one tick so the entity arrays flow through async
pipes into the canvas
+ // and the canvas's internal signals (read by
getBirdseyeComponentData) update.
+ queueMicrotask(() => this.refreshBirdseye());
+ });
}
ngOnDestroy(): void {
@@ -367,8 +398,15 @@ export class ConnectorCanvasComponent implements OnInit,
OnDestroy {
this.store.dispatch(ConnectorCanvasEntityActions.resetConnectorCanvasEntityState());
}
- onTransformChange(_event: { translate: { x: number; y: number }; scale:
number }): void {
- // placeholder for future viewport persistence
+ @HostListener('window:resize')
+ handleWindowResize(): void {
+ if (this.canvasReady) {
+
this.canvasDimensions.set(this.canvasComponent().getCanvasDimensions());
+ }
+ }
+
+ onTransformChange(event: BirdseyeTransform): void {
+ this.birdseyeTransform.set(event);
}
onProcessGroupDoubleClick(event: { processGroupId: string }): void {
@@ -407,6 +445,9 @@ export class ConnectorCanvasComponent implements OnInit,
OnDestroy {
}
onCanvasInitialized(): void {
+ this.canvasReady = true;
+ this.refreshBirdseye();
+
if (this.selectedComponentIds.length > 0) {
if (this.skipTransform()) {
this.store.dispatch(ConnectorCanvasActions.setSkipTransform({
skipTransform: false }));
@@ -416,6 +457,51 @@ export class ConnectorCanvasComponent implements OnInit,
OnDestroy {
}
}
+ private refreshBirdseye(): void {
+ if (!this.canvasReady) {
+ return;
+ }
+ const canvas = this.canvasComponent();
+ this.birdseyeComponents.set(canvas.getBirdseyeComponentData());
+ this.canvasDimensions.set(canvas.getCanvasDimensions());
+ }
+
+ //
=========================================================================
+ // Navigation Control / Birdseye delegation
+ //
=========================================================================
+
+ onNavigationZoomIn(): void {
+ this.canvasComponent().onZoomIn();
+ }
+
+ onNavigationZoomOut(): void {
+ this.canvasComponent().onZoomOut();
+ }
+
+ onNavigationZoomFit(): void {
+ this.canvasComponent().onZoomFit();
+ }
+
+ onNavigationZoomActual(): void {
+ this.canvasComponent().onZoomActual();
+ }
+
+ onNavigationLeaveGroup(): void {
+ this.leaveGroupAction();
+ }
+
+ onBirdseyeViewportChange(event: Position): void {
+ this.canvasComponent().setViewportPosition(event.x, event.y, false);
+ }
+
+ onBirdseyeDragStart(): void {
+ this.canvasComponent().birdseyeDragStart();
+ }
+
+ onBirdseyeDragEnd(): void {
+ this.canvasComponent().birdseyeDragEnd();
+ }
+
//
=========================================================================
// Shared Actions (used by both context menu and keyboard shortcuts)
//
=========================================================================
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.html
index 85fd960e8f5..4ecfbe94ec1 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.html
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.html
@@ -17,7 +17,21 @@
<div class="connector-graph-controls w-96">
<div class="flex flex-col p-2 gap-y-2">
- <!-- Navigation control will be added in Effort 3 (Canvas Navigation &
Birdseye). -->
+ <canvas-navigation-control
+ storageKey="connector-navigation-control"
+ [birdseyeComponents]="birdseyeComponents()"
+ [birdseyeTransform]="birdseyeTransform()"
+ [canvasDimensions]="canvasDimensions()"
+ [canNavigateToParent]="canNavigateToParent()"
+ (viewportChange)="viewportChange.emit($event)"
+ (birdseyeDragStart)="birdseyeDragStart.emit()"
+ (birdseyeDragEnd)="birdseyeDragEnd.emit()"
+ (zoomIn)="zoomIn.emit()"
+ (zoomOut)="zoomOut.emit()"
+ (zoomFit)="zoomFit.emit()"
+ (zoomActual)="zoomActual.emit()"
+ (leaveGroup)="leaveGroup.emit()">
+ </canvas-navigation-control>
<connector-info-control
[connectorEntity]="connectorEntity()"
[entitySaving]="entitySaving()"></connector-info-control>
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.spec.ts
index 9e4d8609552..96b60bc05cd 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.spec.ts
@@ -23,6 +23,8 @@ import { NoopAnimationsModule } from
'@angular/platform-browser/animations';
import { ConnectorGraphControls } from './connector-graph-controls.component';
import { ConnectorInfoControl } from
'./connector-info-control/connector-info-control.component';
import { ConnectorEntity } from '@nifi/shared';
+import { BirdseyeComponentData, BirdseyeTransform } from
'../../../../../ui/common/birdseye/birdseye.types';
+import { Dimension } from '../../../../../ui/common/canvas/canvas.types';
@Component({
selector: 'connector-info-control',
@@ -35,7 +37,15 @@ class MockConnectorInfoControl {
entitySaving = input<boolean>(false);
}
-async function setup(inputs: { connectorEntity?: ConnectorEntity | null;
entitySaving?: boolean } = {}) {
+interface SetupInputs {
+ connectorEntity?: ConnectorEntity | null;
+ entitySaving?: boolean;
+ birdseyeComponents?: BirdseyeComponentData[];
+ birdseyeTransform?: BirdseyeTransform;
+ canvasDimensions?: Dimension;
+}
+
+async function setup(inputs: SetupInputs = {}) {
await TestBed.configureTestingModule({
imports: [ConnectorGraphControls, NoopAnimationsModule]
})
@@ -48,6 +58,12 @@ async function setup(inputs: { connectorEntity?:
ConnectorEntity | null; entityS
const fixture: ComponentFixture<ConnectorGraphControls> =
TestBed.createComponent(ConnectorGraphControls);
fixture.componentRef.setInput('connectorEntity', inputs.connectorEntity ??
null);
fixture.componentRef.setInput('entitySaving', inputs.entitySaving ??
false);
+ fixture.componentRef.setInput('birdseyeComponents',
inputs.birdseyeComponents ?? []);
+ fixture.componentRef.setInput(
+ 'birdseyeTransform',
+ inputs.birdseyeTransform ?? { translate: { x: 0, y: 0 }, scale: 1 }
+ );
+ fixture.componentRef.setInput('canvasDimensions', inputs.canvasDimensions
?? { width: 0, height: 0 });
fixture.detectChanges();
return { fixture, component: fixture.componentInstance };
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.ts
index a04443a6d34..b5d6dcbcc40 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.ts
@@ -15,18 +15,36 @@
* limitations under the License.
*/
-import { Component, input } from '@angular/core';
+import { Component, input, output } from '@angular/core';
import { ConnectorEntity } from '@nifi/shared';
+import { CanvasNavigationControl } from
'../../../../../ui/common/navigation-control/canvas-navigation-control.component';
+import { BirdseyeComponentData, BirdseyeTransform } from
'../../../../../ui/common/birdseye/birdseye.types';
+import { Dimension, Position } from
'../../../../../ui/common/canvas/canvas.types';
import { ConnectorInfoControl } from
'./connector-info-control/connector-info-control.component';
@Component({
selector: 'connector-graph-controls',
standalone: true,
- imports: [ConnectorInfoControl],
+ imports: [CanvasNavigationControl, ConnectorInfoControl],
templateUrl: './connector-graph-controls.component.html',
styleUrls: ['./connector-graph-controls.component.scss']
})
export class ConnectorGraphControls {
connectorEntity = input<ConnectorEntity | null>(null);
entitySaving = input<boolean>(false);
+
+ birdseyeComponents = input.required<BirdseyeComponentData[]>();
+ birdseyeTransform = input.required<BirdseyeTransform>();
+ canvasDimensions = input.required<Dimension>();
+ canNavigateToParent = input<boolean>(false);
+
+ viewportChange = output<Position>();
+ birdseyeDragStart = output<void>();
+ birdseyeDragEnd = output<void>();
+
+ zoomIn = output<void>();
+ zoomOut = output<void>();
+ zoomFit = output<void>();
+ zoomActual = output<void>();
+ leaveGroup = output<void>();
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.html
similarity index 65%
copy from
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.html
copy to
nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.html
index 85fd960e8f5..5ef811d7f06 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.html
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.html
@@ -15,12 +15,4 @@
~ limitations under the License.
-->
-<div class="connector-graph-controls w-96">
- <div class="flex flex-col p-2 gap-y-2">
- <!-- Navigation control will be added in Effort 3 (Canvas Navigation &
Birdseye). -->
- <connector-info-control
- [connectorEntity]="connectorEntity()"
- [entitySaving]="entitySaving()"></connector-info-control>
- <!-- Provenance preview will be added in Effort 7 (Provenance
Preview). -->
- </div>
-</div>
+<div #birdseyeContainer class="birdseye-container border"
[style.height.px]="birdseyeDimensions().height"></div>
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.scss
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.scss
new file mode 100644
index 00000000000..11213b7dfc9
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.scss
@@ -0,0 +1,55 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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.
+ */
+
+// THEMED STYLES (internal mixin)
+// These styles use CSS variables for colors. This mixin is included twice:
+// once for light mode and once under .darkMode for dark mode, mirroring the
+// pattern used by canvas.component.scss.
+@mixin generate-theme() {
+ .birdseye-container {
+ background: var(--mat-sys-background);
+ width: 100%;
+ height: 175px;
+ overflow: hidden;
+ box-sizing: content-box;
+
+ canvas,
+ svg {
+ position: absolute;
+ overflow: hidden;
+ }
+
+ rect.birdseye-brush {
+ stroke: var(--mat-sys-primary);
+ fill: transparent;
+ }
+ }
+}
+
+// ::ng-deep is required because the canvas, svg, and rect elements are
created dynamically in
+// birdseye.component.ts via document.createElement() and D3, NOT declared in
the component
+// template. Angular's ViewEncapsulation.Emulated only adds scoping attributes
to template-defined
+// elements; dynamically created elements don't receive those attributes, so
normal scoped styles
+// won't match them. ::ng-deep disables the encapsulation requirement for
descendant selectors.
+:host ::ng-deep {
+ @include generate-theme();
+
+ // Dark mode styles (same styles, scoped to .darkMode)
+ .darkMode {
+ @include generate-theme();
+ }
+}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.spec.ts
new file mode 100644
index 00000000000..ac7a04e20df
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.spec.ts
@@ -0,0 +1,420 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 { TestBed } from '@angular/core/testing';
+import { ComponentType } from '@nifi/shared';
+import { CanvasBirdseyeComponent } from './birdseye.component';
+import { BirdseyeComponentData, BirdseyeTransform } from './birdseye.types';
+import { Dimension } from '../canvas/canvas.types';
+
+function createMockComponent(
+ options: {
+ id?: string;
+ type?: ComponentType;
+ x?: number;
+ y?: number;
+ width?: number;
+ height?: number;
+ fillColor?: string;
+ } = {}
+): BirdseyeComponentData {
+ return {
+ id: options.id || `component-${Math.random().toString(36).substring(2,
11)}`,
+ type: options.type || ComponentType.Processor,
+ position: {
+ x: options.x ?? 100,
+ y: options.y ?? 100
+ },
+ dimensions: {
+ width: options.width ?? 352,
+ height: options.height ?? 128
+ },
+ ...(options.fillColor !== undefined ? { fillColor: options.fillColor }
: {})
+ };
+}
+
+function createMockTransform(
+ options: {
+ translateX?: number;
+ translateY?: number;
+ scale?: number;
+ } = {}
+): BirdseyeTransform {
+ return {
+ translate: {
+ x: options.translateX ?? 0,
+ y: options.translateY ?? 0
+ },
+ scale: options.scale ?? 1
+ };
+}
+
+function createMockDimensions(
+ options: {
+ width?: number;
+ height?: number;
+ } = {}
+): Dimension {
+ return {
+ width: options.width ?? 1000,
+ height: options.height ?? 800
+ };
+}
+
+interface SetupOptions {
+ components?: BirdseyeComponentData[];
+ transform?: BirdseyeTransform;
+ canvasDimensions?: Dimension;
+}
+
+async function setup(options: SetupOptions = {}) {
+ await TestBed.configureTestingModule({
+ imports: [CanvasBirdseyeComponent]
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(CanvasBirdseyeComponent);
+ const component = fixture.componentInstance;
+
+ fixture.componentRef.setInput('components', options.components ?? []);
+ fixture.componentRef.setInput(
+ 'transform',
+ options.transform ?? {
+ translate: { x: 0, y: 0 },
+ scale: 1
+ }
+ );
+ fixture.componentRef.setInput('canvasDimensions', options.canvasDimensions
?? { width: 1000, height: 800 });
+
+ fixture.detectChanges();
+
+ const containerElement =
fixture.nativeElement.querySelector('.birdseye-container');
+ const canvasElement =
fixture.nativeElement.querySelector('.birdseye-canvas');
+ const svgElement = fixture.nativeElement.querySelector('.birdseye-svg');
+ const brushElement =
fixture.nativeElement.querySelector('.birdseye-brush');
+
+ return {
+ fixture,
+ component,
+ containerElement,
+ canvasElement,
+ svgElement,
+ brushElement
+ };
+}
+
+describe('CanvasBirdseyeComponent', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('Component initialization', () => {
+ it('should create', async () => {
+ const { component } = await setup();
+ expect(component).toBeTruthy();
+ });
+
+ it('should render canvas element for component visualization', async
() => {
+ const { canvasElement } = await setup();
+ expect(canvasElement).toBeTruthy();
+ expect(canvasElement.tagName.toLowerCase()).toBe('canvas');
+ });
+
+ it('should render SVG element for interactive brush', async () => {
+ const { svgElement } = await setup();
+ expect(svgElement).toBeTruthy();
+ expect(svgElement.tagName.toLowerCase()).toBe('svg');
+ });
+
+ it('should render viewport brush element', async () => {
+ const components = [createMockComponent()];
+ const { brushElement } = await setup({ components });
+ expect(brushElement).toBeTruthy();
+ expect(brushElement.tagName.toLowerCase()).toBe('rect');
+ });
+
+ it('should set default canvas style dimensions', async () => {
+ const { canvasElement } = await setup();
+ expect(canvasElement.style.width).toBe('200px');
+ expect(canvasElement.style.height).toBe('150px');
+ });
+
+ it('should set default SVG dimensions', async () => {
+ const { svgElement } = await setup();
+ expect(svgElement.getAttribute('width')).toBe('200');
+ expect(svgElement.getAttribute('height')).toBe('150');
+ });
+ });
+
+ describe('Component rendering', () => {
+ it('should render with components present', async () => {
+ const components = [
+ createMockComponent({ id: 'proc-1', type:
ComponentType.Processor, x: 100, y: 100 }),
+ createMockComponent({ id: 'proc-2', type:
ComponentType.Processor, x: 500, y: 300 })
+ ];
+ const { canvasElement } = await setup({ components });
+ expect(canvasElement).toBeTruthy();
+ });
+
+ it('should handle empty components array', async () => {
+ const { canvasElement, component } = await setup({ components: []
});
+ expect(canvasElement).toBeTruthy();
+ expect(component).toBeTruthy();
+ });
+
+ it('should accept all supported component types without error', async
() => {
+ const componentTypes: ComponentType[] = [
+ ComponentType.Processor,
+ ComponentType.ProcessGroup,
+ ComponentType.RemoteProcessGroup,
+ ComponentType.InputPort,
+ ComponentType.OutputPort,
+ ComponentType.Funnel,
+ ComponentType.Label,
+ ComponentType.Connection
+ ];
+
+ const components = componentTypes.map((type, index) =>
createMockComponent({ type, x: index * 200, y: 0 }));
+ const { component } = await setup({ components });
+ expect(component).toBeTruthy();
+ });
+ });
+
+ describe('Viewport brush', () => {
+ it('should position brush via a transform attribute', async () => {
+ const components = [createMockComponent({ x: 0, y: 0 })];
+ const transform = createMockTransform({ translateX: -100,
translateY: -50, scale: 1 });
+ const { brushElement } = await setup({ components, transform });
+
+ const transformAttr = brushElement.getAttribute('transform');
+ expect(transformAttr).toContain('translate');
+ });
+
+ it('should size brush based on canvas dimensions and scale', async ()
=> {
+ const components = [createMockComponent({ x: 0, y: 0, width: 2000,
height: 1500 })];
+ const transform = createMockTransform({ scale: 0.5 });
+ const canvasDimensions = createMockDimensions({ width: 1000,
height: 800 });
+ const { brushElement } = await setup({ components, transform,
canvasDimensions });
+
+ const width = brushElement.getAttribute('width');
+ const height = brushElement.getAttribute('height');
+ expect(parseFloat(width!)).toBeGreaterThan(0);
+ expect(parseFloat(height!)).toBeGreaterThan(0);
+ });
+
+ it('should enforce minimum brush size of 10', async () => {
+ const components = [createMockComponent({ x: 0, y: 0, width:
10000, height: 10000 })];
+ const transform = createMockTransform({ scale: 0.1 });
+ const canvasDimensions = createMockDimensions({ width: 100,
height: 80 });
+ const { brushElement } = await setup({ components, transform,
canvasDimensions });
+
+ const width = parseFloat(brushElement.getAttribute('width')!);
+ const height = parseFloat(brushElement.getAttribute('height')!);
+ expect(width).toBeGreaterThanOrEqual(10);
+ expect(height).toBeGreaterThanOrEqual(10);
+ });
+
+ it('should reflect a panned viewport in the brush transform', async ()
=> {
+ const components = [createMockComponent({ x: 0, y: 0 }),
createMockComponent({ x: 1000, y: 800 })];
+ const transform = createMockTransform({ translateX: -500,
translateY: -400, scale: 1 });
+ const { brushElement } = await setup({ components, transform });
+
+ const transformAttr = brushElement.getAttribute('transform');
+ expect(transformAttr).toContain('translate');
+ expect(transformAttr).not.toBe('translate(0, 0)');
+ });
+ });
+
+ describe('Bounds calculation', () => {
+ it('should accept components at extreme coordinates', async () => {
+ const components = [
+ createMockComponent({ x: -500, y: -300, width: 100, height: 50
}),
+ createMockComponent({ x: 1000, y: 800, width: 100, height: 50
})
+ ];
+ const { component } = await setup({ components });
+ expect(component).toBeTruthy();
+ });
+
+ it('should accept a viewport panned far from any component', async ()
=> {
+ const components = [createMockComponent({ x: 0, y: 0 })];
+ const transform = createMockTransform({ translateX: -5000,
translateY: -3000 });
+ const canvasDimensions = createMockDimensions({ width: 1000,
height: 800 });
+ const { component } = await setup({ components, transform,
canvasDimensions });
+ expect(component).toBeTruthy();
+ });
+ });
+
+ describe('Output events', () => {
+ it('should expose viewportChange', async () => {
+ const { component } = await setup({ components:
[createMockComponent()] });
+ expect(component.viewportChange).toBeDefined();
+ });
+
+ it('should expose dragStart', async () => {
+ const { component } = await setup({ components:
[createMockComponent()] });
+ expect(component.dragStart).toBeDefined();
+ });
+
+ it('should expose dragEnd', async () => {
+ const { component } = await setup({ components:
[createMockComponent()] });
+ expect(component.dragEnd).toBeDefined();
+ });
+ });
+
+ describe('Component palette', () => {
+ // The default test environment does not implement
CanvasRenderingContext2D, so the
+ // component's rendering code path is normally a no-op. To exercise
the paint logic we
+ // install a stub 2D context on HTMLCanvasElement that records every
value assigned to
+ // fillStyle and strokeStyle while satisfying the methods the
component invokes during
+ // initializeBirdseye(), renderComponents(), and updateBirdseyeSize().
+ function installPaintTracker() {
+ const fillColors: string[] = [];
+ const strokeColors: string[] = [];
+
+ const ctx: any = {
+ _fillStyle: '',
+ _strokeStyle: '',
+ set fillStyle(value: string) {
+ fillColors.push(String(value));
+ this._fillStyle = value;
+ },
+ get fillStyle() {
+ return this._fillStyle;
+ },
+ set strokeStyle(value: string) {
+ strokeColors.push(String(value));
+ this._strokeStyle = value;
+ },
+ get strokeStyle() {
+ return this._strokeStyle;
+ },
+ scale: vi.fn(),
+ clearRect: vi.fn(),
+ save: vi.fn(),
+ restore: vi.fn(),
+ translate: vi.fn(),
+ fillRect: vi.fn(),
+ strokeRect: vi.fn(),
+ setTransform: vi.fn()
+ };
+
+ const spy = vi.spyOn(HTMLCanvasElement.prototype,
'getContext').mockReturnValue(ctx as any);
+
+ return {
+ fillColors,
+ strokeColors,
+ restore: () => spy.mockRestore()
+ };
+ }
+
+ it('should fill processors with the default palette color and a
contrast stroke', async () => {
+ const tracker = installPaintTracker();
+ try {
+ const components = [createMockComponent({ type:
ComponentType.Processor })];
+ const { fixture } = await setup({ components });
+ await fixture.whenStable();
+
+ expect(tracker.fillColors).toContain('#dde4eb');
+ expect(tracker.strokeColors).toContain('#000000');
+ } finally {
+ tracker.restore();
+ }
+ });
+
+ it('should fill labels with the default label palette color', async ()
=> {
+ const tracker = installPaintTracker();
+ try {
+ const components = [createMockComponent({ type:
ComponentType.Label })];
+ const { fixture } = await setup({ components });
+ await fixture.whenStable();
+
+ expect(tracker.fillColors).toContain('#fff7d7');
+ } finally {
+ tracker.restore();
+ }
+ });
+
+ it('should honor a user-configured fillColor override', async () => {
+ const tracker = installPaintTracker();
+ try {
+ const components = [createMockComponent({ type:
ComponentType.Processor, fillColor: '#123456' })];
+ const { fixture } = await setup({ components });
+ await fixture.whenStable();
+
+ expect(tracker.fillColors).toContain('#123456');
+ // #123456 is dark, so the contrast-derived stroke should be
white.
+ expect(tracker.strokeColors).toContain('#ffffff');
+ } finally {
+ tracker.restore();
+ }
+ });
+
+ it('should derive a black stroke for light user-configured fills',
async () => {
+ const tracker = installPaintTracker();
+ try {
+ const components = [createMockComponent({ type:
ComponentType.Label, fillColor: '#fefefe' })];
+ const { fixture } = await setup({ components });
+ await fixture.whenStable();
+
+ expect(tracker.fillColors).toContain('#fefefe');
+ expect(tracker.strokeColors).toContain('#000000');
+ } finally {
+ tracker.restore();
+ }
+ });
+ });
+
+ describe('Cleanup', () => {
+ it('should not throw when destroyed after rendering', async () => {
+ const { fixture } = await setup({ components:
[createMockComponent()] });
+ expect(() => fixture.destroy()).not.toThrow();
+ });
+ });
+
+ describe('Edge cases', () => {
+ it('should handle a single component', async () => {
+ const { component } = await setup({ components:
[createMockComponent()] });
+ expect(component).toBeTruthy();
+ });
+
+ it('should handle components at negative coordinates', async () => {
+ const components = [createMockComponent({ x: -1000, y: -500 }),
createMockComponent({ x: -500, y: -250 })];
+ const { component } = await setup({ components });
+ expect(component).toBeTruthy();
+ });
+
+ it('should handle very small scale', async () => {
+ const components = [createMockComponent()];
+ const transform = createMockTransform({ scale: 0.1 });
+ const { component } = await setup({ components, transform });
+ expect(component).toBeTruthy();
+ });
+
+ it('should handle very large scale', async () => {
+ const components = [createMockComponent()];
+ const transform = createMockTransform({ scale: 8 });
+ const { component } = await setup({ components, transform });
+ expect(component).toBeTruthy();
+ });
+
+ it('should handle zero-size canvas dimensions gracefully', async () =>
{
+ const components = [createMockComponent()];
+ const canvasDimensions = createMockDimensions({ width: 0, height:
0 });
+ const { component } = await setup({ components, canvasDimensions
});
+ expect(component).toBeTruthy();
+ });
+ });
+});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.ts
new file mode 100644
index 00000000000..9867f7ac8b8
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.ts
@@ -0,0 +1,496 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 {
+ AfterViewInit,
+ ChangeDetectionStrategy,
+ Component,
+ ElementRef,
+ OnDestroy,
+ effect,
+ input,
+ output,
+ viewChild
+} from '@angular/core';
+import * as d3 from 'd3';
+import { ComponentType } from '@nifi/shared';
+import { Dimension, Position } from '../canvas/canvas.types';
+import { BirdseyeBounds, BirdseyeComponentData, BirdseyeTransform } from
'./birdseye.types';
+
+/**
+ * Canvas Birdseye Component
+ *
+ * A minimap component that provides an overview of the entire canvas
+ * and allows users to navigate by dragging the viewport brush.
+ *
+ * Architecture: Hybrid Canvas + SVG
+ * - Canvas: Renders component representations (efficient for thousands of
components)
+ * - SVG: Renders the interactive viewport brush (easy drag handling)
+ *
+ * This hybrid approach provides optimal performance:
+ * - Canvas handles static component rendering without DOM overhead
+ * - SVG handles interactive brush with native D3 drag behavior
+ *
+ * Features:
+ * - Renders simplified representations of all canvas components
+ * - Shows current viewport position as a draggable brush
+ * - Drag brush to pan the canvas (delta-based translation)
+ * - Automatically scales to fit all components
+ * - Scales efficiently to thousands of components
+ *
+ * Usage:
+ * ```html
+ * <canvas-birdseye
+ * [components]="birdseyeComponents()"
+ * [transform]="canvasTransform()"
+ * [canvasDimensions]="{ width: canvasWidth, height: canvasHeight }"
+ * (viewportChange)="onBirdseyeViewportChange($event)">
+ * </canvas-birdseye>
+ * ```
+ */
+@Component({
+ selector: 'canvas-birdseye',
+ standalone: true,
+ templateUrl: './birdseye.component.html',
+ styleUrls: ['./birdseye.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush
+})
+export class CanvasBirdseyeComponent implements AfterViewInit, OnDestroy {
+ private birdseyeContainer =
viewChild.required<ElementRef<HTMLDivElement>>('birdseyeContainer');
+
+ components = input.required<BirdseyeComponentData[]>();
+
+ transform = input.required<BirdseyeTransform>();
+
+ canvasDimensions = input.required<Dimension>();
+
+ birdseyeDimensions = input<Dimension>({ width: 200, height: 150 });
+
+ // Emitted during drag with the new translate the canvas should adopt.
+ viewportChange = output<Position>();
+
+ // Parent should call canvas.birdseyeDragStart() to skip visibility
updates.
+ dragStart = output<void>();
+
+ // Parent should call canvas.birdseyeDragEnd() to update visibility.
+ dragEnd = output<void>();
+
+ private canvas: HTMLCanvasElement | null = null;
+ private canvasContext: CanvasRenderingContext2D | null = null;
+
+ private svg: d3.Selection<SVGSVGElement, unknown, null, undefined> | null
= null;
+ private brushGroup: d3.Selection<SVGGElement, unknown, null, undefined> |
null = null;
+ private brush: d3.Selection<SVGRectElement, { x: number; y: number },
null, undefined> | null = null;
+
+ private readonly DPR = window.devicePixelRatio || 1;
+
+ private birdseyeScale = 1;
+ private offsetX = 0;
+ private offsetY = 0;
+ private bounds: BirdseyeBounds = { minX: 0, minY: 0, maxX: 100, maxY: 100
};
+
+ private isDragging = false;
+
+ private destroyed = false;
+
+ private resizeObserver: ResizeObserver | null = null;
+
+ private effectiveWidth = 200;
+
+ // Default palette mapped per ComponentType. Process groups and remote
process groups share a
+ // fill so the two visually merge into a single "group" band on the
minimap. Only processors
+ // and labels render a stroke because they are the only component types
whose fill can be
+ // overridden by a user-configured background color; the stroke is derived
from the resolved
+ // fill so it keeps adequate contrast against any choice. Connection is
intentionally omitted
+ // because getBirdseyeComponentData() does not emit connections, so any
unmapped type
+ // (current or future) falls through to DEFAULT_COLOR.
+ private readonly COMPONENT_COLORS: Partial<Record<ComponentType, { fill:
string; hasStroke: boolean }>> = {
+ [ComponentType.Processor]: { fill: '#dde4eb', hasStroke: true },
+ [ComponentType.ProcessGroup]: { fill: '#728e9b', hasStroke: false },
+ [ComponentType.RemoteProcessGroup]: { fill: '#728e9b', hasStroke:
false },
+ [ComponentType.InputPort]: { fill: '#bbdcde', hasStroke: false },
+ [ComponentType.OutputPort]: { fill: '#bbdcde', hasStroke: false },
+ [ComponentType.Funnel]: { fill: '#ad9897', hasStroke: false },
+ [ComponentType.Label]: { fill: '#fff7d7', hasStroke: true }
+ };
+
+ private readonly DEFAULT_COLOR = { fill: '#dde4eb', hasStroke: false };
+
+ constructor() {
+ effect(() => {
+ const components = this.components();
+ if (this.canvasContext && components && !this.isDragging) {
+ this.renderComponents(components);
+ }
+ });
+
+ // Recalculates bounds to account for viewport position beyond flow
bounds.
+ // Skipped during drag because the brush position is updated directly
in the drag handler.
+ effect(() => {
+ const transform = this.transform();
+ const canvasDims = this.canvasDimensions();
+ if (this.canvasContext && this.brush && transform && canvasDims &&
!this.isDragging) {
+ this.refresh();
+ }
+ });
+
+ effect(() => {
+ const dims = this.birdseyeDimensions();
+ if (this.canvas && this.svg && this.canvasContext) {
+ this.updateBirdseyeSize({ width: this.effectiveWidth, height:
dims.height });
+ }
+ });
+ }
+
+ ngAfterViewInit(): void {
+ this.initializeBirdseye();
+
+ const container = this.birdseyeContainer().nativeElement;
+ this.resizeObserver = new ResizeObserver((entries) => {
+ for (const entry of entries) {
+ const width = entry.contentRect.width;
+ if (width > 0 && this.canvas && this.svg &&
this.canvasContext) {
+ this.updateBirdseyeSize({ width, height:
this.birdseyeDimensions().height });
+ }
+ }
+ });
+ this.resizeObserver.observe(container);
+ }
+
+ ngOnDestroy(): void {
+ this.destroyed = true;
+
+ if (this.resizeObserver) {
+ this.resizeObserver.disconnect();
+ this.resizeObserver = null;
+ }
+
+ // Remove the D3 drag behavior so events do not fire after the
component is destroyed.
+ if (this.brush) {
+ this.brush.on('.drag', null);
+ }
+
+ if (this.svg) {
+ this.svg.remove();
+ this.svg = null;
+ }
+ if (this.canvas) {
+ this.canvas.remove();
+ this.canvas = null;
+ }
+ }
+
+ /**
+ * Initialize the birdseye view with hybrid Canvas + SVG approach
+ */
+ private initializeBirdseye(): void {
+ const container = this.birdseyeContainer().nativeElement;
+
+ // Use the container's actual width (CSS-driven) instead of the input
default.
+ const containerWidth = container.clientWidth ||
this.birdseyeDimensions().width;
+ this.effectiveWidth = containerWidth;
+ const height = this.birdseyeDimensions().height;
+
+ this.canvas = document.createElement('canvas');
+ this.canvas.width = containerWidth * this.DPR;
+ this.canvas.height = height * this.DPR;
+ this.canvas.style.width = `${containerWidth}px`;
+ this.canvas.style.height = `${height}px`;
+ this.canvas.className = 'birdseye-canvas';
+ container.appendChild(this.canvas);
+
+ this.canvasContext = this.canvas.getContext('2d');
+ if (this.canvasContext) {
+ this.canvasContext.scale(this.DPR, this.DPR);
+ }
+
+ this.svg = d3
+ .select(container)
+ .append('svg')
+ .attr('class', 'birdseye-svg')
+ .attr('width', containerWidth)
+ .attr('height', height);
+
+ this.brushGroup = this.svg.append('g').attr('class',
'birdseye-brush-container');
+
+ this.createBrush();
+
+ this.renderComponents(this.components());
+ this.updateBrush(this.transform(), this.canvasDimensions());
+ }
+
+ /**
+ * Create the draggable viewport brush. Translation is delta-based: each
drag event applies
+ * an incremental offset to the current canvas translate rather than
computing an absolute
+ * viewport position. This keeps the brush in lock-step with incremental
canvas pans and
+ * avoids cumulative rounding drift when the user holds and drags
continuously.
+ */
+ private createBrush(): void {
+ if (!this.brushGroup) return;
+
+ const drag = d3
+ .drag<SVGRectElement, { x: number; y: number }>()
+ .subject((_event, d) => {
+ return { x: d.x, y: d.y };
+ })
+ .on('start', (_event, _d) => {
+ if (this.destroyed) {
+ return;
+ }
+ this.isDragging = true;
+ this.dragStart.emit();
+ })
+ .on('drag', (event, d) => {
+ if (this.destroyed) {
+ return;
+ }
+
+ d.x += event.dx;
+ d.y += event.dy;
+
+ if (this.brush) {
+ this.brush.attr('transform', `translate(${d.x}, ${d.y})`);
+ }
+
+ // Translate the canvas by the inverse delta scaled into
canvas coordinates.
+ const currentTransform = this.transform();
+ const scaledDx = event.dx / this.birdseyeScale;
+ const scaledDy = event.dy / this.birdseyeScale;
+
+ const newTranslateX = currentTransform.translate.x - scaledDx
* currentTransform.scale;
+ const newTranslateY = currentTransform.translate.y - scaledDy
* currentTransform.scale;
+
+ this.viewportChange.emit({
+ x: newTranslateX,
+ y: newTranslateY
+ });
+ })
+ .on('end', (_event, _d) => {
+ if (this.destroyed) {
+ return;
+ }
+
+ this.isDragging = false;
+
+ this.dragEnd.emit();
+
+ // Recalculate bounds so the brush stays within the birdseye
area after dragging.
+ this.refresh();
+ });
+
+ const brushContainer = this.brushGroup
+ .append('g')
+ .attr('pointer-events', 'all')
+ .attr('class', 'birdseye-brush-container');
+
+ this.brush = brushContainer.append('rect').attr('class',
'birdseye-brush').datum({ x: 0, y: 0 }).call(drag);
+ }
+
+ /**
+ * Returns the effective birdseye dimensions, using the container's actual
width
+ * (auto-sized via CSS) and the configured height from the input.
+ */
+ private getEffectiveDimensions(): Dimension {
+ return { width: this.effectiveWidth, height:
this.birdseyeDimensions().height };
+ }
+
+ /**
+ * Render component representations using Canvas (efficient for many
components).
+ */
+ private renderComponents(components: BirdseyeComponentData[]): void {
+ if (!this.canvasContext) return;
+
+ const ctx = this.canvasContext;
+
+ const dims = this.getEffectiveDimensions();
+ ctx.clearRect(0, 0, dims.width, dims.height);
+
+ if (!components || components.length === 0) {
+ return;
+ }
+
+ const componentBounds = this.calculateComponentBounds(components);
+
+ // Calculate viewport bounds in canvas coordinates.
+ const transform = this.transform();
+ const canvasDims = this.canvasDimensions();
+ const viewportLeft = -transform.translate.x / transform.scale;
+ const viewportTop = -transform.translate.y / transform.scale;
+ const viewportRight = viewportLeft + canvasDims.width /
transform.scale;
+ const viewportBottom = viewportTop + canvasDims.height /
transform.scale;
+
+ // Total bounds = union of component bounds and viewport bounds so the
birdseye
+ // shows both content AND the viewport even if the viewport is panned
away.
+ this.bounds = {
+ minX: Math.min(componentBounds.minX, viewportLeft),
+ minY: Math.min(componentBounds.minY, viewportTop),
+ maxX: Math.max(componentBounds.maxX, viewportRight),
+ maxY: Math.max(componentBounds.maxY, viewportBottom)
+ };
+
+ const contentWidth = this.bounds.maxX - this.bounds.minX;
+ const contentHeight = this.bounds.maxY - this.bounds.minY;
+
+ const padding = 5;
+ const availableWidth = dims.width - padding * 2;
+ const availableHeight = dims.height - padding * 2;
+
+ const scaleX = contentWidth > 0 ? availableWidth / contentWidth : 1;
+ const scaleY = contentHeight > 0 ? availableHeight / contentHeight : 1;
+ // Don't scale up beyond 1.
+ this.birdseyeScale = Math.min(scaleX, scaleY, 1);
+
+ const scaledWidth = contentWidth * this.birdseyeScale;
+ const scaledHeight = contentHeight * this.birdseyeScale;
+ this.offsetX = (dims.width - scaledWidth) / 2;
+ this.offsetY = (dims.height - scaledHeight) / 2;
+
+ ctx.save();
+
+ // Apply the transformation chain in this order:
+ // translate(offset) -> scale(birdseyeScale) ->
translate(-bounds.min)
+ // Scaling before drawing means strokes scale uniformly with the
content, so a single
+ // strokeStyle assignment produces a visually consistent border for
every component.
+ // Translating by -bounds.min lets the loop pass each component's raw
canvas position
+ // and dimensions without the caller having to map them into birdseye
coordinates.
+ ctx.translate(this.offsetX, this.offsetY);
+ ctx.scale(this.birdseyeScale, this.birdseyeScale);
+ ctx.translate(-this.bounds.minX, -this.bounds.minY);
+
+ for (const component of components) {
+ const color = this.COMPONENT_COLORS[component.type] ||
this.DEFAULT_COLOR;
+ // Honor a user-configured background color (set by Change Color
on processors and
+ // labels) when present; otherwise fall back to the type-based
palette entry.
+ const fill = component.fillColor || color.fill;
+
+ ctx.fillStyle = fill;
+ ctx.fillRect(
+ component.position.x,
+ component.position.y,
+ component.dimensions.width,
+ component.dimensions.height
+ );
+
+ if (color.hasStroke) {
+ ctx.strokeStyle = this.determineContrastColor(fill);
+ ctx.strokeRect(
+ component.position.x,
+ component.position.y,
+ component.dimensions.width,
+ component.dimensions.height
+ );
+ }
+ }
+
+ ctx.restore();
+ }
+
+ /**
+ * Returns black or white depending on whether the supplied hex color is
light or dark, so
+ * the stroke remains legible against any user-configured fill. Uses a
simple luminance
+ * threshold (`parseInt(hex, 16) > 0xffffff / 1.5`) which is cheap to
compute on every
+ * paint and gives correct contrast for the limited fill palette this
component renders.
+ */
+ private determineContrastColor(fill: string): string {
+ const hex = fill.startsWith('#') ? fill.substring(1) : fill;
+ if (parseInt(hex, 16) > 0xffffff / 1.5) {
+ return '#000000';
+ }
+ return '#ffffff';
+ }
+
+ /**
+ * Update the viewport brush position and size.
+ */
+ private updateBrush(transform: BirdseyeTransform, canvasDimensions:
Dimension): void {
+ if (!this.brush) return;
+
+ const components = this.components();
+ if (!components || components.length === 0) return;
+
+ const viewportX = -transform.translate.x / transform.scale;
+ const viewportY = -transform.translate.y / transform.scale;
+
+ const viewportWidth = canvasDimensions.width / transform.scale;
+ const viewportHeight = canvasDimensions.height / transform.scale;
+
+ const brushX = (viewportX - this.bounds.minX) * this.birdseyeScale +
this.offsetX;
+ const brushY = (viewportY - this.bounds.minY) * this.birdseyeScale +
this.offsetY;
+ const brushWidth = viewportWidth * this.birdseyeScale;
+ const brushHeight = viewportHeight * this.birdseyeScale;
+
+ this.brush.datum({ x: brushX, y: brushY });
+
+ this.brush
+ .attr('transform', `translate(${brushX}, ${brushY})`)
+ .attr('width', Math.max(brushWidth, 10))
+ .attr('height', Math.max(brushHeight, 10));
+ }
+
+ /**
+ * Calculate the bounding box of all components.
+ */
+ private calculateComponentBounds(components: BirdseyeComponentData[]):
BirdseyeBounds {
+ if (!components || components.length === 0) {
+ return { minX: 0, minY: 0, maxX: 100, maxY: 100 };
+ }
+
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+
+ for (const component of components) {
+ minX = Math.min(minX, component.position.x);
+ minY = Math.min(minY, component.position.y);
+ maxX = Math.max(maxX, component.position.x +
component.dimensions.width);
+ maxY = Math.max(maxY, component.position.y +
component.dimensions.height);
+ }
+
+ return { minX, minY, maxX, maxY };
+ }
+
+ /**
+ * Recalculate bounds and redraw everything. Called after drag ends so the
brush
+ * stays within the bounds of the new viewport.
+ */
+ private refresh(): void {
+ this.renderComponents(this.components());
+ this.updateBrush(this.transform(), this.canvasDimensions());
+ }
+
+ /**
+ * Update birdseye canvas and SVG sizes when dimensions change.
+ */
+ private updateBirdseyeSize(dims: Dimension): void {
+ if (!this.canvas || !this.svg || !this.canvasContext) return;
+
+ this.effectiveWidth = dims.width;
+
+ this.canvas.width = dims.width * this.DPR;
+ this.canvas.height = dims.height * this.DPR;
+ this.canvas.style.width = `${dims.width}px`;
+ this.canvas.style.height = `${dims.height}px`;
+
+ this.canvasContext.setTransform(1, 0, 0, 1, 0, 0);
+ this.canvasContext.scale(this.DPR, this.DPR);
+
+ this.svg.attr('width', dims.width).attr('height', dims.height);
+
+ this.refresh();
+ }
+}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.types.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.types.ts
new file mode 100644
index 00000000000..43293332776
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.types.ts
@@ -0,0 +1,54 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 { ComponentType } from '@nifi/shared';
+import { Dimension, Position } from '../canvas/canvas.types';
+
+/**
+ * Simplified component data for birdseye rendering
+ */
+export interface BirdseyeComponentData {
+ id: string;
+ type: ComponentType;
+ position: Position;
+ dimensions: Dimension;
+ /**
+ * Optional hex color (e.g. '#aabbcc') used to fill this component on the
birdseye. When
+ * present it overrides the type-based palette so a user-configured
background color on a
+ * processor or label is reflected on the minimap, keeping the overview
visually
+ * consistent with what the user sees on the canvas.
+ */
+ fillColor?: string;
+}
+
+/**
+ * Canvas transform state
+ */
+export interface BirdseyeTransform {
+ translate: Position;
+ scale: number;
+}
+
+/**
+ * Bounding box for component calculations
+ */
+export interface BirdseyeBounds {
+ minX: number;
+ minY: number;
+ maxX: number;
+ maxY: number;
+}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.spec.ts
index c987e3eebce..8ccb9219400 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.spec.ts
@@ -22,7 +22,7 @@ import { ReplaySubject } from 'rxjs';
import { Action } from '@ngrx/store';
import { outputToObservable } from '@angular/core/rxjs-interop';
import { CanvasComponent } from './canvas.component';
-import { BirdseyeComponentData } from './canvas.component';
+import { BirdseyeComponentData } from '../birdseye/birdseye.types';
import { canvasUiFeatureKey, initialCanvasUiState } from
'../../../state/canvas-ui';
import { ComponentType } from '@nifi/shared';
@@ -384,6 +384,48 @@ describe('CanvasComponent', () => {
expect(types).toContain(ComponentType.Funnel);
expect(types).toContain(ComponentType.ProcessGroup);
});
+
+ it('should expose the user-configured background color for a readable
processor', async () => {
+ const processor = createMockProcessor({ id: 'proc-1' });
+ processor.component.style = { 'background-color': '#abcdef' };
+ const { component } = await setup({ processors: [processor] });
+
+ const data = component.getBirdseyeComponentData();
+
+ expect(data[0].fillColor).toBe('#abcdef');
+ });
+
+ it('should leave fillColor undefined for an unauthorized processor
even when a style is present', async () => {
+ const processor = createMockProcessor({ id: 'proc-1' });
+ processor.permissions = { canRead: false, canWrite: false };
+ processor.component.style = { 'background-color': '#abcdef' };
+ const { component } = await setup({ processors: [processor] });
+
+ const data = component.getBirdseyeComponentData();
+
+ expect(data[0].fillColor).toBeUndefined();
+ });
+
+ it('should expose the user-configured background color for a readable
label', async () => {
+ const label = createMockLabel({ id: 'label-1' });
+ label.component.style = { 'background-color': '#fedcba' };
+ const { component } = await setup({ labels: [label] });
+
+ const data = component.getBirdseyeComponentData();
+
+ expect(data[0].fillColor).toBe('#fedcba');
+ });
+
+ it('should leave fillColor undefined for an unauthorized label even
when a style is present', async () => {
+ const label = createMockLabel({ id: 'label-1' });
+ label.permissions = { canRead: false, canWrite: false };
+ label.component.style = { 'background-color': '#fedcba' };
+ const { component } = await setup({ labels: [label] });
+
+ const data = component.getBirdseyeComponentData();
+
+ expect(data[0].fillColor).toBeUndefined();
+ });
});
describe('Birdseye API - setViewportPosition', () => {
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.ts
index 16632bafb9b..c903ab3f7b6 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.ts
@@ -74,18 +74,7 @@ import {
OverlapDetectionConnection,
wouldRemovalCauseOverlap
} from '../overlap-detection.utils';
-
-export interface BirdseyeComponentData {
- id: string;
- type: ComponentType;
- position: Position;
- dimensions: Dimension;
-}
-
-export interface BirdseyeTransform {
- translate: Position;
- scale: number;
-}
+import { BirdseyeComponentData, BirdseyeTransform } from
'../birdseye/birdseye.types';
/**
* Interface for viewport transform storage
@@ -2979,11 +2968,18 @@ export class CanvasComponent implements OnInit,
AfterViewInit, OnDestroy {
// Add processors
this.internalProcessors().forEach((p) => {
+ // Without canRead the caller is not authorized to view the
component definition
+ // (including its configured style), so fall back to the default
palette fill rather
+ // than leaking the user-configured background color through the
minimap.
+ const fillColor = p.entity.permissions?.canRead
+ ? p.entity.component?.style?.['background-color'] || undefined
+ : undefined;
components.push({
id: p.entity.id,
type: ComponentType.Processor,
position: { x: p.entity.position.x, y: p.entity.position.y },
- dimensions: { width: p.ui.dimensions.width, height:
p.ui.dimensions.height }
+ dimensions: { width: p.ui.dimensions.width, height:
p.ui.dimensions.height },
+ fillColor
});
});
@@ -3030,11 +3026,18 @@ export class CanvasComponent implements OnInit,
AfterViewInit, OnDestroy {
// Add labels
this.internalLabels().forEach((l) => {
+ // Same authorization rule as processors above: a caller without
canRead has no
+ // authority to view the label's configured style, so fall back to
the default
+ // palette fill rather than leaking the user-configured background
color.
+ const fillColor = l.entity.permissions?.canRead
+ ? l.entity.component?.style?.['background-color'] || undefined
+ : undefined;
components.push({
id: l.entity.id,
type: ComponentType.Label,
position: { x: l.entity.position.x, y: l.entity.position.y },
- dimensions: { width: l.ui.dimensions.width, height:
l.ui.dimensions.height }
+ dimensions: { width: l.ui.dimensions.width, height:
l.ui.dimensions.height },
+ fillColor
});
});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.html
new file mode 100644
index 00000000000..cd846f01b37
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.html
@@ -0,0 +1,97 @@
+<!--
+ ~ Licensed to the Apache Software Foundation (ASF) under one or more
+ ~ contributor license agreements. See the NOTICE file distributed with
+ ~ this work for additional information regarding copyright ownership.
+ ~ The ASF licenses this file to You 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.
+ -->
+
+<mat-expansion-panel
+ [hideToggle]="true"
+ [expanded]="!navigationCollapsed"
+ (opened)="opened()"
+ (closed)="toggleCollapsed(true)">
+ <mat-expansion-panel-header>
+ <mat-panel-title>
+ <div class="flex items-center gap-x-2">
+ <div class="w-5 fa fa-compass primary-color"></div>
+ <div>Navigation</div>
+ </div>
+ </mat-panel-title>
+ </mat-expansion-panel-header>
+ <div class="flex flex-col gap-y-2">
+ <div class="flex justify-between">
+ <div class="flex gap-x-1">
+ <button
+ mat-icon-button
+ class="primary-icon-button"
+ type="button"
+ matTooltip="Zoom In"
+ aria-label="Zoom In"
+ data-qa="zoom-in-button"
+ (click)="zoomIn.emit()">
+ <i class="fa fa-search-plus"></i>
+ </button>
+ <button
+ mat-icon-button
+ class="primary-icon-button mr-2"
+ type="button"
+ matTooltip="Zoom Out"
+ aria-label="Zoom Out"
+ data-qa="zoom-out-button"
+ (click)="zoomOut.emit()">
+ <i class="fa fa-search-minus"></i>
+ </button>
+ <button
+ mat-icon-button
+ class="primary-icon-button"
+ type="button"
+ matTooltip="Fit to Screen"
+ aria-label="Fit to Screen"
+ data-qa="zoom-fit-button"
+ (click)="zoomFit.emit()">
+ <i class="ml-1 icon icon-zoom-fit"></i>
+ </button>
+ <button
+ mat-icon-button
+ class="primary-icon-button"
+ type="button"
+ matTooltip="Zoom to Actual Size"
+ aria-label="Zoom to Actual Size"
+ data-qa="zoom-actual-button"
+ (click)="zoomActual.emit()">
+ <i class="ml-1 icon icon-zoom-actual"></i>
+ </button>
+ </div>
+ @if (canNavigateToParent()) {
+ <button
+ mat-icon-button
+ class="primary-icon-button"
+ type="button"
+ matTooltip="Leave Group"
+ aria-label="Leave Group"
+ data-qa="leave-group-button"
+ (click)="leaveGroup.emit()">
+ <i class="fa fa-level-up"></i>
+ </button>
+ }
+ </div>
+ <canvas-birdseye
+ [components]="birdseyeComponents()"
+ [transform]="birdseyeTransform()"
+ [canvasDimensions]="canvasDimensions()"
+ (viewportChange)="viewportChange.emit($event)"
+ (dragStart)="birdseyeDragStart.emit()"
+ (dragEnd)="birdseyeDragEnd.emit()">
+ </canvas-birdseye>
+ </div>
+</mat-expansion-panel>
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.scss
similarity index 58%
copy from
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.ts
copy to
nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.scss
index a04443a6d34..2944f981947 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-canvas/graph-controls/connector-graph-controls.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.scss
@@ -14,19 +14,3 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
-
-import { Component, input } from '@angular/core';
-import { ConnectorEntity } from '@nifi/shared';
-import { ConnectorInfoControl } from
'./connector-info-control/connector-info-control.component';
-
-@Component({
- selector: 'connector-graph-controls',
- standalone: true,
- imports: [ConnectorInfoControl],
- templateUrl: './connector-graph-controls.component.html',
- styleUrls: ['./connector-graph-controls.component.scss']
-})
-export class ConnectorGraphControls {
- connectorEntity = input<ConnectorEntity | null>(null);
- entitySaving = input<boolean>(false);
-}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.spec.ts
new file mode 100644
index 00000000000..3c6c9350519
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.spec.ts
@@ -0,0 +1,237 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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 { ComponentFixture, TestBed } from '@angular/core/testing';
+import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { MockComponent } from 'ng-mocks';
+import { Storage } from '@nifi/shared';
+import { CanvasNavigationControl } from
'./canvas-navigation-control.component';
+import { CanvasBirdseyeComponent } from '../birdseye/birdseye.component';
+
+const mockStorage = {
+ getItem: vi.fn().mockReturnValue(null),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ hasItem: vi.fn(),
+ getItemExpiration: vi.fn()
+};
+
+interface SetupOptions {
+ canNavigateToParent?: boolean;
+ storageKey?: string;
+ storedVisibility?: { [key: string]: boolean } | null;
+}
+
+async function setup(options: SetupOptions = {}) {
+ mockStorage.getItem.mockReturnValue(options.storedVisibility ?? null);
+
+ await TestBed.configureTestingModule({
+ imports: [CanvasNavigationControl,
MockComponent(CanvasBirdseyeComponent), NoopAnimationsModule],
+ providers: [{ provide: Storage, useValue: mockStorage }]
+ }).compileComponents();
+
+ const fixture: ComponentFixture<CanvasNavigationControl> =
TestBed.createComponent(CanvasNavigationControl);
+ const component = fixture.componentInstance;
+
+ fixture.componentRef.setInput('birdseyeComponents', []);
+ fixture.componentRef.setInput('birdseyeTransform', { translate: { x: 0, y:
0 }, scale: 1 });
+ fixture.componentRef.setInput('canvasDimensions', { width: 800, height:
600 });
+ fixture.componentRef.setInput('canNavigateToParent',
options.canNavigateToParent ?? false);
+
+ if (options.storageKey) {
+ fixture.componentRef.setInput('storageKey', options.storageKey);
+ }
+
+ fixture.detectChanges();
+
+ return { fixture, component };
+}
+
+describe('CanvasNavigationControl', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('Component initialization', () => {
+ it('should create', async () => {
+ const { component } = await setup();
+ expect(component).toBeTruthy();
+ });
+
+ it('should default to expanded state', async () => {
+ const { component } = await setup();
+ expect(component.navigationCollapsed).toBe(false);
+ });
+
+ it('should restore collapsed state from storage', async () => {
+ const { component } = await setup({
+ storedVisibility: { 'canvas-navigation-control': false }
+ });
+ expect(component.navigationCollapsed).toBe(true);
+ });
+ });
+
+ describe('Zoom buttons', () => {
+ it('should render the zoom in button', async () => {
+ const { fixture } = await setup();
+
expect(fixture.nativeElement.querySelector('[data-qa="zoom-in-button"]')).toBeTruthy();
+ });
+
+ it('should render the zoom out button', async () => {
+ const { fixture } = await setup();
+
expect(fixture.nativeElement.querySelector('[data-qa="zoom-out-button"]')).toBeTruthy();
+ });
+
+ it('should render the zoom fit button', async () => {
+ const { fixture } = await setup();
+
expect(fixture.nativeElement.querySelector('[data-qa="zoom-fit-button"]')).toBeTruthy();
+ });
+
+ it('should render the zoom actual button', async () => {
+ const { fixture } = await setup();
+
expect(fixture.nativeElement.querySelector('[data-qa="zoom-actual-button"]')).toBeTruthy();
+ });
+
+ it('should emit zoomIn when the zoom in button is clicked', async ()
=> {
+ const { fixture, component } = await setup();
+ const emitSpy = vi.spyOn(component.zoomIn, 'emit');
+
+
fixture.nativeElement.querySelector('[data-qa="zoom-in-button"]').click();
+
+ expect(emitSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should emit zoomOut when the zoom out button is clicked', async ()
=> {
+ const { fixture, component } = await setup();
+ const emitSpy = vi.spyOn(component.zoomOut, 'emit');
+
+
fixture.nativeElement.querySelector('[data-qa="zoom-out-button"]').click();
+
+ expect(emitSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should emit zoomFit when the zoom fit button is clicked', async ()
=> {
+ const { fixture, component } = await setup();
+ const emitSpy = vi.spyOn(component.zoomFit, 'emit');
+
+
fixture.nativeElement.querySelector('[data-qa="zoom-fit-button"]').click();
+
+ expect(emitSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should emit zoomActual when the zoom actual button is clicked',
async () => {
+ const { fixture, component } = await setup();
+ const emitSpy = vi.spyOn(component.zoomActual, 'emit');
+
+
fixture.nativeElement.querySelector('[data-qa="zoom-actual-button"]').click();
+
+ expect(emitSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Leave group button', () => {
+ it('should be hidden when canNavigateToParent is false', async () => {
+ const { fixture } = await setup({ canNavigateToParent: false });
+
expect(fixture.nativeElement.querySelector('[data-qa="leave-group-button"]')).toBeNull();
+ });
+
+ it('should be shown when canNavigateToParent is true', async () => {
+ const { fixture } = await setup({ canNavigateToParent: true });
+
expect(fixture.nativeElement.querySelector('[data-qa="leave-group-button"]')).toBeTruthy();
+ });
+
+ it('should emit leaveGroup when clicked', async () => {
+ const { fixture, component } = await setup({ canNavigateToParent:
true });
+ const emitSpy = vi.spyOn(component.leaveGroup, 'emit');
+
+
fixture.nativeElement.querySelector('[data-qa="leave-group-button"]').click();
+
+ expect(emitSpy).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('Birdseye embedding', () => {
+ it('should render the embedded birdseye component', async () => {
+ const { fixture } = await setup();
+
expect(fixture.nativeElement.querySelector('canvas-birdseye')).toBeTruthy();
+ });
+ });
+
+ describe('Collapse persistence', () => {
+ it('should save collapsed state under the default key', async () => {
+ const { component } = await setup();
+
+ component.toggleCollapsed(true);
+
+
expect(mockStorage.setItem).toHaveBeenCalledWith('graph-control-visibility', {
+ 'canvas-navigation-control': false
+ });
+ });
+
+ it('should save expanded state under the default key', async () => {
+ const { component } = await setup();
+
+ component.toggleCollapsed(false);
+
+
expect(mockStorage.setItem).toHaveBeenCalledWith('graph-control-visibility', {
+ 'canvas-navigation-control': true
+ });
+ });
+
+ it('should use a custom storageKey for persistence', async () => {
+ const { component } = await setup({ storageKey:
'connector-navigation-control' });
+
+ component.toggleCollapsed(true);
+
+
expect(mockStorage.setItem).toHaveBeenCalledWith('graph-control-visibility', {
+ 'connector-navigation-control': false
+ });
+ });
+
+ it('should restore collapsed state using a custom storageKey', async
() => {
+ const { component } = await setup({
+ storageKey: 'connector-navigation-control',
+ storedVisibility: { 'connector-navigation-control': false }
+ });
+
+ expect(component.navigationCollapsed).toBe(true);
+ });
+
+ it('should not cross-pollinate state between different storage keys',
async () => {
+ const { component } = await setup({
+ storageKey: 'connector-navigation-control',
+ storedVisibility: { 'navigation-control': false }
+ });
+
+ expect(component.navigationCollapsed).toBe(false);
+ });
+
+ it('should preserve other entries when persisting its key', async ()
=> {
+ const { component } = await setup({
+ storageKey: 'connector-navigation-control',
+ storedVisibility: { 'navigation-control': true }
+ });
+
+ component.toggleCollapsed(true);
+
+
expect(mockStorage.setItem).toHaveBeenCalledWith('graph-control-visibility', {
+ 'navigation-control': true,
+ 'connector-navigation-control': false
+ });
+ });
+ });
+});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.ts
new file mode 100644
index 00000000000..9489d95e34d
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.ts
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You 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, inject, input, OnInit, output } from '@angular/core';
+import { MatButtonModule } from '@angular/material/button';
+import { MatExpansionPanel, MatExpansionPanelHeader, MatExpansionPanelTitle }
from '@angular/material/expansion';
+import { MatTooltip } from '@angular/material/tooltip';
+import { Storage } from '@nifi/shared';
+import { CanvasBirdseyeComponent } from '../birdseye/birdseye.component';
+import { BirdseyeComponentData, BirdseyeTransform } from
'../birdseye/birdseye.types';
+import { Dimension, Position } from '../canvas/canvas.types';
+
+@Component({
+ selector: 'canvas-navigation-control',
+ standalone: true,
+ imports: [
+ CanvasBirdseyeComponent,
+ MatButtonModule,
+ MatExpansionPanel,
+ MatExpansionPanelHeader,
+ MatExpansionPanelTitle,
+ MatTooltip
+ ],
+ templateUrl: './canvas-navigation-control.component.html',
+ styleUrls: ['./canvas-navigation-control.component.scss']
+})
+export class CanvasNavigationControl implements OnInit {
+ private storage = inject(Storage);
+
+ private static readonly CONTROL_VISIBILITY_KEY =
'graph-control-visibility';
+
+ /**
+ * Unique key for persisting the collapsed state in localStorage. Each
consumer should
+ * provide a distinct key to avoid state collisions when multiple
instances of this
+ * component exist across different pages.
+ */
+ storageKey = input<string>('canvas-navigation-control');
+
+ navigationCollapsed = false;
+
+ birdseyeComponents = input.required<BirdseyeComponentData[]>();
+ birdseyeTransform = input.required<BirdseyeTransform>();
+ canvasDimensions = input.required<Dimension>();
+ canNavigateToParent = input<boolean>(false);
+
+ viewportChange = output<Position>();
+ birdseyeDragStart = output<void>();
+ birdseyeDragEnd = output<void>();
+
+ zoomIn = output<void>();
+ zoomOut = output<void>();
+ zoomFit = output<void>();
+ zoomActual = output<void>();
+ leaveGroup = output<void>();
+
+ ngOnInit(): void {
+ try {
+ const item: { [key: string]: boolean } | null =
this.storage.getItem(
+ CanvasNavigationControl.CONTROL_VISIBILITY_KEY
+ );
+ if (item) {
+ this.navigationCollapsed = item[this.storageKey()] === false;
+ }
+ } catch (_e) {
+ // likely could not parse item... ignoring
+ }
+ }
+
+ opened(): void {
+ this.toggleCollapsed(false);
+ }
+
+ toggleCollapsed(collapsed: boolean): void {
+ this.navigationCollapsed = collapsed;
+
+ let item: { [key: string]: boolean } | null = this.storage.getItem(
+ CanvasNavigationControl.CONTROL_VISIBILITY_KEY
+ );
+ if (item == null) {
+ item = {};
+ }
+
+ item[this.storageKey()] = !this.navigationCollapsed;
+ this.storage.setItem(CanvasNavigationControl.CONTROL_VISIBILITY_KEY,
item);
+ }
+}