diff --git a/apps/angular-demo/src/app/app.component.html b/apps/angular-demo/src/app/app.component.html index d0a2c4876..180828c10 100644 --- a/apps/angular-demo/src/app/app.component.html +++ b/apps/angular-demo/src/app/app.component.html @@ -18,6 +18,7 @@ > + diff --git a/apps/angular-demo/src/app/app.component.ts b/apps/angular-demo/src/app/app.component.ts index 8fbf2e458..865924d2d 100644 --- a/apps/angular-demo/src/app/app.component.ts +++ b/apps/angular-demo/src/app/app.component.ts @@ -6,10 +6,13 @@ import { EdgeDrawnEvent, GroupMembershipChangedEvent, initializeModel, + MinimapNodeStyle, NgDiagramBackgroundComponent, NgDiagramComponent, NgDiagramConfig, NgDiagramEdgeTemplateMap, + NgDiagramMinimapComponent, + NgDiagramMinimapNodeTemplateMap, NgDiagramNodeTemplateMap, NgDiagramPaletteItem, NodeResizedEvent, @@ -32,6 +35,7 @@ import { ButtonEdgeComponent } from './edge-template/button-edge/button-edge.com import { CustomPolylineEdgeComponent } from './edge-template/custom-polyline-edge/custom-polyline-edge.component'; import { DashedEdgeComponent } from './edge-template/dashed-edge/dashed-edge.component'; import { LabelledEdgeComponent } from './edge-template/labelled-edge/labelled-edge.component'; +import { ImageMinimapNodeComponent } from './minimap-node-template/image-minimap-node/image-minimap-node.component'; import { PaletteComponent } from './palette/palette.component'; import { ToolbarComponent } from './toolbar/toolbar.component'; @@ -39,7 +43,13 @@ import { ToolbarComponent } from './toolbar/toolbar.component'; selector: 'app-root', templateUrl: './app.component.html', styleUrl: './app.component.scss', - imports: [ToolbarComponent, PaletteComponent, NgDiagramComponent, NgDiagramBackgroundComponent], + imports: [ + ToolbarComponent, + PaletteComponent, + NgDiagramComponent, + NgDiagramBackgroundComponent, + NgDiagramMinimapComponent, + ], providers: [provideNgDiagram()], changeDetection: ChangeDetectionStrategy.OnPush, }) @@ -55,6 +65,8 @@ export class AppComponent { ['dashed-edge', DashedEdgeComponent], ]); + minimapNodeTemplateMap = new NgDiagramMinimapNodeTemplateMap([['image', ImageMinimapNodeComponent]]); + config = { zoom: { max: 2, @@ -205,4 +217,19 @@ export class AppComponent { } model = initializeModel(defaultModel); + + nodeStyle(node: Node): MinimapNodeStyle { + const style: MinimapNodeStyle = {}; + + if (node.id == '13') { + style.shape = 'circle'; + } + + if (node.selected) { + style.stroke = 'var(--ngd-node-stroke-primary-hover)'; + style.strokeWidth = 5; + } + + return style; + } } diff --git a/apps/angular-demo/src/app/minimap-node-template/image-minimap-node/image-minimap-node.component.scss b/apps/angular-demo/src/app/minimap-node-template/image-minimap-node/image-minimap-node.component.scss new file mode 100644 index 000000000..f21203ff4 --- /dev/null +++ b/apps/angular-demo/src/app/minimap-node-template/image-minimap-node/image-minimap-node.component.scss @@ -0,0 +1,10 @@ +:host { + display: contents; + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } +} diff --git a/apps/angular-demo/src/app/minimap-node-template/image-minimap-node/image-minimap-node.component.ts b/apps/angular-demo/src/app/minimap-node-template/image-minimap-node/image-minimap-node.component.ts new file mode 100644 index 000000000..bff529ab8 --- /dev/null +++ b/apps/angular-demo/src/app/minimap-node-template/image-minimap-node/image-minimap-node.component.ts @@ -0,0 +1,19 @@ +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { MinimapNodeStyle, NgDiagramMinimapNodeTemplate, type Node } from 'ng-diagram'; + +@Component({ + selector: 'app-image-minimap-node', + standalone: true, + template: `image`, + styleUrl: './image-minimap-node.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ImageMinimapNodeComponent implements NgDiagramMinimapNodeTemplate { + node = input.required(); + nodeStyle = input(); + + imageUrl = computed(() => { + const data = this.node().data as { imageUrl?: string } | undefined; + return data?.imageUrl ?? 'https://placehold.jp/150x150.png'; + }); +} diff --git a/apps/docs/src/components/angular/minimap/diagram.component.html b/apps/docs/src/components/angular/minimap/diagram.component.html new file mode 100644 index 000000000..d97445a59 --- /dev/null +++ b/apps/docs/src/components/angular/minimap/diagram.component.html @@ -0,0 +1,6 @@ +
+ + + + +
diff --git a/apps/docs/src/components/angular/minimap/diagram.component.scss b/apps/docs/src/components/angular/minimap/diagram.component.scss new file mode 100644 index 000000000..08c961298 --- /dev/null +++ b/apps/docs/src/components/angular/minimap/diagram.component.scss @@ -0,0 +1,12 @@ +:host { + position: relative; + display: flex; +} + +.diagram { + position: relative; + display: flex; + width: 100%; + height: var(--ng-diagram-height); + border: var(--ng-diagram-border); +} diff --git a/apps/docs/src/components/angular/minimap/diagram.component.ts b/apps/docs/src/components/angular/minimap/diagram.component.ts new file mode 100644 index 000000000..5003d761f --- /dev/null +++ b/apps/docs/src/components/angular/minimap/diagram.component.ts @@ -0,0 +1,60 @@ +import '@angular/compiler'; + +import { Component } from '@angular/core'; +import { + initializeModel, + NgDiagramBackgroundComponent, + NgDiagramComponent, + NgDiagramMinimapComponent, + provideNgDiagram, + type NgDiagramConfig, +} from 'ng-diagram'; + +@Component({ + imports: [ + NgDiagramComponent, + NgDiagramBackgroundComponent, + NgDiagramMinimapComponent, + ], + providers: [provideNgDiagram()], + templateUrl: './diagram.component.html', + styleUrls: ['./diagram.component.scss'], +}) +export class DiagramComponent { + config = { + zoom: { + zoomToFit: { + onInit: true, + padding: [50, 250, 50, 50], + }, + }, + } satisfies NgDiagramConfig; + + model = initializeModel({ + nodes: [ + { id: '1', position: { x: 0, y: 0 }, data: { label: 'Node 1' } }, + { id: '2', position: { x: 300, y: 0 }, data: { label: 'Node 2' } }, + { id: '3', position: { x: 150, y: 150 }, data: { label: 'Node 3' } }, + { id: '4', position: { x: 0, y: 300 }, data: { label: 'Node 4' } }, + { id: '5', position: { x: 300, y: 300 }, data: { label: 'Node 5' } }, + ], + edges: [ + { + id: 'e1', + source: '1', + sourcePort: 'port-right', + target: '2', + targetPort: 'port-left', + data: {}, + }, + { + id: 'e2', + source: '4', + sourcePort: 'port-right', + target: '5', + targetPort: 'port-left', + data: {}, + }, + ], + }); +} diff --git a/apps/docs/src/components/angular/minimap/minimap.astro b/apps/docs/src/components/angular/minimap/minimap.astro new file mode 100644 index 000000000..3df7e17e2 --- /dev/null +++ b/apps/docs/src/components/angular/minimap/minimap.astro @@ -0,0 +1,5 @@ +--- +import { DiagramComponent } from './diagram.component'; +--- + + diff --git a/apps/docs/src/content/docs/api/Components/NgDiagramMinimapComponent.md b/apps/docs/src/content/docs/api/Components/NgDiagramMinimapComponent.md new file mode 100644 index 000000000..c60e455e2 --- /dev/null +++ b/apps/docs/src/content/docs/api/Components/NgDiagramMinimapComponent.md @@ -0,0 +1,91 @@ +--- +version: "since v1.0.0" +editUrl: false +next: false +prev: false +title: "NgDiagramMinimapComponent" +--- + +A minimap component that displays a bird's-eye view of the diagram. + +Shows all nodes as small rectangles and a viewport rectangle indicating +the currently visible area. The minimap updates reactively when the +diagram viewport changes (pan/zoom) or when nodes are added/removed/updated. + +The minimap also supports navigation: click and drag on the minimap to pan +the diagram viewport to different areas. + +## Implements + +- `AfterViewInit` + +## Properties + +### height + +> **height**: `InputSignal`\<`number`\> + +Height of the minimap in pixels. + +*** + +### minimapNodeTemplateMap + +> **minimapNodeTemplateMap**: `InputSignal`\<[`NgDiagramMinimapNodeTemplateMap`](/docs/api/types/minimap/ngdiagramminimapnodetemplatemap/)\> + +Optional template map for complete control over node rendering per node type. +Components registered in the map should render SVG elements. + +#### Example + +```typescript +const minimapTemplateMap = new NgDiagramMinimapNodeTemplateMap([ + ['database', DatabaseMinimapNodeComponent], + ['api', ApiMinimapNodeComponent], +]); + +// Usage: + +``` + +*** + +### nodeStyle + +> **nodeStyle**: `InputSignal`\<`undefined` \| [`MinimapNodeStyleFn`](/docs/api/types/minimap/minimapnodestylefn/)\> + +Optional callback function to customize node styling. +Return style properties to override defaults, or null/undefined to use CSS defaults. + +#### Example + +```typescript +nodeStyle = (node: Node) => ({ + fill: node.type === 'database' ? '#4CAF50' : '#9E9E9E', + opacity: node.selected ? 1 : 0.6, +}); +``` + +*** + +### position + +> **position**: `InputSignal`\<[`NgDiagramPanelPosition`](/docs/api/types/ngdiagrampanelposition/)\> + +Position of the minimap panel within the diagram container. + +*** + +### showZoomControls + +> **showZoomControls**: `InputSignal`\<`boolean`\> + +Whether to show zoom controls in the minimap footer. + +*** + +### width + +> **width**: `InputSignal`\<`number`\> + +Width of the minimap in pixels. diff --git a/apps/docs/src/content/docs/api/Directives/NgDiagramMinimapNavigationDirective.md b/apps/docs/src/content/docs/api/Directives/NgDiagramMinimapNavigationDirective.md new file mode 100644 index 000000000..fea23affb --- /dev/null +++ b/apps/docs/src/content/docs/api/Directives/NgDiagramMinimapNavigationDirective.md @@ -0,0 +1,34 @@ +--- +version: "since v1.0.0" +editUrl: false +next: false +prev: false +title: "NgDiagramMinimapNavigationDirective" +--- + +Directive that enables drag navigation on the minimap. +Users can drag on the minimap to move the diagram viewport. + +Supports both mouse and touch input. +Uses pointer capture for reliable touch tracking on mobile devices. + +## Implements + +- `OnDestroy` + +## Methods + +### ngOnDestroy() + +> **ngOnDestroy**(): `void` + +A callback method that performs custom clean-up, invoked immediately +before a directive, pipe, or service instance is destroyed. + +#### Returns + +`void` + +#### Implementation of + +`OnDestroy.ngOnDestroy` diff --git a/apps/docs/src/content/docs/api/Services/NgDiagramViewportService.md b/apps/docs/src/content/docs/api/Services/NgDiagramViewportService.md index a6d64b765..e0e3f0a5c 100644 --- a/apps/docs/src/content/docs/api/Services/NgDiagramViewportService.md +++ b/apps/docs/src/content/docs/api/Services/NgDiagramViewportService.md @@ -25,6 +25,22 @@ this.viewportService.zoom(1.2); ## Properties +### canZoomIn + +> **canZoomIn**: `Signal`\<`boolean`\> + +Returns true if the current zoom level is below the maximum and can be increased. + +*** + +### canZoomOut + +> **canZoomOut**: `Signal`\<`boolean`\> + +Returns true if the current zoom level is above the minimum and can be decreased. + +*** + ### scale > **scale**: `Signal`\<`number`\> @@ -39,6 +55,34 @@ Returns a computed signal for the scale that safely handles uninitialized state. Returns a computed signal for the viewport that safely handles uninitialized state. +## Accessors + +### maxZoom + +#### Get Signature + +> **get** **maxZoom**(): `number` + +Returns the maximum zoom scale from the diagram configuration. + +##### Returns + +`number` + +*** + +### minZoom + +#### Get Signature + +> **get** **minZoom**(): `number` + +Returns the minimum zoom scale from the diagram configuration. + +##### Returns + +`number` + ## Methods ### centerOnNode() @@ -222,7 +266,7 @@ Zooms the viewport by the specified factor. `number` -The factor to zoom by. +The factor to zoom by (e.g., 1.1 for 10% zoom in, 0.9 for 10% zoom out). ##### center? diff --git a/apps/docs/src/content/docs/api/Types/Minimap/MinimapNodeShape.md b/apps/docs/src/content/docs/api/Types/Minimap/MinimapNodeShape.md new file mode 100644 index 000000000..00b0f93c8 --- /dev/null +++ b/apps/docs/src/content/docs/api/Types/Minimap/MinimapNodeShape.md @@ -0,0 +1,11 @@ +--- +version: "since v1.0.0" +editUrl: false +next: false +prev: false +title: "MinimapNodeShape" +--- + +> **MinimapNodeShape** = `"rect"` \| `"circle"` \| `"ellipse"` + +Available shapes for minimap node rendering. diff --git a/apps/docs/src/content/docs/api/Types/Minimap/MinimapNodeStyle.md b/apps/docs/src/content/docs/api/Types/Minimap/MinimapNodeStyle.md new file mode 100644 index 000000000..82441499a --- /dev/null +++ b/apps/docs/src/content/docs/api/Types/Minimap/MinimapNodeStyle.md @@ -0,0 +1,58 @@ +--- +version: "since v1.0.0" +editUrl: false +next: false +prev: false +title: "MinimapNodeStyle" +--- + +Style properties that can be applied to minimap nodes. +All properties are optional - unset properties use CSS defaults. + +## Properties + +### cssClass? + +> `optional` **cssClass**: `string` + +CSS class to apply to the node + +*** + +### fill? + +> `optional` **fill**: `string` + +Fill color for the node + +*** + +### opacity? + +> `optional` **opacity**: `number` + +Opacity from 0 to 1 + +*** + +### shape? + +> `optional` **shape**: [`MinimapNodeShape`](/docs/api/types/minimap/minimapnodeshape/) + +Shape of the node in the minimap. Defaults to 'rect'. + +*** + +### stroke? + +> `optional` **stroke**: `string` + +Stroke color for the node + +*** + +### strokeWidth? + +> `optional` **strokeWidth**: `number` + +Stroke width in pixels diff --git a/apps/docs/src/content/docs/api/Types/Minimap/MinimapNodeStyleFn.md b/apps/docs/src/content/docs/api/Types/Minimap/MinimapNodeStyleFn.md new file mode 100644 index 000000000..167bd7f67 --- /dev/null +++ b/apps/docs/src/content/docs/api/Types/Minimap/MinimapNodeStyleFn.md @@ -0,0 +1,31 @@ +--- +version: "since v1.0.0" +editUrl: false +next: false +prev: false +title: "MinimapNodeStyleFn" +--- + +> **MinimapNodeStyleFn** = (`node`) => [`MinimapNodeStyle`](/docs/api/types/minimap/minimapnodestyle/) \| `null` \| `undefined` + +Function signature for the nodeStyle callback. +Return style properties to override defaults, or null/undefined to use defaults. + +## Parameters + +### node + +[`Node`](/docs/api/types/model/node/) + +## Returns + +[`MinimapNodeStyle`](/docs/api/types/minimap/minimapnodestyle/) \| `null` \| `undefined` + +## Example + +```typescript +const nodeStyle: MinimapNodeStyleFn = (node) => ({ + fill: node.type === 'database' ? '#4CAF50' : '#9E9E9E', + opacity: node.selected ? 1 : 0.6, +}); +``` diff --git a/apps/docs/src/content/docs/api/Types/Minimap/NgDiagramMinimapNodeTemplate.md b/apps/docs/src/content/docs/api/Types/Minimap/NgDiagramMinimapNodeTemplate.md new file mode 100644 index 000000000..a74a7139e --- /dev/null +++ b/apps/docs/src/content/docs/api/Types/Minimap/NgDiagramMinimapNodeTemplate.md @@ -0,0 +1,49 @@ +--- +version: "since v1.0.0" +editUrl: false +next: false +prev: false +title: "NgDiagramMinimapNodeTemplate" +--- + +Interface for custom minimap node components. +Components implementing this interface can be registered in NgDiagramMinimapNodeTemplateMap +to customize how specific node types are rendered in the minimap. + +Custom templates are rendered inside a foreignObject that handles positioning and sizing, +so the component only needs to render content that fills its container. + +## Example + +```typescript +@Component({ + selector: 'my-minimap-node', + standalone: true, + template: ` +
+ {{ node().type }} +
+ `, + styles: [`.minimap-icon { width: 100%; height: 100%; }`] +}) +export class MyMinimapNodeComponent implements NgDiagramMinimapNodeTemplate { + node = input.required(); + nodeStyle = input(); // Required by interface, can be ignored if not needed +} +``` + +## Properties + +### node + +> **node**: `InputSignal`\<[`Node`](/docs/api/types/model/node/)\> + +Input signal containing the original Node object for accessing node data, type, etc. + +*** + +### nodeStyle + +> **nodeStyle**: `InputSignal`\<`undefined` \| [`MinimapNodeStyle`](/docs/api/types/minimap/minimapnodestyle/)\> + +Input signal for style overrides computed by nodeStyle callback. Can be ignored if not needed. diff --git a/apps/docs/src/content/docs/api/Types/Minimap/NgDiagramMinimapNodeTemplateMap.md b/apps/docs/src/content/docs/api/Types/Minimap/NgDiagramMinimapNodeTemplateMap.md new file mode 100644 index 000000000..1e46dac41 --- /dev/null +++ b/apps/docs/src/content/docs/api/Types/Minimap/NgDiagramMinimapNodeTemplateMap.md @@ -0,0 +1,224 @@ +--- +version: "since v1.0.0" +editUrl: false +next: false +prev: false +title: "NgDiagramMinimapNodeTemplateMap" +--- + +Map that associates node type names with their corresponding minimap Angular component classes. +Used by ng-diagram-minimap to determine which custom component to render based on node type. + +## Example + +```typescript +const minimapTemplateMap = new NgDiagramMinimapNodeTemplateMap([ + ['database', DatabaseMinimapNodeComponent], + ['api', ApiMinimapNodeComponent], +]); + +// Usage in template: + +``` + +## Extends + +- `Map`\<`string`, `Type`\<[`NgDiagramMinimapNodeTemplate`](/docs/api/types/minimap/ngdiagramminimapnodetemplate/)\>\> + +## Properties + +### size + +> `readonly` **size**: `number` + +#### Returns + +the number of elements in the Map. + +#### Inherited from + +`Map.size` + +## Methods + +### \[iterator\]() + +> **\[iterator\]**(): `MapIterator`\<\[`string`, `Type$1`\<[`NgDiagramMinimapNodeTemplate`](/docs/api/types/minimap/ngdiagramminimapnodetemplate/)\>\]\> + +Returns an iterable of entries in the map. + +#### Returns + +`MapIterator`\<\[`string`, `Type$1`\<[`NgDiagramMinimapNodeTemplate`](/docs/api/types/minimap/ngdiagramminimapnodetemplate/)\>\]\> + +#### Inherited from + +`Map.[iterator]` + +*** + +### delete() + +> **delete**(`key`): `boolean` + +#### Parameters + +##### key + +`string` + +#### Returns + +`boolean` + +true if an element in the Map existed and has been removed, or false if the element does not exist. + +#### Inherited from + +`Map.delete` + +*** + +### entries() + +> **entries**(): `MapIterator`\<\[`string`, `Type$1`\<[`NgDiagramMinimapNodeTemplate`](/docs/api/types/minimap/ngdiagramminimapnodetemplate/)\>\]\> + +Returns an iterable of key, value pairs for every entry in the map. + +#### Returns + +`MapIterator`\<\[`string`, `Type$1`\<[`NgDiagramMinimapNodeTemplate`](/docs/api/types/minimap/ngdiagramminimapnodetemplate/)\>\]\> + +#### Inherited from + +`Map.entries` + +*** + +### forEach() + +> **forEach**(`callbackfn`, `thisArg?`): `void` + +Executes a provided function once per each key/value pair in the Map, in insertion order. + +#### Parameters + +##### callbackfn + +(`value`, `key`, `map`) => `void` + +##### thisArg? + +`any` + +#### Returns + +`void` + +#### Inherited from + +`Map.forEach` + +*** + +### get() + +> **get**(`key`): `undefined` \| `Type$1`\<[`NgDiagramMinimapNodeTemplate`](/docs/api/types/minimap/ngdiagramminimapnodetemplate/)\> + +Returns a specified element from the Map object. If the value that is associated to the provided key is an object, then you will get a reference to that object and any change made to that object will effectively modify it inside the Map. + +#### Parameters + +##### key + +`string` + +#### Returns + +`undefined` \| `Type$1`\<[`NgDiagramMinimapNodeTemplate`](/docs/api/types/minimap/ngdiagramminimapnodetemplate/)\> + +Returns the element associated with the specified key. If no element is associated with the specified key, undefined is returned. + +#### Inherited from + +`Map.get` + +*** + +### has() + +> **has**(`key`): `boolean` + +#### Parameters + +##### key + +`string` + +#### Returns + +`boolean` + +boolean indicating whether an element with the specified key exists or not. + +#### Inherited from + +`Map.has` + +*** + +### keys() + +> **keys**(): `MapIterator`\<`string`\> + +Returns an iterable of keys in the map + +#### Returns + +`MapIterator`\<`string`\> + +#### Inherited from + +`Map.keys` + +*** + +### set() + +> **set**(`key`, `value`): `this` + +Adds a new element with a specified key and value to the Map. If an element with the same key already exists, the element will be updated. + +#### Parameters + +##### key + +`string` + +##### value + +`Type$1` + +#### Returns + +`this` + +#### Inherited from + +`Map.set` + +*** + +### values() + +> **values**(): `MapIterator`\<`Type$1`\<[`NgDiagramMinimapNodeTemplate`](/docs/api/types/minimap/ngdiagramminimapnodetemplate/)\>\> + +Returns an iterable of values in the map + +#### Returns + +`MapIterator`\<`Type$1`\<[`NgDiagramMinimapNodeTemplate`](/docs/api/types/minimap/ngdiagramminimapnodetemplate/)\>\> + +#### Inherited from + +`Map.values` diff --git a/apps/docs/src/content/docs/api/Types/NgDiagramPanelPosition.md b/apps/docs/src/content/docs/api/Types/NgDiagramPanelPosition.md new file mode 100644 index 000000000..c6c472bac --- /dev/null +++ b/apps/docs/src/content/docs/api/Types/NgDiagramPanelPosition.md @@ -0,0 +1,11 @@ +--- +version: "since v1.0.0" +editUrl: false +next: false +prev: false +title: "NgDiagramPanelPosition" +--- + +> **NgDiagramPanelPosition** = `"top-left"` \| `"top-right"` \| `"bottom-left"` \| `"bottom-right"` + +Position for diagram overlay panels (minimap, watermark, etc.). diff --git a/apps/docs/src/content/docs/api/_readme.md b/apps/docs/src/content/docs/api/_readme.md index da8bc80ab..e4607b270 100644 --- a/apps/docs/src/content/docs/api/_readme.md +++ b/apps/docs/src/content/docs/api/_readme.md @@ -13,6 +13,7 @@ title: "ng-diagram" - [NgDiagramBaseNodeTemplateComponent](/docs/api/components/ngdiagrambasenodetemplatecomponent/) - [NgDiagramComponent](/docs/api/components/ngdiagramcomponent/) - [NgDiagramMarkerComponent](/docs/api/components/ngdiagrammarkercomponent/) +- [NgDiagramMinimapComponent](/docs/api/components/ngdiagramminimapcomponent/) - [NgDiagramNodeResizeAdornmentComponent](/docs/api/components/ngdiagramnoderesizeadornmentcomponent/) - [NgDiagramNodeRotateAdornmentComponent](/docs/api/components/ngdiagramnoderotateadornmentcomponent/) - [NgDiagramPaletteItemComponent](/docs/api/components/ngdiagrampaletteitemcomponent/) @@ -22,6 +23,7 @@ title: "ng-diagram" ## Directives - [NgDiagramGroupHighlightedDirective](/docs/api/directives/ngdiagramgrouphighlighteddirective/) +- [NgDiagramMinimapNavigationDirective](/docs/api/directives/ngdiagramminimapnavigationdirective/) - [NgDiagramNodeSelectedDirective](/docs/api/directives/ngdiagramnodeselecteddirective/) ## Internals @@ -51,6 +53,10 @@ title: "ng-diagram" - [NgDiagramService](/docs/api/services/ngdiagramservice/) - [NgDiagramViewportService](/docs/api/services/ngdiagramviewportservice/) +## Types + +- [NgDiagramPanelPosition](/docs/api/types/ngdiagrampanelposition/) + ## Types/Configuration - [FlowConfig](/docs/api/types/configuration/flowconfig/) @@ -119,6 +125,14 @@ title: "ng-diagram" - [ModelActionType](/docs/api/types/middleware/modelactiontype/) - [ModelActionTypes](/docs/api/types/middleware/modelactiontypes/) +## Types/Minimap + +- [NgDiagramMinimapNodeTemplateMap](/docs/api/types/minimap/ngdiagramminimapnodetemplatemap/) +- [MinimapNodeStyle](/docs/api/types/minimap/minimapnodestyle/) +- [NgDiagramMinimapNodeTemplate](/docs/api/types/minimap/ngdiagramminimapnodetemplate/) +- [MinimapNodeShape](/docs/api/types/minimap/minimapnodeshape/) +- [MinimapNodeStyleFn](/docs/api/types/minimap/minimapnodestylefn/) + ## Types/Model - [Edge](/docs/api/types/model/edge/) diff --git a/apps/docs/src/content/docs/guides/minimap.mdx b/apps/docs/src/content/docs/guides/minimap.mdx new file mode 100644 index 000000000..a441ce284 --- /dev/null +++ b/apps/docs/src/content/docs/guides/minimap.mdx @@ -0,0 +1,241 @@ +--- +version: 'since v1.0.0' +title: Minimap +description: How to use and customize the minimap component in ngDiagram +sidebar: + label: Minimap + badge: New +--- + +import CodeViewer from '@components/code-viewer/code-viewer.astro'; +import Minimap from '../../../components/angular/minimap/minimap.astro'; + +The [``](/docs/api/components/ngdiagramminimapcomponent/) component provides a bird's-eye view of your diagram, showing all nodes and the current viewport position. +It supports click-and-drag navigation to quickly pan to different areas of your diagram. + +:::note +The minimap displays only nodes. Edges are not rendered in the minimap view. +::: + +## Basic Usage + +Add the [``](/docs/api/components/ngdiagramminimapcomponent/) component inside your diagram container: + +```html +
+ + + + +
+``` + +:::note[Service Access] +The minimap can be placed anywhere in your template, but it requires access to ngDiagram services. +Make sure the component is within a context where [`provideNgDiagram()`](/docs/api/utilities/providengdiagram/) has been provided. +::: + +:::note[Container Positioning] +The minimap uses `position: absolute` for corner placement via the `position` input. +Ensure the parent container has `position: relative` for proper positioning: + +```css +.diagram-container { + position: relative; +} +``` + +::: + +## Configuration + +### Position and Size + +The minimap can be positioned in any corner using the [`position`](/docs/api/components/ngdiagramminimapcomponent/#position) input and sized with [`width`](/docs/api/components/ngdiagramminimapcomponent/#width) and [`height`](/docs/api/components/ngdiagramminimapcomponent/#height): + +```html + +``` + +See [`NgDiagramPanelPosition`](/docs/api/types/ngdiagrampanelposition/) for available position values. + +### Zoom Controls + +By default, zoom controls are displayed below the minimap. To hide them: + +```html + +``` + +## Customization via CSS Variables + +:::tip[Theming] +Some CSS variables reference design tokens to support light/dark themes. +For proper multi-theme customization, see the [Styling guide](/docs/intro/styling/) to understand how tokens and primitives work. +::: + +### General Appearance + +You can customize the minimap container appearance using CSS variables: + +```css +:root { + /* Container styling */ + --ngd-minimap-background: ...; + --ngd-minimap-border-color: ...; + --ngd-minimap-border-radius: 1rem; + --ngd-minimap-padding: 0.5rem; + --ngd-minimap-margin: 1rem; + --ngd-minimap-shadow-color: ...; + + /* Viewport indicator */ + --ngd-minimap-viewport-stroke-color: ...; + --ngd-minimap-viewport-stroke-width: 1; +} +``` + +### Node Styling + +Default node appearance in the minimap can be controlled via CSS variables: + +```css +:root { + --ngd-minimap-node-color: ...; + --ngd-minimap-node-opacity: 0.8; +} +``` + +### Zoom Controls + +The zoom controls appearance can be customized with these CSS variables: + +```css +:root { + /* Zoom controls text */ + --ngd-zoom-controls-font-size: 0.8125rem; + --ngd-zoom-controls-font-weight: 500; + --ngd-zoom-controls-color: ...; + + /* Navigation buttons (zoom in/out) */ + --ngd-nav-button-color: ...; + --ngd-nav-button-size: 1.25rem; + --ngd-nav-button-border-radius: 0.5rem; + --ngd-nav-button-padding: 0.6875rem; + --ngd-nav-button-background-color-hover: ...; + --ngd-nav-button-color-active: ...; + --ngd-nav-button-color-disabled: ...; +} +``` + +## Customization via Style Function + +For dynamic node styling based on [`Node`](/docs/api/types/model/node/) properties, use the [`nodeStyle`](/docs/api/components/ngdiagramminimapcomponent/#nodestyle) input. +This accepts a [`MinimapNodeStyleFn`](/docs/api/types/minimap/minimapnodestylefn/) callback: + +```typescript +import { MinimapNodeStyle, Node } from 'ng-diagram'; + +nodeStyle = (node: Node): MinimapNodeStyle => { + const style: MinimapNodeStyle = {}; + + // Different shape for specific nodes + if (node.type === 'database') { + style.shape = 'circle'; + } + + // Highlight selected nodes + if (node.selected) { + style.stroke = '#2196F3'; + style.strokeWidth = 2; + } + + // Custom fill based on node data + if (node.data?.status === 'error') { + style.fill = '#f44336'; + } + + return style; +}; +``` + +```html + +``` + +See [`MinimapNodeStyle`](/docs/api/types/minimap/minimapnodestyle/) for all available style properties. + +## Customization via Templates + +For complete control over node rendering, you can provide custom Angular components per node type using [`minimapNodeTemplateMap`](/docs/api/components/ngdiagramminimapcomponent/#minimapnodetemplatemap). +This is useful when you need to display icons, images, or complex visuals in the minimap. + +:::caution[Performance Consideration] +Custom templates use `foreignObject` and Angular components, which can impact performance with large diagrams. +Use templates sparingly and only for node types that truly require custom rendering. +For simple styling changes, prefer the `nodeStyle` function or CSS variables. +::: + +### Creating a Custom Template + +Your component must implement the [`NgDiagramMinimapNodeTemplate`](/docs/api/types/minimap/ngdiagramminimapnodetemplate/) interface: + +```typescript +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; +import { MinimapNodeStyle, NgDiagramMinimapNodeTemplate, Node } from 'ng-diagram'; + +@Component({ + selector: 'app-image-minimap-node', + standalone: true, + template: ``, + styles: [ + ` + :host { + display: contents; + + img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + } + `, + ], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ImageMinimapNodeComponent implements NgDiagramMinimapNodeTemplate { + node = input.required(); + nodeStyle = input(); + + get imageUrl(): string { + const data = this.node().data as { imageUrl?: string }; + return data?.imageUrl ?? 'placeholder.png'; + } +} +``` + +### Registering Templates + +Create a [`NgDiagramMinimapNodeTemplateMap`](/docs/api/types/minimap/ngdiagramminimapnodetemplatemap/) and pass it to the minimap: + +```typescript +import { NgDiagramMinimapNodeTemplateMap } from 'ng-diagram'; + +minimapNodeTemplateMap = new NgDiagramMinimapNodeTemplateMap([ + ['image', ImageMinimapNodeComponent], + ['custom-type', CustomMinimapNodeComponent], +]); +``` + +```html + +``` + +Nodes with types not in the map will use the default rectangle rendering. + +## Example + + + + +--- diff --git a/apps/docs/src/content/docs/intro/styling.mdx b/apps/docs/src/content/docs/intro/styling.mdx index 98187e403..0644da364 100644 --- a/apps/docs/src/content/docs/intro/styling.mdx +++ b/apps/docs/src/content/docs/intro/styling.mdx @@ -31,18 +31,29 @@ Base color values and fundamental design tokens defined in `primitives`. These a ```css :root { + /* Gray scale */ --ngd-colors-gray-100: /* white */; + --ngd-colors-gray-200: /* near white */; --ngd-colors-gray-300: /* light gray */; --ngd-colors-gray-400: /* medium light gray */; + --ngd-colors-gray-450: /* medium gray light */; --ngd-colors-gray-500: /* medium gray */; --ngd-colors-gray-600: /* medium dark gray */; --ngd-colors-gray-650: /* dark gray */; --ngd-colors-gray-700: /* darker gray */; --ngd-colors-gray-800: /* darkest gray */; + --ngd-colors-gray-900-5: /* near black with 5% opacity */; + --ngd-colors-gray-900-20: /* near black with 20% opacity */; + --ngd-colors-gray-900-50: /* near black with 50% opacity */; + + /* Primary accent */ --ngd-colors-acc1-400: /* primary accent light */; --ngd-colors-acc1-500: /* primary accent */; --ngd-colors-acc1-500-40: /* primary accent with 40% opacity */; --ngd-colors-acc1-500-50: /* primary accent with 50% opacity */; + --ngd-colors-acc1-600: /* primary accent dark */; + + /* Secondary accent */ --ngd-colors-acc4-500: /* secondary accent */; } ``` diff --git a/packages/ng-diagram/api-report/ng-diagram.api.md b/packages/ng-diagram/api-report/ng-diagram.api.md index 23d016b78..af26e810e 100644 --- a/packages/ng-diagram/api-report/ng-diagram.api.md +++ b/packages/ng-diagram/api-report/ng-diagram.api.md @@ -506,6 +506,22 @@ export interface MiddlewareHistoryUpdate { stateUpdate: FlowStateUpdate; } +// @public +export type MinimapNodeShape = 'rect' | 'circle' | 'ellipse'; + +// @public +export interface MinimapNodeStyle { + cssClass?: string; + fill?: string; + opacity?: number; + shape?: MinimapNodeShape; + stroke?: string; + strokeWidth?: number; +} + +// @public +export type MinimapNodeStyleFn = (node: Node_2) => MinimapNodeStyle | null | undefined; + // @public (undocumented) export class MobileBoxSelectionDirective { // (undocumented) @@ -821,6 +837,64 @@ export const NgDiagramMath: { calculateEdgePanningForce: (containerBox: Rect, clientPosition: Point, detectionThreshold: number, forceMultiplier: number) => Point | null; }; +// @public +export class NgDiagramMinimapComponent implements AfterViewInit { + // (undocumented) + hasValidViewport: Signal; + height: InputSignal; + // (undocumented) + isDiagramInitialized: WritableSignal; + // @internal + protected minimapNodes: Signal; + minimapNodeTemplateMap: InputSignal; + // (undocumented) + ngAfterViewInit(): void; + // (undocumented) + nodes: WritableSignal; + // @internal + protected nodesGroupTransform: Signal; + nodeStyle: InputSignal; + position: InputSignal; + showZoomControls: InputSignal; + // @internal + protected transform: Signal; + // (undocumented) + viewport: WritableSignal; + // (undocumented) + viewportRect: Signal; + width: InputSignal; + // (undocumented) + static ɵcmp: i0.ɵɵComponentDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + +// @public +export class NgDiagramMinimapNavigationDirective implements OnDestroy { + // (undocumented) + ngOnDestroy(): void; + // (undocumented) + onPointerDown(event: PointerEvent): void; + // (undocumented) + transform: InputSignal; + // (undocumented) + viewport: InputSignal; + // (undocumented) + static ɵdir: i0.ɵɵDirectiveDeclaration; + // (undocumented) + static ɵfac: i0.ɵɵFactoryDeclaration; +} + +// @public +export interface NgDiagramMinimapNodeTemplate { + node: InputSignal; + nodeStyle: InputSignal; +} + +// @public +export class NgDiagramMinimapNodeTemplateMap extends Map> { +} + // @public export class NgDiagramModelService extends NgDiagramBaseService implements OnDestroy { constructor(); @@ -983,6 +1057,9 @@ export class NgDiagramPaletteItemPreviewComponent { static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export type NgDiagramPanelPosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; + // @public export class NgDiagramPortComponent extends NodeContextGuardBase implements OnInit, OnDestroy, AfterContentInit { constructor(); @@ -1074,11 +1151,15 @@ export class NgDiagramServicesAvailabilityCheckerDirective { // @public export class NgDiagramViewportService extends NgDiagramBaseService { + canZoomIn: Signal; + canZoomOut: Signal; centerOnNode(nodeOrId: string | Node_2): void; centerOnRect(rect: Rect): void; clientToFlowPosition(clientPosition: Point): Point; clientToFlowViewportPosition(clientPosition: Point): Point; flowToClientPosition(flowPosition: Point): Point; + get maxZoom(): number; + get minZoom(): number; moveViewport(x: number, y: number): void; moveViewportBy(dx: number, dy: number): void; scale: Signal; diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.spec.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.spec.ts index 92afd6cab..b21170b38 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.spec.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/diagram/ng-diagram.component.spec.ts @@ -13,6 +13,7 @@ import { import { CursorPositionTrackerService } from '../../services/cursor-position-tracker/cursor-position-tracker.service'; import { InputEventsRouterService } from '../../services/input-events/input-events-router.service'; import { MarkerRegistryService } from '../../services/marker-registry/marker-registry.service'; +import { PanelRegistryService } from '../../services/panel-registry/panel-registry.service'; import { TemplateProviderService } from '../../services/template-provider/template-provider.service'; import { NgDiagramComponent } from './ng-diagram.component'; @@ -112,6 +113,7 @@ describe('AngularAdapterDiagramComponent', () => { CursorPositionTrackerService, InputEventsRouterService, MarkerRegistryService, + PanelRegistryService, ], }).compileComponents(); diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/default-node/ng-diagram-default-minimap-node.component.html b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/default-node/ng-diagram-default-minimap-node.component.html new file mode 100644 index 000000000..9964aeb42 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/default-node/ng-diagram-default-minimap-node.component.html @@ -0,0 +1,16 @@ +@switch (shape()) { + @case ('circle') { + + } + @case ('ellipse') { + + } + @default { + + } +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/default-node/ng-diagram-default-minimap-node.component.scss b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/default-node/ng-diagram-default-minimap-node.component.scss new file mode 100644 index 000000000..09a410c7b --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/default-node/ng-diagram-default-minimap-node.component.scss @@ -0,0 +1,4 @@ +:host { + fill: var(--ngd-minimap-node-color); + opacity: var(--ngd-minimap-node-opacity); +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/default-node/ng-diagram-default-minimap-node.component.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/default-node/ng-diagram-default-minimap-node.component.ts new file mode 100644 index 000000000..7bad259f9 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/default-node/ng-diagram-default-minimap-node.component.ts @@ -0,0 +1,46 @@ +/* eslint-disable @angular-eslint/component-selector */ +import { ChangeDetectionStrategy, Component, computed, input } from '@angular/core'; +import { MinimapBounds, MinimapNodeStyle } from '../ng-diagram-minimap.types'; + +/** + * Internal default component for rendering minimap nodes as SVG shapes. + * Renders directly in SVG context (not inside foreignObject) using + * pre-transformed minimap coordinates. + * + * @internal + */ + +@Component({ + selector: 'g[ng-diagram-default-minimap-node]', + standalone: true, + templateUrl: './ng-diagram-default-minimap-node.component.html', + styleUrl: './ng-diagram-default-minimap-node.component.scss', + host: { + '[class]': 'hostClass()', + '[attr.transform]': 'bounds().transform', + '[style.fill]': 'computedStyle().fill', + '[style.stroke]': 'computedStyle().stroke', + '[style.stroke-width.px]': 'computedStyle().strokeWidth', + '[style.opacity]': 'computedStyle().opacity', + }, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NgDiagramDefaultMinimapNodeComponent { + bounds = input.required(); + nodeStyle = input(); + + computedStyle = computed(() => this.nodeStyle() ?? {}); + shape = computed(() => this.computedStyle().shape ?? 'rect'); + + /** Host class combining base class with optional custom cssClass. */ + hostClass = computed(() => { + const cssClass = this.computedStyle().cssClass; + return cssClass ? `minimap-node ${cssClass}` : 'minimap-node'; + }); + + cx = computed(() => this.bounds().x + this.bounds().width / 2); + cy = computed(() => this.bounds().y + this.bounds().height / 2); + radius = computed(() => Math.min(this.bounds().width, this.bounds().height) / 2); + rx = computed(() => this.bounds().width / 2); + ry = computed(() => this.bounds().height / 2); +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap-navigation.directive.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap-navigation.directive.ts new file mode 100644 index 000000000..1af55786a --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap-navigation.directive.ts @@ -0,0 +1,128 @@ +import { Directive, inject, input, OnDestroy } from '@angular/core'; +import { Viewport } from '../../../core/src'; +import { NgDiagramViewportService } from '../../public-services/ng-diagram-viewport.service'; +import { MinimapTransform } from './ng-diagram-minimap.types'; + +interface Point { + x: number; + y: number; +} + +interface DragState { + isDragging: boolean; + lastPosition: Point; + pointerId: number | null; +} + +/** + * Directive that enables drag navigation on the minimap. + * Users can drag on the minimap to move the diagram viewport. + * + * Supports both mouse and touch input. + * Uses pointer capture for reliable touch tracking on mobile devices. + * + * @public + * @since 1.0.0 + * @category Directives + */ +@Directive({ + selector: '[ngDiagramMinimapNavigation]', + standalone: true, + host: { + '(pointerdown)': 'onPointerDown($event)', + }, +}) +export class NgDiagramMinimapNavigationDirective implements OnDestroy { + private readonly viewportService = inject(NgDiagramViewportService); + + transform = input.required(); + viewport = input.required(); + + private dragState: DragState = { + isDragging: false, + lastPosition: { x: 0, y: 0 }, + pointerId: null, + }; + + ngOnDestroy(): void { + this.removeDocumentListeners(); + } + + onPointerDown(event: PointerEvent): void { + if (event.button !== 0) { + return; + } + + event.preventDefault(); + + this.capturePointer(event); + this.dragState.isDragging = true; + this.dragState.lastPosition = { x: event.clientX, y: event.clientY }; + this.attachDocumentListeners(); + } + + private onPointerMove = (event: PointerEvent): void => { + if (!this.dragState.isDragging) { + return; + } + + const delta = this.calculateClientDelta(event); + this.dragState.lastPosition = { x: event.clientX, y: event.clientY }; + + const viewportDelta = this.calculateViewportDelta(delta); + this.viewportService.moveViewportBy(viewportDelta.x, viewportDelta.y); + }; + + private onPointerUp = (event: PointerEvent): void => { + this.dragState.isDragging = false; + this.releasePointer(event); + this.removeDocumentListeners(); + }; + + private capturePointer(event: PointerEvent): void { + const target = event.target as Element; + target.setPointerCapture(event.pointerId); + this.dragState.pointerId = event.pointerId; + } + + private releasePointer(event: PointerEvent): void { + if (this.dragState.pointerId === null) { + return; + } + + const target = event.target as Element; + if (target.hasPointerCapture(this.dragState.pointerId)) { + target.releasePointerCapture(this.dragState.pointerId); + } + this.dragState.pointerId = null; + } + + private attachDocumentListeners(): void { + document.addEventListener('pointermove', this.onPointerMove); + document.addEventListener('pointerup', this.onPointerUp); + document.addEventListener('pointercancel', this.onPointerUp); + } + + private removeDocumentListeners(): void { + document.removeEventListener('pointermove', this.onPointerMove); + document.removeEventListener('pointerup', this.onPointerUp); + document.removeEventListener('pointercancel', this.onPointerUp); + } + + private calculateClientDelta(event: PointerEvent): Point { + return { + x: event.clientX - this.dragState.lastPosition.x, + y: event.clientY - this.dragState.lastPosition.y, + }; + } + + private calculateViewportDelta(clientDelta: Point): Point { + const { scale: minimapScale } = this.transform(); + const { scale: viewportScale } = this.viewport(); + + return { + x: -(clientDelta.x / minimapScale) * viewportScale, + y: -(clientDelta.y / minimapScale) * viewportScale, + }; + } +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.calculations.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.calculations.ts new file mode 100644 index 000000000..82bb90b7c --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.calculations.ts @@ -0,0 +1,142 @@ +import { Node, Rect, unionRect } from '../../../core/src'; +import { MinimapBounds, MinimapTransform, MinimapViewportRect } from './ng-diagram-minimap.types'; + +/** + * Converts viewport position and scale to a bounding rectangle in diagram coordinate space. + * This represents the visible area of the diagram. + */ +export const convertViewportToDiagramBounds = (viewport: { + x: number; + y: number; + scale: number; + width?: number; + height?: number; +}): Rect => { + const containerWidth = viewport.width ?? 0; + const containerHeight = viewport.height ?? 0; + + return { + x: -viewport.x / viewport.scale, + y: -viewport.y / viewport.scale, + width: containerWidth / viewport.scale, + height: containerHeight / viewport.scale, + }; +}; + +/** + * Creates a union of diagram content bounds and viewport bounds. + * This ensures the minimap shows both the diagram content and the current view position. + */ +export const combineDiagramAndViewportBounds = (diagramBounds: Rect, viewportBounds: Rect): Rect => { + if (viewportBounds.width === 0 || viewportBounds.height === 0) { + return diagramBounds; + } + return unionRect([diagramBounds, viewportBounds]); +}; + +/** + * Calculates the scale factor needed to fit bounds within the available minimap space. + */ +const calculateScaleToFitBounds = ( + bounds: Rect, + minimapWidth: number, + minimapHeight: number, + padding: number +): number => { + const availableWidth = minimapWidth - padding * 2; + const availableHeight = minimapHeight - padding * 2; + + if (bounds.width === 0 || bounds.height === 0) { + return 1; + } + + return Math.min(availableWidth / bounds.width, availableHeight / bounds.height); +}; + +/** + * Calculates the X and Y offsets needed to center the scaled content within the minimap. + */ +const calculateCenteringOffset = ( + bounds: Rect, + minimapWidth: number, + minimapHeight: number, + scale: number +): { offsetX: number; offsetY: number } => { + const scaledWidth = bounds.width * scale; + const scaledHeight = bounds.height * scale; + + return { + offsetX: (minimapWidth - scaledWidth) / 2 - bounds.x * scale, + offsetY: (minimapHeight - scaledHeight) / 2 - bounds.y * scale, + }; +}; + +/** + * Computes the complete minimap transform (scale and offset) to fit bounds within minimap dimensions. + */ +export const calculateMinimapTransform = ( + bounds: Rect, + minimapWidth: number, + minimapHeight: number, + padding: number +): MinimapTransform => { + const scale = calculateScaleToFitBounds(bounds, minimapWidth, minimapHeight, padding); + const { offsetX, offsetY } = calculateCenteringOffset(bounds, minimapWidth, minimapHeight, scale); + return { scale, offsetX, offsetY }; +}; + +/** Default node size when not specified. */ +const DEFAULT_NODE_SIZE = { width: 100, height: 50 }; + +/** + * Extracts node bounds in diagram coordinate space. + * Used with SVG group transform - the group applies scale/offset, + * so individual nodes stay in their original diagram coordinates. + * + * @param node - The diagram node + * @param defaultSize - Optional default size for nodes without explicit size + */ +export const extractNodeBounds = ( + node: Node, + defaultSize: { width: number; height: number } = DEFAULT_NODE_SIZE +): MinimapBounds => { + const size = node.size ?? defaultSize; + const angle = node.angle ?? 0; + + // Calculate rotation center in diagram space + const centerX = node.position.x + size.width / 2; + const centerY = node.position.y + size.height / 2; + + return { + id: node.id, + x: node.position.x, + y: node.position.y, + width: size.width, + height: size.height, + angle, + transform: `rotate(${angle} ${centerX} ${centerY})`, + }; +}; + +/** + * Transforms the viewport rectangle to minimap coordinate space. + */ +export const transformViewportToMinimapSpace = ( + viewport: { x: number; y: number; scale: number; width?: number; height?: number }, + transform: MinimapTransform +): MinimapViewportRect => { + const containerWidth = viewport.width ?? 0; + const containerHeight = viewport.height ?? 0; + + const visibleX = -viewport.x / viewport.scale; + const visibleY = -viewport.y / viewport.scale; + const visibleWidth = containerWidth / viewport.scale; + const visibleHeight = containerHeight / viewport.scale; + + return { + x: visibleX * transform.scale + transform.offsetX, + y: visibleY * transform.scale + transform.offsetY, + width: visibleWidth * transform.scale, + height: visibleHeight * transform.scale, + }; +}; diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.component.html b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.component.html new file mode 100644 index 000000000..189b61907 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.component.html @@ -0,0 +1,46 @@ + + + + @if (isDiagramInitialized()) { + + @for (item of minimapNodes(); track item.bounds.id) { + @if (item.template) { + + + + } @else { + + } + } + + @if (hasValidViewport()) { + + } + } + + @if (showZoomControls()) { + + } + diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.component.scss b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.component.scss new file mode 100644 index 000000000..1b113413b --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.component.scss @@ -0,0 +1,49 @@ +:host { + display: block; + position: absolute; + pointer-events: auto; + z-index: 10; + + &.bottom-right { + bottom: var(--ngd-minimap-margin); + right: var(--ngd-minimap-margin); + } + + &.bottom-left { + bottom: var(--ngd-minimap-margin); + left: var(--ngd-minimap-margin); + } + + &.top-right { + top: var(--ngd-minimap-margin); + right: var(--ngd-minimap-margin); + } + + &.top-left { + top: var(--ngd-minimap-margin); + left: var(--ngd-minimap-margin); + } +} + +.minimap-svg { + display: block; + cursor: pointer; + touch-action: none; +} + +.minimap-background { + fill: transparent; +} + +.minimap-viewport { + stroke: var(--ngd-minimap-viewport-stroke-color); + stroke-width: var(--ngd-minimap-viewport-stroke-width); + fill: transparent; + pointer-events: none; +} + +.minimap-footer { + display: flex; + justify-content: center; + align-items: center; +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.component.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.component.ts new file mode 100644 index 000000000..5fe185f6c --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.component.ts @@ -0,0 +1,194 @@ +import { CommonModule } from '@angular/common'; +import { + AfterViewInit, + ChangeDetectionStrategy, + Component, + computed, + ElementRef, + inject, + input, + signal, +} from '@angular/core'; +import { NgDiagramModelService } from '../../public-services/ng-diagram-model.service'; +import { RendererService } from '../../services/renderer/renderer.service'; +import { NgDiagramPanelPosition } from '../../types/panel-position'; +import { NgDiagramPanelComponent } from '../panel/ng-diagram-panel.component'; +import { NgDiagramZoomControlsComponent } from '../zoom-controls/ng-diagram-zoom-controls.component'; +import { NgDiagramDefaultMinimapNodeComponent } from './default-node/ng-diagram-default-minimap-node.component'; +import { NgDiagramMinimapNavigationDirective } from './ng-diagram-minimap-navigation.directive'; +import { + calculateMinimapTransform, + combineDiagramAndViewportBounds, + convertViewportToDiagramBounds, + extractNodeBounds, + transformViewportToMinimapSpace, +} from './ng-diagram-minimap.calculations'; +import { MinimapNodeData, MinimapNodeStyleFn, NgDiagramMinimapNodeTemplateMap } from './ng-diagram-minimap.types'; + +/** + * A minimap component that displays a bird's-eye view of the diagram. + * + * Shows all nodes as small rectangles and a viewport rectangle indicating + * the currently visible area. The minimap updates reactively when the + * diagram viewport changes (pan/zoom) or when nodes are added/removed/updated. + * + * The minimap also supports navigation: click and drag on the minimap to pan + * the diagram viewport to different areas. + * + * @public + * @since 1.0.0 + * @category Components + */ +@Component({ + selector: 'ng-diagram-minimap', + standalone: true, + imports: [ + NgDiagramMinimapNavigationDirective, + CommonModule, + NgDiagramDefaultMinimapNodeComponent, + NgDiagramZoomControlsComponent, + NgDiagramPanelComponent, + + NgDiagramMinimapNavigationDirective, + CommonModule, + NgDiagramDefaultMinimapNodeComponent, + NgDiagramZoomControlsComponent, + NgDiagramPanelComponent, + ], + templateUrl: './ng-diagram-minimap.component.html', + styleUrls: ['./ng-diagram-minimap.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + '[class]': 'position()', + }, +}) +export class NgDiagramMinimapComponent implements AfterViewInit { + private readonly VIEWPORT_STROKE_WIDTH_CSS_VAR = '--ngd-minimap-viewport-stroke-width'; + + private readonly modelService = inject(NgDiagramModelService); + private readonly renderer = inject(RendererService); + private readonly elementRef = inject(ElementRef); + + /** Cached stroke padding to avoid layout thrashing from repeated getComputedStyle calls. */ + private strokePadding = signal(1); + + /** Position of the minimap panel within the diagram container. */ + position = input('bottom-right'); + + /** Width of the minimap in pixels. */ + width = input(200); + + /** Height of the minimap in pixels. */ + height = input(150); + + /** Whether to show zoom controls in the minimap footer. */ + showZoomControls = input(true); + + /** + * Optional callback function to customize node styling. + * Return style properties to override defaults, or null/undefined to use CSS defaults. + * + * @example + * ```typescript + * nodeStyle = (node: Node) => ({ + * fill: node.type === 'database' ? '#4CAF50' : '#9E9E9E', + * opacity: node.selected ? 1 : 0.6, + * }); + * ``` + */ + nodeStyle = input(); + + /** + * Optional template map for complete control over node rendering per node type. + * Components registered in the map should render SVG elements. + * + * @example + * ```typescript + * const minimapTemplateMap = new NgDiagramMinimapNodeTemplateMap([ + * ['database', DatabaseMinimapNodeComponent], + * ['api', ApiMinimapNodeComponent], + * ]); + * + * // Usage: + * + * ``` + */ + minimapNodeTemplateMap = input(new NgDiagramMinimapNodeTemplateMap()); + + /** @ignore */ + ngAfterViewInit(): void { + // Cache stroke padding after view init to avoid layout thrashing + this.strokePadding.set(this.getStrokePaddingFromCss()); + } + + isDiagramInitialized = this.renderer.isInitialized; + nodes = this.renderer.nodes; + viewport = this.renderer.viewport; + + hasValidViewport = computed(() => { + const viewport = this.viewport(); + return !!viewport.width && !!viewport.height && viewport.width > 0 && viewport.height > 0; + }); + + viewportRect = computed(() => transformViewportToMinimapSpace(this.viewport(), this.transform())); + + /** + * @internal + * Main transform for minimap - updates when viewport or diagram bounds change. + * Used for SVG group transform and viewport rect calculation. + */ + protected transform = computed(() => + calculateMinimapTransform(this.combinedBounds(), this.width(), this.height(), this.strokePadding()) + ); + + /** + * @internal + * SVG transform attribute for the nodes group. + * Converts diagram coordinates to minimap coordinates. + */ + protected nodesGroupTransform = computed(() => { + const t = this.transform(); + return `translate(${t.offsetX}, ${t.offsetY}) scale(${t.scale})`; + }); + + /** + * @internal + * Pre-computed minimap node data in DIAGRAM coordinates (not minimap coordinates). + * The SVG group transform handles the coordinate conversion, so nodes only recalculate + * when diagram content changes, NOT during pan/zoom. + */ + protected minimapNodes = computed((): MinimapNodeData[] => { + const nodes = this.nodes(); + const styleFn = this.nodeStyle(); + const templateMap = this.minimapNodeTemplateMap(); + + return nodes.map((node) => ({ + bounds: extractNodeBounds(node), + diagramNode: node, + nodeStyle: styleFn?.(node) ?? {}, + template: node.type ? (templateMap.get(node.type) ?? null) : null, + })); + }); + + private diagramBounds = computed(() => { + const nodes = this.nodes(); + return this.modelService.computePartsBounds(nodes, []); + }); + + private viewportBoundsInDiagramSpace = computed(() => convertViewportToDiagramBounds(this.viewport())); + + private combinedBounds = computed(() => + combineDiagramAndViewportBounds(this.diagramBounds(), this.viewportBoundsInDiagramSpace()) + ); + + /** + * Reads viewport stroke width from CSS to use as internal padding. + * This prevents the viewport rectangle stroke from being clipped at minimap edges. + */ + private getStrokePaddingFromCss(): number { + const style = getComputedStyle(this.elementRef.nativeElement); + const strokeWidth = style.getPropertyValue(this.VIEWPORT_STROKE_WIDTH_CSS_VAR).trim(); + + return parseFloat(strokeWidth) || 1; + } +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.types.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.types.ts new file mode 100644 index 000000000..2f2649b60 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/minimap/ng-diagram-minimap.types.ts @@ -0,0 +1,157 @@ +import { InputSignal, Type } from '@angular/core'; +import { Node } from '../../../core/src'; + +/** + * Represents the calculated transform data for minimap rendering. + */ +export interface MinimapTransform { + scale: number; + offsetX: number; + offsetY: number; +} + +/** + * Pre-computed node data for minimap rendering. + * Contains transformed bounds, original node reference, styling, and optional custom template. + */ +export interface MinimapNodeData { + bounds: MinimapBounds; + diagramNode: Node; + nodeStyle: MinimapNodeStyle; + template: Type | null; +} + +/** + * Bounding box and transform for a node in minimap coordinate space. + * + * @public + * @since 1.0.0 + * @category Types/Minimap + */ +export interface MinimapBounds { + id: string; + x: number; + y: number; + width: number; + height: number; + angle: number; + transform: string; +} + +/** + * Represents the viewport rectangle in minimap space. + */ +export interface MinimapViewportRect { + x: number; + y: number; + width: number; + height: number; +} + +/** + * Available shapes for minimap node rendering. + * + * @public + * @since 1.0.0 + * @category Types/Minimap + */ +export type MinimapNodeShape = 'rect' | 'circle' | 'ellipse'; + +/** + * Style properties that can be applied to minimap nodes. + * All properties are optional - unset properties use CSS defaults. + * + * @public + * @since 1.0.0 + * @category Types/Minimap + */ +export interface MinimapNodeStyle { + /** Shape of the node in the minimap. Defaults to 'rect'. */ + shape?: MinimapNodeShape; + /** Fill color for the node */ + fill?: string; + /** Stroke color for the node */ + stroke?: string; + /** Stroke width in pixels */ + strokeWidth?: number; + /** Opacity from 0 to 1 */ + opacity?: number; + /** CSS class to apply to the node */ + cssClass?: string; +} + +/** + * Function signature for the nodeStyle callback. + * Return style properties to override defaults, or null/undefined to use defaults. + * + * @public + * @since 1.0.0 + * @category Types/Minimap + * + * @example + * ```typescript + * const nodeStyle: MinimapNodeStyleFn = (node) => ({ + * fill: node.type === 'database' ? '#4CAF50' : '#9E9E9E', + * opacity: node.selected ? 1 : 0.6, + * }); + * ``` + */ +export type MinimapNodeStyleFn = (node: Node) => MinimapNodeStyle | null | undefined; + +/** + * Interface for custom minimap node components. + * Components implementing this interface can be registered in NgDiagramMinimapNodeTemplateMap + * to customize how specific node types are rendered in the minimap. + * + * Custom templates are rendered inside a foreignObject that handles positioning and sizing, + * so the component only needs to render content that fills its container. + * + * @public + * @since 1.0.0 + * @category Types/Minimap + * + * @example + * ```typescript + * @Component({ + * selector: 'my-minimap-node', + * standalone: true, + * template: ` + *
+ * {{ node().type }} + *
+ * `, + * styles: [`.minimap-icon { width: 100%; height: 100%; }`] + * }) + * export class MyMinimapNodeComponent implements NgDiagramMinimapNodeTemplate { + * node = input.required(); + * nodeStyle = input(); // Required by interface, can be ignored if not needed + * } + * ``` + */ +export interface NgDiagramMinimapNodeTemplate { + /** Input signal containing the original Node object for accessing node data, type, etc. */ + node: InputSignal; + /** Input signal for style overrides computed by nodeStyle callback. Can be ignored if not needed. */ + nodeStyle: InputSignal; +} + +/** + * Map that associates node type names with their corresponding minimap Angular component classes. + * Used by ng-diagram-minimap to determine which custom component to render based on node type. + * + * @public + * @since 1.0.0 + * @category Types/Minimap + * + * @example + * ```typescript + * const minimapTemplateMap = new NgDiagramMinimapNodeTemplateMap([ + * ['database', DatabaseMinimapNodeComponent], + * ['api', ApiMinimapNodeComponent], + * ]); + * + * // Usage in template: + * + * ``` + */ +export class NgDiagramMinimapNodeTemplateMap extends Map> {} diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/panel/ng-diagram-panel.component.scss b/packages/ng-diagram/projects/ng-diagram/src/lib/components/panel/ng-diagram-panel.component.scss new file mode 100644 index 000000000..aac47e382 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/panel/ng-diagram-panel.component.scss @@ -0,0 +1,11 @@ +:host { + overflow: hidden; + border-radius: var(--ngd-minimap-border-radius); + padding: var(--ngd-minimap-padding); + background-color: var(--ngd-minimap-background); + box-shadow: 0 8px 16px -4px var(--ngd-minimap-shadow-color); + border: 1px solid var(--ngd-minimap-border-color); + display: flex; + flex-direction: column; + gap: var(--ngd-minimap-padding); +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/panel/ng-diagram-panel.component.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/panel/ng-diagram-panel.component.ts new file mode 100644 index 000000000..9b6bcb728 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/panel/ng-diagram-panel.component.ts @@ -0,0 +1,33 @@ +import { AfterViewInit, ChangeDetectionStrategy, Component, inject, input, OnDestroy } from '@angular/core'; +import { PanelRegistryService } from '../../services/panel-registry/panel-registry.service'; +import { NgDiagramPanelPosition } from '../../types/panel-position'; + +/** + * A generic panel container component that registers itself with the panel registry. + * Used internally to wrap overlay content and coordinate positioning with other components. + * @internal + */ +@Component({ + selector: 'ng-diagram-panel', + standalone: true, + template: '', + styleUrls: ['./ng-diagram-panel.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + '[class]': 'position()', + }, +}) +export class NgDiagramPanelComponent implements AfterViewInit, OnDestroy { + private readonly panelRegistry = inject(PanelRegistryService); + + /** Position of the panel within the diagram container. */ + position = input('bottom-right'); + + ngAfterViewInit(): void { + this.panelRegistry?.register(this); + } + + ngOnDestroy(): void { + this.panelRegistry?.unregister(); + } +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/watermark/watermark.component.scss b/packages/ng-diagram/projects/ng-diagram/src/lib/components/watermark/watermark.component.scss index 0e4e265d4..fac9cbede 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/watermark/watermark.component.scss +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/watermark/watermark.component.scss @@ -1,9 +1,27 @@ :host { position: absolute; - bottom: 1rem; - right: 1rem; width: var(--watermark-size); + &.bottom-right { + bottom: 1rem; + right: 1rem; + } + + &.top-right { + top: 1rem; + right: 1rem; + } + + &.bottom-left { + bottom: 1rem; + left: 1rem; + } + + &.top-left { + top: 1rem; + left: 1rem; + } + svg { opacity: 0.7; height: 100%; diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/watermark/watermark.component.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/watermark/watermark.component.ts index 188e9f128..c73fa4b7b 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/components/watermark/watermark.component.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/watermark/watermark.component.ts @@ -1,9 +1,31 @@ -import { Component } from '@angular/core'; +import { Component, computed, inject } from '@angular/core'; +import { PanelRegistryService } from '../../services/panel-registry/panel-registry.service'; +import { NgDiagramPanelPosition } from '../../types/panel-position'; @Component({ selector: 'ng-diagram-watermark', standalone: true, templateUrl: './watermark.component.html', styleUrls: ['./watermark.component.scss'], + host: { + '[class]': 'position()', + }, }) -export class NgDiagramWatermarkComponent {} +export class NgDiagramWatermarkComponent { + private readonly DEFAULT_POSITION: NgDiagramPanelPosition = 'bottom-right'; + private readonly ALTERNATIVE_POSITION: NgDiagramPanelPosition = 'top-right'; + + private readonly panelRegistry = inject(PanelRegistryService); + + /** + * Computes the watermark position based on panel position. + * If panel is at the same position, watermark moves to avoid overlap. + */ + position = computed(() => { + if (this.panelRegistry.position() === this.DEFAULT_POSITION) { + return this.ALTERNATIVE_POSITION; + } + + return this.DEFAULT_POSITION; + }); +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/zoom-controls/ng-diagram-zoom-controls.component.scss b/packages/ng-diagram/projects/ng-diagram/src/lib/components/zoom-controls/ng-diagram-zoom-controls.component.scss new file mode 100644 index 000000000..9d895de14 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/zoom-controls/ng-diagram-zoom-controls.component.scss @@ -0,0 +1,40 @@ +.zoom-controls-container { + display: flex; + align-items: center; + justify-content: space-between; + + .label { + text-align: center; + font-size: var(--ngd-zoom-controls-font-size); + font-weight: var(--ngd-zoom-controls-font-weight); + color: var(--ngd-zoom-controls-color); + user-select: none; + } +} + +.nav-button { + width: var(--ngd-nav-button-size); + height: var(--ngd-nav-button-size); + color: var(--ngd-nav-button-color); + border-radius: var(--ngd-nav-button-border-radius); + padding: var(--ngd-nav-button-padding); + border: none; + background: none; + box-sizing: content-box; + cursor: pointer; + font-size: var(--ngd-nav-button-size); + line-height: var(--ngd-nav-button-size); + + &:hover:not(:disabled) { + background-color: var(--ngd-nav-button-background-color-hover); + } + + &:active { + color: var(--ngd-nav-button-color-active); + } + + &:disabled { + color: var(--ngd-nav-button-color-disabled); + cursor: default; + } +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/components/zoom-controls/ng-diagram-zoom-controls.component.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/components/zoom-controls/ng-diagram-zoom-controls.component.ts new file mode 100644 index 000000000..929a3a079 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/components/zoom-controls/ng-diagram-zoom-controls.component.ts @@ -0,0 +1,48 @@ +import { ChangeDetectionStrategy, Component, computed, inject, input } from '@angular/core'; +import { NgDiagramViewportService } from '../../public-services/ng-diagram-viewport.service'; + +/** + * A zoom controls component that displays zoom in/out buttons and current zoom level. + */ +@Component({ + selector: 'ng-diagram-zoom-controls', + standalone: true, + template: ` +
+ + {{ zoomPercentage() }}% + +
+ `, + styleUrls: ['./ng-diagram-zoom-controls.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class NgDiagramZoomControlsComponent { + private readonly viewportService = inject(NgDiagramViewportService); + + step = input(0.1); + + zoomPercentage = computed(() => Math.round(this.viewportService.scale() * 100)); + + canZoomIn = computed(() => { + const newScale = this.viewportService.scale() + this.step(); + return newScale <= this.viewportService.maxZoom; + }); + + canZoomOut = computed(() => { + const newScale = this.viewportService.scale() - this.step(); + return newScale >= this.viewportService.minZoom; + }); + + zoomIn(): void { + const currentScale = this.viewportService.scale(); + const factor = (currentScale + this.step()) / currentScale; + this.viewportService.zoom(factor); + } + + zoomOut(): void { + const currentScale = this.viewportService.scale(); + const factor = (currentScale - this.step()) / currentScale; + this.viewportService.zoom(factor); + } +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/providers/ng-diagram.providers.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/providers/ng-diagram.providers.ts index 25c5b76ea..e7120247b 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/providers/ng-diagram.providers.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/providers/ng-diagram.providers.ts @@ -14,6 +14,7 @@ import { InputEventsRouterService } from '../services/input-events/input-events- import { LinkingEventService } from '../services/input-events/linking-event.service'; import { ManualLinkingService } from '../services/input-events/manual-linking.service'; import { MarkerRegistryService } from '../services/marker-registry/marker-registry.service'; +import { PanelRegistryService } from '../services/panel-registry/panel-registry.service'; import { PaletteService } from '../services/palette/palette.service'; import { RendererService } from '../services/renderer/renderer.service'; import { TemplateProviderService } from '../services/template-provider/template-provider.service'; @@ -69,5 +70,6 @@ export function provideNgDiagram(): Provider[] { ManualLinkingService, TemplateProviderService, MarkerRegistryService, + PanelRegistryService, ]; } diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-viewport.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-viewport.service.ts index d49ddb559..859f24be9 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-viewport.service.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/public-services/ng-diagram-viewport.service.ts @@ -38,6 +38,30 @@ export class NgDiagramViewportService extends NgDiagramBaseService { */ scale = computed(() => this.renderer.viewport().scale || 1); + /** + * Returns the minimum zoom scale from the diagram configuration. + */ + get minZoom(): number { + return this.flowCore.config.zoom.min; + } + + /** + * Returns the maximum zoom scale from the diagram configuration. + */ + get maxZoom(): number { + return this.flowCore.config.zoom.max; + } + + /** + * Returns true if the current zoom level is below the maximum and can be increased. + */ + canZoomIn = computed(() => this.scale() < this.maxZoom); + + /** + * Returns true if the current zoom level is above the minimum and can be decreased. + */ + canZoomOut = computed(() => this.scale() > this.minZoom); + // =================== // POSITION CONVERSION METHODS // =================== @@ -97,13 +121,15 @@ export class NgDiagramViewportService extends NgDiagramBaseService { /** * Zooms the viewport by the specified factor. - * @param factor The factor to zoom by. + * @param factor The factor to zoom by (e.g., 1.1 for 10% zoom in, 0.9 for 10% zoom out). * @param center The center point to zoom towards. */ zoom(factor: number, center?: Point | undefined) { - const x = center?.x || this.viewport().x; - const y = center?.y || this.viewport().y; - this.flowCore.commandHandler.emit('zoom', { scale: factor, x, y }); + const currentScale = this.scale(); + const newScale = currentScale * factor; + const x = center?.x ?? this.viewport().x; + const y = center?.y ?? this.viewport().y; + this.flowCore.commandHandler.emit('zoom', { scale: newScale, x, y }); } /** diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/services/panel-registry/panel-registry.service.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/services/panel-registry/panel-registry.service.ts new file mode 100644 index 000000000..7aea4e683 --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/services/panel-registry/panel-registry.service.ts @@ -0,0 +1,31 @@ +import { computed, Injectable, Signal, signal } from '@angular/core'; +import { NgDiagramPanelPosition } from '../../types/panel-position'; + +/** + * Interface for panel components that can register with the panel registry. + * @internal + */ +export interface RegisterablePanel { + position: Signal; +} + +/** + * Service that tracks registered panel components. + * Used by other components (like watermark) to coordinate their positioning + * and avoid overlapping with panels. + * @internal + */ +@Injectable() +export class PanelRegistryService { + private readonly panel = signal(null); + + position = computed(() => this.panel()?.position()); + + register(component: RegisterablePanel): void { + this.panel.set(component); + } + + unregister(): void { + this.panel.set(null); + } +} diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/styles/primitives.css b/packages/ng-diagram/projects/ng-diagram/src/lib/styles/primitives.css index dff957d17..8e29ed09c 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/styles/primitives.css +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/styles/primitives.css @@ -13,9 +13,13 @@ --ngd-colors-gray-650: #383a40; --ngd-colors-gray-700: #27282b; --ngd-colors-gray-800: #151516; + --ngd-colors-gray-900-5: rgba(7, 7, 8, 0.05); + --ngd-colors-gray-900-20: rgba(7, 7, 8, 0.2); + --ngd-colors-gray-900-50: rgba(7, 7, 8, 0.5); --ngd-colors-acc1-400: #a977ff; --ngd-colors-acc1-500: #9140ff; --ngd-colors-acc1-500-40: rgba(145, 64, 255, 0.4); --ngd-colors-acc1-500-50: rgba(145, 64, 255, 0.5); + --ngd-colors-acc1-600: #891aff; --ngd-colors-acc4-500: #1096e7; } diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/styles/styles.css b/packages/ng-diagram/projects/ng-diagram/src/lib/styles/styles.css index 5ddf65896..83a872131 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/styles/styles.css +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/styles/styles.css @@ -64,6 +64,31 @@ --ngd-background-line-major-width: 1; --ngd-background-line-minor-opacity: 0.5; --ngd-background-line-major-opacity: 0.6; + + --ngd-minimap-background: var(--ngd-minimap-bg-primary-default); + --ngd-minimap-border-color: var(--ngd-minimap-stroke-primary-default); + --ngd-minimap-shadow-color: var(--ngd-shadow); + --ngd-minimap-border-radius: 1rem; + --ngd-minimap-padding: 0.5rem; + --ngd-minimap-margin: 1rem; + + --ngd-minimap-node-color: var(--ngd-colors-gray-400); + --ngd-minimap-node-opacity: 0.8; + + --ngd-minimap-viewport-stroke-color: var(--ngd-colors-gray-400); + --ngd-minimap-viewport-stroke-width: 1; + + --ngd-nav-button-color: var(--ngd-nav-button-primary-default); + --ngd-nav-button-size: 1.25rem; + --ngd-nav-button-border-radius: 0.5rem; + --ngd-nav-button-padding: 0.6875rem; + --ngd-nav-button-background-color-hover: var(--ngd-nav-button-primary-hover); + --ngd-nav-button-color-active: var(--ngd-nav-button-primary-pressed); + --ngd-nav-button-color-disabled: var(--ngd-nav-button-primary-disabled); + + --ngd-zoom-controls-font-size: 0.8125rem; + --ngd-zoom-controls-font-weight: 500; + --ngd-zoom-controls-color: var(--ngd-nav-button-primary-default); } .ng-diagram-port-hoverable .ng-diagram-port:hover, diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/styles/tokens.css b/packages/ng-diagram/projects/ng-diagram/src/lib/styles/tokens.css index ef98e54b6..e46718977 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/lib/styles/tokens.css +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/styles/tokens.css @@ -2,7 +2,12 @@ /* DEFAULT LIGHT THEME */ :root { + --ngd-shadow: var(--ngd-colors-gray-900-20); --ngd-txt-primary-default: var(--ngd-colors-gray-800); + --ngd-nav-button-primary-default: var(--ngd-colors-gray-600); + --ngd-nav-button-primary-pressed: var(--ngd-colors-acc1-600); + --ngd-nav-button-primary-hover: var(--ngd-colors-gray-900-5); + --ngd-nav-button-primary-disabled: var(--ngd-colors-gray-450); --ngd-diagram-background-color: var(--ngd-colors-gray-200); --ngd-ui-bg-primary-default: var(--ngd-colors-gray-100); --ngd-ui-bg-tertiary-default: var(--ngd-colors-gray-200); @@ -24,11 +29,18 @@ --ngd-background-line-minor-color: var(--ngd-colors-gray-450); --ngd-background-line-major-color: var(--ngd-colors-gray-500); --ngd-ui-border-color: var(--ngd-colors-gray-300); + --ngd-minimap-bg-primary-default: var(--ngd-colors-gray-100); + --ngd-minimap-stroke-primary-default: var(--ngd-colors-gray-400); } /* OPTIONAL DARK THEME */ html[data-theme='dark'] { + --ngd-shadow: var(--ngd-colors-gray-900-50); --ngd-txt-primary-default: var(--ngd-colors-gray-100); + --ngd-nav-button-primary-default: var(--ngd-colors-gray-400); + --ngd-nav-button-primary-pressed: var(--ngd-colors-acc1-500); + --ngd-nav-button-primary-hover: var(--ngd-colors-gray-900-5); + --ngd-nav-button-primary-disabled: var(--ngd-colors-gray-600); --ngd-diagram-background-color: var(--ngd-colors-gray-800); --ngd-ui-bg-primary-default: var(--ngd-colors-gray-700); --ngd-ui-bg-tertiary-default: var(--ngd-colors-gray-800); @@ -50,4 +62,6 @@ html[data-theme='dark'] { --ngd-background-line-minor-color: var(--ngd-colors-gray-700); --ngd-background-line-major-color: var(--ngd-colors-gray-600); --ngd-ui-border-color: var(--ngd-colors-gray-700); + --ngd-minimap-bg-primary-default: var(--ngd-colors-gray-700); + --ngd-minimap-stroke-primary-default: var(--ngd-colors-gray-600); } diff --git a/packages/ng-diagram/projects/ng-diagram/src/lib/types/panel-position.ts b/packages/ng-diagram/projects/ng-diagram/src/lib/types/panel-position.ts new file mode 100644 index 000000000..695bb26ff --- /dev/null +++ b/packages/ng-diagram/projects/ng-diagram/src/lib/types/panel-position.ts @@ -0,0 +1,8 @@ +/** + * Position for diagram overlay panels (minimap, watermark, etc.). + * + * @public + * @since 1.0.0 + * @category Types + */ +export type NgDiagramPanelPosition = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'; diff --git a/packages/ng-diagram/projects/ng-diagram/src/public-api.ts b/packages/ng-diagram/projects/ng-diagram/src/public-api.ts index 6fc0bfc9e..55ada9db0 100644 --- a/packages/ng-diagram/projects/ng-diagram/src/public-api.ts +++ b/packages/ng-diagram/projects/ng-diagram/src/public-api.ts @@ -11,6 +11,7 @@ export { } from './lib/components/edge-label/base-edge-label/base-edge-label.component'; export { NgDiagramBaseEdgeComponent } from './lib/components/edge/base-edge/base-edge.component'; export { NgDiagramMarkerComponent } from './lib/components/marker/ng-diagram-marker.component'; +export { NgDiagramMinimapComponent } from './lib/components/minimap/ng-diagram-minimap.component'; export { NgDiagramBaseNodeTemplateComponent } from './lib/components/node/base-node-template/ng-diagram-base-node-template.component'; export { NgDiagramNodeResizeAdornmentComponent } from './lib/components/node/resize/ng-diagram-node-resize-adornment.component'; export { NgDiagramNodeRotateAdornmentComponent } from './lib/components/node/rotate/ng-diagram-node-rotate-adornment.component'; @@ -41,6 +42,7 @@ export { ViewportDirective } from './lib/directives/viewport/viewport.directive' export { ZIndexDirective } from './lib/directives/z-index/z-index.directive'; // Public directives +export { NgDiagramMinimapNavigationDirective } from './lib/components/minimap/ng-diagram-minimap-navigation.directive'; export { NgDiagramGroupHighlightedDirective } from './lib/directives/group-highlighted/ng-diagram-group-highlighted.directive'; export { NgDiagramNodeSelectedDirective } from './lib/directives/node-selected/ng-diagram-node-selected.directive'; @@ -55,6 +57,7 @@ export { NgDiagramService } from './lib/public-services/ng-diagram.service'; // Configuration helpers export { configureShortcuts } from './core/src'; +export { NgDiagramMinimapNodeTemplateMap } from './lib/components/minimap/ng-diagram-minimap.types'; export { initializeModel } from './lib/model/initialize-model'; export { provideNgDiagram } from './lib/providers/ng-diagram.providers'; export { NgDiagramEdgeTemplateMap } from './lib/types/edge-template-map'; @@ -62,11 +65,18 @@ export { NgDiagramNodeTemplateMap } from './lib/types/node-template-map'; export { createMiddlewares } from './lib/utils/create-middlewares'; // Types +export type { + MinimapNodeShape, + MinimapNodeStyle, + MinimapNodeStyleFn, + NgDiagramMinimapNodeTemplate, +} from './lib/components/minimap/ng-diagram-minimap.types'; export type { NgDiagramConfig } from './lib/types/config'; export type { NgDiagramEdgeTemplate } from './lib/types/edge-template-map'; export type { PointerInputEvent } from './lib/types/event'; export type { NgDiagramGroupNodeTemplate, NgDiagramNodeTemplate } from './lib/types/node-template-map'; export type { BasePaletteItemData, GroupNodeData, NgDiagramPaletteItem, SimpleNodeData } from './lib/types/palette'; +export type { NgDiagramPanelPosition } from './lib/types/panel-position'; export type { AppMiddlewares } from './lib/utils/create-middlewares'; // Core types re-export