diff --git a/package-lock.json b/package-lock.json
index 51a5024..aa76217 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -18,6 +18,7 @@
"@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0",
"@angular/router": "^19.0.0",
+ "luxon": "^3.6.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
@@ -27,6 +28,7 @@
"@angular/cli": "^19.0.2",
"@angular/compiler-cli": "^19.0.0",
"@types/jasmine": "~5.1.0",
+ "@types/luxon": "^3.6.2",
"jasmine-core": "~5.4.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
@@ -5054,6 +5056,13 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/@types/luxon": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/@types/luxon/-/luxon-3.6.2.tgz",
+ "integrity": "sha512-R/BdP7OxEMc44l2Ex5lSXHoIXTB2JLNa3y2QISIbr58U/YcsffyQrYW//hZSdrfxrjRZj3GcUoxMPGdO8gSYuw==",
+ "dev": true,
+ "license": "MIT"
+ },
"node_modules/@types/mime": {
"version": "1.3.5",
"resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz",
@@ -9845,6 +9854,15 @@
"yallist": "^3.0.2"
}
},
+ "node_modules/luxon": {
+ "version": "3.6.1",
+ "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.6.1.tgz",
+ "integrity": "sha512-tJLxrKJhO2ukZ5z0gyjY1zPh3Rh88Ej9P7jNrZiHMUXHae1yvI2imgOZtL1TO8TW6biMMKfTtAOoEJANgtWBMQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ }
+ },
"node_modules/magic-string": {
"version": "0.30.12",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.12.tgz",
diff --git a/package.json b/package.json
index 44b57ec..fb16e6b 100644
--- a/package.json
+++ b/package.json
@@ -20,6 +20,7 @@
"@angular/platform-browser": "^19.0.0",
"@angular/platform-browser-dynamic": "^19.0.0",
"@angular/router": "^19.0.0",
+ "luxon": "^3.6.1",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
@@ -29,6 +30,7 @@
"@angular/cli": "^19.0.2",
"@angular/compiler-cli": "^19.0.0",
"@types/jasmine": "~5.1.0",
+ "@types/luxon": "^3.6.2",
"jasmine-core": "~5.4.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
@@ -37,4 +39,4 @@
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.6.2"
}
-}
\ No newline at end of file
+}
diff --git a/src/app/app.component.html b/src/app/app.component.html
index 779fa92..03412db 100644
--- a/src/app/app.component.html
+++ b/src/app/app.component.html
@@ -48,4 +48,26 @@
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/app/app.component.ts b/src/app/app.component.ts
index b65713e..dc8877d 100644
--- a/src/app/app.component.ts
+++ b/src/app/app.component.ts
@@ -12,12 +12,18 @@ import { MatChipsModule } from '@angular/material/chips';
import { AsyncPipe } from '@angular/common';
import { MatSelectModule } from '@angular/material/select';
import { MatToolbarModule } from '@angular/material/toolbar';
+import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { SelectFilterComponent } from "./select-filter/select-filter.component";
+import { GanttComponent, GanttItem } from "./gantt/gantt.component";
+import { GrossNetCalculatorComponent } from "./gross-net-calculator/gross-net-calculator.component";
+import { GrossNetCalculatorInputDirective } from './gross-net-calculator/gross-net-calculator-input.directive';
+import { GrossNetCalculatorToggleButtonComponent } from "./gross-net-calculator/toggle-button/toggle-button.component";
@Component({
selector: 'app-root',
- imports: [RouterOutlet, MatToolbarModule, MatIconModule, CommentBoxComponent, MatFormFieldModule, MatInputModule, ReactiveFormsModule, ProgressStepperComponent, MatOptionModule, MatSelectModule, MatAutocompleteModule, MatChipsModule, AsyncPipe, FormsModule, SelectFilterComponent],
+ imports: [RouterOutlet, MatToolbarModule, GrossNetCalculatorInputDirective, MatIconModule, MatButtonModule, CommentBoxComponent, MatFormFieldModule, MatInputModule, ReactiveFormsModule, ProgressStepperComponent, MatOptionModule, MatSelectModule, MatAutocompleteModule, MatChipsModule, AsyncPipe, FormsModule, SelectFilterComponent, GanttComponent, GrossNetCalculatorComponent, GrossNetCalculatorToggleButtonComponent],
+ providers: [GrossNetCalculatorInputDirective],
templateUrl: './app.component.html',
styleUrl: './app.component.scss'
})
@@ -45,4 +51,48 @@ export class AppComponent {
unselectAll(): void {
this.selectedItemsList.setValue([]);
}
+
+
+ ganttItems: GanttItem[] = [{
+ id: '000',
+ title: 'Test 1',
+ items: [{
+ title: '124',
+ start: 1743487875,
+ duration_sec: 7200,
+ }, {
+ title: 'F242',
+ start: 1743660675,
+ duration_sec: 16 * 3600,
+ }
+ ]
+ },
+ {
+ id: '001',
+ title: 'Test 2',
+ items: [{
+ title: '124',
+ start: 1743487875,
+ duration_sec: 7200,
+ }, {
+ title: 'F242',
+ start: 1743660675,
+ duration_sec: 72000,
+ }
+ ]
+ },
+ {
+ id: 'dfaf',
+ title: 'WeekTest',
+ items: [
+ { title: '1', start: 1744005600, duration_sec: 72000 },
+ { title: '2', start: 1744092000, duration_sec: 72000 },
+ { title: '3', start: 1744178400, duration_sec: 72000 },
+ { title: '4', start: 1744264800, duration_sec: 72000 },
+ { title: '5', start: 1744351200, duration_sec: 72000 },
+ { title: '6', start: 1744437600, duration_sec: 72000 },
+ { title: '7', start: 1744524000, duration_sec: 72000 }
+ ]
+ }
+ ]
}
diff --git a/src/app/gantt/gantt.component.html b/src/app/gantt/gantt.component.html
new file mode 100644
index 0000000..9fdac69
--- /dev/null
+++ b/src/app/gantt/gantt.component.html
@@ -0,0 +1,214 @@
+
+
+
+
{{month}}
+
KW {{week}}
+
+
+
+ @for (item of items; track item.id) {
+
{{item.title}}
+ }
+
+
+
+
+ @for(day of [0,1,2,3,4,5,6]; track day) {
+
+ }
+
+ @for (item of items; track item.id) {
+
+
+
+ @for(day of [0,1,2,3,4,5,6]; track day) {
+ @for (time of getDayWorkTime(day); track $index) {
+
+ }}
+
+ @for (bar of item.items; track $index) {
+
+ {{bar.title}}
+ }
+
+ @for(day of [0,1,2,3,4,5,6]; track day) {
+
+
+ @for (time of getDayWorkTime(day); track $index) {
+
+ }
+
+ }
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{month}}
+
+ @for (week of intervalArray; track week.title) {
+
{{week.title}}
+ }
+
+
+
+ @for (item of items; track item.id) {
+
{{item.title}}
+ }
+
+
+
+ @for(day of [0,1,2,3,4,5,6,7]; track day) {
+
+
+
{{getDate(day)}}
+
{{getWeekday(day)}}
+
+
+ @for (hour of hourArray; track hour) {
+ {{hour}}
+ }
+
+
+
+ }
+
+
+
+ @for (item of items; track item.id) {
+
+
+
+ @for(day of [0,1,2,3,4,5,6,7]; track day) {
+ @for (time of getDayWorkTime(day); track $index) {
+
+
+ }}
+
+
+
+ @for (bar of item.items; track $index) {
+
+ {{bar.title}}
+ }
+
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{{view.month}}
+
+ @for (cell of view.intervalDescriptor; track cell.title) {
+
{{cell.title}}
+ }
+
+
+
+ @for (item of items; track item.id) {
+
{{item.title}}
+ }
+
+
+
+ @for(item of view.cellDescriptors; track item.title) {
+
+
+
+
{{item.title}}
+
{{item.subTitle}}
+
+
+ @for (item of view.tickDescriptors; track item.title) {
+ {{item.title}}
+ }
+
+
+
+
+ }
+
+
+
+ @for (item of items; track item.id) {
+
+
+
+ @for(day of view.cellDescriptors; track day.title) {
+ @for (time of day.available; track $index) {
+
+ }}
+
+
+
+ @for (bar of item.items; track $index) {
+
+ {{bar.title}}
+ }
+
+
+ }
+
+
\ No newline at end of file
diff --git a/src/app/gantt/gantt.component.scss b/src/app/gantt/gantt.component.scss
new file mode 100644
index 0000000..93b4545
--- /dev/null
+++ b/src/app/gantt/gantt.component.scss
@@ -0,0 +1,308 @@
+.container {
+ position: relative;
+ width: 100%;
+ background-color: #ccc;
+
+ display: grid;
+ grid-template-columns: max-content 1fr;
+ // column-gap: 1rem;
+
+ --bar-margin: 5px;
+ --row-height: 1.5rem;
+
+ --header-height: 4rem;
+}
+
+.header {
+ height: var(--header-height);
+ display: flex;
+
+ display: flex;
+ flex-flow: column;
+ justify-content: center;
+
+ div {
+ display: flex;
+ justify-content: center;
+ padding: 0.25rem;
+ }
+}
+
+.info-box {
+ grid-column-start: 2;
+
+ .interval-descriptor {
+ display: flex;
+ border: solid thin #fff;
+ border-collapse: collapse;
+
+ font-size: 0.8rem;
+
+ div {
+ background-color: aqua;
+ border-right: solid thin #fff;
+ padding: 0.05rem;
+ box-sizing: border-box;
+
+ display: flex;
+ justify-content: center;
+
+ &:nth-child(2n) {
+ background-color: azure;
+ }
+ }
+
+ :last-child {
+ flex-grow: 1;
+ border-right: none;
+ }
+ }
+}
+
+.table {
+ grid-row-start: 2;
+ margin-top: var(--header-height);
+
+ .table-row {
+ display: flex;
+ align-items: center;
+
+ height: calc(var(--row-height) + 2 * var(--bar-margin));
+ border-top: solid thin #fff;
+
+ padding-right: 1rem;
+ padding-left: 0.5rem;
+ }
+}
+
+.bars {
+ position: relative;
+ width: 100%;
+ display: grid;
+ grid-template-columns: repeat(7, 1fr);
+
+ overflow: hidden;
+
+ .header {
+ border-left: solid thin #fff;
+ }
+
+ .grid {
+ position: relative;
+ border-left: solid thin #fff;
+ box-sizing: border-box;
+
+ height: calc(var(--row-height) + 2 * var(--bar-margin));
+
+ .open {
+ position: absolute;
+ height: 100%;
+ background-color: orange;
+ }
+ }
+
+ .bar-row {
+ position: relative;
+ grid-column: span 7;
+
+ border-top: solid thin #fff;
+ font-size: 0.75rem;
+
+ .bar-container {
+ position: absolute;
+ top: var(--bar-margin);
+ left: 0;
+ z-index: 5;
+
+ .bar {
+ position: absolute;
+ top: 0;
+ left: 0;
+ height: var(--row-height);
+ background-color: blue;
+ color: #fff;
+ box-sizing: border-box;
+
+ border-radius: 0.2rem;
+
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+
+ &:hover {
+ border: solid thin #fff;
+ z-index: 99;
+ cursor: default;
+ }
+ }
+ }
+ }
+}
+
+.new {
+ --bg-color: rgb(247, 247, 247); //#fafafa;
+ --separator-color: rgb(238, 238, 238);
+ --bg-weekend-color: #e7dada;
+ --bg-open-color: #fff;
+ --bg-closed-color: rgb(208, 208, 208);
+
+ --bg-week-color-even: #98afc7;
+ --bg-week-color-odd: #8391a1;
+
+ background-color: var(--bg-color);
+
+ border: solid thin black;
+ box-sizing: border-box;
+
+ .interval-descriptor {
+ div {
+ background-color: var(--bg-week-color-even);
+ color: #fff;
+
+ &:nth-child(2n) {
+ background-color: var(--bg-week-color-odd);
+ }
+ }
+ }
+
+ .bars {
+ display: flex;
+ flex-direction: column;
+
+ padding-top: var(--header-height);
+
+ .open {
+ height: calc(var(--row-height) + 2 * var(--bar-margin));
+ position: absolute;
+ background-color: var(--bg-open-color);
+ top: 0;
+ }
+
+ .bar-row {
+ height: calc(var(--row-height) + 2 * var(--bar-margin));
+ border-color: var(--separator-color);
+ background-color: var(--bg-closed-color);
+ }
+ }
+
+ .calender-head {
+ display: flex;
+ height: var(--header-height);
+ position: absolute;
+ top: 0;
+ height: 100%;
+
+ .spacer {
+ flex-grow: 1;
+ }
+
+ .cell {
+ position: relative;
+ height: 100%;
+ box-sizing: border-box;
+
+ > div {
+ position: absolute;
+ height: 100%;
+ width: 100%;
+ border-right: solid thin var(--separator-color);
+ z-index: 4;
+ }
+
+ &.weekend {
+ background-color: var(--bg-weekend-color);
+ }
+
+ &.sunday {
+ background-color: var(--bg-weekend-color);
+ color: red;
+ }
+
+ &:first-child {
+ border-left: solid thin var(--separator-color);
+ // shift first element if necessary
+ .ticks {
+ transform: translate(var(--first-interval-shift));
+ }
+ }
+
+ &:last-child {
+ .tick {
+ color: red;
+ &:last-child {
+ border-right: none;
+ }
+ }
+ }
+
+ &:not(:first-child) {
+ .content {
+ border-left: none;
+
+ .ticks {
+ margin-left: 1px;
+ }
+ }
+ }
+
+ .day-info {
+ &:first-child {
+ padding-top: 0.4rem;
+ }
+
+ padding-top: 0.1rem;
+ padding-left: 0.25rem;
+ font-size: 0.9rem;
+ font-weight: 500;
+ }
+
+ .content {
+ height: var(--header-height);
+ display: flex;
+ flex-direction: column;
+ justify-content: flex-start;
+ align-items: flex-start;
+ z-index: 1;
+
+ // border: solid thin #000;
+ // border-top: none;
+ // box-sizing: border-box;
+ }
+
+ .ticks {
+ display: flex;
+ color: #000;
+
+ :first-child {
+ border-left: none;
+ }
+
+ .tick {
+ padding-left: 0.2rem;
+ box-sizing: border-box;
+ font-size: 0.7rem;
+ border-right: solid thin #fff;
+ border-color: #000;
+ }
+ }
+ }
+ }
+
+ .table {
+ margin-top: var(--header-height);
+
+ .table-row {
+ border-color: var(--separator-color);
+ }
+ }
+
+ .month-display {
+ padding: 0.2rem;
+ font-weight: 500;
+ font-size: 0.95rem;
+ }
+}
diff --git a/src/app/gantt/gantt.component.spec.ts b/src/app/gantt/gantt.component.spec.ts
new file mode 100644
index 0000000..3e2f659
--- /dev/null
+++ b/src/app/gantt/gantt.component.spec.ts
@@ -0,0 +1,23 @@
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+
+import { GanttComponent } from './gantt.component';
+
+describe('GanttComponent', () => {
+ let component: GanttComponent;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [GanttComponent]
+ })
+ .compileComponents();
+
+ fixture = TestBed.createComponent(GanttComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+});
diff --git a/src/app/gantt/gantt.component.ts b/src/app/gantt/gantt.component.ts
new file mode 100644
index 0000000..ae3c455
--- /dev/null
+++ b/src/app/gantt/gantt.component.ts
@@ -0,0 +1,203 @@
+import { ChangeDetectorRef, Component, ElementRef, Input, ViewChild } from '@angular/core';
+import { Observable } from 'rxjs';
+import { DateTime } from 'luxon';
+import { GanttView } from './views/view';
+
+export interface GanttItem {
+ id: string;
+ title: string;
+ items: GanttBarElement[];
+}
+
+export interface GanttBarElement {
+ title: string,
+ start: number,
+ duration_sec: number,
+}
+
+interface WorkTime {
+ start_day_sec: number,
+ duration_sec: number,
+}
+
+interface IntervalDescriptor {
+ title: string;
+ duration: number,
+}
+
+@Component({
+ selector: 'app-gantt',
+ imports: [],
+ templateUrl: './gantt.component.html',
+ styleUrl: './gantt.component.scss'
+})
+export class GanttComponent {
+ @ViewChild('barContainer') _barContainer!: ElementRef;
+
+ @Input() items: GanttItem[] = [];
+
+ view: GanttView;
+
+ globalBaseFontSize: number = 16;
+ barContainerWidth: number = 0;
+
+ startDate: DateTime;
+
+ weekOpenTime: WorkTime[][] = [
+ [{ start_day_sec: 8 * 3600, duration_sec: 8 * 3600 }],
+ [{ start_day_sec: 8 * 3600, duration_sec: 8 * 3600 }],
+ [{ start_day_sec: 8 * 3600, duration_sec: 8 * 3600 }],
+ [{ start_day_sec: 8 * 3600, duration_sec: 8 * 3600 }],
+ [{ start_day_sec: 8 * 3600, duration_sec: 8 * 3600 }],
+ [{ start_day_sec: 8 * 3600, duration_sec: 8 * 3600 }],
+ [{ start_day_sec: 8 * 3600, duration_sec: 8 * 3600 }],
+ [{ start_day_sec: 8 * 3600, duration_sec: 8 * 3600 }],
+ ];
+
+ constructor(private changeDetectorRef: ChangeDetectorRef) {
+ this.startDate = DateTime.now().minus({ weeks: 1 }).startOf('week').startOf('day').setLocale('de');
+
+ this.view = new GanttView();
+
+ let htmlRoot: HTMLElement = document.getElementsByTagName("html")[0];
+ }
+
+ ngAfterViewInit(): void {
+ // get base font size
+ let fontSize = window.getComputedStyle(this._barContainer.nativeElement).getPropertyValue('font-size');
+ this.globalBaseFontSize = parseInt(fontSize.slice(0, fontSize.length - 2));
+ this.view.globalBaseFontSize = parseInt(fontSize.slice(0, fontSize.length - 2));
+
+ // observe resizing of the bar container
+ new Observable((observer) => {
+ const resizeObserver = new ResizeObserver(() => {
+ observer.next();
+ });
+ resizeObserver.observe(this._barContainer.nativeElement);
+ }).subscribe((change) => {
+ this.barContainerWidth = this._barContainer.nativeElement.offsetWidth;
+ this.view.width = this._barContainer.nativeElement.offsetWidth;
+
+ this.changeDetectorRef.detectChanges();
+ });
+ }
+
+ changeViewType(value: string): void {
+ this.view.changeView(value)
+ }
+
+ getDate(day: number): string {
+ return this.startDate.plus({ days: day }).toFormat('dd.MM');
+ }
+
+ getWeekday(day: number): string {
+ return this.startDate.plus({ days: day }).toFormat('EEE');
+ }
+
+ getDayDuration(day: number): number {
+ if (day === 0) {
+ return Math.abs(this.startDate.diff(this.startDate.endOf('day')).as('seconds'));
+ } else {
+ return 24 * 3600;
+ }
+ }
+
+ getDayWorkTime(day: number): WorkTime[] {
+ return this.weekOpenTime[day];
+ }
+
+ durationToWidth(seconds: number, opts?: { offsetCorrection?: number }): string {
+ let correction = opts?.offsetCorrection ?? 0;
+ return `${this.pixelPerSec * seconds + correction}px`;
+ }
+
+ startToOffset(date: DateTime | number, opts?: { dstCorrection?: boolean, offsetCorrection?: number }): string {
+ // generate date from timestamp if necessary
+ if (typeof date === 'number')
+ date = DateTime.fromSeconds(date);
+ // calculate offset
+ let offset = this.dateDiff(date, this.startDate, opts) * this.pixelPerSec;
+ let correction = opts?.offsetCorrection ?? 0;
+
+ return `${offset + correction}px`;
+ }
+
+ dateDiff(date1: DateTime, date2: DateTime, opts?: { dstCorrection?: boolean }): number {
+ // calculate offset
+ let diff = date1.diff(date2, 'seconds').as('seconds');
+
+ //modify if DST (Daylight Saving Time) has changed // TODO Check if DST Switch is problematic
+ if (opts?.dstCorrection) {
+ if (date1.isInDST && !date2.isInDST) {
+ diff += 3600;
+ } else if (date1.isInDST && !date2.isInDST) {
+ diff -= 3600;
+ }
+ }
+
+ return diff;
+ }
+
+ firstDayIntervalShift(day: number): string {
+ return `${this.getDayDuration(day) / this.segmentBaseSeconds * 100 - 100}%`;
+ }
+
+ moveBack(): void {
+ this.startDate = this.startDate.minus({ days: 3.5 });
+ }
+ moveNext(): void {
+ this.startDate = this.startDate.plus({ days: 3.5 });
+ }
+
+ get segmentBaseSeconds(): number {
+ return 3600 * 24;
+ }
+
+ get pixelPerSec(): number {
+ const SecondsPerWeek = 3600 * 24 * 7;
+ return this.barContainerWidth / SecondsPerWeek;
+ }
+
+ get month(): string {
+ return this.startDate.toFormat('MMMM yyyy')
+
+ }
+
+ get week(): string {
+ return this.startDate.toFormat('nn')
+ }
+
+ // TODO do not recalculate for every element but only once when window is resized or forward or backword is pressed
+ get hourArray(): number[] {
+ let base = 3600 * 24;
+ let intervals = base * this.pixelPerSec / (2 * 0.8 * this.globalBaseFontSize);
+
+ // TODO make calculation more sane!!!
+ let test = Math.floor(24 / Math.ceil((2 * 0.8 * this.globalBaseFontSize) / (base * this.pixelPerSec / 24)));
+
+ return Array.from({ length: test }, (_, i) => i * 24 / test)
+ // return Array.from({ length: 23 }, (_, i) => i + 1)
+ }
+
+ get intervalArray(): IntervalDescriptor[] {
+ let start = this.startDate.get('weekNumber');
+ let end = this.startDate.plus({ week: 1 }).minus({ seconds: 10 }).get('weekNumber');
+ let arr: IntervalDescriptor[] = [];
+
+ for (let i = start; i <= end; i++) {
+ if (i === start && start !== end) {
+ arr.push({
+ title: `KW ${i}`,
+ duration: this.dateDiff(this.startDate.endOf('week'), this.startDate, { dstCorrection: true }) //this.startDate.endOf('week').diff(this.startDate).as('seconds')
+ })
+ } else if (i === end) {
+ arr.push({
+ title: `KW ${i}`,
+ duration: 0 // last element grows automatically
+ })
+ } else { }
+ }
+
+ return arr;
+ }
+}
diff --git a/src/app/gantt/views/base.ts b/src/app/gantt/views/base.ts
new file mode 100644
index 0000000..96e96eb
--- /dev/null
+++ b/src/app/gantt/views/base.ts
@@ -0,0 +1,70 @@
+import { DateTime, Settings } from "luxon";
+import { DescriptorSet } from "./descriptors";
+
+export const defaultAvailable = [{ start_day_sec: 8 * 3600, duration_sec: 8 * 3600, onSaturday: false, onSunOrHoliday: false }];
+
+
+export abstract class GanttViewType {
+ start: DateTime; // start date of this gantt view
+ viewDuration: number; // duration in seconds between start and end of this gantt view
+ cellWidth: number; // width of a single cell (e.g. a day in the week view)
+
+ pixelPerSecond: number = 0; // width in pixel of one second of the gantt view
+ globalBaseFontSize: number = 16; // font size that is used to calculate tick display
+
+ constructor() {
+ // defining time settings
+ Settings.defaultZone = 'utc';
+
+ // default view is week
+ let date: DateTime = DateTime.now();
+
+ this.start = date.startOf('week').startOf('day');
+ this.viewDuration = this.start.endOf('week').endOf('day').diff(this.start).as('seconds');
+ this.cellWidth = this.viewDuration / 7;
+ }
+
+ // update view (e.g. resizing, interval change)
+ update(opts?: { width?: number, action?: 'next' | 'back', today?: boolean }): DescriptorSet {
+ // was panel resized?
+ if (opts?.width)
+ this.pixelPerSecond = opts.width / this.viewDuration;
+ // did the interval change?
+ if (opts?.action)
+ this.calculateIntervalShift(opts.action)
+
+ // recreate descriptors
+ return this.createDescriptorSet();
+ }
+
+ // convert duration to width in pixels
+ durationToWidth(seconds: number, opts?: { offsetCorrection?: number }): string {
+ let correction = opts?.offsetCorrection ?? 0;
+ return `${this.pixelPerSecond * seconds + correction}px`;
+ }
+
+ // get offset in pixels
+ getOffset(date: DateTime | number, opts?: { dstCorrection?: boolean, offsetCorrection?: number }): string {
+ // generate date from timestamp if necessary
+ if (typeof date === 'number')
+ date = DateTime.fromSeconds(date);
+ // calculate offset
+ let offset = date.diff(this.start).as('seconds') * this.pixelPerSecond;
+ let correction = opts?.offsetCorrection ?? 0;
+
+ return `${offset + correction}px`;
+ }
+
+
+ isDaySunOrHoliday(date: DateTime): boolean {
+ return date.weekday === 7
+ }
+ isDayOnWeekend(date: DateTime): boolean {
+ return date.weekday > 5;
+ }
+
+ createDescriptorSet(): DescriptorSet { return { interval: [], cell: [], tick: [] } };
+ calculateIntervalShift(action: 'next' | 'back'): void { }
+ setToday(): void { }
+
+}
\ No newline at end of file
diff --git a/src/app/gantt/views/day.ts b/src/app/gantt/views/day.ts
new file mode 100644
index 0000000..07a6abf
--- /dev/null
+++ b/src/app/gantt/views/day.ts
@@ -0,0 +1,85 @@
+import { DateTime } from "luxon";
+import { defaultAvailable, GanttViewType } from "./base";
+import { CellDescriptor, DescriptorSet, IntervalDescriptor, TickDescriptor } from "./descriptors";
+
+
+export class GanttViewDay extends GanttViewType {
+
+ constructor(date: DateTime) {
+ super()
+
+ this.start = date.startOf('day');
+ this.viewDuration = this.start.endOf('day').diff(this.start).as('seconds');
+ this.cellWidth = this.viewDuration / 1;
+ }
+
+ override setToday(): void {
+ this.start = DateTime.now().startOf('day');
+ }
+
+ override calculateIntervalShift(action: "next" | "back"): void {
+ if (action === 'next')
+ this.start = this.start.plus({ days: 0.5 });
+ else
+ this.start = this.start.minus({ days: 0.5 });
+ }
+
+ override createDescriptorSet(): DescriptorSet { return { interval: this.createIntervals(), cell: this.createCells(), tick: this.createTicks() } };
+
+ // get duration of a day in seconds
+ dayDuration(day?: number): number {
+ // check if first day is only a fraction of a day
+ if (day === 0) {
+ return Math.abs(this.start.diff(this.start.endOf('day')).as('seconds'));
+ } else {
+ return 24 * 3600;
+ }
+ };
+
+ private createIntervals(): IntervalDescriptor[] {
+ let weeks = [this.start.get('weekNumber')];
+ let end2 = this.start.plus({ days: 1 }).minus({ seconds: 10 });
+ if (!this.start.hasSame(end2, 'week'))
+ weeks.push(end2.weekNumber)
+
+ return weeks.map((item, index) => {
+ return {
+ title: `KW ${item}`,
+ width: index === weeks.length - 1 ? '0px' : this.durationToWidth(this.start.endOf('week').diff(this.start).as('seconds'))
+ }
+ })
+ }
+
+ private createCells(): CellDescriptor[] {
+ return Array.from({ length: 2 }, (_, day) => {
+ let date = this.start.plus({ days: day });
+ const duration = this.dayDuration(day);
+ const sunOrHoliday = this.isDaySunOrHoliday(date);
+ const weekend = this.isDayOnWeekend(date);
+
+ return {
+ title: date.toFormat('dd.MM'),
+ subTitle: date.toFormat('EEE'),
+ width: this.durationToWidth(duration),
+ firstTickOffset: `${duration / this.cellWidth * 100 - 100}%`,
+ sunOrHoliday,
+ weekend,
+ available: defaultAvailable.filter(item => ((!weekend && !sunOrHoliday) || (weekend && !sunOrHoliday) && item.onSaturday) || (sunOrHoliday && item.onSunOrHoliday)).map(item => {
+ return { offset: this.getOffset(date.startOf('day').plus({ seconds: item.start_day_sec })), duration: this.durationToWidth(item.duration_sec) }
+ })
+ }
+ });
+ }
+
+ private createTicks(): TickDescriptor[] {
+ // TODO make calculation more sane!!!
+ let test = Math.floor(24 / Math.ceil((2 * 0.8 * this.globalBaseFontSize) / (this.dayDuration() * this.pixelPerSecond / 24)));
+
+ return Array.from({ length: test }, (_, i) => i * 24 / test).map(item => {
+ return {
+ title: item.toString(),
+ width: this.durationToWidth(24 / test * 3600)
+ }
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/app/gantt/views/descriptors.ts b/src/app/gantt/views/descriptors.ts
new file mode 100644
index 0000000..b00301f
--- /dev/null
+++ b/src/app/gantt/views/descriptors.ts
@@ -0,0 +1,27 @@
+export interface DescriptorSet {
+ interval: IntervalDescriptor[],
+ cell: CellDescriptor[],
+ tick: TickDescriptor[],
+}
+
+interface BasicDescriptor {
+ title: string;
+ width: string;
+}
+
+interface AvailableTime {
+ offset: string,
+ duration: string,
+}
+
+export interface IntervalDescriptor extends BasicDescriptor { }
+
+export interface CellDescriptor extends BasicDescriptor {
+ subTitle: string;
+ firstTickOffset: string,
+ sunOrHoliday: boolean;
+ weekend: boolean;
+ available: AvailableTime[];
+}
+
+export interface TickDescriptor extends BasicDescriptor { }
\ No newline at end of file
diff --git a/src/app/gantt/views/month.ts b/src/app/gantt/views/month.ts
new file mode 100644
index 0000000..523dfa7
--- /dev/null
+++ b/src/app/gantt/views/month.ts
@@ -0,0 +1,75 @@
+import { DateTime } from "luxon";
+import { defaultAvailable, GanttViewType } from "./base";
+import { CellDescriptor, DescriptorSet, IntervalDescriptor, TickDescriptor } from "./descriptors";
+
+
+export class GanttViewMonth extends GanttViewType {
+
+ constructor(date: DateTime) {
+ super()
+
+ this.start = date.startOf('week').startOf('day');
+ this.viewDuration = this.start.plus({ days: 4 * 7 - 1 }).endOf('day').diff(this.start).as('seconds');
+ this.cellWidth = this.viewDuration / (4 * 7);
+ }
+
+ override setToday(): void {
+ this.start = DateTime.now().startOf('week').startOf('day');
+ }
+
+ override calculateIntervalShift(action: "next" | "back"): void {
+ if (action === 'next')
+ this.start = this.start.plus({ weeks: 2 });
+ else
+ this.start = this.start.minus({ weeks: 2 });
+ }
+
+ override createDescriptorSet(): DescriptorSet { return { interval: this.createIntervals(), cell: this.createCells(), tick: this.createTicks() } };
+
+ // get duration of a day in seconds
+ dayDuration(): number {
+ return 24 * 3600;
+ };
+
+ private createIntervals(): IntervalDescriptor[] {
+ return Array.from({ length: 4 }, (_, i) => {
+ return {
+ title: `KW ${this.start.plus({ weeks: i }).get('weekNumber')}`,
+ width: i === 3 ? '0px' : this.durationToWidth(this.start.endOf('week').diff(this.start).as('seconds'))
+ }
+ })
+ }
+
+ private createCells(): CellDescriptor[] {
+ return Array.from({ length: 4 * 7 }, (_, day) => {
+ let date = this.start.plus({ days: day });
+ const duration = this.dayDuration();
+ const sunOrHoliday = this.isDaySunOrHoliday(date);
+ const weekend = this.isDayOnWeekend(date);
+
+ return {
+ title: date.toFormat('dd.MM'),
+ subTitle: date.toFormat('EEE'),
+ width: this.durationToWidth(duration),
+ firstTickOffset: '0px',
+ sunOrHoliday,
+ weekend,
+ available: defaultAvailable.filter(item => ((!weekend && !sunOrHoliday) || (weekend && !sunOrHoliday) && item.onSaturday) || (sunOrHoliday && item.onSunOrHoliday)).map(item => {
+ return { offset: this.getOffset(date.startOf('day').plus({ seconds: item.start_day_sec })), duration: this.durationToWidth(item.duration_sec) }
+ })
+ }
+ });
+ }
+
+ private createTicks(): TickDescriptor[] {
+ // TODO make calculation more sane!!!
+ let test = Math.floor(24 / Math.ceil((1.2 * 0.8 * this.globalBaseFontSize) / (this.dayDuration() * this.pixelPerSecond / 24)));
+
+ return Array.from({ length: test }, (_, i) => i * 24 / test).map(item => {
+ return {
+ title: item.toString(),
+ width: this.durationToWidth(24 / test * 3600)
+ }
+ });
+ }
+}
\ No newline at end of file
diff --git a/src/app/gantt/views/view.ts b/src/app/gantt/views/view.ts
new file mode 100644
index 0000000..4d49b78
--- /dev/null
+++ b/src/app/gantt/views/view.ts
@@ -0,0 +1,84 @@
+import { DateTime, Settings } from "luxon";
+import { GanttViewType } from "./base";
+import { GanttViewWeek } from "./week";
+import { GanttViewDay } from "./day";
+import { GanttViewMonth } from "./month";
+import { CellDescriptor, DescriptorSet, IntervalDescriptor, TickDescriptor } from "./descriptors";
+
+// export interface DescriptorSet {
+// interval: IntervalDescriptor[],
+// cell: IntervalDescriptor[],
+// tick: IntervalDescriptor[],
+// }
+
+// export interface IntervalDescriptor {
+// title: string;
+// subTitle?: string;
+// width: string,
+// firstTickOffset?: string,
+// }
+
+export class GanttView {
+
+ viewType: GanttViewType;
+
+ private _width: number = 0; // width of this gantt view in pixel
+
+ descriptorSet: DescriptorSet = {
+ interval: [],
+ cell: [],
+ tick: [],
+ }
+
+ constructor() {
+ // defining time settings
+ Settings.defaultZone = 'utc';
+ Settings.defaultLocale = "de";
+
+ // initialize view dates (as week view)
+ this.viewType = new GanttViewWeek(DateTime.now());
+ }
+
+ set width(width: number) {
+ this._width = width;
+ this.descriptorSet = this.viewType.update({ width: width });
+ }
+ set globalBaseFontSize(size: number) {
+ this.viewType.globalBaseFontSize = size;
+ }
+
+ get month(): string { return this.viewType.start.toFormat('MMMM yyyy') }
+ get week(): string { return this.viewType.start.toFormat('nn') }
+
+ get intervalDescriptor(): IntervalDescriptor[] { return this.descriptorSet.interval };
+ get cellDescriptors(): CellDescriptor[] { return this.descriptorSet.cell };
+ get tickDescriptors(): TickDescriptor[] { return this.descriptorSet.tick };
+
+ durationToWidth(seconds: number, opts?: { offsetCorrection?: number }): string {
+ return this.viewType.durationToWidth(seconds, opts);
+ }
+
+ getOffset(date: DateTime | number, opts?: { dstCorrection?: boolean, offsetCorrection?: number }): string {
+ return this.viewType.getOffset(date, opts);
+ }
+
+ changeView(view: string): void {
+ switch (view) {
+ case 'day': this.viewType = new GanttViewDay(this.viewType.start); break;
+ case 'week': this.viewType = new GanttViewWeek(this.viewType.start); break;
+ case 'month': this.viewType = new GanttViewMonth(this.viewType.start); break;
+ }
+ this.descriptorSet = this.viewType.update({ width: this._width });
+ }
+
+ back(): void {
+ this.descriptorSet = this.viewType.update({ action: 'back' })
+ }
+ next(): void {
+ this.descriptorSet = this.viewType.update({ action: 'next' })
+ }
+ today(): void {
+ this.viewType.setToday()
+ this.descriptorSet = this.viewType.update({ width: this._width });
+ }
+}
diff --git a/src/app/gantt/views/week.ts b/src/app/gantt/views/week.ts
new file mode 100644
index 0000000..87f2818
--- /dev/null
+++ b/src/app/gantt/views/week.ts
@@ -0,0 +1,85 @@
+import { DateTime } from "luxon";
+import { defaultAvailable, GanttViewType } from "./base";
+import { CellDescriptor, DescriptorSet, IntervalDescriptor, TickDescriptor } from "./descriptors";
+
+
+export class GanttViewWeek extends GanttViewType {
+
+ constructor(date: DateTime) {
+ super()
+
+ this.start = date.startOf('week').startOf('day');
+ this.viewDuration = this.start.endOf('week').endOf('day').diff(this.start).as('seconds');
+ this.cellWidth = this.viewDuration / 7;
+ }
+
+ override setToday(): void {
+ this.start = DateTime.now().startOf('week').startOf('day');
+ }
+
+ override calculateIntervalShift(action: "next" | "back"): void {
+ if (action === 'next')
+ this.start = this.start.plus({ days: 3.5 });
+ else
+ this.start = this.start.minus({ days: 3.5 });
+ }
+
+ override createDescriptorSet(): DescriptorSet { return { interval: this.createIntervals(), cell: this.createCells(), tick: this.createTicks() } };
+
+ // get duration of a day in seconds
+ dayDuration(day?: number): number {
+ // check if first day is only a fraction of a day
+ if (day === 0) {
+ return Math.abs(this.start.diff(this.start.endOf('day')).as('seconds'));
+ } else {
+ return 24 * 3600;
+ }
+ };
+
+ private createIntervals(): IntervalDescriptor[] {
+ let weeks = [this.start.get('weekNumber')];
+ let end = this.start.plus({ week: 1 }).minus({ seconds: 10 });
+ if (!this.start.hasSame(end, 'week'))
+ weeks.push(end.weekNumber)
+
+ return weeks.map((item, index) => {
+ return {
+ title: `KW ${item}`,
+ width: index === weeks.length - 1 ? '0px' : this.durationToWidth(this.start.endOf('week').diff(this.start).as('seconds'))
+ }
+ })
+ }
+
+ private createCells(): CellDescriptor[] {
+ return Array.from({ length: 8 }, (_, day) => {
+ let date = this.start.plus({ days: day });
+ const duration = this.dayDuration(day);
+ const sunOrHoliday = this.isDaySunOrHoliday(date);
+ const weekend = this.isDayOnWeekend(date);
+
+ return {
+ title: date.toFormat('dd.MM'),
+ subTitle: date.toFormat('EEE'),
+ width: this.durationToWidth(duration),
+ firstTickOffset: `${duration / this.cellWidth * 100 - 100}%`,
+ sunOrHoliday,
+ weekend,
+ available: defaultAvailable.filter(item => ((!weekend && !sunOrHoliday) || (weekend && !sunOrHoliday) && item.onSaturday) || (sunOrHoliday && item.onSunOrHoliday)).map(item => {
+ return { offset: this.getOffset(date.startOf('day').plus({ seconds: item.start_day_sec })), duration: this.durationToWidth(item.duration_sec) }
+ })
+ }
+ });
+ }
+
+ private createTicks(): TickDescriptor[] {
+ // TODO make calculation more sane!!!
+ let test = Math.floor(24 / Math.ceil((2 * 0.8 * this.globalBaseFontSize) / (this.dayDuration() * this.pixelPerSecond / 24)));
+
+ return Array.from({ length: test }, (_, i) => i * 24 / test).map(item => {
+ return {
+ title: item.toString(),
+ width: this.durationToWidth(24 / test * 3600)
+ }
+ });
+ }
+}
\ No newline at end of file