From 4356e318e3dc4a69643ac91003e22ed55c38d235 Mon Sep 17 00:00:00 2001 From: Stephen Belovarich Date: Tue, 21 Apr 2026 21:04:13 -0700 Subject: [PATCH 1/3] =?UTF-8?q?-=20Enhancement:=20`transitionAfterChanges`?= =?UTF-8?q?,=20`transitionDuringTransform`,=20`layoutTransitionEffect`,=20?= =?UTF-8?q?and=20`applyVisualContinuityBeforeLayout`=20inputs=20configure?= =?UTF-8?q?=20rich=20continuity=20when=20a=20prior=20graph=20had=20nodes,?= =?UTF-8?q?=20allowing=20for=20animations=20between=20graph=20updates.=20-?= =?UTF-8?q?=20Breaking:=20`useLayoutTransitions`=20(default=20`true`=20con?= =?UTF-8?q?trols=20`layout-js-driven`;=20when=20`false`=20it=20applies=20o?= =?UTF-8?q?nly=20during=20JS=20`mode:=20'tween'`);=20-=20Enhancement:=20`G?= =?UTF-8?q?raphComponent`=20`edgePathSampleCount`=20input=20(default=2048,?= =?UTF-8?q?=20clamped=202=E2=80=93512)=20for=20edge=20resampling=20in=20la?= =?UTF-8?q?yout,=20morph,=20and=20`redrawEdge`=20-=20Enhancement:=20`trans?= =?UTF-8?q?itionAfterChanges.morphCapture`=20for=20prior=20translate=20sou?= =?UTF-8?q?rce=20(model=20vs=20main-chart=20DOM,=20optional=20model=20fall?= =?UTF-8?q?back)=20and=20`syncTargetsFromPositionAfterTick`=20/=20`snapAdd?= =?UTF-8?q?edNodeIds`;=20`mergeGraphLayoutTransition`=20merges=20`morphCap?= =?UTF-8?q?ture`=20with=20defaults=20-=20Enhancement:=20`drawComplete`=20e?= =?UTF-8?q?mits=20after=20a=20completed=20draw/tick=20pass=20(paths=20boun?= =?UTF-8?q?d,=20graph=20ready).=20Use=20to=20hide=20loading=20UI=20or=20ru?= =?UTF-8?q?n=20one-shot=20center/zoomToFit=20without=20flashing=20before?= =?UTF-8?q?=20first=20layout.=20-=20Enhancement:=20Minimap=20can=20be=20di?= =?UTF-8?q?splayed=20on=20bottom.=20-=20Fix:=20Observable=20layouts=20(Col?= =?UTF-8?q?a,=20D3=20force)=20faster=20than=20`afterNextRender`=E2=80=94su?= =?UTF-8?q?perseded=20passes=20repaint=20link=20paths=20from=20the=20curre?= =?UTF-8?q?nt=20model=20without=20full=20`redrawLines`=20so=20layout=20mor?= =?UTF-8?q?ph=20is=20not=20cancelled.=20-=20Fix:=20Layout=20morph=20keeps?= =?UTF-8?q?=20`oldLine`=20/=20`oldTextPath`=20on=20paths=20that=20are=20no?= =?UTF-8?q?t=20resampled-tweening=20until=20the=20tween=20ends=20instead?= =?UTF-8?q?=20of=20snapping=20to=20the=20new=20route=20early.=20-=20Fix:?= =?UTF-8?q?=20Drag=20`updateEdge`=20for=20Dagre=20and=20DagreCluster=20use?= =?UTF-8?q?s=20orientation-aware=20paths=20aligned=20with=20DagreNodesOnly?= =?UTF-8?q?.=20-=20Chore:=20Update=20Storybook=20with=20documentation,=20e?= =?UTF-8?q?xamples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/rules/angular-20.mdc | 144 ++ .cursor/rules/code-quality.mdc | 16 + .cursor/skills/code-quality/SKILL.md | 55 + CHANGELOG.md | 11 + documentation.json | 6 +- .../swimlane/ngx-graph/.storybook/preview.ts | 7 + .../src/lib/enums/mini-map-position.enum.ts | 4 +- .../src/lib/graph/graph.component.html | 357 +-- .../src/lib/graph/graph.component.scss | 15 +- .../src/lib/graph/graph.component.spec.ts | 375 +++ .../src/lib/graph/graph.component.ts | 2088 +++++++++++++++-- .../lib/graph/layouts/colaForceDirected.ts | 14 +- .../ngx-graph/src/lib/graph/layouts/dagre.ts | 36 +- .../src/lib/graph/layouts/dagreCluster.ts | 24 +- .../src/lib/graph/layouts/dagreNodesOnly.ts | 37 +- .../lib/graph/layouts/edge-geometry.spec.ts | 149 ++ .../src/lib/graph/layouts/edge-geometry.ts | 242 ++ .../graph/layouts/layout-layered-constants.ts | 5 + .../src/lib/graph/transition.model.spec.ts | 45 + .../src/lib/graph/transition.model.ts | 170 ++ .../ngx-graph/src/lib/models/edge.model.ts | 2 + .../ngx-graph/src/lib/models/layout.model.ts | 5 + projects/swimlane/ngx-graph/src/public_api.ts | 3 + .../ngx-graph/src/stories/DataFormat.mdx | 18 +- .../ngx-graph/src/stories/Introduction.mdx | 7 +- .../ngx-graph/src/stories/Options.mdx | 108 +- .../ngx-graph-cola-branching.component.html | 31 + .../ngx-graph-cola-branching.component.scss | 44 + .../ngx-graph-cola-branching.component.ts | 143 ++ .../ngx-graph-cola-branching.stories.ts | 21 + ...aph-dagre-layout-transition.component.html | 25 + ...aph-dagre-layout-transition.component.scss | 32 + ...graph-dagre-layout-transition.component.ts | 75 + ...x-graph-dagre-layout-transition.stories.ts | 23 + .../components/ngx-graph-msagl/msaglLayout.ts | 29 +- .../ngx-graph-org-tree.component.spec.ts | 6 +- ...-graph-translate-on-changes.component.html | 33 + ...-graph-translate-on-changes.component.scss | 38 + ...gx-graph-translate-on-changes.component.ts | 187 ++ .../ngx-graph-translate-on-changes.stories.ts | 21 + .../ngx-graph/src/stories/graph.stories.ts | 18 +- projects/swimlane/ngx-graph/src/test.ts | 1 - 42 files changed, 4129 insertions(+), 541 deletions(-) create mode 100644 .cursor/rules/angular-20.mdc create mode 100644 .cursor/rules/code-quality.mdc create mode 100644 .cursor/skills/code-quality/SKILL.md create mode 100644 projects/swimlane/ngx-graph/src/lib/graph/graph.component.spec.ts create mode 100644 projects/swimlane/ngx-graph/src/lib/graph/layouts/edge-geometry.spec.ts create mode 100644 projects/swimlane/ngx-graph/src/lib/graph/layouts/edge-geometry.ts create mode 100644 projects/swimlane/ngx-graph/src/lib/graph/layouts/layout-layered-constants.ts create mode 100644 projects/swimlane/ngx-graph/src/lib/graph/transition.model.spec.ts create mode 100644 projects/swimlane/ngx-graph/src/lib/graph/transition.model.ts create mode 100644 projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-cola-branching/ngx-graph-cola-branching.component.html create mode 100644 projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-cola-branching/ngx-graph-cola-branching.component.scss create mode 100644 projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-cola-branching/ngx-graph-cola-branching.component.ts create mode 100644 projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-cola-branching/ngx-graph-cola-branching.stories.ts create mode 100644 projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-dagre-layout-transition/ngx-graph-dagre-layout-transition.component.html create mode 100644 projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-dagre-layout-transition/ngx-graph-dagre-layout-transition.component.scss create mode 100644 projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-dagre-layout-transition/ngx-graph-dagre-layout-transition.component.ts create mode 100644 projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-dagre-layout-transition/ngx-graph-dagre-layout-transition.stories.ts create mode 100644 projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-translate-on-changes/ngx-graph-translate-on-changes.component.html create mode 100644 projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-translate-on-changes/ngx-graph-translate-on-changes.component.scss create mode 100644 projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-translate-on-changes/ngx-graph-translate-on-changes.component.ts create mode 100644 projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-translate-on-changes/ngx-graph-translate-on-changes.stories.ts diff --git a/.cursor/rules/angular-20.mdc b/.cursor/rules/angular-20.mdc new file mode 100644 index 00000000..6ba360c9 --- /dev/null +++ b/.cursor/rules/angular-20.mdc @@ -0,0 +1,144 @@ +--- +description: Angular 20 best practices and coding standards for the projects/web application. +globs: ['projects/web/**/*.{ts,html,scss,css}'] +--- + +# Angular 20 Best Practices — `projects/web` + +## Project Structure + +- Source root: `projects/web/src/` +- App code: `src/app/` (feature modules), `src/common/` (shared), `src/orchestration/` +- Path aliases: `@app/*`, `@common/*`, `@api-clients/*`, `@assets/*`, `@tests/*`, `orchestration/*` +- Always use path aliases for cross-directory imports; use relative imports only within the same feature folder. + +## TypeScript + +- **Strict mode is NOT enabled** — `tsconfig.json` has `strict: false`, `noImplicitAny: false`, `strictNullChecks: false`. Do not assume strict checks. Be defensive with null/undefined handling. Existing `any` usage exists but should not be introduced in new code. +- For TypeScript guidelines (inference, avoid any, interfaces, no magic numbers, no console.log) see **code-quality** skill. + +## UI Library: `@swimlane/ngx-ui` + +**Always use Swimlane ngx-ui controls** — never recreate standard UI with raw `
`/`` + ARIA when an ngx-ui component exists. +Docs: [https://swimlane.github.io/ngx-ui/](https://swimlane.github.io/ngx-ui/) + +| Category | Components | +| ----------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Buttons** | `ngx-button`, `ngx-button-toggle`, `ngx-button-toggle-group`, `ngx-long-press-button`, `ngx-plus-menu` | +| **Form controls** | `ngx-input`, `ngx-input-prefix`, `ngx-input-suffix`, `ngx-input-hint`, `ngx-select`, `ngx-select-option`, `ngx-checkbox`, `ngx-toggle`, `ngx-radiobutton`, `ngx-radiobutton-group`, `ngx-slider`, `ngx-datetime`, `ngx-date-range-picker`, `ngx-codemirror` | +| **Layout** | `ngx-card` (+ `ngx-card-header`, `ngx-card-body`, `ngx-card-footer`, `ngx-card-title`, `ngx-card-subtitle`, `ngx-card-avatar`, `ngx-card-tag`, `ngx-card-section`), `ngx-section`, `ngx-section-header`, `ngx-tabs`, `ngx-tab`, `ngx-toolbar`, `ngx-toolbar-content`, `ngx-split`, `ngx-split-area` | +| **Navigation** | `ngx-dropdown` (+ `ngx-dropdown-toggle`, `ngx-dropdown-menu`), `ngx-navbar`, `ngx-navbar-item`, `ngx-nav-menu`, `ngx-stepper`, `ngx-step` | +| **Overlays** | `ngx-dialog`, `ngx-large-format-dialog-content`, `ngx-large-format-dialog-footer`, `ngx-drawer`, `ngx-dialog-drawer-content`, `ngx-overlay` | +| **Feedback** | `ngx-notification`, `ngx-nag`, `ngx-loading`, `ngx-progress-spinner`, `ngx-tip`, `ngx-alert` | +| **Data** | `ngx-datatable`, `ngx-datatable-column`, `ngx-tree`, `ngx-tree-node`, `ngx-list`, `ngx-json-editor`, `ngx-json-editor-flat` | +| **Directives** | `ngx-tooltip` (attribute), `[autosize]`, `[dblClickCopy]`, `[long-press]`, `[resizeObserver]` | +| **Icons** | `ngx-icon` (726 usages — the most used component) | + +- If an ngx-ui component exists for the need, **use it**. Do not build custom buttons, inputs, dropdowns, dialogs, or tabs from scratch. +- Import components from `@swimlane/ngx-ui` in the component's `imports` array. + +## Components + +- **Any new component is standalone** — do not create non-standalone components or add new components to `NgModules`. Declare dependencies in the component’s `imports` array and import the component where it is used (or via a barrel that re-exports it). +- **Implicit standalone** — do NOT set `standalone: true` in the decorator; it is the default in Angular 20. +- **`ChangeDetectionStrategy.OnPush`** — required on all components. +- **External templates** — use `templateUrl` with a separate `.html` file. Inline templates are not the convention here. +- **Host bindings** — use the `host` object in the decorator, NOT `@HostBinding` / `@HostListener`. +- **No `ngClass` / `ngStyle`** — use native `[class.active]="flag"` and `[style.font-size.px]="size"` bindings. +- For structure and signal inputs/outputs see **angular-component** skill. + +## Code Style + +See **code-quality** skill (max ~30 lines per method, max 3 parameters, `private`, single responsibility, no business logic in components). + +## Dependency Injection + +- **New code:** prefer the `inject()` function. Mark injected services as `private readonly`. +- **Existing code:** constructor injection is prevalent (~460 components). Do not rewrite working constructor injection unless refactoring the component. +- **Do not mix** `inject()` and constructor injection within the same class. +- See **angular-di** skill for tokens and providers. + +## Signals & Reactivity + +- **`input()` / `output()`** — use signal-based inputs and outputs for new components. +- **`signal()` / `computed()`** — use for local component state and derived values. +- **`effect()`** — use for signal-based side effects (e.g., logging, syncing to localStorage). Avoid heavy logic inside effects; keep them lean. +- **`viewChild()` / `viewChildren()` / `contentChild()` / `contentChildren()`** — use signal-based queries instead of the `@ViewChild` / `@ContentChild` decorators. +- **`linkedSignal()`** — available in Angular 20 for two-way derived signals (e.g. a writable signal that resets when a parent signal changes). +- **`resource()`** — available in Angular 20 for declarative async data loading tied to signals. +- **Signal updates** — use `set()` or `update()`, never `mutate()`. +- Adoption is growing (~30 components). Prefer signals for all new component state. +- See **angular-signals** skill for patterns. + +## Templates + +- **No inline logic in templates** — do not put expressions or method calls directly in template bindings. Always define a method in the component `.ts` file with a meaningful name and call it from the template. This keeps templates readable and logic testable. +- **Native control flow** — always use `@if`, `@for`, `@switch`, `@empty`. **Never** use `*ngIf`, `*ngFor`, `*ngSwitch`, or any structural directive syntax in new code. The codebase has fully migrated (~4,400 usages, <25 legacy instances remaining — do not add more). +- Do NOT import `CommonModule`, `NgIf`, `NgFor`, or `NgSwitch` in new components — they are not needed with built-in control flow. +- **`@for` track** — always provide a `track` expression. Prefer `track item.id` over `track $index`. +- **`@defer`** — use for lazy-loading heavy template sections. +- **Async pipe or `toSignal()`** — never manually subscribe in templates. Use `async` pipe for observables or convert with `toSignal()`. +- **Accessibility** — see the dedicated `accessibility.mdc` rule for full WCAG 2.2 AA standards. +- See **angular-component** and **angular-signals** skills for template patterns. + +## Subscriptions + +- Never subscribe manually in components — use `async` pipe or `toSignal()`. +- If you must subscribe in a service, always clean up with `takeUntilDestroyed()` or `DestroyRef`. + +## Async: Prefer RxJS over Promises + +Prefer RxJS Observables over Promises for all async APIs, data flows, and service methods. The codebase has undergone significant refactoring to eliminate Promise-based APIs; do not introduce new ones. See **angular-http** skill (references: Prefer Observables over Promises). + +## Services & HTTP + +- **`providedIn: 'root'`** for singleton services. +- **Single responsibility** — one service, one concern. +- **`inject()` function** preferred for new services. +- **`HttpClient`** — use with typed responses. Handle errors with RxJS `catchError` or `tapResponse` in stores. +- **Interceptors** — use `HttpInterceptorFn` (functional) for cross-cutting concerns (auth headers, error handling, CSRF). +- **Caching** — use `shareReplay({ bufferSize: 1, refCount: true })` for shared observables that shouldn't re-fetch. +- See **angular-http** and **angular-di** skills. + +## Reactive Forms + +- Prefer Reactive Forms (`FormGroup`, `FormControl`) over template-driven forms. +- Use typed forms (`FormGroup<{ name: FormControl }>`). +- Use built-in and custom `ValidatorFn` / `AsyncValidatorFn` for validation — keep validation logic in the form definition, not the template. +- See **angular-forms** skill (including Signal Forms reference). + +## Testing + +- **Framework:** Jasmine + Karma (NOT Jest). This project uses Karma + Jasmine. +- **Always generate tests with new code:** for every new component, service, directive, pipe, store, guard, or resolver, create the co-located `*.spec.ts` file in the same edit. Do not deliver new production code without corresponding tests. +- **Code coverage:** maintain >80% coverage for `projects/web`. New and modified code must include tests that cover main behavior and important edge cases so coverage stays above this target. +- Test behavior, not implementation details. +- Test file path alias: `@tests/*` → `projects/web/tests/*`. +- See **angular-testing** skill for patterns (TestBed, mocking, HTTP testing, signal component tests). See **web-testing** rule for runner, coverage config, and project test utilities. + +## Routing & Lazy Loading + +- Lazy-load feature routes with `loadComponent` / `loadChildren`. +- Heavy components can be lazy-loaded in templates with `@defer`. +- Use functional route guards (`CanActivateFn`, `CanDeactivateFn`) for authentication and authorization. +- Avoid direct DOM manipulation — use Angular's templating and renderer APIs instead. +- See **angular-routing** skill. + +## Format and lint after editing + +- **After modifying any file under `projects/web`**, run format-check, then format only if needed, then lint. Use as **few files as possible** — only the files you actually changed. All commands from the **workspace root**; paths space-separated, relative to the workspace root. +- **1. Check if format is required** (same file list as modified files): + ```bash + npx nx run web:format-check --files="projects/web/src/app/foo/foo.component.ts projects/web/src/app/foo/foo.component.html" + ``` + If this fails (exit non-zero), formatting is required for those files. +- **2. Format** only when format-check indicated format is needed. Use the same file list: + ```bash + npx nx run web:format --files="projects/web/src/app/foo/foo.component.ts projects/web/src/app/foo/foo.component.html" + ``` + If you modified many files and listing them is impractical, use `npx nx run web:format-check` then `npx nx run web:format` (whole project). Prefer listing the specific files. +- **3. Lint:** Always run lint with auto-fix after any format step: + ```bash + npx nx run web:lint --fix + ``` +- Summary: run `format-check --files="..."` for the modified files; if format is required, run `format --files="..."` with the same list; then always run `npx nx run web:lint --fix`. diff --git a/.cursor/rules/code-quality.mdc b/.cursor/rules/code-quality.mdc new file mode 100644 index 00000000..d12213c2 --- /dev/null +++ b/.cursor/rules/code-quality.mdc @@ -0,0 +1,16 @@ +--- +description: Linting, formatting, DRY, and clean-code requirements for all generated code in projects/web +globs: ['projects/web/**/*.{ts,html,scss,css}'] +--- + +# Code quality — `projects/web` + +**Generated code must** follow the repo's existing **linting and formatting**, **DRY**, **KISS**, **functional programming**, and **clean-code** principles, plus **testing requirements**. This applies to TypeScript, HTML, SCSS, and any other code in `projects/web`. + +- **Tests:** Generate co-located `*.spec.ts` files for all new components, services, directives, pipes, stores, guards, and resolvers. Aim for >80% code coverage (see **web-testing** rule). +- For lint/format/DRY/KISS/functional programming/clean-code requirements see **code-quality** skill. + +## Cursor hook (project-specific) + +- **`.cursor/hooks.json`** runs format and lint after each edit (`afterFileEdit`). By default it runs only for files under **allowed paths** (e.g. `projects/web`) and only on the **edited file(s)**. It uses the same tools/configs as the repo. +- You can switch to full project tasks (`task format:swimlane-web`, `task lint:swimlane-web`) by setting `USE_TASK_COMMANDS = true` in **`.cursor/hooks/format-and-lint-web.mjs`**. See that file for `ALLOWED_PATHS` and options. diff --git a/.cursor/skills/code-quality/SKILL.md b/.cursor/skills/code-quality/SKILL.md new file mode 100644 index 00000000..a9e49ff1 --- /dev/null +++ b/.cursor/skills/code-quality/SKILL.md @@ -0,0 +1,55 @@ +--- +name: code-quality +description: Apply linting, formatting, DRY, KISS, functional programming, and clean-code principles to generated or edited code. Use for conforming to project lint/format config, avoiding duplication, and writing maintainable code. Triggers on code review, refactoring, or quality requirements. +--- + +# Code quality + +Generated or edited code must follow the project's **linting and formatting**, **DRY**, **KISS**, **functional programming** principles, and **clean-code** practices. + +## Linting and formatting + +- Conform to the **lint and format config already in the repo** (e.g. ESLint, Prettier). +- Do not introduce lint or style violations. Run the project's linter and formatter and fix any issues before considering the change complete. + +## DRY (Don't Repeat Yourself) — required + +- Do not copy-paste blocks of logic or styles. +- Extract repeated values into constants, variables, tokens, mixins, or shared utilities. +- Reuse existing components, services, and helpers instead of duplicating behavior. + +## KISS (Keep It Simple, Stupid) — required + +- Prefer the **simplest solution** that solves the problem; avoid over-engineering. +- Avoid unnecessary abstractions, layers, or indirection until they are justified by reuse or clarity. +- Prefer clear, readable code over clever or "elegant" code when they conflict. + +## Functional programming principles — required + +- Prefer **pure functions** where possible: same inputs → same outputs, no side effects. +- Avoid **mutable state**; prefer immutable data and updates (e.g. new objects/arrays instead of mutating in place). +- Use **declarative** patterns: `map`, `filter`, `reduce`, and composition over imperative loops when they improve readability. +- Keep **side effects** (I/O, DOM, subscriptions) at the edges; isolate them in services or explicit effect boundaries. + +## Clean code — required + +- Use full, descriptive names; avoid unnecessary abbreviations (unless a well-known project or domain term). +- Keep units of code focused and single-purpose; prefer small, reusable pieces. +- Avoid deep nesting and tangles; code should be easy to change without breaking unrelated behavior. +- Add brief comments or JSDoc for non-obvious behavior and document _why_ when intent is not clear from the code alone. + +## Code style (Angular / TypeScript) + +- **Max ~30 lines per method** — extract private helpers for anything longer. +- **Max 3 parameters** — use an options/config object beyond that. +- **Use `private`** on internal methods and fields. +- **Single responsibility** — one function does one thing. +- **No business logic in components** — delegate to stores/services. + +## TypeScript guidelines + +- Prefer type inference where obvious; annotate return types on public methods and service APIs. +- Avoid `any` — use `unknown` and type-narrow. +- Define clear interfaces and types for component state, service responses, and data models. Co-locate in the feature folder or a shared `models/` directory. +- No magic numbers/strings — extract to named constants or enums. +- No `console.log` in production code — use `console.warn` for caught errors only. diff --git a/CHANGELOG.md b/CHANGELOG.md index 195c27f2..3dd373f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,17 @@ ## HEAD (unreleased) +- Enhancement: `transitionAfterChanges`, `transitionDuringTransform`, `layoutTransitionEffect`, and `applyVisualContinuityBeforeLayout` inputs configure rich continuity when a prior graph had nodes, allowing for animations between graph updates. +- Breaking: `useLayoutTransitions` (default `true` controls `layout-js-driven`; when `false` it applies only during JS `mode: 'tween'`); +- Enhancement: `GraphComponent` `edgePathSampleCount` input (default 48, clamped 2–512) for edge resampling in layout, morph, and `redrawEdge` +- Enhancement: `transitionAfterChanges.morphCapture` for prior translate source (model vs main-chart DOM, optional model fallback) and `syncTargetsFromPositionAfterTick` / `snapAddedNodeIds`; `mergeGraphLayoutTransition` merges `morphCapture` with defaults +- Enhancement: `drawComplete` emits after a completed draw/tick pass (paths bound, graph ready). Use to hide loading UI or run one-shot center/zoomToFit without flashing before first layout. +- Enhancement: Minimap can be displayed on bottom. +- Fix: Observable layouts (Cola, D3 force) faster than `afterNextRender`—superseded passes repaint link paths from the current model without full `redrawLines` so layout morph is not cancelled. +- Fix: Layout morph keeps `oldLine` / `oldTextPath` on paths that are not resampled-tweening until the tween ends instead of snapping to the new route early. +- Fix: Drag `updateEdge` for Dagre and DagreCluster uses orientation-aware paths aligned with DagreNodesOnly. +- Chore: Update Storybook with documentation, examples + ## 12.0.0-alpha.0 - Enhancement: Support for Angular 21 diff --git a/documentation.json b/documentation.json index 901f0d26..2790d58a 100644 --- a/documentation.json +++ b/documentation.json @@ -9,7 +9,11 @@ "components": [], "modules": [], "miscellaneous": [], - "routes": [], + "routes": { + "name": "", + "kind": "module", + "children": [] + }, "coverage": { "count": 0, "status": "low", diff --git a/projects/swimlane/ngx-graph/.storybook/preview.ts b/projects/swimlane/ngx-graph/.storybook/preview.ts index 55b3fe34..de5f9665 100644 --- a/projects/swimlane/ngx-graph/.storybook/preview.ts +++ b/projects/swimlane/ngx-graph/.storybook/preview.ts @@ -1,10 +1,17 @@ import type { Preview } from '@storybook/angular'; +import { applicationConfig } from '@storybook/angular'; +import { provideAnimations } from '@angular/platform-browser/animations'; import { themes } from 'storybook/theming'; import { setCompodocJson } from '@storybook/addon-docs/angular'; import docJson from '../documentation.json'; setCompodocJson(docJson); const preview: Preview = { + decorators: [ + applicationConfig({ + providers: [provideAnimations()] + }) + ], parameters: { docs: { theme: themes.light diff --git a/projects/swimlane/ngx-graph/src/lib/enums/mini-map-position.enum.ts b/projects/swimlane/ngx-graph/src/lib/enums/mini-map-position.enum.ts index cb114327..73edef6c 100644 --- a/projects/swimlane/ngx-graph/src/lib/enums/mini-map-position.enum.ts +++ b/projects/swimlane/ngx-graph/src/lib/enums/mini-map-position.enum.ts @@ -1,4 +1,6 @@ export enum MiniMapPosition { UpperLeft = 'UpperLeft', - UpperRight = 'UpperRight' + UpperRight = 'UpperRight', + LowerLeft = 'LowerLeft', + LowerRight = 'LowerRight' } diff --git a/projects/swimlane/ngx-graph/src/lib/graph/graph.component.html b/projects/swimlane/ngx-graph/src/lib/graph/graph.component.html index 47cd79e8..47d8b4d3 100644 --- a/projects/swimlane/ngx-graph/src/lib/graph/graph.component.html +++ b/projects/swimlane/ngx-graph/src/lib/graph/graph.component.html @@ -1,5 +1,9 @@
@if (initialized && graph) { - - - @if (defsTemplate) { - - } @for (link of graph.edges; track link) { - - } - - - - - @for (node of graph.clusters; track trackNodeBy($index, node)) { - - @if (clusterTemplate && !node.hidden) { - - } @if (!clusterTemplate) { - - - - {{ node.label }} - - + + + @if (defsTemplate) { + + } + @for (link of graph.edges; track link) { + + } + + + + + @for (node of graph.clusters; track trackNodeBy($index, node)) { + + @if (clusterTemplate && !node.hidden) { + + } + @if (!clusterTemplate && !node.hidden) { + + + + {{ node.label }} + + + } + } - } - - - @for (node of graph.compoundNodes; track trackNodeBy($index, node)) { - - @if (nodeTemplate && !node.hidden) { - - } @if (!nodeTemplate) { - - - - {{ node.label }} - - + + @for (node of graph.compoundNodes; track trackNodeBy($index, node)) { + + @if (nodeTemplate && !node.hidden) { + + } + @if (!nodeTemplate && !node.hidden) { + + + + {{ node.label }} + + + } + } - } - - - @for (link of graph.edges; track trackLinkBy($index, link)) { - - @if (linkTemplate) { - - } @if (!linkTemplate) { - + + @for (link of graph.edges; track trackLinkBy($index, link)) { + + @if (linkTemplate) { + + } + @if (!linkTemplate) { + + } + } - } - - - @for (node of graph.nodes; track trackNodeBy($index, node)) { - - @if (nodeTemplate && !node.hidden) { - - } @if (!nodeTemplate) { - + + @for (node of graph.nodes; track trackNodeBy($index, node)) { + + @if (nodeTemplate && !node.hidden) { + + } + @if (!nodeTemplate && !node.hidden) { + + } + } - } - } @@ -143,68 +152,70 @@ @if (showMiniMap) { - - - - - @for (node of graph.nodes; track trackNodeBy($index, node)) { - - @if (miniMapNodeTemplate) { - - } @if (!miniMapNodeTemplate && nodeTemplate) { - - } @if (!nodeTemplate && !miniMapNodeTemplate) { - - } - - } - + + + > + + @for (node of graph.nodes; track trackNodeBy($index, node)) { + + @if (miniMapNodeTemplate) { + + } + @if (!miniMapNodeTemplate && nodeTemplate) { + + } + @if (!nodeTemplate && !miniMapNodeTemplate && !node.hidden) { + + } + + } + + + - }
diff --git a/projects/swimlane/ngx-graph/src/lib/graph/graph.component.scss b/projects/swimlane/ngx-graph/src/lib/graph/graph.component.scss index 83f18337..28fa60e1 100644 --- a/projects/swimlane/ngx-graph/src/lib/graph/graph.component.scss +++ b/projects/swimlane/ngx-graph/src/lib/graph/graph.component.scss @@ -22,6 +22,16 @@ } } +/* Layout morph: JS rAF drives transforms; disable CSS so it does not fight imperative updates. + * `.layout-js-driven` is always on the host when `useLayoutTransitions` is true (default); set that input false + * to apply this class only while `transitionAfterChanges` tweening is active. */ +.layout-js-driven .graph .node-group, +.layout-js-driven .minimap .node-group, +.smooth-layout .graph .node-group, +.smooth-layout .minimap .node-group { + transition: none; +} + .graph { user-select: none; @@ -41,11 +51,8 @@ cursor: move; } + /* Node motion is driven by graph.component.ts (rAF / unified layout), not CSS transition on transform. */ .node-group { - &.old-node { - transition: transform 0.5s ease-in-out; - } - .node { &:focus { outline: none; diff --git a/projects/swimlane/ngx-graph/src/lib/graph/graph.component.spec.ts b/projects/swimlane/ngx-graph/src/lib/graph/graph.component.spec.ts new file mode 100644 index 00000000..0192df25 --- /dev/null +++ b/projects/swimlane/ngx-graph/src/lib/graph/graph.component.spec.ts @@ -0,0 +1,375 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed, fakeAsync, flush, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { Observable, of } from 'rxjs'; + +import { Graph } from '../models/graph.model'; +import { Layout } from '../models/layout.model'; +import { Edge } from '../models/edge.model'; +import { ClusterNode, CompoundNode, Node } from '../models/node.model'; +import { GraphComponent } from './graph.component'; +import { GraphModule } from './graph.module'; + +/** Synchronous layout for tests: positions nodes/clusters/compound nodes and assigns edge polylines. */ +class TestSyncLayout implements Layout { + run(graph: Graph): Observable { + const nodes: Node[] = graph.nodes.map((n, i) => ({ + ...n, + position: { x: 120 + i * 100, y: 140 }, + dimension: { width: n.dimension?.width ?? 48, height: n.dimension?.height ?? 32 } + })); + const clusters: ClusterNode[] = (graph.clusters ?? []).map((c, i) => ({ + ...c, + position: { x: 80, y: 80 }, + dimension: { width: c.dimension?.width ?? 200, height: c.dimension?.height ?? 160 } + })); + const compoundNodes: CompoundNode[] = (graph.compoundNodes ?? []).map(c => ({ + ...c, + position: { x: 400, y: 120 }, + dimension: { width: c.dimension?.width ?? 120, height: c.dimension?.height ?? 80 } + })); + const edges: Edge[] = graph.edges.map(e => ({ + ...e, + points: [ + { x: 50, y: 50 }, + { x: 250, y: 150 } + ] + })); + return of({ + ...graph, + nodes, + clusters, + compoundNodes, + edges + }); + } + + updateEdge(graph: Graph, edge: Edge): Graph { + return graph; + } +} + +class TestLayoutWithCustomParseTranslate extends TestSyncLayout { + parseTranslate(_transformStr: string | undefined): { tx: number; ty: number } { + return { tx: 42, ty: -3 }; + } +} + +@Component({ + selector: 'test-graph-draw-complete-host', + template: ` + + `, + standalone: false +}) +class TestGraphDrawCompleteHostComponent { + syncLayout = new TestSyncLayout(); + + nodes: Node[] = [ + { id: 'n1', label: 'A' }, + { id: 'n2', label: 'B' } + ]; + clusters: ClusterNode[] = [{ id: 'cl1', label: 'Cluster' }]; + compoundNodes: CompoundNode[] = [{ id: 'cp1', label: 'Compound', childNodeIds: ['n1'] }]; + links: Edge[] = [{ id: 'e1', source: 'n1', target: 'n2' }]; + + drawCompleteCount = 0; + + onDrawComplete(): void { + this.drawCompleteCount++; + } +} + +describe('GraphComponent drawComplete', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GraphModule], + declarations: [TestGraphDrawCompleteHostComponent] + }).compileComponents(); + }); + + it('emits drawComplete after link paths are bound and dimensions reflect DOM for nodes, clusters, and compound nodes', fakeAsync(() => { + const fixture = TestBed.createComponent(TestGraphDrawCompleteHostComponent); + const host = fixture.componentInstance; + fixture.detectChanges(); + flush(); + tick(16); + fixture.detectChanges(); + flush(); + + expect(host.drawCompleteCount).toBe(1); + + const graphEl = fixture.debugElement.query(By.directive(GraphComponent)); + const graph = graphEl.componentInstance as GraphComponent; + + expect(graph.graph.edges.length).toBe(1); + expect(graph.linkElements?.length ?? 0).toBe(graph.graph.edges.length); + + for (const linkRef of graph.linkElements ?? []) { + const g = linkRef.nativeElement as SVGGElement; + const path = g.querySelector('path'); + expect(path?.getAttribute('d')?.length).toBeGreaterThan(0); + } + + const assertBBoxMatchesModel = (nodeId: string, model: Node) => { + const g = fixture.nativeElement.querySelector(`#${nodeId}`) as SVGGElement | null; + expect(g).withContext(nodeId).toBeTruthy(); + const bb = g!.getBBox(); + expect(model.dimension.width).toBeCloseTo(bb.width, 0); + expect(model.dimension.height).toBeCloseTo(bb.height, 0); + }; + + graph.graph.nodes.forEach(n => assertBBoxMatchesModel(n.id, n)); + graph.graph.clusters?.forEach(c => assertBBoxMatchesModel(c.id, c)); + graph.graph.compoundNodes?.forEach(c => assertBBoxMatchesModel(c.id, c)); + })); +}); + +@Component({ + selector: 'test-graph-layout-js-host', + template: ` + + `, + standalone: false +}) +class TestGraphLayoutJsHostComponent { + syncLayout = new TestSyncLayout(); + nodes: Node[] = [ + { id: 'n1', label: 'A' }, + { id: 'n2', label: 'B' } + ]; + links: Edge[] = [{ id: 'e1', source: 'n1', target: 'n2' }]; + useLayoutTransitions = true; +} + +@Component({ + selector: 'test-graph-parse-translate-host', + template: ` + + `, + standalone: false +}) +class TestGraphParseTranslateHostComponent { + syncLayout = new TestLayoutWithCustomParseTranslate(); + nodes: Node[] = [ + { id: 'n1', label: 'A' }, + { id: 'n2', label: 'B' } + ]; + links: Edge[] = [{ id: 'e1', source: 'n1', target: 'n2' }]; +} + +describe('GraphComponent layout-js-driven host class', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GraphModule], + declarations: [TestGraphLayoutJsHostComponent] + }).compileComponents(); + }); + + function outerHasLayoutJsDriven(fixture: ComponentFixture): boolean { + const outer = fixture.nativeElement.querySelector('.ngx-graph-outer') as HTMLElement | null; + expect(outer).toBeTruthy(); + return outer!.classList.contains('layout-js-driven'); + } + + it('applies layout-js-driven when useLayoutTransitions is true (default)', fakeAsync(() => { + const fixture = TestBed.createComponent(TestGraphLayoutJsHostComponent); + fixture.detectChanges(); + flush(); + tick(16); + fixture.detectChanges(); + flush(); + expect(outerHasLayoutJsDriven(fixture)).toBe(true); + })); + + it('omits layout-js-driven when useLayoutTransitions is false and tween is inactive', fakeAsync(() => { + const fixture = TestBed.createComponent(TestGraphLayoutJsHostComponent); + fixture.componentInstance.useLayoutTransitions = false; + fixture.detectChanges(); + flush(); + tick(16); + fixture.detectChanges(); + flush(); + expect(outerHasLayoutJsDriven(fixture)).toBe(false); + })); +}); + +/** Default {@link GraphComponent} edge path resample count when `edgePathSampleCount` is unset. */ +const DEFAULT_EDGE_PATH_SAMPLE_COUNT = 48; + +describe('GraphComponent redrawEdge (curve + resampling)', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GraphModule], + declarations: [TestGraphLayoutJsHostComponent] + }).compileComponents(); + }); + + it('resamples route points to default sample count before building line (same as layout tick)', fakeAsync(() => { + const fixture = TestBed.createComponent(TestGraphLayoutJsHostComponent); + fixture.detectChanges(); + flush(); + tick(16); + fixture.detectChanges(); + flush(); + + const graphEl = fixture.debugElement.query(By.directive(GraphComponent)); + const graph = graphEl.componentInstance as GraphComponent; + + const resampleSpy = spyOn(graph as any, 'resamplePolyline').and.callThrough(); + + const edge = graph.graph.edges[0]; + edge.points = [ + { x: 0, y: 0 }, + { x: 50, y: 0 }, + { x: 50, y: 40 }, + { x: 120, y: 40 } + ]; + + graph.redrawEdge(edge); + + expect(resampleSpy).toHaveBeenCalled(); + const [ptsArg, countArg] = resampleSpy.calls.mostRecent().args as [Array<{ x: number; y: number }>, number]; + expect(ptsArg.length).toBe(4); + expect(countArg).toBe(DEFAULT_EDGE_PATH_SAMPLE_COUNT); + expect(edge.line?.length).toBeGreaterThan(0); + })); + + it('sets edge.line to the same stroke as lineAndDisplayFromRoutePoints for those points', fakeAsync(() => { + const fixture = TestBed.createComponent(TestGraphLayoutJsHostComponent); + fixture.detectChanges(); + flush(); + tick(16); + fixture.detectChanges(); + flush(); + + const graphEl = fixture.debugElement.query(By.directive(GraphComponent)); + const graph = graphEl.componentInstance as GraphComponent; + + const pts = [ + { x: 10, y: 10 }, + { x: 90, y: 12 }, + { x: 88, y: 100 }, + { x: 200, y: 95 } + ]; + const edge = graph.graph.edges[0]; + edge.points = pts; + + graph.redrawEdge(edge); + const { line: expectedLine } = (graph as any).lineAndDisplayFromRoutePoints(pts); + expect(edge.line).toBe(expectedLine); + })); + + it('uses edgePathSampleCount when set (clamped)', fakeAsync(() => { + const fixture = TestBed.createComponent(TestGraphLayoutJsHostComponent); + fixture.detectChanges(); + flush(); + tick(16); + fixture.detectChanges(); + flush(); + + const graph = fixture.debugElement.query(By.directive(GraphComponent)).componentInstance as GraphComponent; + graph.edgePathSampleCount = 24; + + const resampleSpy = spyOn(graph as any, 'resamplePolyline').and.callThrough(); + const edge = graph.graph.edges[0]; + edge.points = [ + { x: 0, y: 0 }, + { x: 50, y: 0 } + ]; + graph.redrawEdge(edge); + expect((resampleSpy.calls.mostRecent().args[1] as number) === 24).toBe(true); + + graph.edgePathSampleCount = 1; + graph.redrawEdge(edge); + expect(resampleSpy.calls.mostRecent().args[1] as number).toBe(2); + + graph.edgePathSampleCount = 900; + graph.redrawEdge(edge); + expect(resampleSpy.calls.mostRecent().args[1] as number).toBe(512); + })); +}); + +describe('GraphComponent resolveTranslateFromTransform', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GraphModule], + declarations: [TestGraphParseTranslateHostComponent] + }).compileComponents(); + }); + + it('delegates to Layout.parseTranslate when the layout instance provides it', fakeAsync(() => { + const fixture = TestBed.createComponent(TestGraphParseTranslateHostComponent); + fixture.detectChanges(); + flush(); + tick(16); + fixture.detectChanges(); + flush(); + + const graph = fixture.debugElement.query(By.directive(GraphComponent)).componentInstance as GraphComponent; + expect((graph as any).resolveTranslateFromTransform('translate(1,2)')).toEqual({ tx: 42, ty: -3 }); + expect((graph as any).parseTranslateDefault('translate(1,2)')).toEqual({ tx: 1, ty: 2 }); + })); +}); + +describe('GraphComponent layout anchor helpers (full-scope edge morph)', () => { + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [GraphModule], + declarations: [TestGraphLayoutJsHostComponent] + }).compileComponents(); + }); + + it('layoutCenterFromPreviousTransform recovers layout center from translate (centered nodes)', fakeAsync(() => { + const fixture = TestBed.createComponent(TestGraphLayoutJsHostComponent); + fixture.detectChanges(); + flush(); + tick(16); + fixture.detectChanges(); + flush(); + + const graph = fixture.debugElement.query(By.directive(GraphComponent)).componentInstance as GraphComponent; + const n = graph.graph.nodes[0]; + n.dimension = { width: 48, height: 32 }; + n.position = { x: 200, y: 100 }; + (graph as any).updateNodeGroupTransform(n); + const prev = (graph as any).parseTranslateDefault(n.transform); + const center = (graph as any).layoutCenterFromPreviousTransform(n, prev); + expect(center.x).toBe(200); + expect(center.y).toBe(100); + })); + + it('polylineBetweenLayoutCenters returns four points like buildFallbackEdgePoints', fakeAsync(() => { + const fixture = TestBed.createComponent(TestGraphLayoutJsHostComponent); + fixture.detectChanges(); + flush(); + tick(16); + fixture.detectChanges(); + flush(); + + const graph = fixture.debugElement.query(By.directive(GraphComponent)).componentInstance as GraphComponent; + const s = { x: 0, y: 0 }; + const t = { x: 100, y: 0 }; + const poly = (graph as any).polylineBetweenLayoutCenters(s, t); + expect(poly.length).toBe(4); + expect(poly[0]).toEqual(s); + expect(poly[3]).toEqual(t); + })); +}); diff --git a/projects/swimlane/ngx-graph/src/lib/graph/graph.component.ts b/projects/swimlane/ngx-graph/src/lib/graph/graph.component.ts index 1d50f603..1cbd5dd1 100644 --- a/projects/swimlane/ngx-graph/src/lib/graph/graph.component.ts +++ b/projects/swimlane/ngx-graph/src/lib/graph/graph.component.ts @@ -8,6 +8,8 @@ import { ElementRef, EventEmitter, HostListener, + inject, + Injector, Input, OnDestroy, OnInit, @@ -19,17 +21,18 @@ import { NgZone, ChangeDetectorRef, OnChanges, - SimpleChanges + SimpleChanges, + afterNextRender, + isDevMode } from '@angular/core'; import { select } from 'd3-selection'; import * as shape from 'd3-shape'; -import * as ease from 'd3-ease'; -import 'd3-transition'; import { Observable, Subscription, of, fromEvent as observableFromEvent, Subject } from 'rxjs'; import { first, debounceTime, takeUntil } from 'rxjs/operators'; import { identity, scale, smoothMatrix, toSVG, transform, translate } from 'transformation-matrix'; import { Layout } from '../models/layout.model'; import { LayoutService } from './layouts/layout.service'; +import { LAYERED_NODE_NODE_BETWEEN_LAYERS_PX } from './layouts/layout-layered-constants'; import { Edge } from '../models/edge.model'; import { Node, ClusterNode, CompoundNode } from '../models/node.model'; import { Graph } from '../models/graph.model'; @@ -40,6 +43,16 @@ import { throttleable } from '../utils/throttle'; import { ColorHelper } from '../utils/color.helper'; import { ViewDimensions, calculateViewDimensions } from '../utils/view-dimensions.helper'; import { VisibilityObserver } from '../utils/visibility-observer'; +import { + mergeGraphLayoutTransition, + mergeViewportTransition, + mergeLayoutEffect, + resolveGraphTransitionEasing, + type GraphLayoutTransition, + type LayoutMorphCapture, + type ViewportTranslationTransition, + type LayoutTransitionEffect +} from './transition.model'; /** * Matrix @@ -70,6 +83,16 @@ export interface NgxGraphStateChangeEvent { state: NgxGraphStates; } +/** + * Root graph component (`ngx-graph`). + * + * **Layout transitions:** JS-driven morphing (`transitionAfterChanges` with `mode: 'tween'`) is **opt-in**; if the input is + * omitted, merged defaults use `mode: 'instant'` (no rAF tween). The host may carry `layout-js-driven` (see + * {@link useLayoutTransitions}): when present, styles set `transition: none` on `.node-group` so imperative + * `transform` updates do not fight CSS. **`smooth-layout`** is set only while tweening is active. + * + * **Template outlets:** `ngTemplateOutletContext` includes `transitionAfterChangesActive` when JS-driven layout morphing is active. + */ @Component({ selector: 'ngx-graph', styleUrls: ['./graph.component.scss'], @@ -84,6 +107,8 @@ export interface NgxGraphStateChangeEvent { standalone: false }) export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewInit { + private readonly injector = inject(Injector); + @Input() nodes: Node[] = []; @Input() clusters: ClusterNode[] = []; @Input() compoundNodes: CompoundNode[] = []; @@ -124,6 +149,29 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn @Input() deferDisplayUntilPosition: boolean = false; @Input() centerNodesOnPositionChange = true; @Input() enablePreUpdateTransform = true; + /** + * Layout transition after nodes/links change. Merged with defaults via {@link mergeGraphLayoutTransition}; when omitted or + * empty, defaults apply (`mode: 'instant'`). Use `{ mode: 'tween', scope: 'full' }` for full-graph interpolation, or + * `{ mode: 'tween', scope: 'additive', durationMs: 0 }` for additive-only tweening. + * + * **`mode: 'tween'` is opt-in** (no tween unless you pass a partial that resolves to tween after merge). + */ + @Input() transitionAfterChanges?: Partial; + /** + * Number of samples along each edge polyline when building `line` / morph segments (layout tick, drag + * {@link redrawEdge}, unified morph). Clamped to `[2, 512]`; default `48` when unset or non-finite. + */ + @Input() edgePathSampleCount?: number; + /** + * When `true` (default), the host always has the `layout-js-driven` class so CSS does not animate `.node-group` + * `transform` (matches historical behavior). When `false`, `layout-js-driven` is applied only while JS layout morphing + * is active ({@link layoutJsMorphEnabled}), allowing host CSS transitions on node groups when not using `mode: 'tween'`. + */ + @Input() useLayoutTransitions = true; + /** Optional eased translation for programmatic pan only (`panTo`, `center`, minimap, `zoomToFit` autoCenter). Zoom scale is never animated. */ + @Input() transitionDuringTransform?: Partial; + /** Optional perspective / rotate flair during layout morph only (`mode: 'tween'`). */ + @Input() layoutTransitionEffect?: Partial; @Output() select = new EventEmitter(); @Output() activate: EventEmitter = new EventEmitter(); @@ -131,6 +179,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn @Output() zoomChange: EventEmitter = new EventEmitter(); @Output() clickHandler: EventEmitter = new EventEmitter(); @Output() stateChange: EventEmitter = new EventEmitter(); + @Output() drawComplete = new EventEmitter(); @ContentChild('linkTemplate') linkTemplate: TemplateRef; @ContentChild('nodeTemplate') nodeTemplate: TemplateRef; @@ -139,6 +188,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn @ContentChild('miniMapNodeTemplate') miniMapNodeTemplate: TemplateRef; @ViewChildren('nodeElement') nodeElements: QueryList; + @ViewChildren('clusterElement') clusterElements: QueryList; @ViewChildren('linkElement') linkElements: QueryList; public chartWidth: any; @@ -160,6 +210,9 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn oldNodes: Set = new Set(); oldClusters: Set = new Set(); oldCompoundNodes: Set = new Set(); + /** Incremented at the start of each {@link tick}; completion callbacks only emit when this matches. */ + private drawCompleteTickId = 0; + private _graphDestroyed = false; transformationMatrix: Matrix = identity(); _touchLastX = null; _touchLastY = null; @@ -175,6 +228,34 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn visibilityObserver: VisibilityObserver; private destroy$ = new Subject(); + /** Latest requestAnimationFrame id per edge for imperative path morphing (cancel on relayout / drag). */ + private readonly edgePathRafIds = new Map(); + + /** Single rAF when layout morph unifies node transforms + edge paths. */ + private layoutUnifiedRafId: number | null = null; + + /** rAF for programmatic viewport pan easing (translation only). */ + private viewportPanAnimRafId: number | null = null; + + /** CSS `transform` on `.ngx-graph-outer` during optional layout flair (perspective / rotate). */ + layoutOuterTransform: string | null = null; + + /** `transform-origin` for {@link layoutOuterTransform} when using rotate pivot modes. */ + layoutEffectTransformOrigin = '50% 50%'; + + /** Parsed `translate(tx,ty)` from the graph before a new layout is applied. */ + private previousLayoutTransforms: Map | null = null; + + /** Target `translate(tx,ty)` after tick applyTransforms (before reset to previous for animation). */ + private layoutAnimationTargets: Map | null = null; + + /** Node ids present after the previous completed `tick` (for additive smooth transitions). */ + private priorTickGraphNodeIds = new Set(); + /** Edge keys present after the previous completed `tick` (aligned with {@link linkKeyForLookup}). */ + private priorTickEdgeKeys = new Set(); + /** Snapshot of {@link priorTickEdgeKeys} at the start of the current `tick` (for classifying new edges). */ + private edgeKeysAtLayoutTickStart = new Set(); + constructor( private el: ElementRef, public zone: NgZone, @@ -182,8 +263,37 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn private layoutService: LayoutService ) {} + /** Coloring domain key; default coalesces `label`, then `id`, then `''` so {@link ColorHelper} never receives null/undefined. */ @Input() - groupResultsBy: (node: any) => string = node => node.label; + groupResultsBy: (node: any) => string = node => node.label ?? node.id ?? ''; + + /** Merged layout transition config from {@link transitionAfterChanges} and defaults. */ + get effectiveLayoutTransition(): GraphLayoutTransition { + return mergeGraphLayoutTransition(this.transitionAfterChanges); + } + + /** `true` when rAF morph should run after layout (`mode: 'tween'`). */ + get layoutMorphActive(): boolean { + return this.effectiveLayoutTransition.mode === 'tween'; + } + + /** Whether layout morphing is active (`transitionAfterChanges` resolved to `mode: 'tween'`). Exposed on template outlets as `transitionAfterChangesActive`. */ + get layoutJsMorphEnabled(): boolean { + return this.layoutMorphActive; + } + + /** Host `layout-js-driven` class: always on when {@link useLayoutTransitions} is `true`; otherwise only when {@link layoutJsMorphEnabled}. */ + get layoutJsDrivenHostClass(): boolean { + return this.useLayoutTransitions ? true : this.layoutJsMorphEnabled; + } + + get effectiveViewportTransition() { + return mergeViewportTransition(this.transitionDuringTransform); + } + + get effectiveLayoutEffect(): LayoutTransitionEffect { + return mergeLayoutEffect(this.layoutTransitionEffect); + } /** * Get the current zoom level @@ -268,7 +378,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn ngOnChanges(changes: SimpleChanges): void { this.basicUpdate(); const { layoutSettings } = changes; - this.setLayout(this.layout); + this.setLayout(this.layout, !!changes['layout']); if (layoutSettings) { this.setLayoutSettings(this.layoutSettings); } @@ -277,8 +387,14 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn } } - setLayout(layout: string | Layout): void { - this.initialized = false; + /** + * @param layoutInputChanged - When true, clears `initialized` so `@if (initialized && graph)` does not + * flash empty on unrelated input updates (nodes/links only). Only layout identity changes should reset. + */ + setLayout(layout: string | Layout, layoutInputChanged = false): void { + if (layoutInputChanged) { + this.initialized = false; + } if (!layout) { layout = 'dagre'; } @@ -301,6 +417,10 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn * @memberOf GraphComponent */ ngOnDestroy(): void { + this._graphDestroyed = true; + this.cancelLayoutUnifiedAnimation(); + this.cancelAllEdgePathAnimations(); + this.cancelViewportPanAnimation(); this.unbindEvents(); if (this.visibilityObserver) { this.visibilityObserver.visible.unsubscribe(); @@ -399,14 +519,236 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn return e; }; - this.graph = { + const priorGraph = this.graph; + + const nextGraph: Graph = { nodes: this.nodes.map(n => initializeNode(n)), clusters: this.clusters.map(n => initializeNode(n)), compoundNodes: this.compoundNodes.map(n => initializeNode(n)), edges: this.links.map(e => initializeEdge(e)) }; - requestAnimationFrame(() => this.draw()); + this.applyVisualContinuityBeforeLayout(nextGraph, priorGraph); + + this.graph = nextGraph; + this.draw(); + } + + /** + * While async layout (e.g. ELK) runs, keep showing the previous snapshot's positions and edge routes + * so inputs without x/y do not flash to (0,0). Seeds `capturePreviousLayoutTransforms` with real geometry. + */ + private applyVisualContinuityBeforeLayout(next: Graph, prior: Graph | undefined): void { + if (!prior?.nodes?.length) { + this.markDefaultOriginNodesHiddenUntilLayout(next.nodes); + this.markDefaultOriginNodesHiddenUntilLayout(next.clusters as Node[] | undefined); + this.markDefaultOriginNodesHiddenUntilLayout(next.compoundNodes as Node[] | undefined); + this.setDisplayTransformsFromPositions(next.nodes, next.clusters ?? [], next.compoundNodes ?? []); + return; + } + + const incremental = this._oldLinks.length > 0; + const prevNodeById = new Map(prior.nodes.map(n => [n.id, n])); + const mergeNodes = (items: Node[] | undefined) => { + if (!items) { + return; + } + for (const n of items) { + const p = prevNodeById.get(n.id); + if (p?.position) { + n.position = { ...p.position }; + } + if (p?.dimension) { + n.dimension = { ...p.dimension }; + } + if (incremental && !prevNodeById.has(n.id)) { + n.hidden = true; + } + } + }; + mergeNodes(next.nodes); + if (incremental) { + for (const n of next.nodes ?? []) { + if (!prevNodeById.has(n.id)) { + this.seedProvisionalPositionFromParentEdge(n, next.edges, prevNodeById); + } + } + } + if (prior.clusters?.length && next.clusters?.length) { + const m = new Map(prior.clusters.map(n => [n.id, n])); + for (const n of next.clusters) { + const p = m.get(n.id); + if (p?.position) { + n.position = { ...p.position }; + } + if (p?.dimension) { + n.dimension = { ...p.dimension }; + } + } + } + if (prior.compoundNodes?.length && next.compoundNodes?.length) { + const m = new Map(prior.compoundNodes.map(n => [n.id, n])); + for (const n of next.compoundNodes) { + const p = m.get(n.id); + if (p?.position) { + n.position = { ...p.position }; + } + if (p?.dimension) { + n.dimension = { ...p.dimension }; + } + } + } + + const mg = this.isLayoutMultigraph(); + if (this._oldLinks.length > 0 && next.edges?.length) { + const prevEdgeByKey = new Map(); + for (const e of this._oldLinks) { + prevEdgeByKey.set(this.linkKeyForLookup(e, mg), e); + } + for (const e of next.edges) { + const pe = prevEdgeByKey.get(this.linkKeyForLookup(e, mg)); + if (pe?.points && pe.points.length >= 2) { + e.points = this.clonePoints(pe.points as Array<{ x: number; y: number }>); + if (pe.line) { + e.line = pe.line; + } + if (pe.oldLine) { + e.oldLine = pe.oldLine; + } + if (pe.textPath) { + e.textPath = pe.textPath; + } + } + } + } + + this.setDisplayTransformsFromPositions(next.nodes, next.clusters ?? [], next.compoundNodes ?? []); + + // Do not pass heuristic `position` into ELK (`...node` in elk createNodeTree). With semiInteractive layout, a wrong + // hint skews the first solve; the next segment masks it. `hidden` + transform already set for pre-layout paint. + if (incremental) { + for (const n of next.nodes ?? []) { + if (!prevNodeById.has(n.id)) { + delete n.position; + } + } + } + } + + /** Transforms + colors for display before `tick()` (matches {@link applyTransforms} without new-id tracking). */ + private setDisplayTransformsFromPositions( + nodes: Node[], + clusters: ClusterNode[] | undefined, + compoundNodes: CompoundNode[] | undefined + ): void { + const apply = (items: Node[] | undefined) => { + if (!items) { + return; + } + for (const n of items) { + if (!n.data) { + n.data = {}; + } + n.data.color = this.colors.getColor(this.groupResultsBy(n)); + this.updateNodeGroupTransform(n); + } + }; + apply(nodes); + apply(clusters); + apply(compoundNodes); + } + + private edgeEndpointNodeId(ref: Edge['source'] | Edge['target']): string { + return typeof ref === 'string' ? ref : String((ref as { id?: string }).id ?? ''); + } + + /** Merged ELK `properties` from the active layout (defaults + instance settings). */ + private elkMergedProperties(): Record { + const L = this.layout as { + defaultSettings?: { properties?: Record }; + settings?: { properties?: Record }; + }; + return { ...L?.defaultSettings?.properties, ...L?.settings?.properties }; + } + + /** + * Inter-layer gap for provisional seeding. Graph-level merged props often carry `20` from ElkLayout defaults while + * `createNodeTree` applies {@link LAYERED_NODE_NODE_BETWEEN_LAYERS_PX} per-node — match the latter for seed math. + */ + private getElkLayerSpacingGapPx(): number { + const raw = this.elkMergedProperties()['elk.layered.spacing.nodeNodeBetweenLayers']; + if (raw == null || raw === '') { + return LAYERED_NODE_NODE_BETWEEN_LAYERS_PX; + } + const n = parseFloat(String(raw)); + if (!Number.isFinite(n)) { + return LAYERED_NODE_NODE_BETWEEN_LAYERS_PX; + } + if (n === 20) { + return LAYERED_NODE_NODE_BETWEEN_LAYERS_PX; + } + return n; + } + + private elkDirectionOrDown(): string { + const d = this.elkMergedProperties()['elk.direction']; + return typeof d === 'string' && d.length ? d : 'DOWN'; + } + + /** + * Place the new node center where layered ELK will approximately put it: half parent + inter-layer gap + half child, + * along the layout axis. Aligns with `buildFallbackEdgePoints` (center-to-center) better than a flat pixel delta. + */ + private seedProvisionalPositionFromParentEdge( + n: Node, + edges: Edge[] | undefined, + prevNodeById: Map + ): void { + if (!edges?.length) { + return; + } + const edge = edges.find(e => this.edgeEndpointNodeId(e.target) === n.id); + if (!edge) { + return; + } + const src = prevNodeById.get(this.edgeEndpointNodeId(edge.source)); + if (!src?.position) { + return; + } + const gap = this.getElkLayerSpacingGapPx(); + const sw = src.dimension?.width ?? 30; + const sh = src.dimension?.height ?? 30; + const tw = n.dimension?.width ?? 30; + const th = n.dimension?.height ?? 30; + const sx = src.position.x; + const sy = src.position.y; + switch (this.elkDirectionOrDown()) { + case 'RIGHT': + n.position = { x: sx + sw / 2 + gap + tw / 2, y: sy }; + break; + case 'LEFT': + n.position = { x: sx - sw / 2 - gap - tw / 2, y: sy }; + break; + case 'UP': + n.position = { x: sx, y: sy - sh / 2 - gap - th / 2 }; + break; + default: + n.position = { x: sx, y: sy + sh / 2 + gap + th / 2 }; + } + } + + /** Bootstrap: hide nodes still at default origin until first ELK `tick()` supplies real positions. */ + private markDefaultOriginNodesHiddenUntilLayout(items: Node[] | undefined): void { + if (!items?.length) { + return; + } + for (const n of items) { + const x = n.position?.x ?? 0; + const y = n.position?.y ?? 0; + if (x === 0 && y === 0) { + n.hidden = true; + } + } } /** @@ -417,129 +759,545 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn */ draw(): void { // Recalculate the layout - const result = (this.layout as Layout).run(this.graph); + const result = (this.layout as Layout)?.run(this.graph); const result$ = result instanceof Observable ? result : of(result); this.graphSubscription.add( result$.subscribe(graph => { + this.capturePreviousLayoutTransforms(); this.graph = graph; this.tick(); }) ); } - tick() { - // Transposes view options to the node - const oldNodes: Set = new Set(); - const oldClusters: Set = new Set(); - const oldCompoundNodes: Set = new Set(); - - this.graph.nodes.forEach(n => { - n.transform = `translate(${n.position.x - (this.centerNodesOnPositionChange ? n.dimension.width / 2 : 0) || 0}, ${ - n.position.y - (this.centerNodesOnPositionChange ? n.dimension.height / 2 : 0) || 0 - })`; - if (!n.data) { - n.data = {}; - } - n.data.color = this.colors.getColor(this.groupResultsBy(n)); - if (this.deferDisplayUntilPosition) { - n.hidden = false; - } - oldNodes.add(n.id); - }); + /** Default: first `translate(a, b)` in the node-group transform string. */ + private parseTranslateDefault(transformStr: string | undefined): { tx: number; ty: number } { + const m = /translate\(\s*([\d.-]+)\s*,\s*([\d.-]+)\s*\)/.exec(transformStr || ''); + if (m) { + return { tx: +m[1], ty: +m[2] }; + } + return { tx: 0, ty: 0 }; + } - (this.graph.clusters || []).forEach(n => { - n.transform = `translate(${n.position.x - (this.centerNodesOnPositionChange ? n.dimension.width / 2 : 0) || 0}, ${ - n.position.y - (this.centerNodesOnPositionChange ? n.dimension.height / 2 : 0) || 0 - })`; - if (!n.data) { - n.data = {}; + /** Prefers `Layout.parseTranslate` on the resolved layout object when present. */ + public resolveTranslateFromTransform(transformStr: string | undefined): { tx: number; ty: number } { + const L = this.layout; + if (L && typeof L !== 'string' && typeof (L as Layout).parseTranslate === 'function') { + return (L as Layout).parseTranslate!(transformStr); + } + return this.parseTranslateDefault(transformStr); + } + + /** Same key shape as dagre/graphlib `_edgeLabels` keys and as used in tick edge maps. */ + private linkKeyForLookup(edge: Pick, multigraph: boolean): string { + const source = typeof edge.source === 'string' ? edge.source : String((edge.source as { id?: string }).id ?? ''); + const target = typeof edge.target === 'string' ? edge.target : String((edge.target as { id?: string }).id ?? ''); + return multigraph ? `${source}${target}${edge.id ?? ''}` : `${source}${target}`; + } + + /** Matches layout engines that merge `defaultSettings` with `settings` (e.g. DagreNodesOnly multigraph). */ + private isLayoutMultigraph(): boolean { + if (!this.layout || typeof this.layout === 'string') { + return false; + } + const layout = this.layout as { defaultSettings?: { multigraph?: boolean }; settings?: { multigraph?: boolean } }; + const merged = Object.assign({}, layout.defaultSettings ?? {}, layout.settings ?? {}); + return !!merged.multigraph; + } + + /** + * graphlib `edgeArgsToId`: v + \\x01 + w + \\x01 + name (name defaults to \\x00). + * Aligns with {@link linkKeyForLookup}; falls back to legacy regex when the id is not graphlib-shaped. + */ + private graphlibEdgeLabelIdToLookupKey(edgeLabelId: string, multigraph: boolean): string { + const EDGE_KEY_DELIM = '\x01'; + const DEFAULT_EDGE_NAME = '\x00'; + const parts = edgeLabelId.split(EDGE_KEY_DELIM); + if (parts.length >= 2) { + const v = parts[0]; + const w = parts[1]; + if (!multigraph) { + return `${v}${w}`; } - n.data.color = this.colors.getColor(this.groupResultsBy(n)); - if (this.deferDisplayUntilPosition) { - n.hidden = false; + const name = parts.length >= 3 ? parts[2] : DEFAULT_EDGE_NAME; + if (name === DEFAULT_EDGE_NAME || name === '') { + return `${v}${w}`; } - oldClusters.add(n.id); - }); + return `${v}${w}${name}`; + } + return edgeLabelId.replace(/[^\w-]*/g, ''); + } - (this.graph.compoundNodes || []).forEach(n => { - n.transform = `translate(${n.position.x - (this.centerNodesOnPositionChange ? n.dimension.width / 2 : 0) || 0}, ${ - n.position.y - (this.centerNodesOnPositionChange ? n.dimension.height / 2 : 0) || 0 - })`; - if (!n.data) { - n.data = {}; + /** Snapshot current node group translates before replacing `graph` (layout morph animation). */ + public capturePreviousLayoutTransforms(): void { + if (!this.initialized || !this.graph) { + this.previousLayoutTransforms = null; + return; + } + // No prior tick edges: first paint should not run unified layout tween. + if (!this._oldLinks.length) { + this.previousLayoutTransforms = null; + return; + } + const morph = this.effectiveLayoutTransition.morphCapture; + const source = morph.previousSource ?? 'model-transform'; + if (source === 'model-transform') { + this.previousLayoutTransforms = this.collectPreviousTranslatesFromModelTransforms(); + return; + } + const chartG = this.getMainChartGroupElement(); + this.previousLayoutTransforms = chartG + ? this.collectPreviousTranslatesFromDom(chartG, morph, source === 'dom-with-model-fallback') + : this.collectPreviousTranslatesFromModelTransforms(); + } + + /** Resample count for edge polylines (layout, morph, drag). */ + private effectiveEdgePathSampleCount(): number { + const raw = this.edgePathSampleCount; + const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.floor(raw) : 48; + return Math.min(512, Math.max(2, n)); + } + + private collectPreviousTranslatesFromModelTransforms(): Map { + const m = new Map(); + const collect = (items: Node[] | undefined) => { + items?.forEach(n => { + if (n.transform) { + m.set(n.id, this.resolveTranslateFromTransform(n.transform)); + } + }); + }; + collect(this.graph.nodes); + collect(this.graph.clusters); + collect(this.graph.compoundNodes); + return m; + } + + private getMainChartGroupElement(): SVGGElement | null { + const g = (this.el.nativeElement as HTMLElement).querySelector('g.graph.chart'); + return g instanceof SVGGElement ? g : null; + } + + /** Main-chart `g.node-group` only (not minimap duplicates). */ + private findMainChartNodeGroup(chartG: SVGGElement, nodeId: string): SVGGElement | null { + const el = chartG.querySelector(`g.node-group#${CSS.escape(nodeId)}`); + if (!(el instanceof SVGGElement) || el.closest('.minimap')) { + return null; + } + return el; + } + + private translateFromLayoutPositionForNode(n: Node): { tx: number; ty: number } { + const center = this.centerNodesOnPositionChange; + const dx = center ? (n.dimension?.width ?? 0) / 2 : 0; + const dy = center ? (n.dimension?.height ?? 0) / 2 : 0; + const px = n.position?.x ?? 0; + const py = n.position?.y ?? 0; + return { tx: px - dx || 0, ty: py - dy || 0 }; + } + + private findMorphNodeById( + nodeId: string, + order: NonNullable + ): Node | undefined { + for (const kind of order) { + if (kind === 'compound') { + const hit = this.graph.compoundNodes?.find(x => x.id === nodeId); + if (hit) { + return hit; + } + } else if (kind === 'cluster') { + const hit = this.graph.clusters?.find(x => x.id === nodeId); + if (hit) { + return hit; + } + } else { + const hit = this.graph.nodes.find(x => x.id === nodeId); + if (hit) { + return hit; + } } - n.data.color = this.colors.getColor(this.groupResultsBy(n)); - if (this.deferDisplayUntilPosition) { - n.hidden = false; + } + return undefined; + } + + private modelTranslateForMorphFallback(nodeId: string, morph: LayoutMorphCapture): { tx: number; ty: number } { + const order = morph.modelResolutionOrder ?? ['compound', 'cluster', 'node']; + const n = this.findMorphNodeById(nodeId, order); + if (!n) { + return { tx: 0, ty: 0 }; + } + if (n.transform) { + return this.resolveTranslateFromTransform(n.transform); + } + return this.translateFromLayoutPositionForNode(n); + } + + private isDegenerateTranslate(t: { tx: number; ty: number }, eps: number): boolean { + return Math.hypot(t.tx, t.ty) <= eps; + } + + /** `translate` of `nodeEl`'s origin in `chartG` user space (pan/zoom excluded from node-local chain). */ + private translateNodeGroupInChartUserSpace( + nodeEl: SVGGElement, + chartG: SVGGElement + ): { tx: number; ty: number } | null { + try { + const nodeCtm = nodeEl.getCTM(); + const chartCtm = chartG.getCTM(); + if (!nodeCtm || !chartCtm) { + return null; } - oldCompoundNodes.add(n.id); - }); + const m = chartCtm.inverse().multiply(nodeCtm); + return { tx: m.e, ty: m.f }; + } catch { + return null; + } + } - // Prevent animations on new nodes - setTimeout(() => { - this.oldNodes = oldNodes; - this.oldClusters = oldClusters; - this.oldCompoundNodes = oldCompoundNodes; - }, 500); - - // Update the labels to the new positions - const newLinks = []; - for (const edgeLabelId in this.graph.edgeLabels) { - const edgeLabel = this.graph.edgeLabels[edgeLabelId]; - - const normKey = edgeLabelId.replace(/[^\w-]*/g, ''); - - const isMultigraph = - this.layout && typeof this.layout !== 'string' && this.layout.settings && this.layout.settings.multigraph; - - let oldLink = isMultigraph - ? this._oldLinks.find(ol => `${ol.source}${ol.target}${ol.id}` === normKey) - : this._oldLinks.find(ol => `${ol.source}${ol.target}` === normKey); - - const linkFromGraph = isMultigraph - ? this.graph.edges.find(nl => `${nl.source}${nl.target}${nl.id}` === normKey) - : this.graph.edges.find(nl => `${nl.source}${nl.target}` === normKey); - - if (!oldLink) { - oldLink = linkFromGraph || edgeLabel; - } else if ( - oldLink.data && - linkFromGraph && - linkFromGraph.data && - JSON.stringify(oldLink.data) !== JSON.stringify(linkFromGraph.data) - ) { - // Compare old link to new link and replace if not equal - oldLink.data = linkFromGraph.data; + private collectPreviousTranslatesFromDom( + chartG: SVGGElement, + morph: LayoutMorphCapture, + hybrid: boolean + ): Map { + const eps = morph.degenerateEpsilon ?? 1e-3; + const m = new Map(); + const forNode = (n: Node) => { + const nodeEl = this.findMainChartNodeGroup(chartG, n.id); + const attr = nodeEl?.getAttribute('transform')?.trim() ?? ''; + let t: { tx: number; ty: number }; + + if (nodeEl && attr.length > 0) { + t = this.resolveTranslateFromTransform(attr); + if (hybrid && this.isDegenerateTranslate(t, eps)) { + t = this.modelTranslateForMorphFallback(n.id, morph); + } + } else if (nodeEl && attr.length === 0 && hybrid) { + const ctmT = this.translateNodeGroupInChartUserSpace(nodeEl, chartG); + t = ctmT && !this.isDegenerateTranslate(ctmT, eps) ? ctmT : this.modelTranslateForMorphFallback(n.id, morph); + } else if (n.transform) { + t = this.resolveTranslateFromTransform(n.transform); + } else { + t = this.translateFromLayoutPositionForNode(n); } + m.set(n.id, t); + }; + this.graph.nodes.forEach(forNode); + this.graph.clusters?.forEach(forNode); + this.graph.compoundNodes?.forEach(forNode); + return m; + } + + /** When {@link LayoutMorphCapture.syncTargetsFromPositionAfterTick} is on, recompute targets from `position`. */ + private syncLayoutAnimationTargetsFromPositions(): void { + const targets = this.layoutAnimationTargets; + if (!targets?.size) { + return; + } + const sync = (items: Node[] | undefined) => { + items?.forEach(n => { + if (targets.has(n.id)) { + targets.set(n.id, this.translateFromLayoutPositionForNode(n)); + } + }); + }; + sync(this.graph.nodes); + sync(this.graph.clusters); + sync(this.graph.compoundNodes); + } - oldLink.oldLine = oldLink.line; + /** When {@link LayoutMorphCapture.snapAddedNodeIds} is on, new ids do not tween from a stale origin. */ + private snapMorphPreviousForAddedNodes(nodeIdsAtLayoutTickStart: Set): void { + const prevs = this.previousLayoutTransforms; + const targets = this.layoutAnimationTargets; + if (!prevs?.size || !targets?.size) { + return; + } + const snap = (items: Node[] | undefined) => { + items?.forEach(n => { + if (!nodeIdsAtLayoutTickStart.has(n.id)) { + const tgt = targets.get(n.id); + if (tgt) { + prevs.set(n.id, { tx: tgt.tx, ty: tgt.ty }); + } + } + }); + }; + snap(this.graph.nodes); + snap(this.graph.clusters); + snap(this.graph.compoundNodes); + } + + public applyAdditiveSmoothTransitionFilters( + targets: Map, + nodeIdsAtLayoutTickStart: Set + ): void { + if ( + this.effectiveLayoutTransition.scope !== 'additive' || + nodeIdsAtLayoutTickStart.size === 0 || + !this.previousLayoutTransforms + ) { + return; + } + const prevs = this.previousLayoutTransforms; + const allIds = new Set(); + for (const coll of [this.graph.nodes, this.graph.clusters, this.graph.compoundNodes]) { + coll?.forEach(n => allIds.add(n.id)); + } + for (const id of allIds) { + if (nodeIdsAtLayoutTickStart.has(id)) { + targets.delete(id); + prevs.delete(id); + } else if (!prevs.has(id)) { + const tgt = targets.get(id); + if (tgt) { + // New nodes: do not tween from parent anchor (or a bad 0,0) — appear at final layout coordinates. Edge routes + // still morph via `runUnifiedLayoutAnimation` / `redrawLines` when maps stay populated. + prevs.set(id, { tx: tgt.tx, ty: tgt.ty }); + } + } + } + } - const points = edgeLabel.points; - const line = this.generateLine(points); + private refreshPriorTickGraphIds(multigraph: boolean): void { + const ids = new Set(); + const collect = (items: Node[] | undefined) => { + items?.forEach(n => ids.add(n.id)); + }; + collect(this.graph.nodes); + collect(this.graph.clusters); + collect(this.graph.compoundNodes); + this.priorTickGraphNodeIds = ids; + + const keys = new Set(); + for (const e of this.graph.edges ?? []) { + keys.add(this.linkKeyForLookup(e, multigraph)); + } + this.priorTickEdgeKeys = keys; + } - const newLink = Object.assign({}, oldLink); - newLink.line = line; - newLink.points = points; + /** + * Builds one edge entry for {@link tick}. `lookupKey` / `legacyKey` must match {@link linkKeyForLookup} / + * `_oldLinks` (graphlib uses string ids; ELK uses array `edgeLabels` and real keys from the edge). + */ + private pushTickEdgeLink( + newLinks: Edge[], + edgeLabel: Edge, + lookupKey: string, + legacyKey: string, + oldLinkMap: Map, + graphEdgeMap: Map, + mg: boolean + ): void { + let oldLink = oldLinkMap.get(lookupKey) ?? oldLinkMap.get(legacyKey); + const linkFromGraph = graphEdgeMap.get(lookupKey) ?? graphEdgeMap.get(legacyKey); + + if (!oldLink) { + // Prefer a prior tick edge (has route data) over the fresh graph stub, which often has no `points`. + oldLink = + this._oldLinks.find(ol => { + const k = this.linkKeyForLookup(ol, mg); + return k === lookupKey || k === legacyKey; + }) ?? + linkFromGraph ?? + edgeLabel; + } else if ( + oldLink.data && + linkFromGraph && + linkFromGraph.data && + JSON.stringify(oldLink.data) !== JSON.stringify(linkFromGraph.data) + ) { + oldLink.data = linkFromGraph.data; + } - this.updateMidpointOnEdge(newLink, points); + oldLink.oldLine = oldLink.line; - const textPos = points[Math.floor(points.length / 2)]; - if (textPos) { - newLink.textTransform = `translate(${textPos.x || 0},${textPos.y || 0})`; + const baseEdge = (oldLink || linkFromGraph || edgeLabel) as Edge; + let points = edgeLabel.points as Array<{ x: number; y: number }>; + if (!points || points.length < 2) { + const fb = this.buildFallbackEdgePoints(baseEdge); + if (fb.length >= 2) { + points = fb; } + } + if (!points || points.length < 2) { + points = [ + { x: 0, y: 0 }, + { x: 0, y: 0 } + ]; + } - newLink.textAngle = 0; - if (!newLink.oldLine) { - newLink.oldLine = newLink.line; + // Build `line` from resampled geometry so static paths match drag and morph (same curve + sample count). + const { line, displayPoints } = this.lineAndDisplayFromRoutePoints(points); + + const newLink = Object.assign({}, oldLink); + newLink.line = line; + newLink.points = points; + const hadPreviousRoute = oldLink?.points && Array.isArray(oldLink.points) && (oldLink.points as any[]).length >= 2; + // Raw prior layout polyline; redrawLines resamples to N for morphing (single resampling site). + let previousPoints: Array<{ x: number; y: number }> | undefined = hadPreviousRoute + ? this.clonePoints(oldLink.points as Array<{ x: number; y: number }>) + : undefined; + if ( + this.effectiveLayoutTransition.scope === 'additive' && + this.layoutMorphActive && + !hadPreviousRoute && + points.length >= 2 && + this.edgeKeysAtLayoutTickStart.size > 0 + ) { + const ek = this.linkKeyForLookup(baseEdge, mg); + if (!this.edgeKeysAtLayoutTickStart.has(ek)) { + const s = points[0]; + previousPoints = [ + { x: s.x, y: s.y }, + { x: s.x, y: s.y } + ]; + } + } + if ( + this.effectiveLayoutTransition.scope === 'additive' && + this.layoutMorphActive && + this.edgeKeysAtLayoutTickStart.size > 0 + ) { + const ekStable = this.linkKeyForLookup(baseEdge, mg); + if (this.edgeKeysAtLayoutTickStart.has(ekStable)) { + previousPoints = undefined; } + } + if ( + this.layoutMorphActive && + this.effectiveLayoutTransition.scope === 'full' && + this.previousLayoutTransforms?.size && + !this.edgeKeysAtLayoutTickStart.has(this.linkKeyForLookup(baseEdge, mg)) + ) { + const syn = this.syntheticPreviousEdgePointsFromPriorTransforms(baseEdge); + if (syn && syn.length >= 2) { + previousPoints = this.clonePoints(syn); + } + } + newLink.previousPoints = previousPoints; - this.calcDominantBaseline(newLink); - newLinks.push(newLink); + this.updateMidpointOnEdge(newLink, points); + + const textPos = points[Math.floor(points.length / 2)]; + if (textPos) { + newLink.textTransform = `translate(${textPos.x || 0},${textPos.y || 0})`; + } + + newLink.textAngle = 0; + if (!newLink.oldLine) { + newLink.oldLine = newLink.line; + } + + this.calcDominantBaseline(newLink, displayPoints); + newLinks.push(newLink); + } + + tick() { + const tickId = ++this.drawCompleteTickId; + this.edgeKeysAtLayoutTickStart = new Set(this.priorTickEdgeKeys); + const nodeIdsAtLayoutTickStart = new Set(this.priorTickGraphNodeIds); + const mg = this.isLayoutMultigraph(); + + const previousNodes = this.oldNodes; + const previousClusters = this.oldClusters; + const previousCompoundNodes = this.oldCompoundNodes; + + const newNodeIds: Set = new Set(); + const newClusterIds: Set = new Set(); + const newCompoundNodeIds: Set = new Set(); + + this.applyTransforms(this.graph.nodes, newNodeIds); + this.applyTransforms(this.graph.clusters || [], newClusterIds); + this.applyTransforms(this.graph.compoundNodes || [], newCompoundNodeIds); + + this.layoutAnimationTargets = null; + if (!this.isDragging && this.layoutMorphActive && this.previousLayoutTransforms?.size && this._oldLinks.length) { + const targets = new Map(); + const captureTargets = (items: Node[] | undefined) => { + if (!items) { + return; + } + for (const n of items) { + targets.set(n.id, this.resolveTranslateFromTransform(n.transform)); + } + }; + captureTargets(this.graph.nodes); + captureTargets(this.graph.clusters); + captureTargets(this.graph.compoundNodes); + this.layoutAnimationTargets = targets; + + this.applyAdditiveSmoothTransitionFilters(targets, nodeIdsAtLayoutTickStart); + + const resetToPrevious = (items: Node[] | undefined) => { + if (!items) { + return; + } + const prevs = this.previousLayoutTransforms!; + for (const n of items) { + if (prevs.has(n.id)) { + const prev = prevs.get(n.id)!; + n.transform = `translate(${prev.tx},${prev.ty})`; + } + } + }; + resetToPrevious(this.graph.nodes); + resetToPrevious(this.graph.clusters); + resetToPrevious(this.graph.compoundNodes); + } + + this.oldNodes = previousNodes; + this.oldClusters = previousClusters; + this.oldCompoundNodes = previousCompoundNodes; + + const oldLinkMap = new Map(); + for (const ol of this._oldLinks) { + const key = this.linkKeyForLookup(ol, mg); + oldLinkMap.set(key, ol); + } + + const graphEdgeMap = new Map(); + for (const nl of this.graph.edges) { + const key = this.linkKeyForLookup(nl, mg); + graphEdgeMap.set(key, nl); + } + + const newLinks: Edge[] = []; + if (Array.isArray(this.graph.edgeLabels)) { + for (const edgeLabel of this.graph.edgeLabels) { + const lookupKey = this.linkKeyForLookup(edgeLabel, mg); + const legacyKey = String(edgeLabel.id ?? '').replace(/[^\w-]*/g, ''); + this.pushTickEdgeLink(newLinks, edgeLabel, lookupKey, legacyKey, oldLinkMap, graphEdgeMap, mg); + } + } else if (this.graph.edgeLabels != null) { + for (const edgeLabelId in this.graph.edgeLabels) { + const edgeLabel = this.graph.edgeLabels[edgeLabelId]; + const lookupKey = this.graphlibEdgeLabelIdToLookupKey(edgeLabelId, mg); + const legacyKey = edgeLabelId.replace(/[^\w-]*/g, ''); + this.pushTickEdgeLink(newLinks, edgeLabel, lookupKey, legacyKey, oldLinkMap, graphEdgeMap, mg); + } + } else { + for (const edgeLabel of this.graph.edges ?? []) { + const lookupKey = this.linkKeyForLookup(edgeLabel, mg); + const legacyKey = String(edgeLabel.id ?? '').replace(/[^\w-]*/g, ''); + this.pushTickEdgeLink(newLinks, edgeLabel, lookupKey, legacyKey, oldLinkMap, graphEdgeMap, mg); + } } this.graph.edges = newLinks; - // Map the old links for animations + this.refreshPriorTickGraphIds(mg); + + const morph = this.effectiveLayoutTransition.morphCapture; + if (this.layoutMorphActive && this.layoutAnimationTargets) { + if (morph.syncTargetsFromPositionAfterTick && this.effectiveLayoutTransition.scope === 'full') { + this.syncLayoutAnimationTargetsFromPositions(); + } + if (morph.snapAddedNodeIds && this.previousLayoutTransforms) { + this.snapMorphPreviousForAddedNodes(nodeIdsAtLayoutTickStart); + } + } + if (this.graph.edges) { this._oldLinks = this.graph.edges.map(l => { const newL = Object.assign({}, l); @@ -548,25 +1306,163 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn }); } - this.applyNodeDimensions(); - this.redrawLines(); - this.updateMinimap(); - requestAnimationFrame(() => { - this.applyNodeDimensions(); - this.redrawLines(); - this.updateMinimap(); - - if (this.autoZoom) { - this.zoomToFit({ autoCenter: this.autoCenter ? this.autoCenter : false }); - } else if (this.autoCenter) { - // Auto-center when rendering - this.center(); + this.oldNodes = newNodeIds; + this.oldClusters = newClusterIds; + this.oldCompoundNodes = newCompoundNodeIds; + + // Full-scope morph keeps `node.transform` at previous layout until unified rAF; do not sync from `position` or + // refresh bounds/pan from the new layout here — that would wipe `resetToPrevious` and desync the viewport. + const nodeTweenActive = + !!this.layoutAnimationTargets && this.layoutMorphActive && this.effectiveLayoutTransition.scope !== 'additive'; + if (!nodeTweenActive) { + this.applyNodeDimensions(); + } + // `applyTransforms` used ELK dimensions; `applyNodeDimensions` may change width/height from the DOM. + // Recompute `translate` so the layout center (`position`) stays fixed while the box grows — otherwise edges + // (anchored to ELK geometry) stop meeting the node glyph. + if (!nodeTweenActive) { + this.syncNodeTransformsFromLayoutPositions(); + } + if (this.animate || this.layoutJsMorphEnabled) { + this.cd.detectChanges(); + } + this.scheduleRedrawLinesAfterView(this.animate || this.layoutJsMorphEnabled, tickId); + if (this.hasGraphNodeLikeContent() && !nodeTweenActive) { + this.updateGraphDims(); + } + if (!nodeTweenActive) { + this.updateMinimap(); + } + + if (!nodeTweenActive) { + if (this.autoZoom) { + this.zoomToFit({ autoCenter: this.autoCenter ? this.autoCenter : false }); + } else if (this.autoCenter && !this.autoZoom) { + this.center(); + } } - this.stateChange.emit({ state: NgxGraphStates.Output }); }); - this.cd.markForCheck(); + this.cd.markForCheck(); + } + + private applyTransforms(items: Node[], idCollector: Set): void { + for (const n of items) { + this.updateNodeGroupTransform(n); + if (!n.data) { + n.data = {}; + } + n.data.color = this.colors.getColor(this.groupResultsBy(n)); + // Pre-layout hooks may set `hidden` (e.g. `applyVisualContinuityBeforeLayout` for incremental smooth transitions, + // or `initializeNode` when `deferDisplayUntilPosition`). `tick` runs after layout positions exist — show nodes. + n.hidden = false; + idCollector.add(n.id); + } + } + + /** `translate` for `` from `position` (center when `centerNodesOnPositionChange`). */ + private updateNodeGroupTransform(n: Node): void { + const center = this.centerNodesOnPositionChange; + const dx = center ? (n.dimension?.width ?? 0) / 2 : 0; + const dy = center ? (n.dimension?.height ?? 0) / 2 : 0; + const px = n.position?.x ?? 0; + const py = n.position?.y ?? 0; + n.transform = `translate(${px - dx || 0}, ${py - dy || 0})`; + } + + /** Call after `applyNodeDimensions` when node box size changes but `position` (center) must stay fixed. */ + private syncNodeTransformsFromLayoutPositions(): void { + const sync = (items: Node[] | undefined) => { + items?.forEach(n => this.updateNodeGroupTransform(n)); + }; + sync(this.graph.nodes); + sync(this.graph.clusters); + sync(this.graph.compoundNodes); + } + + /** Optional CSS transform on the chart host during layout morph (`layoutTransitionEffect`). */ + private applyLayoutOuterEffect(tscalar: number): void { + const eff = this.effectiveLayoutEffect; + const lt = this.effectiveLayoutTransition; + if (eff.kind === 'none' || lt.mode !== 'tween') { + this.layoutOuterTransform = null; + return; + } + const peak = eff.peakDegrees ?? 12; + const w = Math.sin(Math.PI * tscalar) * peak; + if (eff.kind === 'perspectiveFlip') { + this.layoutOuterTransform = `perspective(900px) rotateX(${w}deg)`; + return; + } + if (eff.kind === 'rotate') { + const pivot = eff.rotatePivot; + if (pivot === 'graphCenter' && this.graphDims?.width) { + this.layoutEffectTransformOrigin = `${(this.graphDims.width / 2 / Math.max(this.width || 1, 1)) * 100}% ${(this.graphDims.height / 2 / Math.max(this.height || 1, 1)) * 100}%`; + } else if (typeof pivot === 'object' && pivot?.nodeId) { + const n = this.graph?.nodes?.find(nd => nd.id === pivot.nodeId); + if (n?.position && this.width && this.height) { + this.layoutEffectTransformOrigin = `${(n.position.x / this.width) * 100}% ${(n.position.y / this.height) * 100}%`; + } else { + this.layoutEffectTransformOrigin = '50% 50%'; + } + } else { + this.layoutEffectTransformOrigin = '50% 50%'; + } + this.layoutOuterTransform = `rotate(${w}deg)`; + return; + } + this.layoutOuterTransform = null; + } + + private cancelViewportPanAnimation(): void { + if (this.viewportPanAnimRafId != null) { + cancelAnimationFrame(this.viewportPanAnimRafId); + this.viewportPanAnimRafId = null; + } + } + + /** Applies one programmatic pan step with optional viewport easing (translation only). */ + private animateViewportPanDelta(dx: number, dy: number): void { + const cfg = this.effectiveViewportTransition; + if (!cfg.enabled || cfg.durationMs <= 0) { + this.transformationMatrix = transform(this.transformationMatrix, translate(dx, dy)); + this.updateTransform(); + return; + } + this.cancelViewportPanAnimation(); + const easeFn = resolveGraphTransitionEasing(cfg.easing); + const startE = this.transformationMatrix.e; + const startF = this.transformationMatrix.f; + const endE = startE + dx; + const endF = startF + dy; + const duration = cfg.durationMs; + const startMs = performance.now(); + const step = () => { + const u = Math.min(1, (performance.now() - startMs) / duration); + const t = easeFn(u); + this.transformationMatrix.e = startE + t * (endE - startE); + this.transformationMatrix.f = startF + t * (endF - startF); + this.updateTransform(); + if (u >= 1) { + this.viewportPanAnimRafId = null; + return; + } + this.viewportPanAnimRafId = requestAnimationFrame(step); + }; + this.viewportPanAnimRafId = requestAnimationFrame(step); + } + + /** + * Default node template: circle inscribed in the layout box so ELK/Dagre edge ports (box edges) meet the glyph. + */ + defaultNodeCircleRadius(node: Node): number { + const w = node.dimension?.width ?? 0; + const h = node.dimension?.height ?? 0; + if (w > 0 && h > 0) { + return Math.min(w, h) / 2; + } + return 10; } getMinimapTransform(): string { @@ -577,6 +1473,18 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn case MiniMapPosition.UpperRight: { return 'translate(' + (this.dims.width - this.graphDims.width / this.minimapScaleCoefficient) + ',' + 0 + ')'; } + case MiniMapPosition.LowerLeft: { + return 'translate(' + 0 + ',' + (this.dims.height - this.graphDims.height / this.minimapScaleCoefficient) + ')'; + } + case MiniMapPosition.LowerRight: { + return ( + 'translate(' + + (this.dims.width - this.graphDims.width / this.minimapScaleCoefficient) + + ',' + + (this.dims.height - this.graphDims.height / this.minimapScaleCoefficient) + + ')' + ); + } default: { return ''; } @@ -589,13 +1497,50 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn let minY = +Infinity; let maxY = -Infinity; - for (let i = 0; i < this.graph.nodes.length; i++) { - const node = this.graph.nodes[i]; - minX = node.position.x < minX ? node.position.x : minX; - minY = node.position.y < minY ? node.position.y : minY; - maxX = node.position.x + node.dimension.width > maxX ? node.position.x + node.dimension.width : maxX; - maxY = node.position.y + node.dimension.height > maxY ? node.position.y + node.dimension.height : maxY; + const center = this.centerNodesOnPositionChange; + const accumulate = (items: Node[] | undefined) => { + if (!items?.length) { + return; + } + for (let i = 0; i < items.length; i++) { + const node = items[i]; + const w = node.dimension?.width ?? 0; + const h = node.dimension?.height ?? 0; + const px = node.position?.x ?? 0; + const py = node.position?.y ?? 0; + let left: number; + let right: number; + let top: number; + let bottom: number; + if (center) { + left = px - w / 2; + right = px + w / 2; + top = py - h / 2; + bottom = py + h / 2; + } else { + left = px; + right = px + w; + top = py; + bottom = py + h; + } + minX = left < minX ? left : minX; + maxX = right > maxX ? right : maxX; + minY = top < minY ? top : minY; + maxY = bottom > maxY ? bottom : maxY; + } + }; + accumulate(this.graph.nodes); + accumulate(this.graph.compoundNodes); + accumulate(this.graph.clusters); + + if (!isFinite(minX) || !isFinite(maxX) || !isFinite(minY) || !isFinite(maxY)) { + this.graphDims.width = 0; + this.graphDims.height = 0; + this.minimapOffsetX = 0; + this.minimapOffsetY = 0; + return; } + minX -= 100; minY -= 100; maxX += 100; @@ -606,10 +1551,20 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn this.minimapOffsetY = minY; } + /** True when the graph has at least one node, compound node, or cluster (for bounds / minimap). */ + private hasGraphNodeLikeContent(): boolean { + return ( + (this.graph?.nodes?.length ?? 0) + + (this.graph?.compoundNodes?.length ?? 0) + + (this.graph?.clusters?.length ?? 0) > + 0 + ); + } + @throttleable(500) updateMinimap() { - // Calculate the height/width total, but only if we have any nodes - if (this.graph.nodes && this.graph.nodes.length) { + // Calculate the height/width total when there is drawable graph content + if (this.hasGraphNodeLikeContent()) { this.updateGraphDims(); if (this.miniMapMaxWidth) { @@ -626,87 +1581,410 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn } } + /** + * Resolves a layout node, compound node, or cluster by the `` element `id`. + */ + private findLayoutNodeByElementId(elementId: string): Node | undefined { + return ( + this.graph.nodes.find(n => n.id === elementId) ?? + this.graph.compoundNodes?.find(n => n.id === elementId) ?? + this.graph.clusters?.find(c => c.id === elementId) + ); + } + + /** + * Measures one node-group SVG element and writes `dimension` on the model node. + */ + private applyNodeDimensionFromSvgGroup(nativeElement: SVGGraphicsElement, node: Node): void { + // calculate the height + let dims: DOMRect; + try { + dims = nativeElement.getBBox(); + if (!dims.width || !dims.height) { + return; + } + } catch { + // Skip drawing if element is not displayed - Firefox would throw an error here + return; + } + if (this.nodeHeight) { + node.dimension.height = + node.dimension.height && node.meta.forceDimensions ? node.dimension.height : this.nodeHeight; + } else { + node.dimension.height = node.dimension.height && node.meta.forceDimensions ? node.dimension.height : dims.height; + } + + if (this.nodeMaxHeight) { + node.dimension.height = Math.max(node.dimension.height, this.nodeMaxHeight); + } + if (this.nodeMinHeight) { + node.dimension.height = Math.min(node.dimension.height, this.nodeMinHeight); + } + + if (this.nodeWidth) { + node.dimension.width = node.dimension.width && node.meta.forceDimensions ? node.dimension.width : this.nodeWidth; + } else { + // calculate the width + if (nativeElement.getElementsByTagName('text').length) { + let maxTextDims: { width: number; height: number } | undefined; + try { + for (const textElem of nativeElement.getElementsByTagName('text')) { + const currentBBox = textElem.getBBox(); + if (!maxTextDims) { + maxTextDims = currentBBox; + } else { + if (currentBBox.width > maxTextDims.width) { + maxTextDims.width = currentBBox.width; + } + if (currentBBox.height > maxTextDims.height) { + maxTextDims.height = currentBBox.height; + } + } + } + } catch { + // Skip drawing if element is not displayed - Firefox would throw an error here + return; + } + if (!maxTextDims) { + return; + } + node.dimension.width = + node.dimension.width && node.meta.forceDimensions ? node.dimension.width : maxTextDims.width + 20; + } else { + node.dimension.width = node.dimension.width && node.meta.forceDimensions ? node.dimension.width : dims.width; + } + } + + if (this.nodeMaxWidth) { + node.dimension.width = Math.max(node.dimension.width, this.nodeMaxWidth); + } + if (this.nodeMinWidth) { + node.dimension.width = Math.min(node.dimension.width, this.nodeMinWidth); + } + } + /** * Measures the node element and applies the dimensions * * @memberOf GraphComponent */ applyNodeDimensions(): void { - if (this.nodeElements && this.nodeElements.length) { - this.nodeElements.forEach(elem => { - const nativeElement = elem.nativeElement; - const node = this.graph.nodes.find(n => n.id === nativeElement.id); + const measureRefs = (refs: QueryList | undefined) => { + refs?.forEach(elem => { + const nativeElement = elem.nativeElement as SVGGraphicsElement; + const node = this.findLayoutNodeByElementId(nativeElement.id); if (!node) { return; } + this.applyNodeDimensionFromSvgGroup(nativeElement, node); + }); + }; + measureRefs(this.nodeElements); + measureRefs(this.clusterElements); + } - // calculate the height - let dims; - try { - dims = nativeElement.getBBox(); - if (!dims.width || !dims.height) { - return; - } - } catch (ex) { - // Skip drawing if element is not displayed - Firefox would throw an error here - return; - } - if (this.nodeHeight) { - node.dimension.height = - node.dimension.height && node.meta.forceDimensions ? node.dimension.height : this.nodeHeight; - } else { - node.dimension.height = - node.dimension.height && node.meta.forceDimensions ? node.dimension.height : dims.height; + /** + * Runs after Angular commits the template so D3 binds to the live link `` elements + * (OnPush + rAF alone can run too early). Retries once after `requestAnimationFrame` if link + * groups are not ready yet — avoids two immediate `afterNextRender` passes that cancel unified RAF. + */ + private scheduleRedrawLinesAfterView(morph: boolean, tickId: number): void { + afterNextRender( + () => { + this.tryRedrawLinesAfterView(morph, 0, tickId); + }, + { injector: this.injector } + ); + } + + /** + * d3 `select('#…')` for a host subtree element by HTML `id`. Raw ids may contain `--`, leading digits, etc., which are + * invalid in unescaped CSS id selectors. + */ + private selectTextPathInHostById(id: string): any { + return select(this.el.nativeElement).select(`#${CSS.escape(id)}`); + } + + /** + * Imperative paint from `edge.line` / `edge.textPath` only — does not cancel unified layout morph or per-edge rAF. + */ + private repaintLinkPathsDomFromModel(): void { + this.linkElements?.forEach(linkEl => { + const edge = this.graph.edges.find(lin => lin.id === linkEl.nativeElement.id); + if (!edge) { + return; + } + let pathSelection: any = select(linkEl.nativeElement).select('path.edge, path.line'); + if (pathSelection.empty()) { + pathSelection = select(linkEl.nativeElement).select('path'); + } + const textPathEl = edge.id ? this.selectTextPathInHostById(edge.id) : null; + if (!pathSelection.empty()) { + pathSelection.attr('d', edge.line); + } + if (textPathEl && !textPathEl.empty()) { + textPathEl.attr('d', edge.textPath); + } + this.updateMidpointOnEdge(edge, edge.points); + }); + } + + /** + * Binds D3 to link `` elements, then emits {@link stateChange} (Output) and {@link drawComplete} + * when link groups match edge count (or after bounded retries). + * + * Observable layouts (Cola, D3 force) can emit faster than `afterNextRender`; callbacks may run with a + * superseded `tickId`. Those passes must not call {@link redrawLines} with morph enabled — it would + * {@link cancelLayoutUnifiedAnimation} and interrupt full-graph layout morphs. Instead, repaint paths + * from the current model when no rAF tween owns the paths; the latest `tickId` still runs full {@link redrawLines}. + * {@link finalizeTickOutput} alone enforces `drawCompleteTickId`. + */ + private tryRedrawLinesAfterView(morph: boolean, retryDepth: number, tickId: number): void { + if (this._graphDestroyed) { + return; + } + const currentPass = tickId === this.drawCompleteTickId; + if (currentPass) { + this.redrawLines(morph); + } else if (this.layoutUnifiedRafId == null && this.edgePathRafIds.size === 0) { + this.repaintLinkPathsDomFromModel(); + } + const expected = this.graph.edges?.length ?? 0; + const got = this.linkElements?.length ?? 0; + const linksReady = expected === 0 || got === expected; + if (linksReady) { + this.finalizeTickOutput(tickId); + return; + } + if (retryDepth < 2) { + requestAnimationFrame(() => { + afterNextRender( + () => { + this.tryRedrawLinesAfterView(morph, retryDepth + 1, tickId); + }, + { injector: this.injector } + ); + }); + return; + } + if (isDevMode()) { + console.warn( + '[ngx-graph] Link group count did not match edge count after retries; emitting drawComplete anyway.', + { expected, got } + ); + } + this.finalizeTickOutput(tickId); + } + + private finalizeTickOutput(tickId: number): void { + if (this._graphDestroyed || tickId !== this.drawCompleteTickId) { + return; + } + this.stateChange.emit({ state: NgxGraphStates.Output }); + this.drawComplete.emit(); + } + + private cancelEdgePathAnimation(edgeId: string): void { + const rafId = this.edgePathRafIds.get(edgeId); + if (rafId != null) { + cancelAnimationFrame(rafId); + this.edgePathRafIds.delete(edgeId); + } + } + + private cancelAllEdgePathAnimations(): void { + for (const rafId of this.edgePathRafIds.values()) { + cancelAnimationFrame(rafId); + } + this.edgePathRafIds.clear(); + } + + private cancelLayoutUnifiedAnimation(): void { + if (this.layoutUnifiedRafId != null) { + cancelAnimationFrame(this.layoutUnifiedRafId); + this.layoutUnifiedRafId = null; + } + } + + /** + * One rAF driver: same eased t for node transforms (lerp) and edge path d (interpolatePinnedEdgeRoute). + * In `additive` mode, only edges morph; node transforms stay at layout output from `tick()` (no positional tween). + */ + private runUnifiedLayoutAnimation( + edgeMorphs: Array<{ + pathSelection: any; + textPathEl: any; + prevRaw: Array<{ x: number; y: number }>; + nextRaw: Array<{ x: number; y: number }>; + resampledPrev: Array<{ x: number; y: number }>; + resampledNext: Array<{ x: number; y: number }>; + }>, + durationMs: number + ): void { + const targets = this.layoutAnimationTargets!; + const prevs = this.previousLayoutTransforms!; + const easeFn = resolveGraphTransitionEasing(this.effectiveLayoutTransition.easing); + const skipNodePositionTween = this.effectiveLayoutTransition.scope === 'additive'; + + this.zone.runOutsideAngular(() => { + for (const m of edgeMorphs) { + const pts0 = this.interpolatePinnedEdgeRoute(m.prevRaw, m.nextRaw, m.resampledPrev, m.resampledNext, 0); + m.pathSelection.attr('d', this.generateLine(pts0)); + if (m.textPathEl && !m.textPathEl.empty()) { + const { textPath } = this.lineAndTextPathFromPoints(pts0); + m.textPathEl.attr('d', textPath); } + } + this.applyLayoutOuterEffect(0); + + const startMs = performance.now(); - if (this.nodeMaxHeight) { - node.dimension.height = Math.max(node.dimension.height, this.nodeMaxHeight); + const applyNodeTransforms = (tscalar: number) => { + if (skipNodePositionTween) { + return; } - if (this.nodeMinHeight) { - node.dimension.height = Math.min(node.dimension.height, this.nodeMinHeight); + const apply = (items: Node[] | undefined) => { + if (!items) { + return; + } + for (const n of items) { + const tgt = targets.get(n.id); + const prv = prevs.get(n.id); + if (tgt && prv) { + const tx = prv.tx + tscalar * (tgt.tx - prv.tx); + const ty = prv.ty + tscalar * (tgt.ty - prv.ty); + n.transform = `translate(${tx},${ty})`; + } + } + }; + apply(this.graph.nodes); + apply(this.graph.clusters); + apply(this.graph.compoundNodes); + }; + + const step = () => { + const elapsed = performance.now() - startMs; + const u = durationMs <= 0 ? 1 : Math.min(1, elapsed / durationMs); + const tscalar = easeFn(u); + + for (const m of edgeMorphs) { + const pts = this.interpolatePinnedEdgeRoute(m.prevRaw, m.nextRaw, m.resampledPrev, m.resampledNext, tscalar); + m.pathSelection.attr('d', this.generateLine(pts)); + if (m.textPathEl && !m.textPathEl.empty()) { + const { textPath } = this.lineAndTextPathFromPoints(pts); + m.textPathEl.attr('d', textPath); + } } - if (this.nodeWidth) { - node.dimension.width = - node.dimension.width && node.meta.forceDimensions ? node.dimension.width : this.nodeWidth; - } else { - // calculate the width - if (nativeElement.getElementsByTagName('text').length) { - let maxTextDims: { width: number; height: number }; - try { - for (const textElem of nativeElement.getElementsByTagName('text')) { - const currentBBox = textElem.getBBox(); - if (!maxTextDims) { - maxTextDims = currentBBox; - } else { - if (currentBBox.width > maxTextDims.width) { - maxTextDims.width = currentBBox.width; - } - if (currentBBox.height > maxTextDims.height) { - maxTextDims.height = currentBBox.height; + // Apply node transforms in the same synchronous turn as D3 path updates so one paint + // shows edges and nodes at the same eased t (avoids nodes leading or lagging edge splines). + applyNodeTransforms(tscalar); + this.applyLayoutOuterEffect(tscalar); + this.zone.run(() => { + this.cd.markForCheck(); + }); + + if (u >= 1) { + this.zone.run(() => { + if (!skipNodePositionTween) { + const finalize = (items: Node[] | undefined) => { + if (!items) { + return; + } + for (const n of items) { + const tgt = targets.get(n.id); + if (tgt) { + n.transform = `translate(${tgt.tx},${tgt.ty})`; } } + }; + finalize(this.graph.nodes); + finalize(this.graph.clusters); + finalize(this.graph.compoundNodes); + } + for (const e of this.graph.edges) { + e.previousPoints = undefined; + } + this.layoutAnimationTargets = null; + this.previousLayoutTransforms = null; + this.layoutOuterTransform = null; + this.repaintLinkPathsDomFromModel(); + this.cd.markForCheck(); + if (!skipNodePositionTween) { + if (this.hasGraphNodeLikeContent()) { + this.updateGraphDims(); + } + this.updateMinimap(); + if (this.autoCenter && !this.autoZoom) { + this.center(); } - } catch (ex) { - // Skip drawing if element is not displayed - Firefox would throw an error here - return; } - node.dimension.width = - node.dimension.width && node.meta.forceDimensions ? node.dimension.width : maxTextDims.width + 20; - } else { - node.dimension.width = - node.dimension.width && node.meta.forceDimensions ? node.dimension.width : dims.width; - } + }); + this.layoutUnifiedRafId = null; + return; } - if (this.nodeMaxWidth) { - node.dimension.width = Math.max(node.dimension.width, this.nodeMaxWidth); + this.layoutUnifiedRafId = requestAnimationFrame(step); + }; + + this.layoutUnifiedRafId = requestAnimationFrame(step); + }); + } + + /** + * Imperative path morph: D3 transitions do not reliably repaint `d` under Zone; rAF does. + */ + private runEdgePathMorphAnimation( + edgeKey: string, + pathSelection: any, + textPathEl: any, + edge: Edge, + prevRaw: Array<{ x: number; y: number }>, + nextRaw: Array<{ x: number; y: number }>, + resampledPrev: Array<{ x: number; y: number }>, + resampledNext: Array<{ x: number; y: number }>, + durationMs: number, + startLine: string, + startText: string + ): void { + this.cancelEdgePathAnimation(edgeKey); + + const easeFn = resolveGraphTransitionEasing(this.effectiveLayoutTransition.easing); + + this.zone.runOutsideAngular(() => { + pathSelection.attr('d', startLine); + if (textPathEl && !textPathEl.empty()) { + textPathEl.attr('d', startText); + } + + const startMs = performance.now(); + + const step = () => { + const elapsed = performance.now() - startMs; + const u = durationMs <= 0 ? 1 : Math.min(1, elapsed / durationMs); + const t = easeFn(u); + const pts = this.interpolatePinnedEdgeRoute(prevRaw, nextRaw, resampledPrev, resampledNext, t); + pathSelection.attr('d', this.generateLine(pts)); + if (textPathEl && !textPathEl.empty()) { + const { textPath } = this.lineAndTextPathFromPoints(pts); + textPathEl.attr('d', textPath); } - if (this.nodeMinWidth) { - node.dimension.width = Math.min(node.dimension.width, this.nodeMinWidth); + + if (u >= 1) { + this.edgePathRafIds.delete(edgeKey); + this.zone.run(() => { + edge.previousPoints = undefined; + }); + return; } - }); - } + + const rafId = requestAnimationFrame(step); + this.edgePathRafIds.set(edgeKey, rafId); + }; + + const rafId = requestAnimationFrame(step); + this.edgePathRafIds.set(edgeKey, rafId); + }); } /** @@ -715,37 +1993,357 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn * @memberOf GraphComponent */ redrawLines(_animate = this.animate): void { + const lt = this.effectiveLayoutTransition; + const duration = !_animate || !this.layoutMorphActive ? 0 : Math.max(0, lt.durationMs); + + if (!_animate) { + this.cancelLayoutUnifiedAnimation(); + this.cancelAllEdgePathAnimations(); + this.layoutAnimationTargets = null; + this.previousLayoutTransforms = null; + } + + const unifiedLayout = + _animate && + this.layoutMorphActive && + !!this.layoutAnimationTargets?.size && + !!this.previousLayoutTransforms?.size; + + if (unifiedLayout) { + this.cancelLayoutUnifiedAnimation(); + this.cancelAllEdgePathAnimations(); + + const mgUnified = this.isLayoutMultigraph(); + + const edgeMorphs: Array<{ + pathSelection: any; + textPathEl: any; + prevRaw: Array<{ x: number; y: number }>; + nextRaw: Array<{ x: number; y: number }>; + resampledPrev: Array<{ x: number; y: number }>; + resampledNext: Array<{ x: number; y: number }>; + }> = []; + + this.linkElements?.forEach(linkEl => { + const edge = this.graph.edges.find(lin => lin.id === linkEl.nativeElement.id); + + if (!edge) { + return; + } + + let pathSelection: any = select(linkEl.nativeElement).select('path.edge, path.line'); + if (pathSelection.empty()) { + pathSelection = select(linkEl.nativeElement).select('path'); + } + + const textPathEl = edge.id ? this.selectTextPathInHostById(edge.id) : null; + + const prevRaw = edge.previousPoints; + const nextRaw = edge.points; + const n = this.effectiveEdgePathSampleCount(); + const hasPrev = !!(prevRaw && prevRaw.length >= 2); + const prevN = hasPrev ? this.resamplePolyline(prevRaw, n) : []; + const nextN = nextRaw && nextRaw.length >= 2 ? this.resamplePolyline(nextRaw, n) : []; + + let skipStableEdgeMorph = false; + if ( + this.effectiveLayoutTransition.scope === 'additive' && + this.edgeKeysAtLayoutTickStart.size > 0 && + _animate + ) { + const ek = this.linkKeyForLookup(edge, mgUnified); + skipStableEdgeMorph = this.edgeKeysAtLayoutTickStart.has(ek); + } + + const canMorph = + !skipStableEdgeMorph && + hasPrev && + nextRaw && + nextRaw.length >= 2 && + prevN.length >= 2 && + nextN.length >= 2 && + prevN.length === nextN.length; + + if (!pathSelection.empty()) { + if (canMorph) { + edgeMorphs.push({ + pathSelection, + textPathEl, + prevRaw: this.clonePoints(prevRaw as Array<{ x: number; y: number }>), + nextRaw, + resampledPrev: prevN, + resampledNext: nextN + }); + } else { + // Keep prior route on the path while node `` transforms still reflect `resetToPrevious`; snapping to + // `edge.line` here desyncs edges from nodes for the whole tween (Elk orientation full-scope blip). + pathSelection.attr('d', edge.oldLine ?? edge.line); + } + } + + if (textPathEl && !textPathEl.empty() && !canMorph) { + textPathEl.attr('d', edge.oldTextPath ?? edge.textPath); + } + + this.updateMidpointOnEdge(edge, edge.points); + }); + + this.runUnifiedLayoutAnimation(edgeMorphs, duration); + return; + } + this.linkElements?.forEach(linkEl => { const edge = this.graph.edges.find(lin => lin.id === linkEl.nativeElement.id); - if (edge) { - const linkSelection: any = select(linkEl.nativeElement).select('.line'); - linkSelection - .attr('d', edge.oldLine) - .transition() - .ease(ease.easeSinInOut) - .duration(_animate ? 500 : 0) - .attr('d', edge.line); - - const textPathSelection: any = select(this.el.nativeElement).select(`#${edge.id}`); - textPathSelection - .attr('d', edge.oldTextPath) - .transition() - .ease(ease.easeSinInOut) - .duration(_animate ? 500 : 0) - .attr('d', edge.textPath); + if (!edge) { + return; + } + + const edgeKey = String(edge.id ?? linkEl.nativeElement.id); - this.updateMidpointOnEdge(edge, edge.points); + let pathSelection: any = select(linkEl.nativeElement).select('path.edge, path.line'); + if (pathSelection.empty()) { + pathSelection = select(linkEl.nativeElement).select('path'); + } + + const textPathEl = edge.id ? this.selectTextPathInHostById(edge.id) : null; + + const prevRaw = edge.previousPoints; + const nextRaw = edge.points; + const n = this.effectiveEdgePathSampleCount(); + const hasPrev = !!(prevRaw && prevRaw.length >= 2); + const prevN = hasPrev ? this.resamplePolyline(prevRaw, n) : []; + const nextN = nextRaw && nextRaw.length >= 2 ? this.resamplePolyline(nextRaw, n) : []; + + const canMorph = + _animate && + hasPrev && + nextRaw && + nextRaw.length >= 2 && + prevN.length >= 2 && + nextN.length >= 2 && + prevN.length === nextN.length; + + const resampledPrev = canMorph ? prevN : []; + const resampledNext = canMorph ? nextN : []; + + if (!pathSelection.empty()) { + if (canMorph) { + const startLine = this.generateLine( + this.interpolatePinnedEdgeRoute(prevRaw!, nextRaw, resampledPrev, resampledNext, 0) + ); + const startText = edge.oldTextPath ?? edge.textPath ?? ''; + this.runEdgePathMorphAnimation( + edgeKey, + pathSelection, + textPathEl, + edge, + prevRaw!, + nextRaw, + resampledPrev, + resampledNext, + duration, + startLine, + startText + ); + } else { + pathSelection.attr('d', edge.line); + } } + + if (textPathEl && !textPathEl.empty() && !canMorph) { + textPathEl.attr('d', edge.textPath); + } + + this.updateMidpointOnEdge(edge, edge.points); }); } + private clonePoints(points: Array<{ x: number; y: number }>): Array<{ x: number; y: number }> { + return points.map(p => ({ x: p.x, y: p.y })); + } + + /** Resample a polyline to `count` points along cumulative arc length. */ + private resamplePolyline(points: Array<{ x: number; y: number }>, count: number): Array<{ x: number; y: number }> { + if (!points?.length || count < 2) { + return points?.length ? this.clonePoints(points) : []; + } + if (points.length === 1) { + return Array.from({ length: count }, () => ({ ...points[0] })); + } + + const segLens: number[] = []; + let total = 0; + for (let i = 1; i < points.length; i++) { + const d = Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y); + segLens.push(d); + total += d; + } + if (total === 0) { + return Array.from({ length: count }, () => ({ ...points[0] })); + } + + const result: Array<{ x: number; y: number }> = []; + for (let k = 0; k < count; k++) { + const targetDist = (k / (count - 1)) * total; + let acc = 0; + for (let s = 0; s < segLens.length; s++) { + const sl = segLens[s]; + if (acc + sl >= targetDist || s === segLens.length - 1) { + const t = sl === 0 ? 0 : Math.min(1, Math.max(0, (targetDist - acc) / sl)); + const a = points[s]; + const b = points[s + 1]; + result.push({ + x: a.x + t * (b.x - a.x), + y: a.y + t * (b.y - a.y) + }); + break; + } + acc += sl; + } + } + return result; + } + + /** + * Interpolate between two layout snapshots. The first and last points always follow + * the raw layout endpoints (ports on source/target nodes); interior points blend the + * arc-length–resampled routes so straight, orthogonal, and curved polylines all morph smoothly. + */ + private interpolatePinnedEdgeRoute( + prev: Array<{ x: number; y: number }>, + next: Array<{ x: number; y: number }>, + resampledPrev: Array<{ x: number; y: number }>, + resampledNext: Array<{ x: number; y: number }>, + s: number + ): Array<{ x: number; y: number }> { + const n = resampledPrev.length; + if (n < 2 || resampledNext.length !== n) { + return prev?.length ? this.clonePoints(prev) : []; + } + const start = { + x: prev[0].x + s * (next[0].x - prev[0].x), + y: prev[0].y + s * (next[0].y - prev[0].y) + }; + const pe = prev[prev.length - 1]; + const ne = next[next.length - 1]; + const end = { + x: pe.x + s * (ne.x - pe.x), + y: pe.y + s * (ne.y - pe.y) + }; + const out: Array<{ x: number; y: number }> = []; + for (let i = 0; i < n; i++) { + const a = resampledPrev[i]; + const b = resampledNext[i]; + out.push({ + x: a.x + s * (b.x - a.x), + y: a.y + s * (b.y - a.y) + }); + } + out[0] = start; + out[n - 1] = end; + return out; + } + + private lineAndTextPathFromPoints(points: Array<{ x: number; y: number }>): { + line: string; + textPath: string; + } { + if (!points?.length) { + return { line: '', textPath: '' }; + } + const line = this.generateLine(points); + const firstPoint = points[0]; + const lastPoint = points[points.length - 1]; + if (lastPoint.x < firstPoint.x) { + return { line, textPath: this.generateLine([...points].reverse()) }; + } + return { line, textPath: line }; + } + + /** + * Layout-space node center from a prior `translate(tx,ty)` (inverse of {@link updateNodeGroupTransform}). + */ + private layoutCenterFromPreviousTransform(node: Node, prev: { tx: number; ty: number }): { x: number; y: number } { + const center = this.centerNodesOnPositionChange; + const dx = center ? (node.dimension?.width ?? 0) / 2 : 0; + const dy = center ? (node.dimension?.height ?? 0) / 2 : 0; + return { x: prev.tx + dx, y: prev.ty + dy }; + } + + /** Same curve template as {@link buildFallbackEdgePoints} — smooth polyline between two layout centers. */ + private polylineBetweenLayoutCenters( + s: { x: number; y: number }, + t: { x: number; y: number } + ): Array<{ x: number; y: number }> { + const dx = t.x - s.x; + const dy = t.y - s.y; + const dist = Math.hypot(dx, dy) || 1; + const ox = (dx / dist) * Math.min(dist * 0.35, 120); + const oy = (dy / dist) * Math.min(dist * 0.35, 120); + return [s, { x: s.x + ox, y: s.y + oy }, { x: t.x - ox, y: t.y - oy }, t]; + } + + /** + * Prior-tick polyline for a brand-new edge key so full-scope morph starts from anchors aligned with + * `previousLayoutTransforms` (and current position for endpoints without a prior transform). + */ + private syntheticPreviousEdgePointsFromPriorTransforms(edge: Edge): Array<{ x: number; y: number }> | undefined { + const prevs = this.previousLayoutTransforms; + if (!prevs?.size) { + return undefined; + } + const srcId = typeof edge.source === 'string' ? edge.source : (edge.source as { id?: string })?.id; + const tgtId = typeof edge.target === 'string' ? edge.target : (edge.target as { id?: string })?.id; + const src = this.graph.nodes?.find(n => n.id === srcId); + const tgt = this.graph.nodes?.find(n => n.id === tgtId); + const s = this.layoutCenterForSyntheticEdgeEndpoint(src, prevs); + const t = this.layoutCenterForSyntheticEdgeEndpoint(tgt, prevs); + if (s === undefined || t === undefined) { + return undefined; + } + return this.polylineBetweenLayoutCenters(s, t); + } + + private layoutCenterForSyntheticEdgeEndpoint( + node: Node | undefined, + prevs: Map + ): { x: number; y: number } | undefined { + if (!node) { + return undefined; + } + const p = prevs.get(node.id); + if (p) { + return this.layoutCenterFromPreviousTransform(node, p); + } + if (node.position) { + return { x: node.position.x, y: node.position.y }; + } + return undefined; + } + + /** + * When layout does not provide edge routes, build a smooth polyline between node centers (Bezier-friendly). + */ + private buildFallbackEdgePoints(edge: Edge): Array<{ x: number; y: number }> { + const srcId = typeof edge.source === 'string' ? edge.source : (edge.source as any)?.id; + const tgtId = typeof edge.target === 'string' ? edge.target : (edge.target as any)?.id; + const src = this.graph.nodes.find(n => n.id === srcId); + const tgt = this.graph.nodes.find(n => n.id === tgtId); + if (!src?.position || !tgt?.position) { + return []; + } + const s = { x: src.position.x, y: src.position.y }; + const t = { x: tgt.position.x, y: tgt.position.y }; + return this.polylineBetweenLayoutCenters(s, t); + } + /** * Calculate the text directions / flipping * * @memberOf GraphComponent */ - calcDominantBaseline(link: any): void { + calcDominantBaseline(link: any, displayPoints?: Array<{ x: number; y: number }>): void { const firstPoint = link.points[0]; const lastPoint = link.points[link.points.length - 1]; link.oldTextPath = link.textPath; @@ -753,8 +2351,9 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn if (lastPoint.x < firstPoint.x) { link.dominantBaseline = 'text-before-edge'; - // reverse text path for when its flipped upside down - link.textPath = this.generateLine([...link.points].reverse()); + const rev = + displayPoints && displayPoints.length >= 2 ? [...displayPoints].reverse() : [...link.points].reverse(); + link.textPath = this.generateLine(rev); } else { link.dominantBaseline = 'text-after-edge'; link.textPath = link.line; @@ -775,6 +2374,19 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn return lineFunction(points); } + /** + * Resamples the route polyline to {@link edgePathSampleCount} (via {@link effectiveEdgePathSampleCount}) and builds + * the stroke with {@link generateLine} so drag-time paths match layout ticks and morphs (same d3 `curve` + density). + */ + private lineAndDisplayFromRoutePoints(points: Array<{ x: number; y: number }>): { + line: string; + displayPoints: Array<{ x: number; y: number }>; + } { + const n = this.effectiveEdgePathSampleCount(); + const displayPoints = this.resamplePolyline(points, n); + return { line: this.generateLine(displayPoints), displayPoints }; + } + /** * Zoom was invoked from event * @@ -829,6 +2441,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn * @param y */ pan(x: number, y: number, ignoreZoomLevel: boolean = false): void { + this.cancelViewportPanAnimation(); const zoomLevel = ignoreZoomLevel ? 1 : this.zoomLevel; this.transformationMatrix = transform(this.transformationMatrix, translate(x / zoomLevel, y / zoomLevel)); @@ -847,12 +2460,9 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn const panX = -this.panOffsetX - x * this.zoomLevel + this.dims.width / 2; const panY = -this.panOffsetY - y * this.zoomLevel + this.dims.height / 2; - this.transformationMatrix = transform( - this.transformationMatrix, - translate(panX / this.zoomLevel, panY / this.zoomLevel) - ); - - this.updateTransform(); + const dx = panX / this.zoomLevel; + const dy = panY / this.zoomLevel; + this.animateViewportPanDelta(dx, dy); } /** @@ -860,6 +2470,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn * */ zoom(factor: number): void { + this.cancelViewportPanAnimation(); this.transformationMatrix = transform(this.transformationMatrix, scale(factor, factor)); this.zoomChange.emit(this.zoomLevel); this.updateTransform(); @@ -926,10 +2537,23 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn } redrawEdge(edge: Edge) { - const line = this.generateLine(edge.points); - this.calcDominantBaseline(edge); + let points = edge.points as Array<{ x: number; y: number }>; + if (!points || points.length < 2) { + const fb = this.buildFallbackEdgePoints(edge); + if (fb.length >= 2) { + points = fb; + } + } + if (!points || points.length < 2) { + points = [ + { x: 0, y: 0 }, + { x: 0, y: 0 } + ]; + } + const { line, displayPoints } = this.lineAndDisplayFromRoutePoints(points); edge.oldLine = edge.line; edge.line = line; + this.calcDominantBaseline(edge, displayPoints); } /** @@ -1054,6 +2678,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn * @memberOf GraphComponent */ onTouchStart(event: any): void { + this.cancelViewportPanAnimation(); this._touchLastX = event.changedTouches[0].clientX; this._touchLastY = event.changedTouches[0].clientY; @@ -1129,19 +2754,48 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn } /** - * On minimap pan event. Pans the graph to the clicked position + * On minimap pan event. Pans the graph to the clicked position. + * Uses screen→local conversion so it works for any `miniMapPosition` (the old formula assumed UpperRight). * * @memberOf GraphComponent */ onMinimapPanTo(event: MouseEvent): void { - const x = - event.offsetX - (this.dims.width - (this.graphDims.width + this.minimapOffsetX) / this.minimapScaleCoefficient); - const y = event.offsetY + this.minimapOffsetY / this.minimapScaleCoefficient; - - this.panTo(x * this.minimapScaleCoefficient, y * this.minimapScaleCoefficient); + const p = this.minimapClientEventToGraphCoords(event); + if (!p) { + return; + } + this.panTo(p.gx, p.gy); this.isMinimapPanning = true; } + /** + * Map a click on the minimap background to graph (world) coordinates. `minimapTransform` is applied on the host + * ``; `getScreenCTM()` on the target rect includes that transform so all corners behave the same. + */ + private minimapClientEventToGraphCoords(event: MouseEvent): { gx: number; gy: number } | null { + const el = event.currentTarget; + if (!(el instanceof SVGGraphicsElement)) { + return null; + } + const svg = el.ownerSVGElement; + if (!svg) { + return null; + } + const pt = svg.createSVGPoint(); + pt.x = event.clientX; + pt.y = event.clientY; + const ctm = el.getScreenCTM(); + if (!ctm) { + return null; + } + const local = pt.matrixTransform(ctm.inverse()); + const s = this.minimapScaleCoefficient; + return { + gx: this.minimapOffsetX + local.x * s, + gy: this.minimapOffsetY + local.y * s + }; + } + /** * Center the graph in the viewport */ diff --git a/projects/swimlane/ngx-graph/src/lib/graph/layouts/colaForceDirected.ts b/projects/swimlane/ngx-graph/src/lib/graph/layouts/colaForceDirected.ts index fd713482..2f44f39d 100644 --- a/projects/swimlane/ngx-graph/src/lib/graph/layouts/colaForceDirected.ts +++ b/projects/swimlane/ngx-graph/src/lib/graph/layouts/colaForceDirected.ts @@ -171,14 +171,13 @@ export class ColaForceDirectedLayout implements Layout { .map(edge => { const source: any = toNode(internalGraph.nodes, edge.source); const target: any = toNode(internalGraph.nodes, edge.target); + const p0 = (source.bounds as Rectangle).rayIntersection(target.bounds.cx(), target.bounds.cy()); + const p1 = (target.bounds as Rectangle).rayIntersection(source.bounds.cx(), source.bounds.cy()); return { ...edge, source: source.id, target: target.id, - points: [ - (source.bounds as Rectangle).rayIntersection(target.bounds.cx(), target.bounds.cy()), - (target.bounds as Rectangle).rayIntersection(source.bounds.cx(), source.bounds.cy()) - ] + points: [p0, p1] }; }) .concat( @@ -189,14 +188,13 @@ export class ColaForceDirectedLayout implements Layout { sourceNode || internalGraph.groups.find(foundGroup => (foundGroup as any).id === groupLink.source); const target = targetNode || internalGraph.groups.find(foundGroup => (foundGroup as any).id === groupLink.target); + const p0 = (source.bounds as Rectangle).rayIntersection(target.bounds.cx(), target.bounds.cy()); + const p1 = (target.bounds as Rectangle).rayIntersection(source.bounds.cx(), source.bounds.cy()); return { ...groupLink, source: source.id, target: target.id, - points: [ - (source.bounds as Rectangle).rayIntersection(target.bounds.cx(), target.bounds.cy()), - (target.bounds as Rectangle).rayIntersection(source.bounds.cx(), source.bounds.cy()) - ] + points: [p0, p1] }; }) ); diff --git a/projects/swimlane/ngx-graph/src/lib/graph/layouts/dagre.ts b/projects/swimlane/ngx-graph/src/lib/graph/layouts/dagre.ts index e68088ca..0fa0c777 100644 --- a/projects/swimlane/ngx-graph/src/lib/graph/layouts/dagre.ts +++ b/projects/swimlane/ngx-graph/src/lib/graph/layouts/dagre.ts @@ -3,6 +3,12 @@ import { Graph } from '../../models/graph.model'; import { id } from '../../utils/id'; import * as dagre from 'dagre'; import { Edge } from '../../models/edge.model'; +import { + dagreDragPolyline, + rankOrderAxesFromDagreRankdir, + resolveDagreDragEdgeStyle, + type DagreDragEdgeStyle +} from './edge-geometry'; export enum Orientation { LEFT_TO_RIGHT = 'LR', @@ -30,6 +36,13 @@ export interface DagreSettings { ranker?: 'network-simplex' | 'tight-tree' | 'longest-path'; multigraph?: boolean; compound?: boolean; + /** Offset along rank axis for drag-time edge control points (default 20). */ + curveDistance?: number; + /** + * How to rebuild edge polylines while dragging: `auto` infers from the last laid-out `edge.points`, + * or set explicitly (`orthogonal` / `smooth` / `straight`). + */ + dragEdgeStyle?: DagreDragEdgeStyle; } export class DagreLayout implements Layout { @@ -40,6 +53,7 @@ export class DagreLayout implements Layout { edgePadding: 100, rankPadding: 100, nodePadding: 50, + curveDistance: 20, multigraph: true, compound: true }; @@ -74,20 +88,14 @@ export class DagreLayout implements Layout { updateEdge(graph: Graph, edge: Edge): Graph { const sourceNode = graph.nodes.find(n => n.id === edge.source); const targetNode = graph.nodes.find(n => n.id === edge.target); - - // determine new arrow position - const dir = sourceNode.position.y <= targetNode.position.y ? -1 : 1; - const startingPoint = { - x: sourceNode.position.x, - y: sourceNode.position.y - dir * (sourceNode.dimension.height / 2) - }; - const endingPoint = { - x: targetNode.position.x, - y: targetNode.position.y + dir * (targetNode.dimension.height / 2) - }; - - // generate new points - edge.points = [startingPoint, endingPoint]; + if (!sourceNode?.position || !targetNode?.position) { + return graph; + } + const settings = Object.assign({}, this.defaultSettings, this.settings); + const axes = rankOrderAxesFromDagreRankdir(settings.orientation); + const curveDistance = settings.curveDistance ?? 20; + const resolved = resolveDagreDragEdgeStyle(settings.dragEdgeStyle ?? 'auto', edge.points); + edge.points = dagreDragPolyline(sourceNode, targetNode, axes, curveDistance, resolved); return graph; } diff --git a/projects/swimlane/ngx-graph/src/lib/graph/layouts/dagreCluster.ts b/projects/swimlane/ngx-graph/src/lib/graph/layouts/dagreCluster.ts index ffc79c93..63db9fbf 100644 --- a/projects/swimlane/ngx-graph/src/lib/graph/layouts/dagreCluster.ts +++ b/projects/swimlane/ngx-graph/src/lib/graph/layouts/dagreCluster.ts @@ -5,6 +5,7 @@ import * as dagre from 'dagre'; import { Edge } from '../../models/edge.model'; import { Node, ClusterNode } from '../../models/node.model'; import { DagreSettings, Orientation } from './dagre'; +import { dagreDragPolyline, rankOrderAxesFromDagreRankdir, resolveDagreDragEdgeStyle } from './edge-geometry'; export class DagreClusterLayout implements Layout { defaultSettings: DagreSettings = { @@ -14,6 +15,7 @@ export class DagreClusterLayout implements Layout { edgePadding: 100, rankPadding: 100, nodePadding: 50, + curveDistance: 20, multigraph: true, compound: true }; @@ -54,20 +56,14 @@ export class DagreClusterLayout implements Layout { updateEdge(graph: Graph, edge: Edge): Graph { const sourceNode = graph.nodes.find(n => n.id === edge.source); const targetNode = graph.nodes.find(n => n.id === edge.target); - - // determine new arrow position - const dir = sourceNode.position.y <= targetNode.position.y ? -1 : 1; - const startingPoint = { - x: sourceNode.position.x, - y: sourceNode.position.y - dir * (sourceNode.dimension.height / 2) - }; - const endingPoint = { - x: targetNode.position.x, - y: targetNode.position.y + dir * (targetNode.dimension.height / 2) - }; - - // generate new points - edge.points = [startingPoint, endingPoint]; + if (!sourceNode?.position || !targetNode?.position) { + return graph; + } + const settings = Object.assign({}, this.defaultSettings, this.settings); + const axes = rankOrderAxesFromDagreRankdir(settings.orientation); + const curveDistance = settings.curveDistance ?? 20; + const resolved = resolveDagreDragEdgeStyle(settings.dragEdgeStyle ?? 'auto', edge.points); + edge.points = dagreDragPolyline(sourceNode, targetNode, axes, curveDistance, resolved); return graph; } diff --git a/projects/swimlane/ngx-graph/src/lib/graph/layouts/dagreNodesOnly.ts b/projects/swimlane/ngx-graph/src/lib/graph/layouts/dagreNodesOnly.ts index 06947dac..8a42155c 100644 --- a/projects/swimlane/ngx-graph/src/lib/graph/layouts/dagreNodesOnly.ts +++ b/projects/swimlane/ngx-graph/src/lib/graph/layouts/dagreNodesOnly.ts @@ -4,6 +4,7 @@ import { id } from '../../utils/id'; import * as dagre from 'dagre'; import { Edge } from '../../models/edge.model'; import { DagreSettings, Orientation } from './dagre'; +import { dagreDragPolyline, rankOrderAxesFromDagreRankdir, resolveDagreDragEdgeStyle } from './edge-geometry'; export interface DagreNodesOnlySettings extends DagreSettings { curveDistance?: number; @@ -59,34 +60,14 @@ export class DagreNodesOnlyLayout implements Layout { updateEdge(graph: Graph, edge: Edge): Graph { const sourceNode = graph.nodes.find(n => n.id === edge.source); const targetNode = graph.nodes.find(n => n.id === edge.target); - const rankAxis: 'x' | 'y' = this.settings.orientation === 'BT' || this.settings.orientation === 'TB' ? 'y' : 'x'; - const orderAxis: 'x' | 'y' = rankAxis === 'y' ? 'x' : 'y'; - const rankDimension = rankAxis === 'y' ? 'height' : 'width'; - // determine new arrow position - const dir = sourceNode.position[rankAxis] <= targetNode.position[rankAxis] ? -1 : 1; - const startingPoint = { - [orderAxis]: sourceNode.position[orderAxis], - [rankAxis]: sourceNode.position[rankAxis] - dir * (sourceNode.dimension[rankDimension] / 2) - }; - const endingPoint = { - [orderAxis]: targetNode.position[orderAxis], - [rankAxis]: targetNode.position[rankAxis] + dir * (targetNode.dimension[rankDimension] / 2) - }; - - const curveDistance = this.settings.curveDistance || this.defaultSettings.curveDistance; - // generate new points - edge.points = [ - startingPoint, - { - [orderAxis]: startingPoint[orderAxis], - [rankAxis]: startingPoint[rankAxis] - dir * curveDistance - }, - { - [orderAxis]: endingPoint[orderAxis], - [rankAxis]: endingPoint[rankAxis] + dir * curveDistance - }, - endingPoint - ]; + if (!sourceNode?.position || !targetNode?.position) { + return graph; + } + const settings = Object.assign({}, this.defaultSettings, this.settings); + const axes = rankOrderAxesFromDagreRankdir(settings.orientation); + const curveDistance = settings.curveDistance ?? this.defaultSettings.curveDistance ?? 20; + const resolved = resolveDagreDragEdgeStyle(settings.dragEdgeStyle ?? 'auto', edge.points); + edge.points = dagreDragPolyline(sourceNode, targetNode, axes, curveDistance, resolved); const edgeLabelId = `${edge.source}${EDGE_KEY_DELIM}${edge.target}${EDGE_KEY_DELIM}${DEFAULT_EDGE_NAME}`; const matchingEdgeLabel = graph.edgeLabels[edgeLabelId]; if (matchingEdgeLabel) { diff --git a/projects/swimlane/ngx-graph/src/lib/graph/layouts/edge-geometry.spec.ts b/projects/swimlane/ngx-graph/src/lib/graph/layouts/edge-geometry.spec.ts new file mode 100644 index 00000000..ae642da4 --- /dev/null +++ b/projects/swimlane/ngx-graph/src/lib/graph/layouts/edge-geometry.spec.ts @@ -0,0 +1,149 @@ +import { + dagreDragPolyline, + edgePointsAfterDrag, + elkDragPolyline, + inferDragStyleFromPoints, + normalizeElkEdgeRouting, + orthogonalManhattanPolyline, + rankOrderAxesFromDagreRankdir, + rankOrderAxesFromElkDirection +} from './edge-geometry'; +import type { Node } from '../../models/node.model'; + +function node(cx: number, cy: number, w: number, h: number): Node { + return { + id: 'n', + position: { x: cx, y: cy }, + dimension: { width: w, height: h } + }; +} + +describe('edge-geometry', () => { + describe('rankOrderAxesFromDagreRankdir', () => { + it('maps LR to rank x', () => { + expect(rankOrderAxesFromDagreRankdir('LR')).toEqual({ rankAxis: 'x', orderAxis: 'y' }); + }); + it('maps TB to rank y', () => { + expect(rankOrderAxesFromDagreRankdir('TB')).toEqual({ rankAxis: 'y', orderAxis: 'x' }); + }); + }); + + describe('rankOrderAxesFromElkDirection', () => { + it('maps RIGHT to rank x', () => { + expect(rankOrderAxesFromElkDirection('RIGHT')).toEqual({ rankAxis: 'x', orderAxis: 'y' }); + }); + it('maps DOWN to rank y', () => { + expect(rankOrderAxesFromElkDirection('DOWN')).toEqual({ rankAxis: 'y', orderAxis: 'x' }); + }); + }); + + describe('edgePointsAfterDrag', () => { + it('produces four points for LR with horizontal separation', () => { + const a = node(100, 100, 40, 40); + const b = node(300, 100, 40, 40); + const pts = edgePointsAfterDrag(a, b, { + curveDistance: 20, + rankAxis: 'x', + orderAxis: 'y' + }); + expect(pts.length).toBe(4); + // Source right face toward target; target left face toward source (rank x, dir -1) + expect(pts[0].x).toBeCloseTo(120, 5); + expect(pts[0].y).toBeCloseTo(100, 5); + expect(pts[3].x).toBeCloseTo(280, 5); + expect(pts[3].y).toBeCloseTo(100, 5); + }); + + it('produces four points for TB with vertical separation', () => { + const a = node(100, 100, 40, 40); + const b = node(100, 300, 40, 40); + const pts = edgePointsAfterDrag(a, b, { + curveDistance: 20, + rankAxis: 'y', + orderAxis: 'x' + }); + expect(pts.length).toBe(4); + expect(pts[0].y).toBeLessThan(pts[3].y); + }); + }); + + describe('normalizeElkEdgeRouting', () => { + it('maps SPLINES and ORTHOGONAL', () => { + expect(normalizeElkEdgeRouting('SPLINES')).toBe('SPLINES'); + expect(normalizeElkEdgeRouting('ORTHOGONAL')).toBe('ORTHOGONAL'); + expect(normalizeElkEdgeRouting('POLYLINE')).toBe('POLYLINE'); + }); + }); + + describe('orthogonalManhattanPolyline', () => { + it('uses one bend for diagonal ports', () => { + const pts = orthogonalManhattanPolyline({ x: 0, y: 0 }, { x: 10, y: 20 }); + expect(pts.length).toBe(3); + expect(pts[0].x).toBe(0); + expect(pts[1].x).toBe(10); + expect(pts[1].y).toBe(0); + expect(pts[2].y).toBe(20); + }); + }); + + describe('inferDragStyleFromPoints', () => { + it('detects orthogonal polylines', () => { + expect( + inferDragStyleFromPoints([ + { x: 0, y: 0 }, + { x: 10, y: 0 }, + { x: 10, y: 20 } + ]) + ).toBe('orthogonal'); + }); + it('detects straight two-point edges', () => { + expect( + inferDragStyleFromPoints([ + { x: 0, y: 0 }, + { x: 100, y: 0 } + ]) + ).toBe('straight'); + }); + it('does not treat slight float drift on a segment as orthogonal', () => { + expect( + inferDragStyleFromPoints([ + { x: 0, y: 0 }, + { x: 100, y: 0.5 }, + { x: 100, y: 50 } + ]) + ).toBe('smooth'); + }); + }); + + describe('dagreDragPolyline', () => { + it('uses four-point smooth path for smooth, Manhattan for orthogonal', () => { + const a = node(100, 100, 40, 40); + const b = node(300, 200, 40, 40); + const axes = { rankAxis: 'x' as const, orderAxis: 'y' as const }; + const smooth = dagreDragPolyline(a, b, axes, 20, 'smooth'); + const ortho = dagreDragPolyline(a, b, axes, 20, 'orthogonal'); + expect(smooth.length).toBe(4); + expect(ortho.length).toBe(3); + expect(smooth).not.toEqual(ortho); + }); + }); + + describe('elkDragPolyline', () => { + it('uses axis-aligned path for ORTHOGONAL', () => { + const a = node(100, 100, 40, 40); + const b = node(300, 200, 40, 40); + const pts = elkDragPolyline(a, b, { rankAxis: 'x', orderAxis: 'y' }, 20, 'ORTHOGONAL'); + for (let i = 0; i < pts.length - 1; i++) { + const dx = Math.abs(pts[i].x - pts[i + 1].x); + const dy = Math.abs(pts[i].y - pts[i + 1].y); + expect(dx < 1e-3 || dy < 1e-3).toBe(true); + } + }); + it('uses four points for SPLINES', () => { + const a = node(100, 100, 40, 40); + const b = node(300, 100, 40, 40); + const pts = elkDragPolyline(a, b, { rankAxis: 'x', orderAxis: 'y' }, 20, 'SPLINES'); + expect(pts.length).toBe(4); + }); + }); +}); diff --git a/projects/swimlane/ngx-graph/src/lib/graph/layouts/edge-geometry.ts b/projects/swimlane/ngx-graph/src/lib/graph/layouts/edge-geometry.ts new file mode 100644 index 00000000..51c36ce7 --- /dev/null +++ b/projects/swimlane/ngx-graph/src/lib/graph/layouts/edge-geometry.ts @@ -0,0 +1,242 @@ +import type { Node } from '../../models/node.model'; + +const AXIS_EPS = 1e-3; + +/** Dagre `rankdir` string (e.g. LR, TB). */ +export type DagreRankdir = string | undefined; + +/** ELK `elk.direction` value (e.g. RIGHT, DOWN). */ +export type ElkDirection = string | undefined; + +export interface RankOrderAxes { + rankAxis: 'x' | 'y'; + orderAxis: 'x' | 'y'; +} + +/** + * Maps Dagre `rankdir` to rank (layer) vs order (within layer) axes, matching + * {@link DagreNodesOnlyLayout} / graphlib conventions. + */ +export function rankOrderAxesFromDagreRankdir(rankdir: DagreRankdir): RankOrderAxes { + const r = rankdir ?? 'LR'; + const rankAxis: 'x' | 'y' = r === 'BT' || r === 'TB' ? 'y' : 'x'; + const orderAxis: 'x' | 'y' = rankAxis === 'y' ? 'x' : 'y'; + return { rankAxis, orderAxis }; +} + +/** + * Maps ELK layered `elk.direction` to rank vs order axes for drag-time edge ports. + */ +export function rankOrderAxesFromElkDirection(direction: ElkDirection): RankOrderAxes { + const u = (direction ?? 'DOWN').toUpperCase(); + if (u === 'LEFT' || u === 'RIGHT') { + return { rankAxis: 'x', orderAxis: 'y' }; + } + return { rankAxis: 'y', orderAxis: 'x' }; +} + +export interface DragEdgePointsOptions { + curveDistance: number; + rankAxis: 'x' | 'y'; + orderAxis: 'x' | 'y'; +} + +/** Drag-time edge style for Dagre-family layouts (`layoutSettings` on `ngx-graph`). */ +export type DagreDragEdgeStyle = 'auto' | 'orthogonal' | 'smooth' | 'straight'; + +/** Resolved style after `auto` heuristic. */ +export type ResolvedDagreDragStyle = 'orthogonal' | 'smooth' | 'straight'; + +function withRankOrder( + rankAxis: 'x' | 'y', + orderAxis: 'x' | 'y', + rank: number, + order: number +): { x: number; y: number } { + return rankAxis === 'x' ? { x: rank, y: order } : { x: order, y: rank }; +} + +/** + * Rank-facing attachment points (centers projected to box faces toward the other node), + * matching the endpoints used by {@link edgePointsAfterDrag}. + */ +export function rankFacePortPoints( + source: Node, + target: Node, + axes: RankOrderAxes +): [{ x: number; y: number }, { x: number; y: number }] { + const { rankAxis, orderAxis } = axes; + const sp = source.position ?? { x: 0, y: 0 }; + const tp = target.position ?? { x: 0, y: 0 }; + const sw = source.dimension?.width ?? 0; + const sh = source.dimension?.height ?? 0; + const tw = target.dimension?.width ?? 0; + const th = target.dimension?.height ?? 0; + const halfRankS = (rankAxis === 'x' ? sw : sh) / 2; + const halfRankT = (rankAxis === 'x' ? tw : th) / 2; + const dir = sp[rankAxis] <= tp[rankAxis] ? -1 : 1; + const sRank = sp[rankAxis] - dir * halfRankS; + const tRank = tp[rankAxis] + dir * halfRankT; + const sOrd = sp[orderAxis]; + const tOrd = tp[orderAxis]; + return [withRankOrder(rankAxis, orderAxis, sRank, sOrd), withRankOrder(rankAxis, orderAxis, tRank, tOrd)]; +} + +/** Axis-aligned Manhattan path (one bend) between two ports. */ +export function orthogonalManhattanPolyline( + p0: { x: number; y: number }, + p1: { x: number; y: number } +): Array<{ x: number; y: number }> { + if (Math.abs(p0.x - p1.x) < AXIS_EPS || Math.abs(p0.y - p1.y) < AXIS_EPS) { + return [p0, p1]; + } + return [p0, { x: p1.x, y: p0.y }, p1]; +} + +/** Normalize ELK `elk.edgeRouting` for branching. */ +export function normalizeElkEdgeRouting(value: string | undefined): 'SPLINES' | 'ORTHOGONAL' | 'POLYLINE' | 'UNKNOWN' { + const u = (value ?? '').toUpperCase(); + if (u.includes('SPLINE') || u === 'SPLINES') { + return 'SPLINES'; + } + if (u === 'ORTHOGONAL') { + return 'ORTHOGONAL'; + } + if (u === 'POLYLINE') { + return 'POLYLINE'; + } + return 'UNKNOWN'; +} + +function segmentAxisAligned(a: { x: number; y: number }, b: { x: number; y: number }): boolean { + return Math.abs(a.x - b.x) < AXIS_EPS || Math.abs(a.y - b.y) < AXIS_EPS; +} + +/** + * Classify last laid-out polyline for `dragEdgeStyle: 'auto'`. + */ +export function inferDragStyleFromPoints( + points: Array<{ x: number; y: number }> | undefined | null +): ResolvedDagreDragStyle { + if (!points || points.length < 2) { + return 'smooth'; + } + if (points.length === 2) { + return 'straight'; + } + let allOrtho = true; + for (let i = 0; i < points.length - 1; i++) { + if (!segmentAxisAligned(points[i], points[i + 1])) { + allOrtho = false; + break; + } + } + if (allOrtho) { + return 'orthogonal'; + } + if (points.length >= 3) { + return 'smooth'; + } + return 'straight'; +} + +/** + * Resolves `auto` using prior `edge.points`, otherwise returns the explicit style. + */ +export function resolveDagreDragEdgeStyle( + dragEdgeStyle: DagreDragEdgeStyle | undefined, + priorPoints: Array<{ x: number; y: number }> | undefined +): ResolvedDagreDragStyle { + const mode = dragEdgeStyle ?? 'auto'; + if (mode === 'auto') { + return inferDragStyleFromPoints(priorPoints); + } + return mode; +} + +/** + * Builds drag polyline for Dagre / DagreCluster / DagreNodesOnly from resolved style. + * `smooth` uses {@link edgePointsAfterDrag} (rank-offset interior points) for curved spline-style strokes; + * `orthogonal` uses {@link orthogonalManhattanPolyline}; `straight` uses two port points only. + */ +export function dagreDragPolyline( + source: Node, + target: Node, + axes: RankOrderAxes, + curveDistance: number, + style: ResolvedDagreDragStyle +): Array<{ x: number; y: number }> { + if (style === 'straight') { + const [p0, p1] = rankFacePortPoints(source, target, axes); + return [p0, p1]; + } + if (style === 'orthogonal') { + const [p0, p1] = rankFacePortPoints(source, target, axes); + return orthogonalManhattanPolyline(p0, p1); + } + return edgePointsAfterDrag(source, target, { curveDistance, ...axes }); +} + +/** + * Drag-time polyline for ElkLayout from normalized `elk.edgeRouting`. + * `UNKNOWN` defaults to smooth spline-style segments (four-point rank path). + */ +export function elkDragPolyline( + source: Node, + target: Node, + axes: RankOrderAxes, + curveDistance: number, + routing: ReturnType +): Array<{ x: number; y: number }> { + const mode = routing === 'UNKNOWN' ? 'SPLINES' : routing; + const [p0, p1] = rankFacePortPoints(source, target, axes); + if (mode === 'ORTHOGONAL' || mode === 'POLYLINE') { + return orthogonalManhattanPolyline(p0, p1); + } + return edgePointsAfterDrag(source, target, { curveDistance, ...axes }); +} + +/** + * Builds a 4-point polyline for interactive drag updates: ports on the rank-facing + * sides of each node box (using node centers + dimensions), plus two interior + * control points so d3 curve generators (e.g. `curveBasis`) have knots to bend. + * + * Aligns with {@link DagreNodesOnlyLayout.updateEdge}, with explicit rank/order axes + * so LR/RL/TB/BT and ELK directions attach on the correct face. + */ +export function edgePointsAfterDrag( + source: Node, + target: Node, + options: DragEdgePointsOptions +): Array<{ x: number; y: number }> { + const { rankAxis, orderAxis, curveDistance } = options; + const sp = source.position ?? { x: 0, y: 0 }; + const tp = target.position ?? { x: 0, y: 0 }; + const dir = sp[rankAxis] <= tp[rankAxis] ? -1 : 1; + const cd = Math.max(0, curveDistance); + const axes = { rankAxis, orderAxis }; + const [startingPoint, endingPoint] = rankFacePortPoints(source, target, axes); + + return [ + startingPoint, + withRankOrder(rankAxis, orderAxis, startingPoint[rankAxis] - dir * cd, startingPoint[orderAxis]), + withRankOrder(rankAxis, orderAxis, endingPoint[rankAxis] + dir * cd, endingPoint[orderAxis]), + endingPoint + ]; +} + +/** + * When `style` is `linear`, returns only the two port points (straight segment). + * Otherwise returns the full 4-point drag polyline. + */ +export function edgePointsForDragMode( + source: Node, + target: Node, + options: DragEdgePointsOptions & { style?: 'linear' | 'smooth' } +): Array<{ x: number; y: number }> { + const pts = edgePointsAfterDrag(source, target, options); + if (options.style === 'linear') { + return [pts[0], pts[pts.length - 1]]; + } + return pts; +} diff --git a/projects/swimlane/ngx-graph/src/lib/graph/layouts/layout-layered-constants.ts b/projects/swimlane/ngx-graph/src/lib/graph/layouts/layout-layered-constants.ts new file mode 100644 index 00000000..a6f8635f --- /dev/null +++ b/projects/swimlane/ngx-graph/src/lib/graph/layouts/layout-layered-constants.ts @@ -0,0 +1,5 @@ +/** + * Default inter-layer node spacing (px) used when seeding provisional node positions for layered layouts. + * Align with `elk.layered.spacing.nodeNodeBetweenLayers` when using ELK-style `layoutSettings.properties`. + */ +export const LAYERED_NODE_NODE_BETWEEN_LAYERS_PX = 72; diff --git a/projects/swimlane/ngx-graph/src/lib/graph/transition.model.spec.ts b/projects/swimlane/ngx-graph/src/lib/graph/transition.model.spec.ts new file mode 100644 index 00000000..6e78b3ae --- /dev/null +++ b/projects/swimlane/ngx-graph/src/lib/graph/transition.model.spec.ts @@ -0,0 +1,45 @@ +import { + DEFAULT_GRAPH_LAYOUT_TRANSITION, + mergeGraphLayoutTransition, + mergeLayoutMorphCapture +} from './transition.model'; + +describe('mergeLayoutMorphCapture', () => { + it('fills defaults when partial is empty', () => { + expect(mergeLayoutMorphCapture({})).toEqual(mergeLayoutMorphCapture(undefined)); + }); + + it('overrides only provided keys', () => { + const m = mergeLayoutMorphCapture({ snapAddedNodeIds: true }); + expect(m.snapAddedNodeIds).toBe(true); + expect(m.previousSource).toBe('model-transform'); + expect(m.syncTargetsFromPositionAfterTick).toBe(false); + }); +}); + +describe('mergeGraphLayoutTransition', () => { + it('deep-merges morphCapture with defaults', () => { + const merged = mergeGraphLayoutTransition({ + mode: 'tween', + morphCapture: { previousSource: 'dom-svg', snapAddedNodeIds: true } + }); + expect(merged.mode).toBe('tween'); + expect(merged.morphCapture.previousSource).toBe('dom-svg'); + expect(merged.morphCapture.snapAddedNodeIds).toBe(true); + expect(merged.morphCapture.syncTargetsFromPositionAfterTick).toBe(false); + expect(merged.morphCapture.degenerateEpsilon).toBe(1e-3); + }); + + it('preserves default morph when transition fields are set without morphCapture', () => { + const merged = mergeGraphLayoutTransition({ mode: 'tween' }); + expect(merged.morphCapture).toEqual(mergeLayoutMorphCapture(DEFAULT_GRAPH_LAYOUT_TRANSITION.morphCapture)); + }); + + it('merges partial morphCapture without dropping sibling keys from defaults', () => { + const merged = mergeGraphLayoutTransition({ + morphCapture: { degenerateEpsilon: 0.5 } + }); + expect(merged.morphCapture.degenerateEpsilon).toBe(0.5); + expect(merged.morphCapture.previousSource).toBe('model-transform'); + }); +}); diff --git a/projects/swimlane/ngx-graph/src/lib/graph/transition.model.ts b/projects/swimlane/ngx-graph/src/lib/graph/transition.model.ts new file mode 100644 index 00000000..710f0abb --- /dev/null +++ b/projects/swimlane/ngx-graph/src/lib/graph/transition.model.ts @@ -0,0 +1,170 @@ +import * as d3ease from 'd3-ease'; + +/** Named easings mapped to d3-ease factories (same as layout morph animations). */ +export type GraphTransitionEasingName = 'linear' | 'cubicIn' | 'cubicOut' | 'cubicInOut' | 'quadInOut' | 'elasticOut'; + +export type GraphLayoutTransitionMode = 'none' | 'instant' | 'tween'; + +/** Where “previous” node-group translates come from before `graph` is replaced (layout morph). */ +export type LayoutMorphPreviousSource = + /** Current behavior: read `transform` on the incoming graph model only. */ + | 'model-transform' + /** Read `g.node-group[id]` under `.graph.chart` (excludes minimap). */ + | 'dom-svg' + /** DOM first; if translate is near zero, use model `position` / `transform` (see `degenerateEpsilon`). */ + | 'dom-with-model-fallback'; + +/** Options for capturing prior node translates when `mode: 'tween'`. */ +export interface LayoutMorphCapture { + /** Default `model-transform` (backward compatible). */ + previousSource?: LayoutMorphPreviousSource; + /** Used with `dom-with-model-fallback`. Default `1e-3`. */ + degenerateEpsilon?: number; + /** + * Order when resolving a node id on the **model** graph for fallback math. + * Default `['compound', 'cluster', 'node']`. + */ + modelResolutionOrder?: Array<'compound' | 'cluster' | 'node'>; + /** After `tick()`, refresh `layoutAnimationTargets` from layout `position` for full-scope tween. Default false. */ + syncTargetsFromPositionAfterTick?: boolean; + /** For ids added since last tick, set previous = target so they do not tween from origin. Default false. */ + snapAddedNodeIds?: boolean; +} + +export const DEFAULT_LAYOUT_MORPH_CAPTURE: LayoutMorphCapture = { + previousSource: 'model-transform', + degenerateEpsilon: 1e-3, + modelResolutionOrder: ['compound', 'cluster', 'node'], + syncTargetsFromPositionAfterTick: false, + snapAddedNodeIds: false +}; + +export function mergeLayoutMorphCapture(partial: LayoutMorphCapture | null | undefined): LayoutMorphCapture { + return { ...DEFAULT_LAYOUT_MORPH_CAPTURE, ...partial }; +} + +/** + * Layout transition after graph model / layout output changes. + * - `none` / `instant`: keep prior snapshot for continuity, then snap to final layout in one frame (no rAF morph). + * - `tween`: interpolate node translates and edge paths over `durationMs` with `easing`. + */ +export interface GraphLayoutTransition { + mode: GraphLayoutTransitionMode; + /** When `tween`, only new ids interpolate in additive mode; stable nodes/edges snap. */ + scope: 'full' | 'additive'; + durationMs: number; + easing: GraphTransitionEasingName | ((t: number) => number); + /** When `mode: 'tween'`, how prior node translates are captured (DOM vs model). */ + morphCapture?: LayoutMorphCapture; +} + +export const DEFAULT_GRAPH_LAYOUT_TRANSITION: GraphLayoutTransition = { + mode: 'instant', + scope: 'full', + durationMs: 500, + easing: 'cubicInOut', + morphCapture: mergeLayoutMorphCapture(undefined) +}; + +/** Programmatic viewport pan (panTo, center, minimap, zoomToFit autoCenter): optional eased translation only; zoom scale unchanged. */ +export interface ViewportTranslationTransition { + enabled: boolean; + durationMs: number; + easing: GraphTransitionEasingName | ((t: number) => number); +} + +export const DEFAULT_VIEWPORT_TRANSLATION_TRANSITION: ViewportTranslationTransition = { + enabled: false, + durationMs: 280, + easing: 'cubicOut' +}; + +/** Optional flair during layout tween only (default off). */ +export interface LayoutTransitionEffect { + kind: 'none' | 'perspectiveFlip' | 'rotate'; + /** Max rotation in degrees (perspective flip uses rotateX). */ + peakDegrees?: number; + /** For `rotate`: pivot in graph coordinates. */ + rotatePivot?: 'graphCenter' | 'viewportCenter' | { nodeId: string }; +} + +export const DEFAULT_LAYOUT_TRANSITION_EFFECT: LayoutTransitionEffect = { + kind: 'none', + peakDegrees: 12 +}; + +export function resolveGraphTransitionEasing( + easing: GraphLayoutTransition['easing'] | ViewportTranslationTransition['easing'] +): (t: number) => number { + if (typeof easing === 'function') { + return easing; + } + switch (easing) { + case 'linear': + return d3ease.easeLinear; + case 'cubicIn': + return d3ease.easeCubicIn; + case 'cubicOut': + return d3ease.easeCubicOut; + case 'quadInOut': + return d3ease.easeQuadInOut; + case 'elasticOut': + return d3ease.easeElasticOut; + case 'cubicInOut': + default: + return d3ease.easeCubicInOut; + } +} + +/** + * Resolves final layout transition from the graph `transitionAfterChanges` input merged with defaults. + * When unset or empty, returns {@link DEFAULT_GRAPH_LAYOUT_TRANSITION} (`mode: 'instant'`). + */ +export function mergeGraphLayoutTransition( + explicit: Partial | null | undefined +): GraphLayoutTransition { + const baseMorph = mergeLayoutMorphCapture(DEFAULT_GRAPH_LAYOUT_TRANSITION.morphCapture); + const base: GraphLayoutTransition = { ...DEFAULT_GRAPH_LAYOUT_TRANSITION, morphCapture: baseMorph }; + if (explicit != null) { + const defined = Object.fromEntries( + Object.entries(explicit).filter(([, v]) => v !== undefined) + ) as Partial; + if (Object.keys(defined).length > 0) { + const { morphCapture: morphPartial, ...rest } = defined; + return { + ...base, + ...rest, + morphCapture: mergeLayoutMorphCapture(morphPartial ?? base.morphCapture) + }; + } + } + return base; +} + +export function mergeViewportTransition( + explicit: Partial | null | undefined +): ViewportTranslationTransition { + if (explicit != null) { + const defined = Object.fromEntries( + Object.entries(explicit).filter(([, v]) => v !== undefined) + ) as Partial; + if (Object.keys(defined).length > 0) { + return { ...DEFAULT_VIEWPORT_TRANSLATION_TRANSITION, ...defined }; + } + } + return { ...DEFAULT_VIEWPORT_TRANSLATION_TRANSITION }; +} + +export function mergeLayoutEffect( + explicit: Partial | null | undefined +): LayoutTransitionEffect { + if (explicit != null) { + const defined = Object.fromEntries( + Object.entries(explicit).filter(([, v]) => v !== undefined) + ) as Partial; + if (Object.keys(defined).length > 0) { + return { ...DEFAULT_LAYOUT_TRANSITION_EFFECT, ...defined }; + } + } + return { ...DEFAULT_LAYOUT_TRANSITION_EFFECT }; +} diff --git a/projects/swimlane/ngx-graph/src/lib/models/edge.model.ts b/projects/swimlane/ngx-graph/src/lib/models/edge.model.ts index 0b4abca6..b3ab31cd 100644 --- a/projects/swimlane/ngx-graph/src/lib/models/edge.model.ts +++ b/projects/swimlane/ngx-graph/src/lib/models/edge.model.ts @@ -7,6 +7,8 @@ export interface Edge { label?: string; data?: any; points?: any; + /** Raw layout polyline from before the latest tick; morphing resamples this in redrawLines. */ + previousPoints?: Array<{ x: number; y: number }>; line?: string; textTransform?: string; textAngle?: number; diff --git a/projects/swimlane/ngx-graph/src/lib/models/layout.model.ts b/projects/swimlane/ngx-graph/src/lib/models/layout.model.ts index 3bf11505..0eb7890b 100644 --- a/projects/swimlane/ngx-graph/src/lib/models/layout.model.ts +++ b/projects/swimlane/ngx-graph/src/lib/models/layout.model.ts @@ -3,6 +3,10 @@ import { Edge } from './edge.model'; import { Node } from './node.model'; import { Observable } from 'rxjs'; +/** + * Layout engine contract. Optional hooks support custom drag behavior and parsing `Node.transform` for + * layout transition bookkeeping (same translate semantics as the graph default: node-group origin in layout space). + */ export interface Layout { settings?: any; run(graph: Graph): Graph | Observable; @@ -10,4 +14,5 @@ export interface Layout { onDragStart?(draggingNode: Node, $event: MouseEvent): void; onDrag?(draggingNode: Node, $event: MouseEvent): void; onDragEnd?(draggingNode: Node, $event: MouseEvent): void; + parseTranslate?(transformStr: string | undefined): { tx: number; ty: number }; } diff --git a/projects/swimlane/ngx-graph/src/public_api.ts b/projects/swimlane/ngx-graph/src/public_api.ts index 0c3a78df..8b0aad89 100644 --- a/projects/swimlane/ngx-graph/src/public_api.ts +++ b/projects/swimlane/ngx-graph/src/public_api.ts @@ -11,6 +11,7 @@ export * from './lib/models/node.model'; export * from './lib/graph/graph.component'; export * from './lib/graph/graph.module'; +export * from './lib/graph/transition.model'; export * from './lib/graph/mouse-wheel.directive'; export * from './lib/graph/layouts/colaForceDirected'; @@ -19,6 +20,8 @@ export * from './lib/graph/layouts/d3ForceDirected'; export * from './lib/graph/layouts/dagre'; export * from './lib/graph/layouts/dagreCluster'; export * from './lib/graph/layouts/dagreNodesOnly'; +export * from './lib/graph/layouts/layout-layered-constants'; +export * from './lib/graph/layouts/edge-geometry'; export * from './lib/enums/mini-map-position.enum'; export * from './lib/enums/panning.enum'; diff --git a/projects/swimlane/ngx-graph/src/stories/DataFormat.mdx b/projects/swimlane/ngx-graph/src/stories/DataFormat.mdx index bfccae01..cd104d7d 100644 --- a/projects/swimlane/ngx-graph/src/stories/DataFormat.mdx +++ b/projects/swimlane/ngx-graph/src/stories/DataFormat.mdx @@ -5,30 +5,34 @@ import { Meta } from '@storybook/addon-docs/blocks'; # Data Format -The graph data is passed through 3 inputs of the component: `nodes`, `edges`, and `clusters`. All of the interfaces for these inputs can be imported from the npm package. +The graph data is passed through the component inputs **`nodes`**, **`links`**, **`clusters`**, and optionally **`compoundNodes`**. All of the interfaces for these inputs can be imported from the npm package. ## Nodes The `nodes` input accepts an array of items of type `Node`. -## Edges +## Links -The `edges` input accepts an array of items of type `Edge`. +The `links` input accepts an array of items of type `Edge` (edges in the graph model). ## Clusters (Optional) The `clusters` input accepts an array of items of type `ClusterNode`. This input will only be used by layouts that support it (Dagre). Other layouts will ignore it. Clusters are regions denoted by a background behind other nodes. -## Container Nodes (Optional) +## Compound nodes (Optional) -The `containerNodes` input accepts an array of items of type `ContainerNode`. This input will only be used by layouts that support it (Elk). Other layouts will ignore it. Compound Nodes are containers of nodes. +The **`compoundNodes`** input accepts an array of items of type **`CompoundNode`**. This input is only used by layouts that support compound containers (e.g. **`ElkLayout`** for elkjs`). Other layouts ignore it. Compound nodes are containers of other nodes. -- Container Nodes and Clusters share the same API, although behave differently based on the provided layout. +- **`compoundNodes`** and **`clusters`** share a similar shape in the model, but behave differently depending on the layout. + +### Edge fields for layout morph + +Optional **`previousPoints`** on **`Edge`** can help layout morphing resample edge paths between ticks. For ELK drag routing, you can also set routing hints on **`edge.data`** (see **Documentation / Interface** and ELK layout docs). ## Example ```js -this.edges = [ +this.links = [ { id: 'a', source: '1', diff --git a/projects/swimlane/ngx-graph/src/stories/Introduction.mdx b/projects/swimlane/ngx-graph/src/stories/Introduction.mdx index b071c5e9..a3bfffaa 100644 --- a/projects/swimlane/ngx-graph/src/stories/Introduction.mdx +++ b/projects/swimlane/ngx-graph/src/stories/Introduction.mdx @@ -73,5 +73,8 @@ models to nodes and edges displayed in a data visualization. DAG layouts are use - Add the ngx-graph component. - Provide a model for nodes and links (also known as edges). - ngx-graph is compatible with Dagre and Elk, but can also accept customized layouts via the layout Input (not - demoed in this example). + ngx-graph ships Dagre-family and other built-in layouts; **ELK** layered layout must be provided\*\* (install `elkjs`). You can also pass a custom `Layout` via the `layout` input. + +**Layout transitions** after model changes are configured with **`transitionAfterChanges`** (and related inputs such as **`useLayoutTransitions`**, **`transitionDuringTransform`**, **`layoutTransitionEffect`**). See **[Documentation / Interface](?path=/docs/documentation-interface--docs)** for the full inputs/outputs table and a short “Layout morph & continuity” guide. + +**Storybook examples:** [NgxGraphDagreLayoutTransition](?path=/story/example-ngxgraphdagrelayouttransition--dagre-layout-transition) (rank direction change + full-scope tween + `(drawComplete)`), [NgxGraphTranslateOnChanges](?path=/story/example-ngxgraphtranslateonchanges--translate-on-changes) (same graph vs new graph + tween), [NgxGraphColaBranching](?path=/story/example-ngxgraphcolabranching--cola-branching) (additive growth + `transitionAfterChanges`). diff --git a/projects/swimlane/ngx-graph/src/stories/Options.mdx b/projects/swimlane/ngx-graph/src/stories/Options.mdx index 1d8bfe3f..8657cc2c 100644 --- a/projects/swimlane/ngx-graph/src/stories/Options.mdx +++ b/projects/swimlane/ngx-graph/src/stories/Options.mdx @@ -9,55 +9,71 @@ This guide outlines useful `Input`, `Output` and methods available on `GraphComp ## Inputs -| Property | Type | Default Value | Description | -| ------------------------ | --------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | -| view | number[] | | the size of the graph element - accepts an array of two numbers `[width, height]`. If not specified, the graph is resized to fit its parent element | -| nodes | Node[] | [] | the list of graph nodes | -| links | Edge[] | [] | the list of graph edges | -| clusters | ClusterNode[] | [] | the list of cluster nodes | -| compoundNodes | CompoundNode[] | [] | the list of compound nodes | -| layout | string or Layout | 'dagre' | the graph layout - can be either one of the built-in layouts or a custom layout | -| layoutSettings | any | | the setting for the layout | -| curve | | the interpolation function used to generate the curve. It accepts any d3.curve function | -| draggingEnabled | boolean | true | enable dragging nodes | -| panningEnabled | boolean | true | enable panning | -| panOffsetX | number | | set the current `x` position of the graph | -| panOffsetY | number | | set the current `y` position of the graph | -| panningAxis | string | 'both', 'horizontal', 'vertical' | set panning direction | -| enableZoom | boolean | true | enable zoom | -| animate | boolean | false | enable animations | -| zoomSpeed | number | 0.1 | the zoom speed | -| zoomLevel | number | | the zoom level | -| minZoomLevel | number | 0.1 | the minimum zoom level | -| maxZoomLevel | number | 4.0 | the maximum zoom level | -| zoomLevel | number | | sets the zoom level, set autoZoom to false to use | -| autoZoom | boolean | false | automatically zoom the graph to fit in the available viewport when the graph is updated | -| panOnZoom | boolean | true | pan to the mouse cursor while zooming | -| autoCenter | boolean | false | center the graph in the viewport when the graph is updated | -| update\$ | Observable\ | | update the graph | -| center\$ | Observable\ | | center the graph | -| zoomToFit\$ | Observable\ | | zoom the graph to fit in the viewport | -| panToNode\$ | Observable\ | | pans the graph to center on the node ID passed emitted from the observable | -| nodeHeight | number | | the height of the nodes **deprecated** | -| nodeMaxHeight | number | | the max height of the nodes **deprecated** | -| nodeMinHeight | number | | the min height of the nodes **deprecated** | -| nodeWidth | number | | the width of the nodes **deprecated** | -| nodeMinWidth | number | | the min width of the nodes **deprecated** | -| nodeMaxWidth | number | | the max width of the nodes **deprecated** | -| showMiniMap | boolean | false | show/hide the minimap | -| miniMapMaxWidth | number | 100 | the maximum width of the minimap (in pixels) | -| miniMapMaxHeight | number | | the maximum height of the minimap (in pixels) | -| miniMapPosition | MiniMapPosition | MiniMapPosition.UpperRight | the position of the minimap | -| enablePreUpdateTransform | boolean | true | When set to false, disables an extra transform cycle when the graph updates | +| Property | Type | Default Value | Description | +| ------------------------- | ---------------------------------------- | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| view | number[] | | the size of the graph element - accepts an array of two numbers `[width, height]`. If not specified, the graph is resized to fit its parent element | +| nodes | Node[] | [] | the list of graph nodes | +| links | Edge[] | [] | the list of graph edges | +| clusters | ClusterNode[] | [] | the list of cluster nodes | +| compoundNodes | CompoundNode[] | [] | the list of compound nodes | +| layout | string or Layout | 'dagre' | the graph layout - can be either one of the built-in layouts or a custom layout | +| layoutSettings | any | | layout-specific options (e.g. Dagre: `dragEdgeStyle` `auto` / `orthogonal` / `smooth` / `straight`; for ELK via custom `ElkLayout`: `properties['elk.edgeRouting']` and `curveDistance`) | +| curve | | the interpolation function used to generate the curve. It accepts any d3.curve function | +| draggingEnabled | boolean | true | enable dragging nodes; while dragging, the active layout’s `updateEdge` refreshes edge polylines (Dagre, DagreCluster, DagreNodesOnly, and `ElkLayout` use shared orientation-aware ports and multi-point paths so edges stay curved and aligned with node boxes) | +| panningEnabled | boolean | true | enable panning | +| panOffsetX | number | | set the current `x` position of the graph | +| panOffsetY | number | | set the current `y` position of the graph | +| panningAxis | string | 'both', 'horizontal', 'vertical' | set panning direction | +| enableZoom | boolean | true | enable zoom | +| animate | boolean | false | enable animations | +| zoomSpeed | number | 0.1 | the zoom speed | +| zoomLevel | number | | the zoom level | +| minZoomLevel | number | 0.1 | the minimum zoom level | +| maxZoomLevel | number | 4.0 | the maximum zoom level | +| zoomLevel | number | | sets the zoom level, set autoZoom to false to use | +| autoZoom | boolean | false | automatically zoom the graph to fit in the available viewport when the graph is updated | +| panOnZoom | boolean | true | pan to the mouse cursor while zooming | +| autoCenter | boolean | false | center the graph in the viewport when the graph is updated | +| update\$ | Observable\ | | update the graph | +| center\$ | Observable\ | | center the graph | +| zoomToFit\$ | Observable\ | | zoom the graph to fit in the viewport | +| panToNode\$ | Observable\ | | pans the graph to center on the node ID passed emitted from the observable | +| nodeHeight | number | | the height of the nodes **deprecated** | +| nodeMaxHeight | number | | the max height of the nodes **deprecated** | +| nodeMinHeight | number | | the min height of the nodes **deprecated** | +| nodeWidth | number | | the width of the nodes **deprecated** | +| nodeMinWidth | number | | the min width of the nodes **deprecated** | +| nodeMaxWidth | number | | the max width of the nodes **deprecated** | +| showMiniMap | boolean | false | show/hide the minimap | +| miniMapMaxWidth | number | 100 | the maximum width of the minimap (in pixels) | +| miniMapMaxHeight | number | | the maximum height of the minimap (in pixels) | +| miniMapPosition | MiniMapPosition | MiniMapPosition.UpperRight | Minimap corner: `UpperLeft`, `UpperRight`, `LowerLeft`, `LowerRight` | +| enablePreUpdateTransform | boolean | true | When set to false, disables an extra transform cycle when the graph updates | +| edgePathSampleCount | number | 48 | Samples per edge polyline when building `line` / morph segments (layout tick, drag, tween); clamped to 2–512 | +| transitionAfterChanges | Partial\ | merged defaults (`mode: 'instant'`) | Layout morph: `mode: 'tween'`, `scope`, `durationMs`, `easing`, and optional **`morphCapture`** (`previousSource`, `degenerateEpsilon`, `modelResolutionOrder`, `syncTargetsFromPositionAfterTick`, `snapAddedNodeIds`). Use `mergeGraphLayoutTransition` to preview merged config. | +| useLayoutTransitions | boolean | true | When `true`, host always has `layout-js-driven` so CSS does not animate `.node-group` `transform` (historical behavior). When `false`, that class applies only while JS layout morphing is active, so CSS transitions on node groups can run when not using `transitionAfterChanges.mode: 'tween'`. Host `smooth-layout` is set only while tweening. | +| transitionDuringTransform | Partial\ | merged defaults (`enabled: false`) | Optional eased **translation** for programmatic pan (`panTo`, `center`, minimap, `zoomToFit` with `autoCenter`). Zoom scale is never animated. Merge with `mergeViewportTransition`. | +| layoutTransitionEffect | Partial\ | merged defaults (`kind: 'none'`) | Optional perspective / rotate during layout morph only when `transitionAfterChanges` resolves to `mode: 'tween'`. Merge with `mergeLayoutEffect`. | ## Outputs -| Event | Description | -| ----------- | ------------------------------------------------------------------------------------ | -| activate | element activation event (mouse enter) | -| deactivate | element deactivation event (mouse leave) | -| zoomChange | zoom change event, emits the new zoom level | -| stateChange | state change event, emits lifecycle events like `init`, `transform`, and `subscribe` | +| Event | Description | +| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| activate | element activation event (mouse enter) | +| deactivate | element deactivation event (mouse leave) | +| zoomChange | zoom change event, emits the new zoom level | +| stateChange | state change event, emits lifecycle events like `init`, `transform`, and `subscribe` | +| drawComplete | emits after a completed draw/tick pass (paths bound, graph ready). Use to hide loading UI or run one-shot center/`zoomToFit` without flashing before first layout. | + +## Layout morph & continuity + +- **`transitionAfterChanges`:** merged with defaults via `mergeGraphLayoutTransition`. Omitted or empty ⇒ `mode: 'instant'` (no rAF tween). Use **`mode: 'tween'`** for interpolated node translates and edge paths over **`durationMs`** with **`easing`**. +- **`scope`:** **`full`** interpolates the whole graph; **`additive`** only tweens new ids while stable nodes/edges snap—useful for growing graphs (see **Example / NgxGraphColaBranching**). +- **`useLayoutTransitions`:** coordinates host classes `layout-js-driven` and `smooth-layout` with JS morph so CSS and imperative `transform` updates do not fight. +- **`morphCapture`:** tune **`previousSource`** (model vs DOM) when prior positions are wrong; **`syncTargetsFromPositionAfterTick`** / **`snapAddedNodeIds`** for full-scope or newly-added nodes. +- **Custom templates:** `ngTemplateOutletContext` includes **`transitionAfterChangesActive`** when JS-driven layout morphing is active. + +See **Example / NgxGraphDagreLayoutTransition**, **Example / NgxGraphTranslateOnChanges**, and **Example / NgxGraphColaBranching** for `(drawComplete)`, rank-direction changes, and additive growth patterns. ## Dimensions diff --git a/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-cola-branching/ngx-graph-cola-branching.component.html b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-cola-branching/ngx-graph-cola-branching.component.html new file mode 100644 index 00000000..3ce40c01 --- /dev/null +++ b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-cola-branching/ngx-graph-cola-branching.component.html @@ -0,0 +1,31 @@ +
+
+

+ ColaForceDirectedLayout positions nodes with straight links between node bounds (not ELK-style + splines or orthogonal routing). Dragging restarts the force simulation rather than applying Dagre/ELK drag + polyline rules. transitionAfterChanges (tween, additive scope) only tweens new nodes and edges. + First segment has no motion; use Add segment or Auto-play. +

+
+ + + +
+

Step {{ stepIndex + 1 }} / {{ maxStep + 1 }} (click Add segment to begin)

+
+ @if (nodes.length && links.length) { + + } @else { +

Click Add segment to grow the branching graph.

+ } +
diff --git a/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-cola-branching/ngx-graph-cola-branching.component.scss b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-cola-branching/ngx-graph-cola-branching.component.scss new file mode 100644 index 00000000..4ba6dc90 --- /dev/null +++ b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-cola-branching/ngx-graph-cola-branching.component.scss @@ -0,0 +1,44 @@ +.demo { + padding: 1rem; +} + +.toolbar { + margin-bottom: 1rem; +} + +.hint { + margin: 0 0 0.75rem; + max-width: 48rem; + font-size: 0.875rem; + line-height: 1.4; +} + +.actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + align-items: center; + margin-bottom: 0.5rem; +} + +button { + padding: 0.5rem 1rem; + cursor: pointer; +} + +button:disabled { + cursor: not-allowed; + opacity: 0.6; +} + +.meta { + margin: 0; + font-size: 0.8125rem; + color: #555; +} + +.placeholder { + margin: 2rem 0; + font-size: 0.9375rem; + color: #444; +} diff --git a/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-cola-branching/ngx-graph-cola-branching.component.ts b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-cola-branching/ngx-graph-cola-branching.component.ts new file mode 100644 index 00000000..79bd687e --- /dev/null +++ b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-cola-branching/ngx-graph-cola-branching.component.ts @@ -0,0 +1,143 @@ +import { Component } from '@angular/core'; +import type { OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import * as shape from 'd3-shape'; +import type { Edge, Node, Layout } from '@swimlane/ngx-graph'; +import { ColaForceDirectedLayout, NgxGraphModule } from '@swimlane/ngx-graph'; + +/** Cumulative graph snapshots: each step adds nodes and edges in a branching tree. */ +const BRANCHING_STEPS: Array<{ nodes: Node[]; links: Edge[] }> = (() => { + const n = (id: string, label: string): Node => ({ id, label }); + const e = (id: string, source: string, target: string): Edge => ({ id, source, target }); + return [ + { + nodes: [n('1', 'Root'), n('2', 'A')], + links: [e('a', '1', '2')] + }, + { + nodes: [n('1', 'Root'), n('2', 'A'), n('3', 'B')], + links: [e('a', '1', '2'), e('b', '1', '3')] + }, + { + nodes: [n('1', 'Root'), n('2', 'A'), n('3', 'B'), n('4', 'A1')], + links: [e('a', '1', '2'), e('b', '1', '3'), e('c', '2', '4')] + }, + { + nodes: [n('1', 'Root'), n('2', 'A'), n('3', 'B'), n('4', 'A1'), n('5', 'A2')], + links: [e('a', '1', '2'), e('b', '1', '3'), e('c', '2', '4'), e('d', '2', '5')] + }, + { + nodes: [n('1', 'Root'), n('2', 'A'), n('3', 'B'), n('4', 'A1'), n('5', 'A2'), n('6', 'B1')], + links: [e('a', '1', '2'), e('b', '1', '3'), e('c', '2', '4'), e('d', '2', '5'), e('f', '3', '6')] + }, + { + nodes: [n('1', 'Root'), n('2', 'A'), n('3', 'B'), n('4', 'A1'), n('5', 'A2'), n('6', 'B1'), n('7', 'B2')], + links: [ + e('a', '1', '2'), + e('b', '1', '3'), + e('c', '2', '4'), + e('d', '2', '5'), + e('f', '3', '6'), + e('g', '3', '7') + ] + }, + { + nodes: [ + n('1', 'Root'), + n('2', 'A'), + n('3', 'B'), + n('4', 'A1'), + n('5', 'A2'), + n('6', 'B1'), + n('7', 'B2'), + n('8', 'Leaf') + ], + links: [ + e('a', '1', '2'), + e('b', '1', '3'), + e('c', '2', '4'), + e('d', '2', '5'), + e('f', '3', '6'), + e('g', '3', '7'), + e('h', '6', '8') + ] + } + ]; +})(); + +@Component({ + selector: 'ngx-graph-cola-branching-demo', + templateUrl: './ngx-graph-cola-branching.component.html', + styleUrls: ['./ngx-graph-cola-branching.component.scss'], + imports: [NgxGraphModule, CommonModule] +}) +export class NgxGraphColaBranchingDemoComponent implements OnDestroy { + /** Straight segments between node bounds; `curveLinear` matches the two-point polylines from Cola. */ + readonly curve = shape.curveLinear; + + readonly layout: Layout = new ColaForceDirectedLayout(); + + layoutSettings = { + viewDimensions: { width: 720, height: 480 } + }; + + nodes: Node[] = []; + links: Edge[] = []; + + stepIndex = -1; + autoTimer: ReturnType | null = null; + readonly maxStep = BRANCHING_STEPS.length - 1; + + addSegment(): void { + if (this.stepIndex >= this.maxStep) { + return; + } + this.stepIndex++; + const snap = BRANCHING_STEPS[this.stepIndex]; + this.nodes = snap.nodes.map(x => ({ ...x })); + this.links = snap.links.map(x => ({ ...x })); + } + + reset(): void { + this.stopAuto(); + this.stepIndex = -1; + this.nodes = []; + this.links = []; + } + + toggleAuto(): void { + if (this.autoTimer) { + this.stopAuto(); + } else { + if (this.stepIndex >= this.maxStep) { + this.reset(); + } + this.autoTimer = setInterval(() => { + if (this.stepIndex >= this.maxStep) { + this.stopAuto(); + return; + } + this.addSegment(); + }, 900); + } + } + + stopAuto(): void { + if (this.autoTimer) { + clearInterval(this.autoTimer); + this.autoTimer = null; + } + } + + get atEnd(): boolean { + return this.stepIndex >= this.maxStep; + } + + get autoLabel(): string { + return this.autoTimer ? 'Pause auto' : 'Auto-play segments'; + } + + ngOnDestroy(): void { + this.stopAuto(); + } +} diff --git a/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-cola-branching/ngx-graph-cola-branching.stories.ts b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-cola-branching/ngx-graph-cola-branching.stories.ts new file mode 100644 index 00000000..24cdbe5d --- /dev/null +++ b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-cola-branching/ngx-graph-cola-branching.stories.ts @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { NgxGraphColaBranchingDemoComponent } from './ngx-graph-cola-branching.component'; + +const meta: Meta = { + title: 'Example/NgxGraphColaBranching', + component: NgxGraphColaBranchingDemoComponent, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: `**Cola branching growth:** add nodes and edges in stages with \`curveLinear\` (straight links between node bounds from Cola) and \`transitionAfterChanges\` (\`{ mode: 'tween', scope: 'additive', durationMs: 0 }\`). After the first segment, each update interpolates from the previous layout. Use **Add segment** or **Auto-play**.` + } + } + } +}; + +export default meta; +type Story = StoryObj; + +export const ColaBranching: Story = {}; diff --git a/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-dagre-layout-transition/ngx-graph-dagre-layout-transition.component.html b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-dagre-layout-transition/ngx-graph-dagre-layout-transition.component.html new file mode 100644 index 00000000..5903d9a4 --- /dev/null +++ b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-dagre-layout-transition/ngx-graph-dagre-layout-transition.component.html @@ -0,0 +1,25 @@ +
+
+

+ transitionAfterChanges (tween, full scope, durationMs: 2000) + interpolates nodes and edges over two seconds when Dagre re-lays out. Toggle TB / LR to change + rank direction; larger viewport fits the graph in both orientations. animate is off so only + layout-driven motion runs. +

+ +
+
+ +
+
diff --git a/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-dagre-layout-transition/ngx-graph-dagre-layout-transition.component.scss b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-dagre-layout-transition/ngx-graph-dagre-layout-transition.component.scss new file mode 100644 index 00000000..73cbb3e0 --- /dev/null +++ b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-dagre-layout-transition/ngx-graph-dagre-layout-transition.component.scss @@ -0,0 +1,32 @@ +.demo { + padding: 1rem; +} + +.toolbar { + margin-bottom: 1rem; +} + +.hint { + margin: 0 0 0.75rem; + max-width: 48rem; + font-size: 0.875rem; + line-height: 1.4; +} + +button { + padding: 0.5rem 1rem; + cursor: pointer; +} + +.graph-shell { + min-height: 800px; + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.graph-shell--visible { + opacity: 1; + visibility: visible; + pointer-events: auto; +} diff --git a/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-dagre-layout-transition/ngx-graph-dagre-layout-transition.component.ts b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-dagre-layout-transition/ngx-graph-dagre-layout-transition.component.ts new file mode 100644 index 00000000..31be1aa7 --- /dev/null +++ b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-dagre-layout-transition/ngx-graph-dagre-layout-transition.component.ts @@ -0,0 +1,75 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import type { Edge, Node } from '@swimlane/ngx-graph'; +import { NgxGraphModule, Orientation } from '@swimlane/ngx-graph'; + +const NODE_DIM = { width: 36, height: 36 }; + +function dagreDemoNode(id: string, label: string): Node { + return { + id, + label, + dimension: { ...NODE_DIM }, + meta: { forceDimensions: true } + }; +} + +/** + * Dagre TB ↔ LR toggle with **transitionAfterChanges** (full-scope tween, 2000 ms) so nodes and edges interpolate when + * rank direction changes. + */ +@Component({ + selector: 'ngx-graph-dagre-layout-transition-demo', + templateUrl: './ngx-graph-dagre-layout-transition.component.html', + styleUrls: ['./ngx-graph-dagre-layout-transition.component.scss'], + imports: [NgxGraphModule, CommonModule] +}) +export class NgxGraphDagreLayoutTransitionDemoComponent { + readonly nodes: Node[] = [ + dagreDemoNode('1', 'Node A'), + dagreDemoNode('2', 'Node B'), + dagreDemoNode('3', 'Node C'), + dagreDemoNode('4', 'Node D'), + dagreDemoNode('5', 'Node E'), + dagreDemoNode('6', 'Node F') + ]; + + readonly links: Edge[] = [ + { id: 'a', source: '1', target: '2' }, + { id: 'b', source: '1', target: '3' }, + { id: 'c', source: '3', target: '4' }, + { id: 'd', source: '3', target: '5' }, + { id: 'e', source: '4', target: '5' }, + { id: 'f', source: '2', target: '6' } + ]; + + /** Hide until first `drawComplete` only (initial pan/center). Do not toggle off on layout changes — that would flash blank on every button press. */ + chartVisible = false; + + /** Center viewport once; later toggles would otherwise re-center every tick. */ + recenterOnLayout = true; + + readonly groupByNodeId = (node: Node) => node.id; + + private orientation = Orientation.TOP_TO_BOTTOM; + + /** `viewDimensions` matches the ngx-graph `view` size for Storybook; Dagre ignores extra keys. */ + layoutSettings: { orientation: Orientation; viewDimensions?: { width: number; height: number } } = { + orientation: this.orientation, + viewDimensions: { width: 1200, height: 800 } + }; + + onGraphDrawComplete(): void { + this.chartVisible = true; + this.recenterOnLayout = false; + } + + toggleOrientation(): void { + this.orientation = + this.orientation === Orientation.TOP_TO_BOTTOM ? Orientation.LEFT_TO_RIGHT : Orientation.TOP_TO_BOTTOM; + this.layoutSettings = { + ...this.layoutSettings, + orientation: this.orientation + }; + } +} diff --git a/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-dagre-layout-transition/ngx-graph-dagre-layout-transition.stories.ts b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-dagre-layout-transition/ngx-graph-dagre-layout-transition.stories.ts new file mode 100644 index 00000000..64492c83 --- /dev/null +++ b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-dagre-layout-transition/ngx-graph-dagre-layout-transition.stories.ts @@ -0,0 +1,23 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { NgxGraphDagreLayoutTransitionDemoComponent } from './ngx-graph-dagre-layout-transition.component'; + +const meta: Meta = { + title: 'Example/NgxGraphDagreLayoutTransition', + component: NgxGraphDagreLayoutTransitionDemoComponent, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: `**Dagre** graph toggling rank direction (TB ↔ LR) with **transitionAfterChanges** (\`full\` scope, \`durationMs: 2000\`) so nodes and edges tween over two seconds on re-layout. The viewport is sized so both orientations fit. + +**animate** is off so the host enter animation does not run; use **transitionAfterChanges** for interpolation on model updates.` + } + } + } +}; + +export default meta; +type Story = StoryObj; + +export const DagreLayoutTransition: Story = {}; diff --git a/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-msagl/msaglLayout.ts b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-msagl/msaglLayout.ts index d2131930..185a0de4 100644 --- a/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-msagl/msaglLayout.ts +++ b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-msagl/msaglLayout.ts @@ -13,9 +13,6 @@ import { } from 'msagl-js'; import type { GeomEdge } from 'msagl-js'; -const DEFAULT_EDGE_NAME = '\x00'; -const EDGE_KEY_DELIM = '\x01'; - export class MSAGLLayout implements Layout { public run(graph: Graph): Graph { const g = this.createGeomGraph(graph); @@ -30,8 +27,6 @@ export class MSAGLLayout implements Layout { g.layoutSettings = ss; layoutGraphWithSugiayma(g); - graph.edgeLabels = []; - for (const node of g.shallowNodes()) { const graphNode = graph.nodes.find(n => n.id === node.id); graphNode.position = { @@ -46,9 +41,12 @@ export class MSAGLLayout implements Layout { const geomEdges = Array.from(g.edges()); for (const edge of graph.edges) { - this.updateGraphEdge(graph, edge, geomEdges); + this.updateGraphEdge(edge, geomEdges); } + // Match ELK / GraphComponent.tick array branch: `edgeLabels` must list edges with `points` or tick iterates zero links. + graph.edgeLabels = graph.edges; + return graph; } @@ -80,21 +78,14 @@ export class MSAGLLayout implements Layout { return g; } - public updateGraphEdge(graph: Graph, edge: Edge, geomEdges: any): Graph { - const geoEdge = geomEdges.find(e => e.source.id === edge.source && e.target.id === edge.target); - edge.points = this.getPointsFromGeoEdge(geoEdge); - - const edgeLabelId = `${edge.source}${EDGE_KEY_DELIM}${edge.target}${EDGE_KEY_DELIM}${DEFAULT_EDGE_NAME}`; - const matchingEdgeLabel = graph.edgeLabels[edgeLabelId]; - if (matchingEdgeLabel) { - matchingEdgeLabel.points = edge.points; - } else { - graph.edgeLabels[edgeLabelId] = { points: edge.points }; - } - return graph; + public updateGraphEdge(edge: Edge, geomEdges: GeomEdge[]): void { + const src = String(edge.source); + const tgt = String(edge.target); + const geoEdge = geomEdges.find(e => e.source.id === src && e.target.id === tgt); + edge.points = geoEdge ? this.getPointsFromGeoEdge(geoEdge) : []; } - private getPointsFromGeoEdge(e: GeomEdge): any { + private getPointsFromGeoEdge(e: GeomEdge): Array<{ x: number; y: number }> { const result = []; const points = interpolateICurve(e.curve, e.curve.end.sub(e.curve.start).length / 20); diff --git a/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-org-tree/ngx-graph-org-tree.component.spec.ts b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-org-tree/ngx-graph-org-tree.component.spec.ts index f81b9714..5ce3f67b 100644 --- a/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-org-tree/ngx-graph-org-tree.component.spec.ts +++ b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-org-tree/ngx-graph-org-tree.component.spec.ts @@ -7,11 +7,11 @@ describe('NgxGraphOrgTreeComponent', () => { let component: NgxGraphOrgTreeComponent; let fixture: ComponentFixture; - beforeEach(async(() => { - TestBed.configureTestingModule({ + beforeEach(async () => { + await TestBed.configureTestingModule({ declarations: [NgxGraphOrgTreeComponent] }).compileComponents(); - })); + }); beforeEach(() => { fixture = TestBed.createComponent(NgxGraphOrgTreeComponent); diff --git a/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-translate-on-changes/ngx-graph-translate-on-changes.component.html b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-translate-on-changes/ngx-graph-translate-on-changes.component.html new file mode 100644 index 00000000..de1d73e3 --- /dev/null +++ b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-translate-on-changes/ngx-graph-translate-on-changes.component.html @@ -0,0 +1,33 @@ +
+
+

+ Smooth translation of nodes and edges on layout updates uses + transitionAfterChanges (mode: 'tween', scope: 'full', + durationMs: 2000) — there is no separate translateOnChanges input. + Randomize layout keeps the same nodes and edges and only changes Dagre orientation, ranker, and + spacing — edge paths tween because the same (source, target) pairs existed on the previous layout. + New random graph may add or remove nodes (between 4 and 10) and rebuild edges as a random DAG; + most edges are new pairs vs the prior tick, so paths may snap while nodes still tween. The shell + stays hidden until the first drawComplete; autoCenter runs only then so nodes do not + animate from the corner on every change. +

+
+ + +
+
+
+ +
+
diff --git a/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-translate-on-changes/ngx-graph-translate-on-changes.component.scss b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-translate-on-changes/ngx-graph-translate-on-changes.component.scss new file mode 100644 index 00000000..e88baf1f --- /dev/null +++ b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-translate-on-changes/ngx-graph-translate-on-changes.component.scss @@ -0,0 +1,38 @@ +.demo { + padding: 1rem; +} + +.toolbar { + margin-bottom: 1rem; +} + +.hint { + margin: 0 0 0.75rem; + max-width: 48rem; + font-size: 0.875rem; + line-height: 1.4; +} + +.toolbar-actions { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; +} + +button { + padding: 0.5rem 1rem; + cursor: pointer; +} + +.graph-shell { + min-height: 800px; + opacity: 0; + visibility: hidden; + pointer-events: none; +} + +.graph-shell--visible { + opacity: 1; + visibility: visible; + pointer-events: auto; +} diff --git a/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-translate-on-changes/ngx-graph-translate-on-changes.component.ts b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-translate-on-changes/ngx-graph-translate-on-changes.component.ts new file mode 100644 index 00000000..82eb2ad4 --- /dev/null +++ b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-translate-on-changes/ngx-graph-translate-on-changes.component.ts @@ -0,0 +1,187 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import type { Edge, Node } from '@swimlane/ngx-graph'; +import { NgxGraphModule, Orientation } from '@swimlane/ngx-graph'; +import type { DagreSettings } from '@swimlane/ngx-graph'; + +const NODE_DIM = { width: 36, height: 36 }; + +const MIN_NODES = 4; +const MAX_NODES = 10; + +function dagreDemoNode(id: string, label: string): Node { + return { + id, + label, + dimension: { ...NODE_DIM }, + meta: { forceDimensions: true } + }; +} + +function pickRandom(items: readonly T[]): T { + return items[Math.floor(Math.random() * items.length)]!; +} + +function randomInt(min: number, max: number): number { + return Math.floor(min + Math.random() * (max - min + 1)); +} + +/** Fisher–Yates shuffle (copy). */ +function shuffle(items: readonly T[]): T[] { + const a = [...items]; + for (let i = a.length - 1; i > 0; i--) { + const j = Math.floor(Math.random() * (i + 1)); + [a[i], a[j]] = [a[j], a[i]]; + } + return a; +} + +const ORIENTATIONS: Orientation[] = [ + Orientation.TOP_TO_BOTTOM, + Orientation.LEFT_TO_RIGHT, + Orientation.RIGHT_TO_LEFT, + Orientation.BOTTOM_TO_TOM +]; + +const RANKERS: NonNullable[] = ['network-simplex', 'tight-tree', 'longest-path']; + +/** Stable edge id for simple graphs without parallel edges (matches Dagre non-multigraph lookup by source+target). */ +function edgeId(source: string, target: string): string { + return `${source}--${target}`; +} + +/** + * Dagre with **transitionAfterChanges** (`mode: 'tween'`, `scope: 'full'`, 2000 ms): node `translate(...)` and edge + * paths interpolate between layouts. There is no separate `translateOnChanges` input—use `transitionAfterChanges`. + */ +@Component({ + selector: 'ngx-graph-translate-on-changes-demo', + templateUrl: './ngx-graph-translate-on-changes.component.html', + styleUrls: ['./ngx-graph-translate-on-changes.component.scss'], + imports: [NgxGraphModule, CommonModule] +}) +export class NgxGraphTranslateOnChangesDemoComponent { + nodes: Node[] = [ + dagreDemoNode('1', 'Node A'), + dagreDemoNode('2', 'Node B'), + dagreDemoNode('3', 'Node C'), + dagreDemoNode('4', 'Node D'), + dagreDemoNode('5', 'Node E'), + dagreDemoNode('6', 'Node F') + ]; + + links: Edge[] = [ + { id: edgeId('1', '2'), source: '1', target: '2' }, + { id: edgeId('1', '3'), source: '1', target: '3' }, + { id: edgeId('3', '4'), source: '3', target: '4' }, + { id: edgeId('3', '5'), source: '3', target: '5' }, + { id: edgeId('4', '5'), source: '4', target: '5' }, + { id: edgeId('2', '6'), source: '2', target: '6' } + ]; + + /** Next numeric id for newly added nodes (string keys). */ + private nextNodeId = 7; + + /** Hide until first `drawComplete` only; do not toggle off on layout changes (avoids blank flash). */ + chartVisible = false; + + /** Center viewport once; keep `false` after first draw so randomize does not re-center every time. */ + recenterOnLayout = true; + + readonly groupByNodeId = (node: Node) => node.id; + + layoutSettings: DagreSettings & { viewDimensions?: { width: number; height: number } } = { + orientation: Orientation.TOP_TO_BOTTOM, + ranker: 'network-simplex', + rankPadding: 80, + marginX: 24, + marginY: 24, + acyclicer: 'greedy', + viewDimensions: { width: 1200, height: 800 } + }; + + onGraphDrawComplete(): void { + this.chartVisible = true; + this.recenterOnLayout = false; + } + + /** + * Random DAG on `nodeIds`: a random Hamiltonian path plus random forward chords in a topological order + * (shuffle order, only edges from earlier to later index) so Dagre never sees cycles. + */ + private buildRandomEdges(nodeIds: string[]): Edge[] { + if (nodeIds.length < 2) { + return []; + } + const ord = shuffle(nodeIds); + const edges: Edge[] = []; + const seen = new Set(); + const add = (source: string, target: string) => { + const k = `${source}\0${target}`; + if (seen.has(k)) { + return; + } + seen.add(k); + edges.push({ id: edgeId(source, target), source, target }); + }; + for (let i = 0; i < ord.length - 1; i++) { + add(ord[i], ord[i + 1]); + } + for (let i = 0; i < ord.length; i++) { + for (let j = i + 2; j < ord.length; j++) { + if (Math.random() < 0.42) { + add(ord[i], ord[j]); + } + } + } + return edges; + } + + /** + * Same nodes and edges; only Dagre settings change. Prior `(source, target)` routes exist on the last tick so edge + * paths participate in the unified tween. + */ + randomizeLayoutOnly(): void { + this.nodes = this.nodes.map(n => ({ ...n })); + this.links = this.links.map(e => ({ ...e })); + this.shuffleLayoutSettings(); + } + + /** + * New random DAG (may add/remove nodes between 4 and 10). Most edges are new `(source, target)` pairs vs the prior + * tick, so ngx-graph has no prior polyline to morph from and paths may snap while nodes still tween. + */ + randomizeGraphTopology(): void { + let nodes = [...this.nodes]; + + if (nodes.length > MIN_NODES && Math.random() < 0.45) { + const removeId = pickRandom(nodes).id; + nodes = nodes.filter(n => n.id !== removeId); + } + + if (nodes.length < MAX_NODES && Math.random() < 0.45) { + const id = String(this.nextNodeId++); + nodes = [...nodes, dagreDemoNode(id, `Node ${id}`)]; + } + + const ids = nodes.map(n => n.id); + const links = this.buildRandomEdges(ids); + + this.nodes = nodes.map(n => ({ ...n })); + this.links = links.map(e => ({ ...e })); + this.shuffleLayoutSettings(); + } + + private shuffleLayoutSettings(): void { + this.layoutSettings = { + ...this.layoutSettings, + orientation: pickRandom(ORIENTATIONS), + ranker: pickRandom(RANKERS), + rankPadding: randomInt(40, 140), + marginX: randomInt(16, 48), + marginY: randomInt(16, 48), + acyclicer: 'greedy', + viewDimensions: { width: 1200, height: 800 } + }; + } +} diff --git a/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-translate-on-changes/ngx-graph-translate-on-changes.stories.ts b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-translate-on-changes/ngx-graph-translate-on-changes.stories.ts new file mode 100644 index 00000000..edf439e5 --- /dev/null +++ b/projects/swimlane/ngx-graph/src/stories/demos/components/ngx-graph-translate-on-changes/ngx-graph-translate-on-changes.stories.ts @@ -0,0 +1,21 @@ +import type { Meta, StoryObj } from '@storybook/angular'; + +import { NgxGraphTranslateOnChangesDemoComponent } from './ngx-graph-translate-on-changes.component'; + +const meta: Meta = { + title: 'Example/NgxGraphTranslateOnChanges', + component: NgxGraphTranslateOnChangesDemoComponent, + parameters: { + layout: 'fullscreen', + docs: { + description: { + component: `**Translate-on-changes style behavior** is configured with \`transitionAfterChanges\` (tween, full scope, 2000 ms), not a separate \`translateOnChanges\` input. **Randomize layout** keeps the same graph and only changes Dagre settings so edge paths tween. **New random graph** rebuilds topology; edges may snap when \`(source, target)\` pairs do not match the previous tick.` + } + } + } +}; + +export default meta; +type Story = StoryObj; + +export const TranslateOnChanges: Story = {}; diff --git a/projects/swimlane/ngx-graph/src/stories/graph.stories.ts b/projects/swimlane/ngx-graph/src/stories/graph.stories.ts index e90521d3..02186b77 100644 --- a/projects/swimlane/ngx-graph/src/stories/graph.stories.ts +++ b/projects/swimlane/ngx-graph/src/stories/graph.stories.ts @@ -1,8 +1,6 @@ import type { Meta, StoryObj } from '@storybook/angular'; import { applicationConfig } from '@storybook/angular'; // import { expect, userEvent, within } from '@storybook/test'; -import { importProvidersFrom } from '@angular/core'; -import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { LayoutService, GraphComponent, MiniMapPosition } from '../public_api'; const meta: Meta = { @@ -12,7 +10,8 @@ const meta: Meta = { // Apply application config to all stories applicationConfig({ // List of providers and environment providers that should be available to the root component and all its children. - providers: [LayoutService, importProvidersFrom(BrowserAnimationsModule)] + // Animations: global `provideAnimations()` in `.storybook/preview.ts` + providers: [LayoutService] }) ], parameters: { @@ -65,6 +64,10 @@ export const Demo: Story = { deferDisplayUntilPosition: false, centerNodesOnPositionChange: true, enablePreUpdateTransform: true, + edgePathSampleCount: 48, + useLayoutTransitions: true, + /** Merged with defaults; use `mode: 'tween'` for layout morph (see Documentation / Interface). */ + transitionAfterChanges: { mode: 'instant' }, miniMapPosition: MiniMapPosition.UpperRight, zoomSpeed: 0.1, minZoomLevel: 0.1, @@ -122,6 +125,15 @@ Demo.parameters = { enablePreUpdateTransform: { control: { type: 'boolean' } }, + edgePathSampleCount: { + control: { type: 'number', min: 2, max: 512, step: 1 } + }, + useLayoutTransitions: { + control: { type: 'boolean' } + }, + transitionAfterChanges: { + control: { type: 'object' } + }, animate: { control: { type: 'boolean' } }, diff --git a/projects/swimlane/ngx-graph/src/test.ts b/projects/swimlane/ngx-graph/src/test.ts index 40003e46..777fa11e 100644 --- a/projects/swimlane/ngx-graph/src/test.ts +++ b/projects/swimlane/ngx-graph/src/test.ts @@ -1,6 +1,5 @@ // This file is required by karma.conf.js and loads recursively all the .spec and framework files -import 'core-js/es7/reflect'; import 'zone.js'; import 'zone.js/testing'; import { getTestBed } from '@angular/core/testing'; From 55012542455f17b47878e8bd9a0f9d6508d0032c Mon Sep 17 00:00:00 2001 From: Stephen Belovarich Date: Tue, 21 Apr 2026 22:03:18 -0700 Subject: [PATCH 2/3] feat: upgrade component to latest --- CHANGELOG.md | 6 + .../src/lib/graph/graph.component.html | 51 +- .../src/lib/graph/graph.component.scss | 14 + .../src/lib/graph/graph.component.spec.ts | 26 +- .../src/lib/graph/graph.component.ts | 474 +++++++++--------- .../ngx-graph/src/lib/graph/graph.module.ts | 7 +- .../src/lib/graph/mouse-wheel.directive.ts | 11 +- .../ngx-graph/src/lib/ngx-graph.module.ts | 6 +- .../src/lib/utils/visibility-observer.ts | 3 +- 9 files changed, 318 insertions(+), 280 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3dd373f9..597dd09b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## HEAD (unreleased) +- Breaking: `GraphComponent` is now a standalone component built on signal APIs (`input`, `output`, `model`, `contentChild`, `viewChildren`) instead of decorator-based `@Input` / `@Output` and classic queries. The published `NgxGraphModule` and `GraphModule` wrappers are still there so existing NgModule-based applications can import the graph as before, but those modules are deprecated; new code should import `GraphComponent` (and add it to the consuming component or route `imports` array) the same way you would any other standalone piece. +- Breaking: From TypeScript, inputs behave like signal readers: for example, read the current node list with `graph.nodes()` rather than `graph.nodes`. The `layout`, `curve`, and `activeEntries` bindings use `model()` where the graph needs to write back internally; consumers generally keep using normal property bindings, while code inside the component updates via `.set()`. +- Breaking: Outputs are `OutputRef` values, not `EventEmitter`, so they do not offer `.pipe()`. To keep using RxJS operators, turn an output into an observable with `outputToObservable` from `@angular/core/rxjs-interop` (in an appropriate injection context), or subscribe to the ref directly. Payload types (such as `NgxGraphStateChangeEvent`) are unchanged; only the subscription shape differs. +- Breaking: `@ViewChildren` / `@ContentChild` style access is replaced with signal query functions, so in component code you call `graph.linkElements()` (or the other query methods) instead of keeping a `QueryList` on a property. +- Breaking: The in-component `animations: [ trigger(...) ]` definition was removed. Enter styling on the root now uses the framework’s `animate.enter` class binding together with keyframes in the graph stylesheet, which replaces the old opacity `:enter` transition and avoids the deprecated `trigger` API. Angular does not support mixing the legacy `animations` metadata and `animate.enter` / `animate.leave` on the same component. The old `[@.disabled]` host binding for the legacy tree is also gone; it was scoped to the previous animation system and did not drive node positions, but projected templates that relied on that subtree being disabled for their own `[@...]` triggers may need a different approach. +- Breaking: Optional viewport inputs `zoomLevel`, `panOffsetX`, and `panOffsetY` are now signal inputs wired through `effect()` to `zoomTo` and `panTo`, so the graph no longer assigns those via a property setter, and the effects run when the parent binding’s value actually changes. If a parent expression changes on every data refresh, you may see extra viewport updates; binding stable values (or leaving these inputs unset) matches the old mental model. Automatic fit and centering still come from the documented `autoZoom`, `autoCenter`, and `zoomToFit$` behavior. If you use `transitionAfterChanges` with full-scope layout morph, whether the post-tick block runs `zoomToFit` or `center` can differ from the additive or instant paths, as before. - Enhancement: `transitionAfterChanges`, `transitionDuringTransform`, `layoutTransitionEffect`, and `applyVisualContinuityBeforeLayout` inputs configure rich continuity when a prior graph had nodes, allowing for animations between graph updates. - Breaking: `useLayoutTransitions` (default `true` controls `layout-js-driven`; when `false` it applies only during JS `mode: 'tween'`); - Enhancement: `GraphComponent` `edgePathSampleCount` input (default 48, clamped 2–512) for edge resampling in layout, morph, and `redrawEdge` diff --git a/projects/swimlane/ngx-graph/src/lib/graph/graph.component.html b/projects/swimlane/ngx-graph/src/lib/graph/graph.component.html index 47d8b4d3..7cd4ae7f 100644 --- a/projects/swimlane/ngx-graph/src/lib/graph/graph.component.html +++ b/projects/swimlane/ngx-graph/src/lib/graph/graph.component.html @@ -5,8 +5,7 @@ [style.transform]="layoutOuterTransform" [style.transform-origin]="layoutEffectTransformOrigin" [style.width.px]="width" - [@animationState]="'active'" - [@.disabled]="!animate" + [animate.enter]="animate() ? 'ngx-graph-outer-fade' : null" (mouseWheelUp)="onZoom($event, 'in')" (mouseWheelDown)="onZoom($event, 'out')" mouseWheel @@ -20,8 +19,8 @@ class="graph chart" > - @if (defsTemplate) { - + @if (defsTemplate()) { + } @for (link of graph.edges; track link) { @@ -40,18 +39,18 @@ - @if (clusterTemplate && !node.hidden) { + @if (clusterTemplate() && !node.hidden) { } - @if (!clusterTemplate && !node.hidden) { + @if (!clusterTemplate() && !node.hidden) { - @if (nodeTemplate && !node.hidden) { + @if (nodeTemplate() && !node.hidden) { } - @if (!nodeTemplate && !node.hidden) { + @if (!nodeTemplate() && !node.hidden) { @for (link of graph.edges; track trackLinkBy($index, link)) { - @if (linkTemplate) { + @if (linkTemplate()) { } - @if (!linkTemplate) { + @if (!linkTemplate()) { } @@ -118,19 +117,19 @@ - @if (nodeTemplate && !node.hidden) { + @if (nodeTemplate() && !node.hidden) { } - @if (!nodeTemplate && !node.hidden) { + @if (!nodeTemplate() && !node.hidden) { - @if (showMiniMap) { + @if (showMiniMap()) { - @if (miniMapNodeTemplate) { + @if (miniMapNodeTemplate()) { } - @if (!miniMapNodeTemplate && nodeTemplate) { + @if (!miniMapNodeTemplate() && nodeTemplate()) { } - @if (!nodeTemplate && !miniMapNodeTemplate && !node.hidden) { + @if (!nodeTemplate() && !miniMapNodeTemplate() && !node.hidden) { `, - standalone: false + imports: [GraphComponent] }) class TestGraphDrawCompleteHostComponent { syncLayout = new TestSyncLayout(); @@ -93,8 +92,7 @@ class TestGraphDrawCompleteHostComponent { describe('GraphComponent drawComplete', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [GraphModule], - declarations: [TestGraphDrawCompleteHostComponent] + imports: [TestGraphDrawCompleteHostComponent] }).compileComponents(); }); @@ -113,9 +111,9 @@ describe('GraphComponent drawComplete', () => { const graph = graphEl.componentInstance as GraphComponent; expect(graph.graph.edges.length).toBe(1); - expect(graph.linkElements?.length ?? 0).toBe(graph.graph.edges.length); + expect(graph.linkElements()?.length ?? 0).toBe(graph.graph.edges.length); - for (const linkRef of graph.linkElements ?? []) { + for (const linkRef of graph.linkElements() ?? []) { const g = linkRef.nativeElement as SVGGElement; const path = g.querySelector('path'); expect(path?.getAttribute('d')?.length).toBeGreaterThan(0); @@ -147,7 +145,7 @@ describe('GraphComponent drawComplete', () => { [useLayoutTransitions]="useLayoutTransitions" > `, - standalone: false + imports: [GraphComponent] }) class TestGraphLayoutJsHostComponent { syncLayout = new TestSyncLayout(); @@ -164,7 +162,7 @@ class TestGraphLayoutJsHostComponent { template: ` `, - standalone: false + imports: [GraphComponent] }) class TestGraphParseTranslateHostComponent { syncLayout = new TestLayoutWithCustomParseTranslate(); @@ -178,8 +176,7 @@ class TestGraphParseTranslateHostComponent { describe('GraphComponent layout-js-driven host class', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [GraphModule], - declarations: [TestGraphLayoutJsHostComponent] + imports: [TestGraphLayoutJsHostComponent] }).compileComponents(); }); @@ -217,8 +214,7 @@ const DEFAULT_EDGE_PATH_SAMPLE_COUNT = 48; describe('GraphComponent redrawEdge (curve + resampling)', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [GraphModule], - declarations: [TestGraphLayoutJsHostComponent] + imports: [TestGraphLayoutJsHostComponent] }).compileComponents(); }); @@ -310,8 +306,7 @@ describe('GraphComponent redrawEdge (curve + resampling)', () => { describe('GraphComponent resolveTranslateFromTransform', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [GraphModule], - declarations: [TestGraphParseTranslateHostComponent] + imports: [TestGraphParseTranslateHostComponent] }).compileComponents(); }); @@ -332,8 +327,7 @@ describe('GraphComponent resolveTranslateFromTransform', () => { describe('GraphComponent layout anchor helpers (full-scope edge morph)', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [GraphModule], - declarations: [TestGraphLayoutJsHostComponent] + imports: [TestGraphLayoutJsHostComponent] }).compileComponents(); }); diff --git a/projects/swimlane/ngx-graph/src/lib/graph/graph.component.ts b/projects/swimlane/ngx-graph/src/lib/graph/graph.component.ts index 1cbd5dd1..6c729062 100644 --- a/projects/swimlane/ngx-graph/src/lib/graph/graph.component.ts +++ b/projects/swimlane/ngx-graph/src/lib/graph/graph.component.ts @@ -1,34 +1,34 @@ // rename transition due to conflict with d3 transition -import { animate, style, transition as ngTransition, trigger } from '@angular/animations'; import { AfterViewInit, ChangeDetectionStrategy, Component, - ContentChild, ElementRef, - EventEmitter, HostListener, inject, Injector, - Input, OnDestroy, OnInit, - Output, - QueryList, TemplateRef, - ViewChildren, ViewEncapsulation, NgZone, ChangeDetectorRef, OnChanges, SimpleChanges, afterNextRender, - isDevMode + isDevMode, + effect, + input, + model, + output, + contentChild, + viewChildren } from '@angular/core'; +import { NgTemplateOutlet } from '@angular/common'; import { select } from 'd3-selection'; import * as shape from 'd3-shape'; import { Observable, Subscription, of, fromEvent as observableFromEvent, Subject } from 'rxjs'; -import { first, debounceTime, takeUntil } from 'rxjs/operators'; +import { debounceTime, takeUntil } from 'rxjs/operators'; import { identity, scale, smoothMatrix, toSVG, transform, translate } from 'transformation-matrix'; import { Layout } from '../models/layout.model'; import { LayoutService } from './layouts/layout.service'; @@ -43,6 +43,7 @@ import { throttleable } from '../utils/throttle'; import { ColorHelper } from '../utils/color.helper'; import { ViewDimensions, calculateViewDimensions } from '../utils/view-dimensions.helper'; import { VisibilityObserver } from '../utils/visibility-observer'; +import { MouseWheelDirective } from './mouse-wheel.directive'; import { mergeGraphLayoutTransition, mergeViewportTransition, @@ -99,56 +100,51 @@ export interface NgxGraphStateChangeEvent { templateUrl: 'graph.component.html', encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, - animations: [ - trigger('animationState', [ - ngTransition(':enter', [style({ opacity: 0 }), animate('500ms 100ms', style({ opacity: 1 }))]) - ]) - ], - standalone: false + imports: [NgTemplateOutlet, MouseWheelDirective] }) export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewInit { private readonly injector = inject(Injector); - @Input() nodes: Node[] = []; - @Input() clusters: ClusterNode[] = []; - @Input() compoundNodes: CompoundNode[] = []; - @Input() links: Edge[] = []; - @Input() activeEntries: any[] = []; - @Input() curve: any; - @Input() draggingEnabled = true; - @Input() nodeHeight: number; - @Input() nodeMaxHeight: number; - @Input() nodeMinHeight: number; - @Input() nodeWidth: number; - @Input() nodeMinWidth: number; - @Input() nodeMaxWidth: number; - @Input() panningEnabled: boolean = true; - @Input() panningAxis: PanningAxis = PanningAxis.Both; - @Input() enableZoom = true; - @Input() zoomSpeed = 0.1; - @Input() minZoomLevel = 0.1; - @Input() maxZoomLevel = 4.0; - @Input() autoZoom = false; - @Input() panOnZoom = true; - @Input() animate? = false; - @Input() autoCenter = false; - @Input() update$: Observable; - @Input() center$: Observable; - @Input() zoomToFit$: Observable; - @Input() panToNode$: Observable; - @Input() layout: string | Layout; - @Input() layoutSettings: any; - @Input() enableTrackpadSupport = false; - @Input() showMiniMap: boolean = false; - @Input() miniMapMaxWidth: number = 100; - @Input() miniMapMaxHeight: number; - @Input() miniMapPosition: MiniMapPosition = MiniMapPosition.UpperRight; - @Input() view: [number, number]; - @Input() scheme: any = 'cool'; - @Input() customColors: any; - @Input() deferDisplayUntilPosition: boolean = false; - @Input() centerNodesOnPositionChange = true; - @Input() enablePreUpdateTransform = true; + readonly nodes = input([]); + readonly clusters = input([]); + readonly compoundNodes = input([]); + readonly links = input([]); + readonly activeEntries = model([]); + readonly curve = model(undefined); + readonly draggingEnabled = input(true); + readonly nodeHeight = input(undefined); + readonly nodeMaxHeight = input(undefined); + readonly nodeMinHeight = input(undefined); + readonly nodeWidth = input(undefined); + readonly nodeMinWidth = input(undefined); + readonly nodeMaxWidth = input(undefined); + readonly panningEnabled = input(true); + readonly panningAxis = input(PanningAxis.Both); + readonly enableZoom = input(true); + readonly zoomSpeed = input(0.1); + readonly minZoomLevel = input(0.1); + readonly maxZoomLevel = input(4.0); + readonly autoZoom = input(false); + readonly panOnZoom = input(true); + readonly animate = input(false); + readonly autoCenter = input(false); + readonly update$ = input>(undefined); + readonly center$ = input>(undefined); + readonly zoomToFit$ = input>(undefined); + readonly panToNode$ = input>(undefined); + readonly layout = model(undefined); + readonly layoutSettings = input(undefined); + readonly enableTrackpadSupport = input(false); + readonly showMiniMap = input(false); + readonly miniMapMaxWidth = input(100); + readonly miniMapMaxHeight = input(undefined); + readonly miniMapPosition = input(MiniMapPosition.UpperRight); + readonly view = input<[number, number]>(undefined); + readonly scheme = input('cool'); + readonly customColors = input(undefined); + readonly deferDisplayUntilPosition = input(false); + readonly centerNodesOnPositionChange = input(true); + readonly enablePreUpdateTransform = input(true); /** * Layout transition after nodes/links change. Merged with defaults via {@link mergeGraphLayoutTransition}; when omitted or * empty, defaults apply (`mode: 'instant'`). Use `{ mode: 'tween', scope: 'full' }` for full-graph interpolation, or @@ -156,40 +152,47 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn * * **`mode: 'tween'` is opt-in** (no tween unless you pass a partial that resolves to tween after merge). */ - @Input() transitionAfterChanges?: Partial; + readonly transitionAfterChanges = input>(undefined); /** * Number of samples along each edge polyline when building `line` / morph segments (layout tick, drag * {@link redrawEdge}, unified morph). Clamped to `[2, 512]`; default `48` when unset or non-finite. */ - @Input() edgePathSampleCount?: number; + readonly edgePathSampleCount = input(undefined); /** * When `true` (default), the host always has the `layout-js-driven` class so CSS does not animate `.node-group` * `transform` (matches historical behavior). When `false`, `layout-js-driven` is applied only while JS layout morphing * is active ({@link layoutJsMorphEnabled}), allowing host CSS transitions on node groups when not using `mode: 'tween'`. */ - @Input() useLayoutTransitions = true; + readonly useLayoutTransitions = input(true); /** Optional eased translation for programmatic pan only (`panTo`, `center`, minimap, `zoomToFit` autoCenter). Zoom scale is never animated. */ - @Input() transitionDuringTransform?: Partial; + readonly transitionDuringTransform = input>(undefined); /** Optional perspective / rotate flair during layout morph only (`mode: 'tween'`). */ - @Input() layoutTransitionEffect?: Partial; - - @Output() select = new EventEmitter(); - @Output() activate: EventEmitter = new EventEmitter(); - @Output() deactivate: EventEmitter = new EventEmitter(); - @Output() zoomChange: EventEmitter = new EventEmitter(); - @Output() clickHandler: EventEmitter = new EventEmitter(); - @Output() stateChange: EventEmitter = new EventEmitter(); - @Output() drawComplete = new EventEmitter(); - - @ContentChild('linkTemplate') linkTemplate: TemplateRef; - @ContentChild('nodeTemplate') nodeTemplate: TemplateRef; - @ContentChild('clusterTemplate') clusterTemplate: TemplateRef; - @ContentChild('defsTemplate') defsTemplate: TemplateRef; - @ContentChild('miniMapNodeTemplate') miniMapNodeTemplate: TemplateRef; - - @ViewChildren('nodeElement') nodeElements: QueryList; - @ViewChildren('clusterElement') clusterElements: QueryList; - @ViewChildren('linkElement') linkElements: QueryList; + readonly layoutTransitionEffect = input>(undefined); + + /** Template alias `zoomLevel` — imperatively sets zoom; see {@link zoomTo}. */ + readonly zoomLevelInput = input(undefined, { alias: 'zoomLevel' }); + /** Template alias `panOffsetX` — imperatively pans; see {@link panTo}. */ + readonly panOffsetXInput = input(undefined, { alias: 'panOffsetX' }); + /** Template alias `panOffsetY` — imperatively pans; see {@link panTo}. */ + readonly panOffsetYInput = input(undefined, { alias: 'panOffsetY' }); + + readonly select = output(); + readonly activate = output(); + readonly deactivate = output(); + readonly zoomChange = output(); + readonly clickHandler = output(); + readonly stateChange = output(); + readonly drawComplete = output(); + + readonly linkTemplate = contentChild>('linkTemplate'); + readonly nodeTemplate = contentChild>('nodeTemplate'); + readonly clusterTemplate = contentChild>('clusterTemplate'); + readonly defsTemplate = contentChild>('defsTemplate'); + readonly miniMapNodeTemplate = contentChild>('miniMapNodeTemplate'); + + readonly nodeElements = viewChildren('nodeElement'); + readonly clusterElements = viewChildren('clusterElement'); + readonly linkElements = viewChildren('linkElement'); public chartWidth: any; @@ -261,15 +264,36 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn public zone: NgZone, public cd: ChangeDetectorRef, private layoutService: LayoutService - ) {} + ) { + effect(() => { + const level = this.zoomLevelInput(); + if (level == null || isNaN(Number(level))) { + return; + } + this.zoomTo(Number(level)); + }); + effect(() => { + const x = this.panOffsetXInput(); + if (x == null || isNaN(Number(x))) { + return; + } + this.panTo(Number(x), null); + }); + effect(() => { + const y = this.panOffsetYInput(); + if (y == null || isNaN(Number(y))) { + return; + } + this.panTo(null, Number(y)); + }); + } /** Coloring domain key; default coalesces `label`, then `id`, then `''` so {@link ColorHelper} never receives null/undefined. */ - @Input() - groupResultsBy: (node: any) => string = node => node.label ?? node.id ?? ''; + readonly groupResultsBy = input<(node: any) => string>(node => node.label ?? node.id ?? ''); /** Merged layout transition config from {@link transitionAfterChanges} and defaults. */ get effectiveLayoutTransition(): GraphLayoutTransition { - return mergeGraphLayoutTransition(this.transitionAfterChanges); + return mergeGraphLayoutTransition(this.transitionAfterChanges()); } /** `true` when rAF morph should run after layout (`mode: 'tween'`). */ @@ -284,15 +308,15 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn /** Host `layout-js-driven` class: always on when {@link useLayoutTransitions} is `true`; otherwise only when {@link layoutJsMorphEnabled}. */ get layoutJsDrivenHostClass(): boolean { - return this.useLayoutTransitions ? true : this.layoutJsMorphEnabled; + return this.useLayoutTransitions() ? true : this.layoutJsMorphEnabled; } get effectiveViewportTransition() { - return mergeViewportTransition(this.transitionDuringTransform); + return mergeViewportTransition(this.transitionDuringTransform()); } get effectiveLayoutEffect(): LayoutTransitionEffect { - return mergeLayoutEffect(this.layoutTransitionEffect); + return mergeLayoutEffect(this.layoutTransitionEffect()); } /** @@ -302,14 +326,6 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn return this.transformationMatrix.a; } - /** - * Set the current zoom level - */ - @Input('zoomLevel') - set zoomLevel(level) { - this.zoomTo(Number(level)); - } - /** * Get the current `x` position of the graph */ @@ -317,14 +333,6 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn return this.transformationMatrix.e; } - /** - * Set the current `x` position of the graph - */ - @Input('panOffsetX') - set panOffsetX(x) { - this.panTo(Number(x), null); - } - /** * Get the current `y` position of the graph */ @@ -332,14 +340,6 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn return this.transformationMatrix.f; } - /** - * Set the current `y` position of the graph - */ - @Input('panOffsetY') - set panOffsetY(y) { - this.panTo(null, Number(y)); - } - /** * Angular lifecycle event * @@ -347,26 +347,30 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn * @memberOf GraphComponent */ ngOnInit(): void { - if (this.update$) { - this.update$.pipe(takeUntil(this.destroy$)).subscribe(() => { + const update$ = this.update$(); + if (update$) { + update$.pipe(takeUntil(this.destroy$)).subscribe(() => { this.update(); }); } - if (this.center$) { - this.center$.pipe(takeUntil(this.destroy$)).subscribe(() => { + const center$ = this.center$(); + if (center$) { + center$.pipe(takeUntil(this.destroy$)).subscribe(() => { this.center(); }); } - if (this.zoomToFit$) { - this.zoomToFit$.pipe(takeUntil(this.destroy$)).subscribe(options => { + const zoomToFit$ = this.zoomToFit$(); + if (zoomToFit$) { + zoomToFit$.pipe(takeUntil(this.destroy$)).subscribe(options => { this.zoomToFit(options ? options : {}); }); } - if (this.panToNode$) { - this.panToNode$.pipe(takeUntil(this.destroy$)).subscribe((nodeId: string) => { + const panToNode$ = this.panToNode$(); + if (panToNode$) { + panToNode$.pipe(takeUntil(this.destroy$)).subscribe((nodeId: string) => { this.panToNodeId(nodeId); }); } @@ -378,11 +382,12 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn ngOnChanges(changes: SimpleChanges): void { this.basicUpdate(); const { layoutSettings } = changes; - this.setLayout(this.layout, !!changes['layout']); + const layout = this.layout(); + this.setLayout(layout, !!changes['layout']); if (layoutSettings) { - this.setLayoutSettings(this.layoutSettings); + this.setLayoutSettings(this.layoutSettings()); } - if (this.layout && this.nodes.length && this.links.length) { + if (layout && this.nodes().length && this.links().length) { this.update(); } } @@ -399,14 +404,15 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn layout = 'dagre'; } if (typeof layout === 'string') { - this.layout = this.layoutService.getLayout(layout); - this.setLayoutSettings(this.layoutSettings); + this.layout.set(this.layoutService.getLayout(layout)); + this.setLayoutSettings(this.layoutSettings()); } } setLayoutSettings(settings: any): void { - if (this.layout && typeof this.layout !== 'string') { - this.layout.settings = settings; + const layout = this.layout(); + if (layout && typeof layout !== 'string') { + layout.settings = settings; } } @@ -453,8 +459,8 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn */ update(): void { this.basicUpdate(); - if (!this.curve) { - this.curve = shape.curveBundle.beta(1); + if (!this.curve()) { + this.curve.set(shape.curveBundle.beta(1)); } this.zone.run(() => { @@ -491,9 +497,11 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn n.id = id(); } if (!n.dimension) { + const nodeWidth = this.nodeWidth(); + const nodeHeight = this.nodeHeight(); n.dimension = { - width: this.nodeWidth ? this.nodeWidth : 30, - height: this.nodeHeight ? this.nodeHeight : 30 + width: nodeWidth ? nodeWidth : 30, + height: nodeHeight ? nodeHeight : 30 }; n.meta.forceDimensions = false; } else { @@ -504,7 +512,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn x: 0, y: 0 }; - if (this.deferDisplayUntilPosition) { + if (this.deferDisplayUntilPosition()) { n.hidden = true; } } @@ -522,10 +530,10 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn const priorGraph = this.graph; const nextGraph: Graph = { - nodes: this.nodes.map(n => initializeNode(n)), - clusters: this.clusters.map(n => initializeNode(n)), - compoundNodes: this.compoundNodes.map(n => initializeNode(n)), - edges: this.links.map(e => initializeEdge(e)) + nodes: this.nodes().map(n => initializeNode(n)), + clusters: this.clusters().map(n => initializeNode(n)), + compoundNodes: this.compoundNodes().map(n => initializeNode(n)), + edges: this.links().map(e => initializeEdge(e)) }; this.applyVisualContinuityBeforeLayout(nextGraph, priorGraph); @@ -649,7 +657,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn if (!n.data) { n.data = {}; } - n.data.color = this.colors.getColor(this.groupResultsBy(n)); + n.data.color = this.colors.getColor(this.groupResultsBy()(n)); this.updateNodeGroupTransform(n); } }; @@ -664,7 +672,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn /** Merged ELK `properties` from the active layout (defaults + instance settings). */ private elkMergedProperties(): Record { - const L = this.layout as { + const L = this.layout() as { defaultSettings?: { properties?: Record }; settings?: { properties?: Record }; }; @@ -759,7 +767,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn */ draw(): void { // Recalculate the layout - const result = (this.layout as Layout)?.run(this.graph); + const result = (this.layout() as Layout)?.run(this.graph); const result$ = result instanceof Observable ? result : of(result); this.graphSubscription.add( result$.subscribe(graph => { @@ -781,7 +789,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn /** Prefers `Layout.parseTranslate` on the resolved layout object when present. */ public resolveTranslateFromTransform(transformStr: string | undefined): { tx: number; ty: number } { - const L = this.layout; + const L = this.layout(); if (L && typeof L !== 'string' && typeof (L as Layout).parseTranslate === 'function') { return (L as Layout).parseTranslate!(transformStr); } @@ -797,10 +805,11 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn /** Matches layout engines that merge `defaultSettings` with `settings` (e.g. DagreNodesOnly multigraph). */ private isLayoutMultigraph(): boolean { - if (!this.layout || typeof this.layout === 'string') { + const layoutValue = this.layout(); + if (!layoutValue || typeof layoutValue === 'string') { return false; } - const layout = this.layout as { defaultSettings?: { multigraph?: boolean }; settings?: { multigraph?: boolean } }; + const layout = layoutValue as { defaultSettings?: { multigraph?: boolean }; settings?: { multigraph?: boolean } }; const merged = Object.assign({}, layout.defaultSettings ?? {}, layout.settings ?? {}); return !!merged.multigraph; } @@ -853,7 +862,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn /** Resample count for edge polylines (layout, morph, drag). */ private effectiveEdgePathSampleCount(): number { - const raw = this.edgePathSampleCount; + const raw = this.edgePathSampleCount(); const n = typeof raw === 'number' && Number.isFinite(raw) ? Math.floor(raw) : 48; return Math.min(512, Math.max(2, n)); } @@ -888,7 +897,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn } private translateFromLayoutPositionForNode(n: Node): { tx: number; ty: number } { - const center = this.centerNodesOnPositionChange; + const center = this.centerNodesOnPositionChange(); const dx = center ? (n.dimension?.width ?? 0) / 2 : 0; const dy = center ? (n.dimension?.height ?? 0) / 2 : 0; const px = n.position?.x ?? 0; @@ -1324,10 +1333,11 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn if (!nodeTweenActive) { this.syncNodeTransformsFromLayoutPositions(); } - if (this.animate || this.layoutJsMorphEnabled) { + const animateValue = this.animate(); + if (animateValue || this.layoutJsMorphEnabled) { this.cd.detectChanges(); } - this.scheduleRedrawLinesAfterView(this.animate || this.layoutJsMorphEnabled, tickId); + this.scheduleRedrawLinesAfterView(animateValue || this.layoutJsMorphEnabled, tickId); if (this.hasGraphNodeLikeContent() && !nodeTweenActive) { this.updateGraphDims(); } @@ -1336,9 +1346,11 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn } if (!nodeTweenActive) { - if (this.autoZoom) { - this.zoomToFit({ autoCenter: this.autoCenter ? this.autoCenter : false }); - } else if (this.autoCenter && !this.autoZoom) { + const autoZoom = this.autoZoom(); + if (autoZoom) { + const autoCenter = this.autoCenter(); + this.zoomToFit({ autoCenter: autoCenter ? autoCenter : false }); + } else if (this.autoCenter() && !autoZoom) { this.center(); } } @@ -1353,7 +1365,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn if (!n.data) { n.data = {}; } - n.data.color = this.colors.getColor(this.groupResultsBy(n)); + n.data.color = this.colors.getColor(this.groupResultsBy()(n)); // Pre-layout hooks may set `hidden` (e.g. `applyVisualContinuityBeforeLayout` for incremental smooth transitions, // or `initializeNode` when `deferDisplayUntilPosition`). `tick` runs after layout positions exist — show nodes. n.hidden = false; @@ -1363,7 +1375,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn /** `translate` for `` from `position` (center when `centerNodesOnPositionChange`). */ private updateNodeGroupTransform(n: Node): void { - const center = this.centerNodesOnPositionChange; + const center = this.centerNodesOnPositionChange(); const dx = center ? (n.dimension?.width ?? 0) / 2 : 0; const dy = center ? (n.dimension?.height ?? 0) / 2 : 0; const px = n.position?.x ?? 0; @@ -1466,7 +1478,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn } getMinimapTransform(): string { - switch (this.miniMapPosition) { + switch (this.miniMapPosition()) { case MiniMapPosition.UpperLeft: { return ''; } @@ -1497,7 +1509,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn let minY = +Infinity; let maxY = -Infinity; - const center = this.centerNodesOnPositionChange; + const center = this.centerNodesOnPositionChange(); const accumulate = (items: Node[] | undefined) => { if (!items?.length) { return; @@ -1567,14 +1579,13 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn if (this.hasGraphNodeLikeContent()) { this.updateGraphDims(); - if (this.miniMapMaxWidth) { - this.minimapScaleCoefficient = this.graphDims.width / this.miniMapMaxWidth; + const miniMapMaxWidth = this.miniMapMaxWidth(); + if (miniMapMaxWidth) { + this.minimapScaleCoefficient = this.graphDims.width / miniMapMaxWidth; } - if (this.miniMapMaxHeight) { - this.minimapScaleCoefficient = Math.max( - this.minimapScaleCoefficient, - this.graphDims.height / this.miniMapMaxHeight - ); + const miniMapMaxHeight = this.miniMapMaxHeight(); + if (miniMapMaxHeight) { + this.minimapScaleCoefficient = Math.max(this.minimapScaleCoefficient, this.graphDims.height / miniMapMaxHeight); } this.minimapTransform = this.getMinimapTransform(); @@ -1607,22 +1618,25 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn // Skip drawing if element is not displayed - Firefox would throw an error here return; } - if (this.nodeHeight) { - node.dimension.height = - node.dimension.height && node.meta.forceDimensions ? node.dimension.height : this.nodeHeight; + const nodeHeight = this.nodeHeight(); + if (nodeHeight) { + node.dimension.height = node.dimension.height && node.meta.forceDimensions ? node.dimension.height : nodeHeight; } else { node.dimension.height = node.dimension.height && node.meta.forceDimensions ? node.dimension.height : dims.height; } - if (this.nodeMaxHeight) { - node.dimension.height = Math.max(node.dimension.height, this.nodeMaxHeight); + const nodeMaxHeight = this.nodeMaxHeight(); + if (nodeMaxHeight) { + node.dimension.height = Math.max(node.dimension.height, nodeMaxHeight); } - if (this.nodeMinHeight) { - node.dimension.height = Math.min(node.dimension.height, this.nodeMinHeight); + const nodeMinHeight = this.nodeMinHeight(); + if (nodeMinHeight) { + node.dimension.height = Math.min(node.dimension.height, nodeMinHeight); } - if (this.nodeWidth) { - node.dimension.width = node.dimension.width && node.meta.forceDimensions ? node.dimension.width : this.nodeWidth; + const nodeWidth = this.nodeWidth(); + if (nodeWidth) { + node.dimension.width = node.dimension.width && node.meta.forceDimensions ? node.dimension.width : nodeWidth; } else { // calculate the width if (nativeElement.getElementsByTagName('text').length) { @@ -1655,11 +1669,13 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn } } - if (this.nodeMaxWidth) { - node.dimension.width = Math.max(node.dimension.width, this.nodeMaxWidth); + const nodeMaxWidth = this.nodeMaxWidth(); + if (nodeMaxWidth) { + node.dimension.width = Math.max(node.dimension.width, nodeMaxWidth); } - if (this.nodeMinWidth) { - node.dimension.width = Math.min(node.dimension.width, this.nodeMinWidth); + const nodeMinWidth = this.nodeMinWidth(); + if (nodeMinWidth) { + node.dimension.width = Math.min(node.dimension.width, nodeMinWidth); } } @@ -1669,7 +1685,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn * @memberOf GraphComponent */ applyNodeDimensions(): void { - const measureRefs = (refs: QueryList | undefined) => { + const measureRefs = (refs: readonly ElementRef[] | undefined) => { refs?.forEach(elem => { const nativeElement = elem.nativeElement as SVGGraphicsElement; const node = this.findLayoutNodeByElementId(nativeElement.id); @@ -1679,8 +1695,8 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn this.applyNodeDimensionFromSvgGroup(nativeElement, node); }); }; - measureRefs(this.nodeElements); - measureRefs(this.clusterElements); + measureRefs(this.nodeElements()); + measureRefs(this.clusterElements()); } /** @@ -1709,7 +1725,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn * Imperative paint from `edge.line` / `edge.textPath` only — does not cancel unified layout morph or per-edge rAF. */ private repaintLinkPathsDomFromModel(): void { - this.linkElements?.forEach(linkEl => { + this.linkElements()?.forEach(linkEl => { const edge = this.graph.edges.find(lin => lin.id === linkEl.nativeElement.id); if (!edge) { return; @@ -1750,7 +1766,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn this.repaintLinkPathsDomFromModel(); } const expected = this.graph.edges?.length ?? 0; - const got = this.linkElements?.length ?? 0; + const got = this.linkElements()?.length ?? 0; const linksReady = expected === 0 || got === expected; if (linksReady) { this.finalizeTickOutput(tickId); @@ -1781,6 +1797,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn return; } this.stateChange.emit({ state: NgxGraphStates.Output }); + // TODO: The 'emit' function requires a mandatory void argument this.drawComplete.emit(); } @@ -1915,7 +1932,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn this.updateGraphDims(); } this.updateMinimap(); - if (this.autoCenter && !this.autoZoom) { + if (this.autoCenter() && !this.autoZoom()) { this.center(); } } @@ -1992,7 +2009,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn * * @memberOf GraphComponent */ - redrawLines(_animate = this.animate): void { + redrawLines(_animate = this.animate()): void { const lt = this.effectiveLayoutTransition; const duration = !_animate || !this.layoutMorphActive ? 0 : Math.max(0, lt.durationMs); @@ -2024,7 +2041,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn resampledNext: Array<{ x: number; y: number }>; }> = []; - this.linkElements?.forEach(linkEl => { + this.linkElements()?.forEach(linkEl => { const edge = this.graph.edges.find(lin => lin.id === linkEl.nativeElement.id); if (!edge) { @@ -2092,7 +2109,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn return; } - this.linkElements?.forEach(linkEl => { + this.linkElements()?.forEach(linkEl => { const edge = this.graph.edges.find(lin => lin.id === linkEl.nativeElement.id); if (!edge) { @@ -2265,7 +2282,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn * Layout-space node center from a prior `translate(tx,ty)` (inverse of {@link updateNodeGroupTransform}). */ private layoutCenterFromPreviousTransform(node: Node, prev: { tx: number; ty: number }): { x: number; y: number } { - const center = this.centerNodesOnPositionChange; + const center = this.centerNodesOnPositionChange(); const dx = center ? (node.dimension?.width ?? 0) / 2 : 0; const dy = center ? (node.dimension?.height ?? 0) / 2 : 0; return { x: prev.tx + dx, y: prev.ty + dy }; @@ -2370,7 +2387,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn .line() .x(d => d.x) .y(d => d.y) - .curve(this.curve); + .curve(this.curve()); return lineFunction(points); } @@ -2393,25 +2410,25 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn * @memberOf GraphComponent */ onZoom($event: WheelEvent, direction: string): void { - if (this.enableTrackpadSupport && !$event.ctrlKey) { + if (this.enableTrackpadSupport() && !$event.ctrlKey) { this.pan($event.deltaX * -1, $event.deltaY * -1); return; } - const zoomFactor = 1 + (direction === 'in' ? this.zoomSpeed : -this.zoomSpeed); + const zoomFactor = 1 + (direction === 'in' ? this.zoomSpeed() : -this.zoomSpeed()); // Check that zooming wouldn't put us out of bounds const newZoomLevel = this.zoomLevel * zoomFactor; - if (newZoomLevel <= this.minZoomLevel || newZoomLevel >= this.maxZoomLevel) { + if (newZoomLevel <= this.minZoomLevel() || newZoomLevel >= this.maxZoomLevel()) { return; } // Check if zooming is enabled or not - if (!this.enableZoom) { + if (!this.enableZoom()) { return; } - if (this.panOnZoom === true && $event) { + if (this.panOnZoom() === true && $event) { // Absolute mouse X/Y on the screen const mouseX = $event.clientX; const mouseY = $event.clientY; @@ -2484,7 +2501,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn this.transformationMatrix.a = isNaN(level) ? this.transformationMatrix.a : Number(level); this.transformationMatrix.d = isNaN(level) ? this.transformationMatrix.d : Number(level); this.zoomChange.emit(this.zoomLevel); - if (this.enablePreUpdateTransform) { + if (this.enablePreUpdateTransform()) { this.updateTransform(); } this.update(); @@ -2496,20 +2513,21 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn * @memberOf GraphComponent */ onDrag(event: MouseEvent): void { - if (!this.draggingEnabled) { + if (!this.draggingEnabled()) { return; } const node = this.draggingNode; - if (this.layout && typeof this.layout !== 'string' && this.layout.onDrag) { - this.layout.onDrag(node, event); + const layout = this.layout(); + if (layout && typeof layout !== 'string' && layout.onDrag) { + layout.onDrag(node, event); } node.position.x += event.movementX / this.zoomLevel; node.position.y += event.movementY / this.zoomLevel; // move the node - const x = node.position.x - (this.centerNodesOnPositionChange ? node.dimension.width / 2 : 0); - const y = node.position.y - (this.centerNodesOnPositionChange ? node.dimension.height / 2 : 0); + const x = node.position.x - (this.centerNodesOnPositionChange() ? node.dimension.width / 2 : 0); + const y = node.position.y - (this.centerNodesOnPositionChange() ? node.dimension.height / 2 : 0); node.transform = `translate(${x}, ${y})`; for (const link of this.graph.edges) { @@ -2519,8 +2537,8 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn (link.target as any).id === node.id || (link.source as any).id === node.id ) { - if (this.layout && typeof this.layout !== 'string') { - const result = this.layout.updateEdge(this.graph, link); + if (layout && typeof layout !== 'string') { + const result = layout.updateEdge(this.graph, link); const result$ = result instanceof Observable ? result : of(result); this.graphSubscription.add( result$.subscribe(graph => { @@ -2584,11 +2602,11 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn * @memberOf GraphComponent */ onActivate(event): void { - if (this.activeEntries.indexOf(event) > -1) { + if (this.activeEntries().indexOf(event) > -1) { return; } - this.activeEntries = [event, ...this.activeEntries]; - this.activate.emit({ value: event, entries: this.activeEntries }); + this.activeEntries.set([event, ...this.activeEntries()]); + this.activate.emit({ value: event, entries: this.activeEntries() }); } /** @@ -2597,12 +2615,13 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn * @memberOf GraphComponent */ onDeactivate(event): void { - const idx = this.activeEntries.indexOf(event); + const idx = this.activeEntries().indexOf(event); - this.activeEntries.splice(idx, 1); - this.activeEntries = [...this.activeEntries]; + const next = [...this.activeEntries()]; + next.splice(idx, 1); + this.activeEntries.set(next); - this.deactivate.emit({ value: event, entries: this.activeEntries }); + this.deactivate.emit({ value: event, entries: this.activeEntries() }); } /** @@ -2611,8 +2630,8 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn * @memberOf GraphComponent */ getSeriesDomain(): any[] { - return this.nodes - .map(d => this.groupResultsBy(d)) + return this.nodes() + .map(d => this.groupResultsBy()(d)) .reduce((nodes: string[], node): any[] => (nodes.indexOf(node) !== -1 ? nodes : nodes.concat([node])), []) .sort(); } @@ -2644,7 +2663,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn * @memberOf GraphComponent */ setColors(): void { - this.colors = new ColorHelper(this.scheme, this.seriesDomain, this.customColors); + this.colors = new ColorHelper(this.scheme(), this.seriesDomain, this.customColors()); } /** @@ -2655,9 +2674,9 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn @HostListener('document:mousemove', ['$event']) onMouseMove($event: MouseEvent): void { this.isMouseMoveCalled = true; - if ((this.isPanning || this.isMinimapPanning) && this.panningEnabled) { - this.panWithConstraints(this.panningAxis, $event); - } else if (this.isDragging && this.draggingEnabled) { + if ((this.isPanning || this.isMinimapPanning) && this.panningEnabled()) { + this.panWithConstraints(this.panningAxis(), $event); + } else if (this.isDragging && this.draggingEnabled()) { this.onDrag($event); } } @@ -2691,7 +2710,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn */ @HostListener('document:touchmove', ['$event']) onTouchMove($event: any): void { - if (this.isPanning && this.panningEnabled) { + if (this.isPanning && this.panningEnabled()) { const clientX = $event.changedTouches[0].clientX; const clientY = $event.changedTouches[0].clientY; const movementX = clientX - this._touchLastX; @@ -2722,8 +2741,9 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn this.isDragging = false; this.isPanning = false; this.isMinimapPanning = false; - if (this.layout && typeof this.layout !== 'string' && this.layout.onDragEnd) { - this.layout.onDragEnd(this.draggingNode, event); + const layout = this.layout(); + if (layout && typeof layout !== 'string' && layout.onDragEnd) { + layout.onDragEnd(this.draggingNode, event); } } @@ -2733,14 +2753,15 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn * @memberOf GraphComponent */ onNodeMouseDown(event: MouseEvent, node: any): void { - if (!this.draggingEnabled) { + if (!this.draggingEnabled()) { return; } this.isDragging = true; this.draggingNode = node; - if (this.layout && typeof this.layout !== 'string' && this.layout.onDragStart) { - this.layout.onDragStart(node, event); + const layout = this.layout(); + if (layout && typeof layout !== 'string' && layout.onDragStart) { + layout.onDragStart(node, event); } } @@ -2816,16 +2837,16 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn const widthZoom = this.dims.width / this.graphDims.width; let zoomLevel = Math.min(heightZoom, widthZoom, 1); - if (zoomLevel < this.minZoomLevel) { - zoomLevel = this.minZoomLevel; + if (zoomLevel < this.minZoomLevel()) { + zoomLevel = this.minZoomLevel(); } - if (zoomLevel > this.maxZoomLevel) { - zoomLevel = this.maxZoomLevel; + if (zoomLevel > this.maxZoomLevel()) { + zoomLevel = this.maxZoomLevel(); } if (zoomOptions?.force === true || zoomLevel !== this.zoomLevel) { - this.zoomLevel = zoomLevel; + this.zoomTo(zoomLevel); if (zoomOptions?.autoCenter !== true) { this.updateTransform(); @@ -2851,7 +2872,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn } getCompoundNodeChildren(ids: Array) { - return this.nodes.filter(node => ids.includes(node.id)); + return this.nodes().filter(node => ids.includes(node.id)); } private panWithConstraints(key: string, event: MouseEvent) { @@ -2884,7 +2905,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn edge.midPoint = points[Math.floor(points.length / 2)]; } else { // Checking if the current layout is Elk - if ((this.layout as Layout)?.settings?.properties?.['elk.direction']) { + if ((this.layout() as Layout)?.settings?.properties?.['elk.direction']) { this._calcMidPointElk(edge, points); } else { const _first = points[points.length / 2]; @@ -2902,7 +2923,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn let _secondX = null; let _firstY = null; let _secondY = null; - const orientation = (this.layout as Layout).settings?.properties['elk.direction']; + const orientation = (this.layout() as Layout).settings?.properties['elk.direction']; const hasBend = orientation === 'RIGHT' ? points.some(p => p.y !== points[0].y) : points.some(p => p.x !== points[0].x); @@ -2933,9 +2954,10 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn } public basicUpdate(): void { - if (this.view) { - this.width = this.view[0]; - this.height = this.view[1]; + const view = this.view(); + if (view) { + this.width = view[0]; + this.height = view[1]; } else { const dims = this.getContainerDims(); if (dims) { @@ -3015,8 +3037,8 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn return ( this.hasGraphDims() && this.hasNodeDims() && - ((this.compoundNodes?.length ? this.hasCompoundNodeDims() : true) || - (this.clusters?.length ? this.hasClusterDims() : true)) + ((this.compoundNodes()?.length ? this.hasCompoundNodeDims() : true) || + (this.clusters()?.length ? this.hasClusterDims() : true)) ); } diff --git a/projects/swimlane/ngx-graph/src/lib/graph/graph.module.ts b/projects/swimlane/ngx-graph/src/lib/graph/graph.module.ts index 2dd5df48..15749be1 100644 --- a/projects/swimlane/ngx-graph/src/lib/graph/graph.module.ts +++ b/projects/swimlane/ngx-graph/src/lib/graph/graph.module.ts @@ -6,9 +6,12 @@ import { CommonModule } from '@angular/common'; import { VisibilityObserver } from '../utils/visibility-observer'; export { GraphComponent, LayoutService }; +/** + * @deprecated `GraphComponent`, `MouseWheelDirective`, and `VisibilityObserver` are now standalone. + * Import them directly into your component's `imports` array instead of importing this module. + */ @NgModule({ - imports: [CommonModule], - declarations: [GraphComponent, MouseWheelDirective, VisibilityObserver], + imports: [CommonModule, GraphComponent, MouseWheelDirective, VisibilityObserver], exports: [GraphComponent, MouseWheelDirective], providers: [LayoutService] }) diff --git a/projects/swimlane/ngx-graph/src/lib/graph/mouse-wheel.directive.ts b/projects/swimlane/ngx-graph/src/lib/graph/mouse-wheel.directive.ts index 8a6e144b..606ad3aa 100644 --- a/projects/swimlane/ngx-graph/src/lib/graph/mouse-wheel.directive.ts +++ b/projects/swimlane/ngx-graph/src/lib/graph/mouse-wheel.directive.ts @@ -1,4 +1,4 @@ -import { Directive, Output, HostListener, EventEmitter } from '@angular/core'; +import { Directive, HostListener, output } from '@angular/core'; /** * Mousewheel directive @@ -8,14 +8,11 @@ import { Directive, Output, HostListener, EventEmitter } from '@angular/core'; */ // tslint:disable-next-line: directive-selector @Directive({ - selector: '[mouseWheel]', - standalone: false + selector: '[mouseWheel]' }) export class MouseWheelDirective { - @Output() - mouseWheelUp = new EventEmitter(); - @Output() - mouseWheelDown = new EventEmitter(); + readonly mouseWheelUp = output(); + readonly mouseWheelDown = output(); @HostListener('mousewheel', ['$event']) onMouseWheelChrome(event: any): void { diff --git a/projects/swimlane/ngx-graph/src/lib/ngx-graph.module.ts b/projects/swimlane/ngx-graph/src/lib/ngx-graph.module.ts index 5dfba216..7ad54e58 100644 --- a/projects/swimlane/ngx-graph/src/lib/ngx-graph.module.ts +++ b/projects/swimlane/ngx-graph/src/lib/ngx-graph.module.ts @@ -2,8 +2,12 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { GraphModule } from './graph/graph.module'; +/** + * @deprecated `GraphComponent` is now standalone. Import it directly into your component's + * `imports` array instead of importing `NgxGraphModule`. + */ @NgModule({ - imports: [CommonModule], + imports: [CommonModule, GraphModule], exports: [GraphModule] }) export class NgxGraphModule {} diff --git a/projects/swimlane/ngx-graph/src/lib/utils/visibility-observer.ts b/projects/swimlane/ngx-graph/src/lib/utils/visibility-observer.ts index 66b71cf4..ff16f8d2 100644 --- a/projects/swimlane/ngx-graph/src/lib/utils/visibility-observer.ts +++ b/projects/swimlane/ngx-graph/src/lib/utils/visibility-observer.ts @@ -5,8 +5,7 @@ import { Output, EventEmitter, NgZone, Directive, ElementRef } from '@angular/co */ @Directive({ // tslint:disable-next-line:directive-selector - selector: 'visibility-observer', - standalone: false + selector: 'visibility-observer' }) export class VisibilityObserver { @Output() visible: EventEmitter = new EventEmitter(); From 6d503b223125ba9af6b77be7b36ae29af064b6b3 Mon Sep 17 00:00:00 2001 From: Stephen Belovarich Date: Wed, 22 Apr 2026 13:48:59 -0700 Subject: [PATCH 3/3] - feat: give minimap an optional margin - fix: blocker for drawing minimap in some instances - fix: restyle minimap colors - fix: drawComplete should fire when all nodes are ready --- .../src/lib/enums/mini-map-position.enum.ts | 14 +++++++ .../src/lib/graph/graph.component.html | 2 +- .../src/lib/graph/graph.component.scss | 8 ++-- .../src/lib/graph/graph.component.ts | 38 +++++++++++++++---- 4 files changed, 49 insertions(+), 13 deletions(-) diff --git a/projects/swimlane/ngx-graph/src/lib/enums/mini-map-position.enum.ts b/projects/swimlane/ngx-graph/src/lib/enums/mini-map-position.enum.ts index 73edef6c..74fed605 100644 --- a/projects/swimlane/ngx-graph/src/lib/enums/mini-map-position.enum.ts +++ b/projects/swimlane/ngx-graph/src/lib/enums/mini-map-position.enum.ts @@ -4,3 +4,17 @@ export enum MiniMapPosition { LowerLeft = 'LowerLeft', LowerRight = 'LowerRight' } + +export interface MiniMapMargin { + top: number; + right: number; + bottom: number; + left: number; +} + +export const DefaultMiniMapMargin = { + top: 0, + right: 0, + bottom: 0, + left: 0 +}; diff --git a/projects/swimlane/ngx-graph/src/lib/graph/graph.component.html b/projects/swimlane/ngx-graph/src/lib/graph/graph.component.html index 7cd4ae7f..13cf1b09 100644 --- a/projects/swimlane/ngx-graph/src/lib/graph/graph.component.html +++ b/projects/swimlane/ngx-graph/src/lib/graph/graph.component.html @@ -168,7 +168,7 @@ " > - @for (node of graph.nodes; track trackNodeBy($index, node)) { + @for (node of graph?.nodes ?? []; track trackNodeBy($index, node)) { (100); readonly miniMapMaxHeight = input(undefined); readonly miniMapPosition = input(MiniMapPosition.UpperRight); + readonly miniMapMargin = input(DefaultMiniMapMargin); readonly view = input<[number, number]>(undefined); readonly scheme = input('cool'); readonly customColors = input(undefined); @@ -229,6 +230,7 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn height: number; resizeSubscription: any; visibilityObserver: VisibilityObserver; + private waitForGraphDims: ReturnType; private destroy$ = new Subject(); /** Latest requestAnimationFrame id per edge for imperative path morphing (cancel on relayout / drag). */ @@ -375,6 +377,13 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn }); } + this.waitForGraphDims = setInterval(() => { + if (this.hasDims()) { + clearInterval(this.waitForGraphDims); + this.drawComplete.emit(); + } + }, 1000); + this.minimapClipPathId = `minimapClip${id()}`; this.stateChange.emit({ state: NgxGraphStates.Subscribe }); } @@ -432,6 +441,9 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn this.visibilityObserver.visible.unsubscribe(); this.visibilityObserver.destroy(); } + if (this.waitForGraphDims) { + clearInterval(this.waitForGraphDims); + } this.destroy$.next(); this.destroy$.complete(); } @@ -1480,20 +1492,32 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn getMinimapTransform(): string { switch (this.miniMapPosition()) { case MiniMapPosition.UpperLeft: { - return ''; + return 'translate(' + this.miniMapMargin().left + ',' + this.miniMapMargin().top + ')'; } case MiniMapPosition.UpperRight: { - return 'translate(' + (this.dims.width - this.graphDims.width / this.minimapScaleCoefficient) + ',' + 0 + ')'; + return ( + 'translate(' + + (this.dims.width - this.graphDims.width / this.minimapScaleCoefficient - this.miniMapMargin().right) + + ',' + + this.miniMapMargin().top + + ')' + ); } case MiniMapPosition.LowerLeft: { - return 'translate(' + 0 + ',' + (this.dims.height - this.graphDims.height / this.minimapScaleCoefficient) + ')'; + return ( + 'translate(' + + this.miniMapMargin().left + + ',' + + (this.dims.height - this.graphDims.height / this.minimapScaleCoefficient - this.miniMapMargin().bottom) + + ')' + ); } case MiniMapPosition.LowerRight: { return ( 'translate(' + - (this.dims.width - this.graphDims.width / this.minimapScaleCoefficient) + + (this.dims.width - this.graphDims.width / this.minimapScaleCoefficient - this.miniMapMargin().right) + ',' + - (this.dims.height - this.graphDims.height / this.minimapScaleCoefficient) + + (this.dims.height - this.graphDims.height / this.minimapScaleCoefficient - this.miniMapMargin().bottom) + ')' ); } @@ -1797,8 +1821,6 @@ export class GraphComponent implements OnInit, OnChanges, OnDestroy, AfterViewIn return; } this.stateChange.emit({ state: NgxGraphStates.Output }); - // TODO: The 'emit' function requires a mandatory void argument - this.drawComplete.emit(); } private cancelEdgePathAnimation(edgeId: string): void {