This commit is contained in:
Hlars 2025-04-24 18:09:00 +02:00
parent 7a0ecf281a
commit 6b76e5b905
14 changed files with 1268 additions and 2 deletions

18
package-lock.json generated
View File

@ -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",

View File

@ -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"
}
}
}

View File

@ -48,4 +48,26 @@
</mat-select>
</mat-form-field>
<!--
<mat-form-field appearance="fill">
<mat-label>Choose an option</mat-label>
<input matInput [grossNetCalculator]="calculator">
<gross-net-calculator-toggle-button matIconPrefix></gross-net-calculator-toggle-button>
<gross-net-calculator #calculator></gross-net-calculator>
</mat-form-field> -->
<div style="margin: 1rem">
<app-gantt [items]="ganttItems"></app-gantt>
</div>
<!-- component.html -->
<!-- <ngx-gantt #gantt [items]="items">
<ngx-gantt-table>
<ngx-gantt-column name="test" width="300px">
<ng-template #cell let-item="item"> {{ item.title }} </ng-template>
</ngx-gantt-column>
</ngx-gantt-table>
</ngx-gantt> -->
<router-outlet />

View File

@ -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 }
]
}
]
}

View File

@ -0,0 +1,214 @@
<div class="container">
<div class="info-box">
<div><button (click)="moveBack()">Back</button><button (click)="moveNext()">Next</button></div>
<div>{{month}}</div>
<div>KW {{week}}</div>
</div>
<div class="table">
<!-- <div class="header"></div> -->
@for (item of items; track item.id) {
<div class="table-row">{{item.title}}</div>
}
<!-- <div>fjadjfldjalfjldaf</div> -->
</div>
<div class="bars" #barContainer>
@for(day of [0,1,2,3,4,5,6]; track day) {
<div class="header">
<div>{{getDate(day)}}</div>
<div>{{getWeekday(day)}}</div>
</div>
}
@for (item of items; track item.id) {
<div class="bar-row bars">
<div class="bar-container">
<!-- Draw open periods -->
@for(day of [0,1,2,3,4,5,6]; track day) {
@for (time of getDayWorkTime(day); track $index) {
<div style="height: 100px; position: absolute; background-color: purple;"
[style.width]="durationToWidth(time.duration_sec)"
[style.left]="startToOffset(day * 24 * 3600 + time.start_day_sec + startDate.toSeconds())"> </div>
}}
<!-- Draw gantt bars -->
@for (bar of item.items; track $index) {
<div class="bar" [style.width]="durationToWidth(bar.duration_sec)"
[style.left]="startToOffset(bar.start, {dstCorrection: true})">
{{bar.title}}</div>
}
</div>
@for(day of [0,1,2,3,4,5,6]; track day) {
<div class="grid">
<!-- <div></div> -->
@for (time of getDayWorkTime(day); track $index) {
<div class="open" [style.width]="durationToWidth(time.duration_sec)"
[style.left]="durationToWidth(time.start_day_sec)"> </div>
}
</div>
}
</div>
}
<!-- <div class="bar-row bars">
<div class="bar-container">
<div class="bar" [style.width]="durationToWidth(24*3600)" [style.left]="startToOffset(test)">fdaf</div>
<div class="bar bar2" [style.width]="'400px'">fdaf</div>
</div>
@for(day of [0,1,2,3,4,5,6]; track day) {
<div class="grid" [style.height]="'100px'">
</div>
}
</div> -->
</div>
</div>
<div class="container new">
<div class="info-box">
<div>
<button (click)="moveBack()">Back</button><button (click)="moveNext()">Next</button>
<select>
<option value="month">Month</option>
<option value="week">week</option>
<option value="day">Day</option>
</select>
</div>
<div>{{month}}</div>
<div class="interval-descriptor">
@for (week of intervalArray; track week.title) {
<div [style.width]="durationToWidth(week.duration, { offsetCorrection: -1})">{{week.title}}</div>
}
</div>
</div>
<div class="table">
@for (item of items; track item.id) {
<div class="table-row">{{item.title}}</div>
}
</div>
<div class="bars" #barContainer>
<div class="calender-head">
@for(day of [0,1,2,3,4,5,6,7]; track day) {
<div class="interval" [style.--first-interval-shift]="firstDayIntervalShift(day)"
[style.width]="durationToWidth(getDayDuration(day))">
<div class="content">
<span>{{getDate(day)}}</span>
<span>{{getWeekday(day)}}</span>
<span class="spacer"></span>
<div class="ticks">
@for (hour of hourArray; track hour) {
<span class="tick" [style.width]="durationToWidth(24 / hourArray.length * 3600)">{{hour}}</span>
}
</div>
</div>
</div>
}
</div>
@for (item of items; track item.id) {
<div class="bar-row">
<!-- Draw open periods -->
<div>
@for(day of [0,1,2,3,4,5,6,7]; track day) {
@for (time of getDayWorkTime(day); track $index) {
<div class="open" [style.width]="durationToWidth(time.duration_sec)"
[style.left]="startToOffset(startDate.startOf('day').plus({days: day, seconds: time.start_day_sec}), {dstCorrection: true})">
</div>
}}
</div>
<!-- Draw gantt bars -->
<div class="bar-container">
@for (bar of item.items; track $index) {
<div class="bar" [style.width]="durationToWidth(bar.duration_sec)"
[style.left]="startToOffset(bar.start, {dstCorrection: true})">
{{bar.title}}</div>
}
</div>
</div>
}
</div>
</div>
<div class="container new">
<div class="info-box">
<div>
<button (click)="view.back()">Back</button><button (click)="view.next()">Next</button>
<select #viewSelect value="week" (change)="changeViewType(viewSelect.value)">
<option value="month">Month</option>
<option value="week" selected="selected">week</option>
<option value="day">Day</option>
</select>
<button (click)="view.today()">Today</button>
</div>
<div class="month-display">{{view.month}}</div>
<div class="interval-descriptor">
@for (cell of view.intervalDescriptor; track cell.title) {
<div [style.width]="cell.width">{{cell.title}}</div>
}
</div>
</div>
<div class="table">
@for (item of items; track item.id) {
<div class="table-row">{{item.title}}</div>
}
</div>
<div class="bars" #barContainer>
<div class="calender-head">
@for(item of view.cellDescriptors; track item.title) {
<div class="cell" [class.weekend]="item.weekend" [class.sunday]="item.sunOrHoliday"
[style.--first-interval-shift]="item.firstTickOffset" [style.width]="item.width">
<div>
<div class="content">
<span class="day-info">{{item.title}}</span>
<span class="day-info">{{item.subTitle}}</span>
<span class="spacer"></span>
<div class="ticks">
@for (item of view.tickDescriptors; track item.title) {
<span class="tick" [style.width]="item.width">{{item.title}}</span>
}
</div>
</div>
</div>
</div>
}
</div>
@for (item of items; track item.id) {
<div class="bar-row">
<!-- Draw open periods -->
<div>
@for(day of view.cellDescriptors; track day.title) {
@for (time of day.available; track $index) {
<div class="open" [style.width]="time.duration" [style.left]="time.offset"> </div>
}}
</div>
<!-- Draw gantt bars -->
<div class="bar-container">
@for (bar of item.items; track $index) {
<div class="bar" [style.width]="view.durationToWidth(bar.duration_sec)"
[style.left]="view.getOffset(bar.start)">
{{bar.title}}</div>
}
</div>
</div>
}
</div>
</div>

View File

@ -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;
}
}

View File

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { GanttComponent } from './gantt.component';
describe('GanttComponent', () => {
let component: GanttComponent;
let fixture: ComponentFixture<GanttComponent>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [GanttComponent]
})
.compileComponents();
fixture = TestBed.createComponent(GanttComponent);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View File

@ -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 = <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;
}
}

View File

@ -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 { }
}

View File

@ -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 <TickDescriptor>{
title: item.toString(),
width: this.durationToWidth(24 / test * 3600)
}
});
}
}

View File

@ -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 { }

View File

@ -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 <TickDescriptor>{
title: item.toString(),
width: this.durationToWidth(24 / test * 3600)
}
});
}
}

View File

@ -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 });
}
}

View File

@ -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 <TickDescriptor>{
title: item.toString(),
width: this.durationToWidth(24 / test * 3600)
}
});
}
}