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

Reply via email to