To display timeframes in relation to each other a Date/Timeline Chart can be used. It is part of the ngx-simple-charts library. It can look like this:

This chart is used to show the composition of a portfolio at different points in time. The current time is marked with the blue line and the chart can be scrolled to the desired time position with the scrollbars or the arrows. Each bar can have a tooltip and can be clicked. This chart shows all the portfolio positions in the time frame.

Using the component

The date-time-chart is used in the AngularPortfolioMgr project in the portfolio-timechart component. To display the portfolio components at different points in time. The component properties are the ChartItem items and the boolean ‘showDays’ that determines if the single days are displayed in the header. The items are created in the portfolio-timechart.component.ts:

@Component({
  selector: "app-portfolio-timechart",
  templateUrl: "./portfolio-timechart.component.html",
  styleUrls: ["./portfolio-timechart.component.scss"],
})
export class PortfolioTimechartComponent implements OnInit {
  @Input({required: true})
  public selPortfolio: Portfolio;
  protected start = new Date();
  protected items: ChartItem<Event>[] = [];
  protected showDays = false;
  
  constructor(private portfolioService: PortfolioService) {}

  ngOnInit(): void { 
    this.portfolioService.getPortfolioByIdWithHistory(this.selPortfolio.id)
      .subscribe(result => {
	//console.log(result);
	const myMap = result.symbols.filter(mySymbol => 
          !mySymbol.symbol.includes(ServiceUtils.PORTFOLIO_MARKER))
	  .reduce((acc, mySymbol) => {
            const myValue = !acc[mySymbol.symbol] ? [] : 
              acc[mySymbol.symbol];
	    myValue.push(mySymbol);
            acc.set(mySymbol.symbol,myValue);		   
            return acc;
	  },new Map<string,Symbol[]>());		
	  const myItems: ChartItem<Event>[] = [];
	  let myIndex = 0;
          myMap.forEach((myValue,myKey) => {
          const myStart = myValue.map(mySym => 
            new Date(mySym.changedAt)).reduce((acc, value) => 
              acc.valueOf() < value.valueOf() ? value : acc);
	     const myEndItem = myValue.reduce((acc,value) => 
               acc.changedAt.valueOf() < value.changedAt.valueOf() ? 
                 value : acc);
	     const myEnd = !myEndItem?.removedAt ? null : 
               new Date(myEndItem.removedAt);
	     let myItem = new ChartItem<Event>();
	     myItem.id = myIndex;
	     myItem.lineId = myKey;
	     myItem.details = myValue[0].description;
	     myItem.name = myValue[0].name;
	     myItem.start = myStart;
	     myItem.end = myEnd;
	     myItem.id = myIndex;
	     myIndex = myIndex++;
	     myItems.push(myItem);
	  });		
	  //console.log(myItems);	
	  this.items = myItems;		    
	});
	//this.testData();    
  }
...
}

The first the component is created that expects a portfolio object as input property. In the constructor the portfolioService is injected that can read all portfolio components. First the portfolioService method ‘getPortfolioByIdWithHistory(…)’ is called with the portfolioId to fetch the portfolio with all its historic components. The result is then filtered to exclude the portfolio entry and reduced to create a map of symbol->component entries.

Creating the component

The ChartItems are defined in chart-item.ts:

export class ChartItem<T extends Event> {
  id: number = -1;
  lineId: string = '';
  name: string = '';
  details: string = '';
  start: Date | null = null;
  end: Date | null = null;
  eventEmitter = new EventEmitter<T>();
  cssClass = '';
  headerAnchorId = '';
}

The creating template

The template is found in sc-date-time-chart.html:

<div class="time-chart" #timeChart>
  <div class="time-chart-header" (scroll)="scrollContainer($event)">
    <mat-icon class="prev-year" fontIcon="arrow_back" 
      (click)="scrollToTime(-1)"></mat-icon>
    <mat-icon class="next-year" fontIcon="arrow_forward" 
      (click)="scrollToTime(1)"></mat-icon>
    <div class="current-time" [id]="CURRENT_TIME" 
      [style.right.px]="dayPx" [style.height.px]="timeChartHeight">
    </div>
    <ng-container *ngIf="showDays">
      <div class="header-line" #headerLine>
        <div *ngFor="let periodMonth of periodMonths; 
          let indexOfElement = index" class="header-box" 
          [id]="monthHeaderAnchorIds[indexOfElement]"
          [style.width.px]="periodMonth.daysInMonth * (DAY_WIDTH + 2) - 2">
          {{ periodMonth.toJSDate() | date : "MMMM YYYY" }}
        </div>
      </div>
      <div class="header-line">
        <div *ngFor="let periodDay of periodDays" class="header-box"
          [class.header-sunday]="periodDay.weekday === 7"
          [style.width.px]="DAY_WIDTH">
          {{ periodDay.toJSDate() | date : "dd" }}
        </div>
      </div>
    </ng-container>
    <ng-container *ngIf="!showDays">
      <div class="header-line" #headerLine>
        <div *ngFor="let periodYear of periodYears; 
          let indexOfElement = index" class="header-box" 
          [id]="yearHeaderAnchorIds[indexOfElement]"
          [style.width.px]="12 * (MONTH_WIDTH + 2) - 2">
          {{ periodYear.toJSDate().getFullYear() }}
        </div>
      </div>
      <div class="header-line">
        <div *ngFor="let periodMonth of periodMonths" class="header-box"
          [style.width.px]="MONTH_WIDTH">
          {{ periodMonth.toJSDate() | date : "MMMM" }}
        </div>
      </div>
    </ng-container>
    <div class="chart-line" *ngFor="let myItems of lineKeyToItems" 
      #tooltip="matTooltip"
      [matTooltip]="!myItems.items[0]?.name && !myItems.items[0]?.details
        ? 'None': myItems.items[0]?.name + ', ' + myItems.items[0]?.details"
      matTooltipPosition="above"
      matTooltipHideDelay="300"
      [style.height.px]="chartLineHeight">
      <div *ngFor="let item of myItems.items; let indexOfElement = index"
        class="stock-line" [style.width.px]="calcWidthPxItem(item)"
        [class.stock-line-start]="!!item.start"
        [class.stock-line-end]="!!item.end" 
        [style.left.px]="calcStartPxItem(item)"
        [style.top.px]="-chartLineHeight * indexOfElement">
        <div class="stock-name">{{ item.name }}</div>
      </div>
    </div>
  </div>
</div>

The div with the ‘(scroll)=”scrollContainer($event)’ has the function that is called when the div is scrolled and calculates value of the ‘dayPx’ property.

The ‘<mat-icon …>’ components show the arrow icons that scroll the component with ‘(click)=”scrollToTime(…)”‘ method.

The div with the ‘[id]=”CURRENT_TIME”‘ creates a div with the width of 2px of border at the x position ‘[style.right.px]=”dayPx”‘ and the height of ‘[style.height.px]=”timeChartHeight”. Both properties are calculated in the component class.

The ‘<ng-container>’ contain the 2 header lines with the year/month or month/day headers with the ‘showdays’ property determining witch is shown. The month/day headers are created with the ‘periodMonths’ array that is iterated and creates the ‘indexOfElement’. The ‘[id]=”monthHeaderAnchorIds[indexOfElement]”‘ is used to create ids that can be used with ‘scrollIntoView(…)’. The ‘[style.width.px]=”periodMonth.daysInMonth * (DAY_WIDTH + 2) – 2″ ‘ calculates the width of the month div based on the days of the month. The ‘{{ periodMonth.toJSDate() | date : “MMMM YYYY” }}’ displays the formatted month and year. The days are displayed in a similar manner based on the ‘periodDays’ array.

The ‘<div class=”chart-line” …’ iterates over the ‘lineKeyToItems’ map that contains the key for each line and the items to display on the line and sets the height of the line. The Tooltips are added with the with the ‘#tooltip’ variable and the value is set with ‘[matTooltip]=”…”‘. The next ‘<div …>’ iterates over the items of each map entry. The property ‘[style.width.px]=”calcWidthPxItem(item)”‘ calculates the width of the item div in pixels. The property ‘[class.stock-line-start]=”!!item.start”‘ determines if the css class to render a beginning is added and the same is done for endings. The property ‘[style.left.px]=”calcStartPxItem(item)”‘ calculates the start x position of the div on the hole component. The property ‘[style.top.px]=”-chartLineHeight * indexOfElement”‘ puts all the divs on the same height despite of ‘display: block’ that would normally render each div on its own line. The divs a rendered over/next to each other.

The component to calculate the values

The component is split in the ScDateTimeChartBase and the ScDateTimeChart classes. The file sc-date-time-chart-base.ts contains the common parts:

export class ScDateTimeChartBase {
  protected localStart = new Date();
  protected localShowDays: boolean = false;
  protected end: Date | null = null;
  protected localItems: ChartItem<Event>[] = [];
  protected periodDays: DateTime[] = [];
  protected periodMonths: DateTime[] = [];
  protected periodYears: DateTime[] = [];
  protected monthHeaderAnchorIds: string[] = [];
  protected yearHeaderAnchorIds: string[] = [];
  protected readonly DAY_WIDTH = 20;
  protected readonly MONTH_WIDTH = 100;

  constructor(protected locale: string) {}

  protected calcChartTime(): void {
    this.localStart = !this.localStart ? new Date() : this.localStart;
    if (this.localItems.length < 1) {
      return;
    }
    this.localStart = this.localItems.map((myItem) => myItem.start)
      .filter((myStart) => !!myStart)
      .reduce((acc, myItem) =>
          (myItem as Date).valueOf() < (acc as Date).valueOf() ? 
          myItem : acc, new Date()) as Date;
    const myEndOfYear = new Date(new Date().getFullYear(), 
      11, 31, 23, 59, 59);
    const endOfYear = DateTime.fromJSDate(myEndOfYear)
      .setLocale(this.locale)
      .setZone(Intl.DateTimeFormat().resolvedOptions().timeZone)
      .toJSDate();
    const lastEndItem = this.localItems.reduce((acc, newItem) => {
      const accEnd = !!acc?.end?.valueOf() ? acc?.end?.valueOf() : -1;
      const newItemEnd = !!newItem?.end?.valueOf()
        ? newItem?.end?.valueOf() : -1;
      return accEnd < newItemEnd ? newItem : acc;
    });
    const openEndItems = this.localItems.filter((newItem) => !newItem?.end);
    const lastEndYear = !!lastEndItem?.end?.getFullYear()
      ? lastEndItem!.end.getFullYear() : -1;
    this.end = openEndItems.length > 0 || !this.localShowDays ? endOfYear
      : lastEndYear < 1 ? endOfYear : lastEndItem.end;
    this.periodDays = [];
    for (let myDay = DateTime.fromObject({
      year: this.localStart.getFullYear(), 
      month: this.localStart.getMonth() + 1, day: 1});
      myDay.toMillis() <= DateTime.fromJSDate(this.end).toMillis();
      myDay = myDay.plus({ days: 1 })
    ) {
      this.periodDays.push(myDay);
    }
    this.periodMonths = [];
    this.monthHeaderAnchorIds = [];
    for (let myMonth = DateTime.fromObject({
      year: this.localStart.getFullYear(),
      month: !!this.localShowDays ? this.localStart.getMonth() + 1 : 1,
      day: 1});
      myMonth.toMillis() <= DateTime.fromJSDate(this.end).toMillis();
      myMonth = myMonth.plus({ months: 1 })
    ) {
      this.periodMonths.push(myMonth);
      this.monthHeaderAnchorIds.push(
        'M_' + this.generateHeaderAnchorId(myMonth));
    }
    this.periodYears = [];
    this.yearHeaderAnchorIds = [];
    for (let myYear = DateTime.fromObject({
        year: this.localStart.getFullYear(),
        month: 1, day: 1});
      myYear.toMillis() <= DateTime.fromJSDate(this.end).toMillis();
      myYear = myYear.plus({ years: 1 })
    ) {
      this.periodYears.push(myYear);
      this.yearHeaderAnchorIds.push('Y_' +   
        this.generateHeaderAnchorId(myYear));
    }    
  }

  protected generateHeaderAnchorId(dateTime: DateTime): string {
    const headerAnchorId = '' + dateTime.year + '_' + dateTime.month + 
      '_' + new Date().getMilliseconds().toString(16);
    return headerAnchorId;
  }
}

The properties ‘localStart’, ‘showDays’, localItems’ are set by the parent component. The constants ‘DAY_WIDTH’ and ‘MONTH_WIDTH’ are used for common widths of the day and month divs. The other properties are calculated in the methods.

The ‘calcChartTime()’ method first sets the ‘localStart’ property and returns if the ‘localItems’ array is empty.

Then the ‘localStart’ Date property is recalculated based on the ‘item.start’ dates.

Then the ‘end’ Date property is calculated. First the ‘endOfYear’ is calculated based on the current timezone. Then the ‘lastEndItem’ is filtered or created with the last items end date. Then the ‘end’ property is set to the current year or the end year of the last item that is displayed.

Then the ‘periodDays’ array is filled with the ‘Date()’ objects of the days between the ‘localStart’ and ‘end’ properties. Then the array of the ‘periodMonths’ and ‘periodYears’ properties are filled in the same manner and the ‘monthHeaderAnchorIds’ and ‘yearHeaderAnchorIds’ arrays are filled with unique ids based on their dates.

The Angular Component is found in the sc-date-time-chart.component.ts file:

interface LineKeyToItems {
  lineKey: string;
  items: ChartItem<Event>[];
}

@Component({
  selector: 'sc-date-time-chart',
  templateUrl: './sc-date-time-chart.component.html',
  styleUrls: ['./sc-date-time-chart.component.scss'],
})
export class ScDateTimeChartComponent
  extends ScDateTimeChartBase
  implements OnInit, AfterViewInit
{
  protected dayPx = -10;
  protected anchoreIdIndex = 0;
  protected nextAnchorId = '';
  protected timeChartHeight = 0;
  protected chartLineHeight = 0;
  protected lineKeyToItems: LineKeyToItems[] = [];
  protected readonly CURRENT_TIME = 'currentTime';

  @ViewChild('timeChart')
  private timeChartRef: ElementRef | null = null;
  @ViewChild('headerLine')
  private headerLineRef: ElementRef | null = null;

  constructor(@Inject(LOCALE_ID) locale: string) {
    super(locale);
  }

  ngAfterViewInit(): void {
    this.calcTimeChartValues();
    setTimeout(() => {
      let myPeriods = !this.showDays ? this.periodYears : this.periodMonths;
      myPeriods = myPeriods.filter((myPeriod) => myPeriod.diffNow().seconds 
        <= 0);
      const myPeriodIndex = myPeriods.length === 0 ? -1 : 
        myPeriods.length - 1;
      if (myPeriodIndex >= 0) {
        this.scrollToAnchorId(
          !this.showDays
            ? this.yearHeaderAnchorIds[myPeriodIndex]
            : this.monthHeaderAnchorIds[myPeriodIndex]
        );
      }
      this.calcTimeChartValues();
    }, 1000);
  }

  ngOnInit(): void {
    this.calcChartTime();
  }

  protected calcTimeChartValues(): void {
    setTimeout(() => {
      this.timeChartHeight = this.timeChartRef?.nativeElement?.offsetHeight;
      this.chartLineHeight = this.headerLineRef?.nativeElement?.clientHeight;
    });
  }

...

The ScDateTimeChartComponent has the properties that are calculated based on the injected or template values. The ‘@ViewChild’ annotations inject the ‘timeChart’ reference and the ‘headerLine’ reference.

The ‘ngOnInit()’ calls the ‘calcChartTime()’ method of the base class.

The ‘ngAfterViewInit()’ method calls ‘calcTimeChartValues()’ to set properties for the template. The ‘setTimeout(…)’ is used to filter for the last period(years or months based on ‘showDays’) before now and then(if found) uses the method ‘scrollToAnchorId(…)’ to scroll to the id of the anchor div. The method ‘calcTimeChartValues()’ is called again to update the values that cause the heights to be updated.

The currentDay is marked by a div with ‘position: absolute;’ that is whos position is caculated like this:

  protected scrollContainer(event: Event): void {
    const myScrollWidth = (event.target as Element).scrollWidth;
    const myClientWidth = (event.target as Element).clientWidth;
    const myScrollRight =
      myScrollWidth - myClientWidth - (event.target as Element).scrollLeft;
    let myScrollDayPosition = 0;
    const today = DateTime.now()
      .setLocale(this.locale)
      .setZone(Intl.DateTimeFormat().resolvedOptions().timeZone)
      .toJSDate();
    myScrollDayPosition = myScrollWidth - this.calcStartPx(today);
    const leftDayContainerBoundary =
      myScrollDayPosition + myClientWidth < myScrollWidth
        ? myScrollWidth : myScrollDayPosition + myScrollWidth;    
    this.dayPx = myScrollWidth - leftDayContainerBoundary -
      myScrollRight + myScrollDayPosition;
  }

  protected calcStartPx(start: Date): number {
    const chartStart = DateTime.fromObject({
      year: this.start.getFullYear(),
      month: !this.showDays ? 1 : this.start.getMonth() + 1,
      day: 1,
    });
    const itemInterval = Interval.fromDateTimes(chartStart,
      !!start ? DateTime.fromJSDate(start) : chartStart);
    const itemPeriods = !this.showDays ? itemInterval.length('months')
      : itemInterval.length('days');
    const result = itemPeriods * 
      ((!this.showDays ? this.MONTH_WIDTH : this.DAY_WIDTH) + 2);
    return result;
  }

The method ‘scrollContainer(…)’ is called on every scroll because the day div is positioned absolute. The client width and the scroll div width, are read from the event and the scroll position from the right border is calculated. Then the current time is created based on the timezone and the ‘myScrollDayPosition’ is calculated to have the position of the day div on the scrolling div. The ‘leftDayContainerBoundery’ is the combined scroll div width and the day position. Then the property ‘dayPx’ position is calculated.

The method ‘calcStartPx(…)’ calculates the position in pixels of a the ‘start’ date in the scroll div. The ‘itemInterval’ is created from the ‘chartStart’ time to the ‘start’ time of the method parameter. Based on ‘showDays’ the amount of ‘itemPeriods’ is calculated and the periods are multiplied with ‘MONTH_WIDTH’ or ‘DAY_WIDTH’ based on ‘showDays’.

The scrolling of the component is done with these methods:

  protected scrollToTime(timeDiff: number): void {
    const anchorIds = !this.showDays
      ? this.yearHeaderAnchorIds
      : this.monthHeaderAnchorIds;
    this.anchoreIdIndex =
      this.anchoreIdIndex + timeDiff < 0
        ? 0
        : this.anchoreIdIndex + timeDiff >= anchorIds.length
        ? anchorIds.length - 1
        : this.anchoreIdIndex + timeDiff;
    this.scrollToAnchorId(anchorIds[this.anchoreIdIndex]);
  }

  protected scrollToAnchorId(anchorId: string): void {
    const element = document.getElementById(anchorId);
    element?.scrollIntoView({
      block: 'start',
      behavior: 'smooth',
      inline: 'nearest',
    });
  }

First the anchorIds(year/month) array is set based on the ‘showDays’. Then the ‘anchoreIdIndex’ is set with the timeDiff to select the new anchor index. Then the method ‘scrollToAnchorId(…)’ is called with the ‘anchorId’ array and the ‘anchorIdIndex’ as array index.

The method ‘scrollToAnchorId(..)’ selects the div element of the ‘anchorId’ and calls the function ‘scrollIntoView(…)’ to scroll the div element smoothly into the view.

Conclusion

The component took some time to get it working. The result is a scrolling date/time chart that can display serveral items below each other according to their timeframes. Multiple timeframes for one item are also supported and are displayed over or next to each other. The component scrolls on startup to the current day and supports scrolling with a buttons.