-
+
+
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 9e4d86095526..96b60bc05cde 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
(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 = 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 a04443a6d34f..b5d6dcbcc403 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(null);
entitySaving = input(false);
+
+ birdseyeComponents = input.required();
+ birdseyeTransform = input.required();
+ canvasDimensions = input.required();
+ canNavigateToParent = input(false);
+
+ viewportChange = output();
+ birdseyeDragStart = output();
+ birdseyeDragEnd = output();
+
+ zoomIn = output();
+ zoomOut = output();
+ zoomFit = output();
+ zoomActual = output();
+ leaveGroup = output();
}
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.html
new file mode 100644
index 000000000000..5ef811d7f064
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/birdseye/birdseye.component.html
@@ -0,0 +1,18 @@
+
+
+
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 000000000000..11213b7dfc9d
--- /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 000000000000..ac7a04e20dfe
--- /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 000000000000..9867f7ac8b83
--- /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
+ *
+ *
+ * ```
+ */
+@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>('birdseyeContainer');
+
+ components = input.required();
+
+ transform = input.required();
+
+ canvasDimensions = input.required();
+
+ birdseyeDimensions = input({ width: 200, height: 150 });
+
+ // Emitted during drag with the new translate the canvas should adopt.
+ viewportChange = output();
+
+ // Parent should call canvas.birdseyeDragStart() to skip visibility updates.
+ dragStart = output();
+
+ // Parent should call canvas.birdseyeDragEnd() to update visibility.
+ dragEnd = output();
+
+ private canvas: HTMLCanvasElement | null = null;
+ private canvasContext: CanvasRenderingContext2D | null = null;
+
+ private svg: d3.Selection | null = null;
+ private brushGroup: d3.Selection | null = null;
+ private brush: d3.Selection | 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> = {
+ [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()
+ .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 000000000000..432933327763
--- /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 c987e3eebce8..8ccb9219400f 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 16632bafb9bc..c903ab3f7b6b 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 000000000000..cd846f01b375
--- /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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ @if (canNavigateToParent()) {
+
+ }
+
+
+
+
+
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.scss b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.scss
new file mode 100644
index 000000000000..2944f9819474
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/navigation-control/canvas-navigation-control.component.scss
@@ -0,0 +1,16 @@
+/*
+ * 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.
+ */
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 000000000000..3c6c93505192
--- /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 = 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 000000000000..9489d95e34db
--- /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('canvas-navigation-control');
+
+ navigationCollapsed = false;
+
+ birdseyeComponents = input.required();
+ birdseyeTransform = input.required();
+ canvasDimensions = input.required();
+ canNavigateToParent = input(false);
+
+ viewportChange = output();
+ birdseyeDragStart = output();
+ birdseyeDragEnd = output();
+
+ zoomIn = output();
+ zoomOut = output();
+ zoomFit = output();
+ zoomActual = output();
+ leaveGroup = output();
+
+ 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);
+ }
+}