Angular Gantt mit RxJS Tutorial
Dieses Tutorial zeigt ein praktisches Angular-Muster für zustandsgesteuerte Gantt-Verwaltung mithilfe eines injizierbaren RxJS-Dienstes.
Das Ergebnis:
- ein
BehaviorSubjecthält Aufgaben (tasks), Verbindungen (links) und Gantt-Konfiguration (Gantt config), - die Komponente bildet ein ViewModel mit
combineLatestund bindet über denAsyncPipe, - Gantt-Bearbeitungen fließen über
data.batchSavein den Store ein, - Rückgängig/Wiederherstellen (undo/redo) und Zoom-Änderungen werden im selben Dienst gegen Snapshots des Zustands gehandhabt.
Ein vollständiges, funktionsfähiges Projekt, das dieser Anleitung folgt, befindet sich auf GitHub: angular-gantt-rxjs-starter.
Voraussetzungen
- Angular-Anwendung mit installiertem Angular Gantt (siehe Installation)
- Funktionsfähige Wrapper-Darstellung (siehe Quick Start)
- Grundkenntnisse in Angular DI und RxJS
Projektstruktur
Wir teilen das Gantt-Feature in drei Ordner auf, damit jedes Teilstück eine Aufgabe hat:
src/app/
data/
gantt-seed.data.ts initial tasks, links, and zoom config
gantt/
gantt-shell.component.* feature shell and DHTMLX Gantt host
gantt-toolbar.component.ts zoom and history controls
gantt.types.ts shared Gantt feature types
state/
apply-batch-changes.ts pure task/link batch change helper
gantt-state.models.ts RxJS state and history types
gantt-state.service.ts RxJS state, batch flow, zoom, history
GanttStateService wird von GanttShellComponent bereitgestellt (nicht im Root), sodass jede gerenderte Shell isolierte Tasks, Links und Undo/Redo-Historie erhält.
1. Typen definieren und Seed-Daten
Gemeinsame Typen: src/app/gantt/gantt.types.ts
import type { GanttConfigOptions, ZoomLevel } from '@dhtmlx/trial-angular-gantt';
export type ZoomLevelName = 'day' | 'month' | 'year';
export interface GanttConfig {
zoom: {
current: ZoomLevelName;
levels: readonly ZoomLevel[];
};
options?: Partial<GanttConfigOptions>;
}
ZoomLevelName ist eine enge String-Union, die vom Toolbar- und Store-Austausch verwendet wird. GanttConfig hält den Zoom-Zustand neben allen zusätzlichen Gantt-Optionen, die die Funktion benötigt.
Zustandsmodelle: src/app/state/gantt-state.models.ts
import type { SerializedLink, SerializedTask } from '@dhtmlx/trial-angular-gantt';
import type { GanttConfig } from '../gantt/gantt.types';
export interface HistorySnapshot {
tasks: SerializedTask[];
links: SerializedLink[];
config: GanttConfig;
}
export interface GanttState {
tasks: SerializedTask[];
links: SerializedLink[];
config: GanttConfig;
past: HistorySnapshot[];
future: HistorySnapshot[];
maxHistory: number;
}
Snapshots enthalten config (welches die aktuelle Zoom-Stufe trägt), daher wird beim Rückgängigmachen auch der Zoom zusammen mit den Daten wiederhergestellt.
Seed-Daten: src/app/data/gantt-seed.data.ts
import type { SerializedLink, SerializedTask, ZoomLevel } from '@dhtmlx/trial-angular-gantt';
import type { GanttConfig } from '../gantt/gantt.types';
export const defaultZoomLevels: readonly ZoomLevel[] = [
{
name: 'day',
scale_height: 27,
min_column_width: 80,
scales: [{ unit: 'day', step: 1, format: '%d %M' }],
},
{
name: 'month',
scale_height: 50,
min_column_width: 120,
scales: [
{ unit: 'month', format: '%F, %Y' },
{ unit: 'week', format: 'Week #%W' },
],
},
{
name: 'year',
scale_height: 50,
min_column_width: 36,
scales: [{ unit: 'year', step: 1, format: '%Y' }],
},
];
export const seedTasks: SerializedTask[] = [
{ id: '1', text: 'Project setup', type: 'project', start_date: '2026-04-02T00:00:00.000Z', duration: 8, progress: 0.35, parent: 0, open: true },
{ id: '2', text: 'Install Angular shell', type: 'task', start_date: '2026-04-02T00:00:00.000Z', duration: 2, progress: 1, parent: '1' },
{ id: '3', text: 'Verify Gantt wrapper', type: 'task', start_date: '2026-04-04T00:00:00.000Z', duration: 3, progress: 0.4, parent: '1' },
{ id: '4', text: 'Static render ready', type: 'milestone', start_date: '2026-04-08T00:00:00.000Z', duration: 0, progress: 0, parent: '1' },
];
export const seedLinks: SerializedLink[] = [
{ id: '1', source: '2', target: '3', type: '0' },
{ id: '2', source: '3', target: '4', type: '0' },
];
export const defaultGanttConfig: GanttConfig = {
zoom: {
current: 'day',
levels: defaultZoomLevels,
},
options: {
row_height: 36,
bar_height: 24,
},
};
Verwendet wurden String-IDs und ISO-Datumsstrings. Der Wrapper akzeptiert beides, aber gemischte Formate erschweren Diff- und Snapshot-Analysen.
2. Definieren des Batch-Anwendungs-Helfers
src/app/state/apply-batch-changes.ts ist eine Pure-Funktion, die eine Liste von DataCallbackChange-Aufnahmen auf ein Entität-Array anwendet. Sie wird von Tasks und Links über einen generischen Typ gemeinsam genutzt, sodass der Store zweimal mit unterschiedlichen Typen aufruft.
import type { DataCallbackChange, TaskId } from '@dhtmlx/trial-angular-gantt';
type EntityWithId = {
id: TaskId;
};
function idsMatch(left: TaskId, right: TaskId): boolean {
return String(left) === String(right);
}
function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null;
}
function isSupportedAction(action: string): action is 'create' | 'update' | 'delete' {
return action === 'create' || action === 'update' || action === 'delete';
}
export function applyEntityChanges<T extends EntityWithId>(
entities: T[],
changes: DataCallbackChange[] = [],
): T[] {
const nextEntities = [...entities];
changes.forEach((change) => {
if (!isSupportedAction(change.action)) {
return;
}
const index = nextEntities.findIndex((entity) => idsMatch(entity.id, change.id));
if (change.action === 'delete') {
if (index !== -1) {
nextEntities.splice(index, 1);
}
return;
}
if (!isObject(change.data)) {
return;
}
if (change.action === 'create') {
nextEntities.push({ ...(change.data as T) });
return;
}
if (index !== -1) {
nextEntities[index] = {
...nextEntities[index],
...(change.data as Partial<T>),
};
}
});
return nextEntities;
}
Dies ist der reducer-ähnliche Kern für gruppierte Gantt-Änderungen. Die Funktion:
- gibt ein neues Array zurück (mutiert das Eingabe-Array nie),
- konvertiert IDs mit
String(), weil der Wrapperstring | numberverwendet und jede Form übereinstimmen sollte, - überspringt nicht unterstützte Aktionen und Payloads, die keine Objekte sind, anstatt eine Ausnahme zu werfen – der Gantt-Callback läuft im Renderzyklus des Wrappers, daher sollte eine fehlerhafte Änderung die Seite nicht crashen.
3. Aufbau des GanttStateService
src/app/state/gantt-state.service.ts verwaltet den Zustand, leitet abgeleitete Observable-Ströme für die Komponente ab und wendet Batch-Änderungen an.
import { Injectable } from '@angular/core';
import type {
BatchChanges,
GanttConfigOptions,
SerializedLink,
SerializedTask,
TaskId,
} from '@dhtmlx/trial-angular-gantt';
import { BehaviorSubject, distinctUntilChanged, map } from 'rxjs';
import { defaultGanttConfig, seedLinks, seedTasks } from '../data/gantt-seed.data';
import type { GanttConfig, ZoomLevelName } from '../gantt/gantt.types';
import { applyEntityChanges } from './apply-batch-changes';
import type { GanttState, HistorySnapshot } from './gantt-state.models';
function buildZoomConfig(config: GanttConfig, zoomLevel: ZoomLevelName): GanttConfig {
return {
...config,
zoom: { ...config.zoom, current: zoomLevel },
};
}
function snapshotState(state: GanttState): HistorySnapshot {
return {
tasks: state.tasks.map((task) => ({ ...task })),
links: state.links.map((link) => ({ ...link })),
config: structuredClone(state.config),
};
}
function idsMatch(left: TaskId, right: TaskId): boolean {
return String(left) === String(right);
}
@Injectable()
export class GanttStateService {
private readonly stateSubject = new BehaviorSubject<GanttState>({
tasks: seedTasks,
links: seedLinks,
config: buildZoomConfig(defaultGanttConfig, defaultGanttConfig.zoom.current),
past: [],
future: [],
maxHistory: 50,
});
readonly state$ = this.stateSubject.asObservable();
readonly tasks$ = this.state$.pipe(map((state) => state.tasks));
readonly links$ = this.state$.pipe(map((state) => state.links));
readonly config$ = this.state$.pipe(map((state) => state.config));
private buildWrapperConfig(config: GanttConfig): Partial<GanttConfigOptions> {
const zoomConfig = config.zoom.levels.find((level) => level.name === config.zoom.current);
if (!zoomConfig) {
return config.options ?? {};
}
return {
...config.options,
scale_height: zoomConfig.scale_height,
min_column_width: zoomConfig.min_column_width,
scales: zoomConfig.scales.map((scale) => ({ ...scale })) as typeof zoomConfig.scales,
};
}
readonly wrapperConfig$ = this.config$.pipe(map((config) => this.buildWrapperConfig(config)));
readonly zoomLevel$ = this.config$.pipe(
map((config) => config.zoom.current),
distinctUntilChanged<ZoomLevelName>(),
);
readonly canUndo$ = this.state$.pipe(
map((state) => state.past.length > 0),
distinctUntilChanged<boolean>(),
);
readonly canRedo$ = this.state$.pipe(
map((state) => state.future.length > 0),
distinctUntilChanged<boolean>(),
);
applyBatch(changes: BatchChanges): void {
const hasChanges = Boolean(changes.tasks?.length) || Boolean(changes.links?.length);
if (!hasChanges) return;
const state = this.stateSubject.value;
const tasks = applyEntityChanges<SerializedTask>(state.tasks, changes.tasks);
const links = applyEntityChanges<SerializedLink>(state.links, changes.links);
this.commit({ tasks, links });
}
setZoom(zoomLevel: ZoomLevelName): void {
const state = this.stateSubject.value;
if (state.config.zoom.current === zoomLevel) return;
this.commit({ config: buildZoomConfig(state.config, zoomLevel) });
}
undo(): void {
const state = this.stateSubject.value;
const previous = state.past.at(-1);
if (!previous) return;
this.stateSubject.next({
...state,
tasks: previous.tasks,
links: previous.links,
config: previous.config,
past: state.past.slice(0, -1),
future: [snapshotState(state), ...state.future],
});
}
redo(): void {
const state = this.stateSubject.value;
const next = state.future[0];
if (!next) return;
this.stateSubject.next({
...state,
tasks: next.tasks,
links: next.links,
config: next.config,
past: [...state.past, snapshotState(state)].slice(-state.maxHistory),
future: state.future.slice(1),
});
}
// CRUD-Hilfsfunktionen (createTask, updateTask, deleteTask, createLink, updateLink, deleteLink)
// sind der Kürze halber hier ausgelassen. Jedes geht über `commit()`, damit es in die Historie eingeht.
// Siehe das Demo-Beispiel für vollständige Implementierungen.
private commit(change: Partial<Pick<GanttState, 'tasks' | 'links' | 'config'>>): void {
const state = this.stateSubject.value;
this.stateSubject.next({
...state,
...change,
past: [...state.past, snapshotState(state)].slice(-state.maxHistory),
future: [],
});
}
}
Warum diese Form funktioniert:
- Der Service exponiert mehrere enge Streams (
tasks$,links$,wrapperConfig$,zoomLevel$,canUndo$,canRedo$) statt eines einzelnen gebündelten ViewModels. Die Komponente wählt die benötigten Streams aus und das Framework bound nur jene<dhx-gantt>-Inputs neu, die sich tatsächlich geändert haben. wrapperConfig$projiziert die typisierteGanttConfig-des Demoprojekts in die FormPartial<GanttConfigOptions>, die der Wrapper erwartet – das Domänenmodell des Stores und die Input-Form des Wrappers sind absichtlich nicht vom gleichen Typ.distinctUntilChangedwird nur auf Streams primitiver Werte angewendet (zoomLevel$,canUndo$,canRedo$). Beitasks$/links$/config$würde der Operator nichts tun – jeder Commit erzeugt ein frisches Array oder Objekt, daher erkennt die Referenz-Gleichheitstests kein Duplikat.commit()schiebt immer einen Snapshot inpastund leertfuture, sodass jede zustandsverändernde Aktion rückgängig gemacht werden kann.setZoomläuft aus dem gleichen Grund übercommit.- Snapshots klonen Aufgaben und Verknüpfungen flach (
{ ...task }) und verwendenstructuredClonefür die Config – ausreichend, um Undo/Redo unabhängig von nachfolgenden Bearbeitungen zu halten, ohne dass ein Tiefenkopie-Overhead bei jedem Commit entsteht.
Seit v9.1.3 erkennt der Wrapper ISO-Datumstrings automatisch, daher überspringt dieses Demo Overrides von format_date/parse_date im Store. Die Shell-Komponente installiert sie weiterhin als templates (siehe Schritt 5) für den Fall, dass ein Diagramm Date-Instanzen gemischt mit Strings erhält – das parse_date-Template normalisiert beides. Siehe Loading dates in ISO format für die vollständige Datumshandhabungsgeschichte des Wrappers.
4. Aufbau der Toolbar-Komponente
src/app/gantt/gantt-toolbar.component.ts ist eine rein präsentationsbasierte Komponente: Eingaben für den aktuellen Zustand, Ausgaben für die Absicht des Benutzers. Sie kennt den Store nicht.
import { Component, EventEmitter, Input, Output } from '@angular/core';
import type { ZoomLevelName } from './gantt.types';
@Component({
selector: 'app-gantt-toolbar',
standalone: true,
template: `
<div className="gantt-toolbar" aria-label="Gantt toolbar">
<div className="gantt-toolbar-group" aria-label="Zoom level">
<button type="button" [class.active]="zoomLevel === 'day'" (click)="zoomSelected.emit('day')">Day</button>
<button type="button" [class.active]="zoomLevel === 'month'" (click)="zoomSelected.emit('month')">Month</button>
<button type="button" [class.active]="zoomLevel === 'year'" (click)="zoomSelected.emit('year')">Year</button>
</div>
<div className="gantt-toolbar-group" aria-label="History controls">
<button type="button" [disabled]="!canUndo" (click)="undoSelected.emit()">Undo</button>
<button type="button" [disabled]="!canRedo" (click)="redoSelected.emit()">Redo</button>
</div>
</div>
`,
})
export class GanttToolbarComponent {
@Input({ required: true }) zoomLevel!: ZoomLevelName;
@Input({ required: true }) canUndo = false;
@Input({ required: true }) canRedo = false;
@Output() readonly zoomSelected = new EventEmitter<ZoomLevelName>();
@Output() readonly undoSelected = new EventEmitter<void>();
@Output() readonly redoSelected = new EventEmitter<void>();
}
Diesen Presenter-Traffic oben zu halten, bedeutet, dass der Shell entscheiden kann, wie der Zustand bezogen wird (anderer Store, andere Eingabenamen), ohne die Toolbar zu berühren.
5. Die Shell mit combineLatest zusammensetzen
src/app/gantt/gantt-shell.component.ts stellt den Store bereit, baut ein einzelnes ViewModel aus den schmalen Streams des Stores und exposed die Templates/DatenConfig, die der Wrapper benötigt.
import { AsyncPipe } from '@angular/common';
import { Component, inject } from '@angular/core';
import {
DhxGanttComponent,
type AngularGanttDataConfig,
type AngularGanttTemplates,
} from '@dhtmlx/trial-angular-gantt';
import { combineLatest } from 'rxjs';
import type { ZoomLevelName } from './gantt.types';
import { GanttStateService } from '../state/gantt-state.service';
import { GanttToolbarComponent } from './gantt-toolbar.component';
@Component({
selector: 'app-gantt-shell',
standalone: true,
imports: [AsyncPipe, DhxGanttComponent, GanttToolbarComponent],
providers: [GanttStateService],
templateUrl: './gantt-shell.component.html',
})
export class GanttShellComponent {
private readonly ganttState = inject(GanttStateService);
protected readonly vm$ = combineLatest({
tasks: this.ganttState.tasks$,
links: this.ganttState.links$,
config: this.ganttState.wrapperConfig$,
zoomLevel: this.ganttState.zoomLevel$,
canUndo: this.ganttState.canUndo$,
canRedo: this.ganttState.canRedo$,
});
protected readonly templates: AngularGanttTemplates = {
parse_date: (value: string | Date) => this.parseDate(value),
format_date: (date: string | Date) => this.formatDate(date),
};
protected readonly dataConfig: AngularGanttDataConfig = {
batchSave: (changes) => {
this.ganttState.applyBatch(changes);
},
};
protected setZoom(zoomLevel: ZoomLevelName): void {
this.ganttState.setZoom(zoomLevel);
}
protected undo(): void {
this.ganttState.undo();
}
protected redo(): void {
this.ganttState.redo();
}
private parseDate(value: string | Date): Date {
return value instanceof Date ? value : new Date(value);
}
private formatDate(value: string | Date): string {
return this.parseDate(value).toISOString();
}
}
Die Vorlage nutzt Angular 17+ Control-Flow:
@if (vm$ | async; as vm) {
<section class="gantt-feature-shell" aria-label="Gantt feature area">
<app-gantt-toolbar
[zoomLevel]="vm.zoomLevel"
[canUndo]="vm.canUndo"
[canRedo]="vm.canRedo"
(zoomSelected)="setZoom($event)"
(undoSelected)="undo()"
(redoSelected)="redo()">
</app-gantt-toolbar>
<div class="gantt-host">
<dhx-gantt
[tasks]="vm.tasks"
[links]="vm.links"
[config]="vm.config"
[templates]="templates"
[data]="dataConfig">
</dhx-gantt>
</div>
</section>
}
Einige Details, die es wert sind hervorgehoben zu werden:
providers: [GanttStateService]auf der Shell, nicht im Root – jede Shell-Instanz erhält ihren eigenen Store. Zwei nebeneinander angezeigte Gantt-Diagramme auf derselben Seite teilen sich keinen Undo-Verlauf.combineLatestwartet, bis jeder beitragende Stream mindestens einmal emittiert hat. Das passt hier gut, weil derBehaviorSubjectund die abgeleitetendistinctUntilChanged-Streams alle einen Anfangswert haben.templatesunddataConfigleben im Componenten, nicht im Service. Der Service bleibt unbewusst davon, wie der Wrapper Datumswerte formatiert oder woher seine Callback-Funktionen kommen – nur die Shell kennt die API-Oberfläche des Wrappers.
6. In die App einbinden
src/app/app.ts mountet die Shell als Root-Ansicht (oder als Route-Ziel):
import { Component } from '@angular/core';
import { GanttShellComponent } from './gantt/gantt-shell.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [GanttShellComponent],
template: `<app-gantt-shell></app-gantt-shell>`,
})
export class AppComponent {}
7. Datenfluss und Begründung
Für eine typische Bearbeitung (zum Beispiel beim Ziehen einer Aufgabe):
- Der Benutzer bearbeitet eine Aufgabe im Diagramm.
- Gantt emittiert mehrere Low-Level-Änderungen.
- Der Wrapper fasst sie zusammen und ruft
data.batchSave(changes)auf. - Die Shell leitet den Aufruf an
GanttStateService.applyBatch(changes)weiter. - Der Service führt
applyEntityChangesauf Tasks und Links aus, danncommit({ tasks, links }). commitlegt einenHistorySnapshotinpastab, leertfutureund gibt den neuen Zustand aus.- Die Streams
tasks$,links$,wrapperConfig$und die FlagscanUndo$/canRedo$durchlaufen allecombineLatest, das ViewModel wird neu aufgebaut und Angular bindet die geänderten<dhx-gantt>-Inputs erneut.
Dieser Ansatz hält Angulars State als Quelle der Wahrheit und bewahrt zugleich die Fähigkeit, hochvolumige Chart-Aktionen effizient zu handhaben – Gantts batchSave fasst eine Benutzer-Geste in eine einzige Store-Aktualisierung zusammen.
8. Häufige Stolpersteine
- Verwendung von
data.savestattbatchSavebei Seiten mit starkem Auto-Scheduling. Eine einzelne Drag-Bewegung mit aktiviertem Auto-Scheduling kann Dutzende von Low-Level-Änderungen erzeugen;data.savewürde bei jeder Änderung einen Snapshot anlegen, den Verlauf aufblasen und viele Neuberechnungen auslösen.batchSavefasst eine Benutzer-Geste in einen einzelnen Commit zusammen. - Mutation von
vm.tasksodervm.linksdirekt in der Komponente. Die ViewModel-Arrays sind dieselben Referenzen, die der Store hält. In-place-Modifikationen verfälschen sowohl den aktuellen Zustand als auch Snapshots, die diese Referenzen teilen. - Wiederverwenden von Snapshot-Arrays ohne Klonen bei Undo/Redo.
snapshotStateklont jede Aufgabe und jeden Link mittels Spread, damit zukünftige Commits die Historie nicht rückwirkend verändern können. Das Überspringen dieses Schrittes mag beim ersten Undo funktionieren, beim zweiten Undo jedoch scheitern. - Mischung von imperativen
gantt.instance-Mutationen mit store-gesteuerten Eingaben. Greifen Sie in die zugrunde liegende Gantt-Instanz ein, um eine Aufgabe hinzuzufügen, sieht der Store das nicht – die nächste Emission vontasks$überschreibt Ihre imperative Änderung.