opf/openproject

View on GitHub
frontend/src/app/features/work-packages/components/wp-table/timeline/global-elements/wp-timeline-relations.directive.ts

Summary

Maintainability
C
1 day
Test Coverage
// -- copyright
// OpenProject is an open source project management software.
// Copyright (C) 2012-2024 the OpenProject GmbH
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License version 3.
//
// OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
// Copyright (C) 2006-2013 Jean-Philippe Lang
// Copyright (C) 2010-2013 the ChiliProject Team
//
// This program is free software; you can redistribute it and/or
// modify it under the terms of the GNU General Public License
// as published by the Free Software Foundation; either version 2
// of the License, or (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program; if not, write to the Free Software
// Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
//
// See COPYRIGHT and LICENSE files for more details.
//++

import {
  Component, ElementRef, Injector, OnInit,
} from '@angular/core';
import { IsolatedQuerySpace } from 'core-app/features/work-packages/directives/query-space/isolated-query-space';
import { State } from '@openproject/reactivestates';
import { combineLatest } from 'rxjs';
import { filter, map, take } from 'rxjs/operators';
import { States } from 'core-app/core/states/states.service';
import { WorkPackageViewTimelineService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-timeline.service';
import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator';
import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin';
import { RelationsStateValue, WorkPackageRelationsService } from '../../../wp-relations/wp-relations.service';
import { WorkPackageTimelineCell } from '../cells/wp-timeline-cell';
import { WorkPackageTimelineTableController } from '../container/wp-timeline-container.directive';
import { timelineElementCssClass, TimelineViewParameters } from '../wp-timeline';
import { TimelineRelationElement, workPackagePrefix } from './timeline-relation-element';

const DEBUG_DRAW_RELATION_LINES_WITH_COLOR = false;

export const timelineGlobalElementCssClassname = 'relation-line';

function newSegment(vp:TimelineViewParameters,
  classNames:string[],
  yPosition:number,
  top:number,
  left:number,
  width:number,
  height:number,
  color?:string):HTMLElement {
  const segment = document.createElement('div');
  segment.classList.add(
    timelineElementCssClass,
    timelineGlobalElementCssClassname,
    ...classNames,
  );

  // segment.style.backgroundColor = color;
  segment.style.top = `${(yPosition * 40) + top}px`;
  segment.style.left = `${left}px`;
  segment.style.width = `${width}px`;
  segment.style.height = `${height}px`;

  if (DEBUG_DRAW_RELATION_LINES_WITH_COLOR && color !== undefined) {
    segment.style.zIndex = '9999999';
    segment.style.backgroundColor = color;
  }
  return segment;
}

@Component({
  selector: 'wp-timeline-relations',
  template: '<div class="wp-table-timeline--relations"></div>',
})
export class WorkPackageTableTimelineRelations extends UntilDestroyedMixin implements OnInit {
  @InjectField() querySpace:IsolatedQuerySpace;

  private container:JQuery;

  private workPackagesWithRelations:{ [workPackageId:string]:State<RelationsStateValue> } = {};

  constructor(public readonly injector:Injector,
    public elementRef:ElementRef,
    public states:States,
    public workPackageTimelineTableController:WorkPackageTimelineTableController,
    public wpTableTimeline:WorkPackageViewTimelineService,
    public wpRelations:WorkPackageRelationsService) {
    super();
  }

  ngOnInit() {
    const $element = jQuery(this.elementRef.nativeElement);
    this.container = $element.find('.wp-table-timeline--relations');
    this.workPackageTimelineTableController
      .onRefreshRequested('relations', (vp:TimelineViewParameters) => this.refreshView());

    this.setupRelationSubscription();
  }

  private refreshView() {
    this.update();
  }

  private get workPackageIdOrder() {
    return this.workPackageTimelineTableController.workPackageIdOrder;
  }

  /**
   * Refresh relations of visible rows.
   */
  private setupRelationSubscription() {
    // for all visible WorkPackage rows...
    combineLatest([
      this.querySpace.renderedWorkPackages.values$(),
      this.wpTableTimeline.live$(),
    ])
      .pipe(
        filter(([_, timeline]) => timeline.visible),
        this.untilDestroyed(),
        map(([rendered, _]) => rendered),
      )
      .subscribe((list) => {
        // ... make sure that the corresponding relations are loaded ...
        const wps = _.compact(list.map((row) => row.workPackageId) as string[]);
        this.wpRelations.requireAll(wps);

        wps.forEach((wpId) => {
          const relationsForWorkPackage = this.wpRelations.state(wpId);
          this.workPackagesWithRelations[wpId] = relationsForWorkPackage;

          // ... once they are loaded, display them.
          relationsForWorkPackage.values$()
            .pipe(
              take(1),
            )
            .subscribe(() => {
              this.renderWorkPackagesRelations([wpId]);
            });
        });
      });

    // When a WorkPackage changes, redraw the corresponding relations
    this.states.workPackages.observeChange()
      .pipe(
        this.untilDestroyed(),
        filter(() => this.wpTableTimeline.isVisible),
      )
      .subscribe(([workPackageId]) => {
        this.renderWorkPackagesRelations([workPackageId]);
      });
  }

  private renderWorkPackagesRelations(workPackageIds:string[]) {
    workPackageIds.forEach((workPackageId) => {
      const workPackageWithRelation = this.workPackagesWithRelations[workPackageId];
      if (_.isNil(workPackageWithRelation)) {
        return;
      }

      this.removeRelationElementsForWorkPackage(workPackageId);
      const relations = _.values(workPackageWithRelation.value);
      const relationsList = _.values(relations);
      relationsList.forEach((relation) => {
        if (!(relation.type === 'precedes'
          || relation.type === 'follows')) {
          return;
        }

        const elem = new TimelineRelationElement(relation.ids.from, relation);
        this.renderElement(this.workPackageTimelineTableController.viewParameters, elem);
      });
    });
  }

  private update() {
    this.removeAllVisibleElements();
    this.renderElements();
  }

  private removeRelationElementsForWorkPackage(workPackageId:string) {
    const className = workPackagePrefix(workPackageId);
    const found = this.container.find(`.${className}`);
    found.remove();
  }

  private removeAllVisibleElements() {
    this.container.find(`.${timelineGlobalElementCssClassname}`).remove();
  }

  private renderElements() {
    const wpIdsWithRelations:string[] = _.keys(this.workPackagesWithRelations);
    this.renderWorkPackagesRelations(wpIdsWithRelations);
  }

  /**
   * Render a single relation to all shown work packages. Since work packages may occur multiple
   * times in the timeline, iterate all potential combinations and render them.
   * @param vp
   * @param e
   */
  private renderElement(vp:TimelineViewParameters, e:TimelineRelationElement) {
    const involved = e.relation.ids;

    const startCells = this.workPackageTimelineTableController.workPackageCells(involved.to);
    const endCells = this.workPackageTimelineTableController.workPackageCells(involved.from);

    // If either sources or targets are not rendered, ignore this relation
    if (startCells.length === 0 || endCells.length === 0) {
      return;
    }

    // Now, render all sources to all targets
    startCells.forEach((startCell) => {
      const idxFrom = this.workPackageTimelineTableController.workPackageIndex(startCell.classIdentifier);
      endCells.forEach((endCell) => {
        const idxTo = this.workPackageTimelineTableController.workPackageIndex(endCell.classIdentifier);
        this.renderRelation(vp, e, idxFrom, idxTo, startCell, endCell);
      });
    });
  }

  private renderRelation(vp:TimelineViewParameters,
    e:TimelineRelationElement,
    idxFrom:number,
    idxTo:number,
    startCell:WorkPackageTimelineCell,
    endCell:WorkPackageTimelineCell) {
    const rowFrom = this.workPackageIdOrder[idxFrom];
    const rowTo = this.workPackageIdOrder[idxTo];

    // If any of the targets are hidden in the table, skip
    if (!(rowFrom && rowTo) || (rowFrom.hidden || rowTo.hidden)) {
      return;
    }

    // Skip if relations cannot be drawn between these cells
    if (!startCell.canConnectRelations() || !endCell.canConnectRelations()) {
      return;
    }

    // Get X values
    // const hookLength = endCell.getPaddingLeftForIncomingRelationLines();
    const startX = startCell.getMarginLeftOfRightSide() - startCell.getPaddingRightForOutgoingRelationLines();
    const targetX = endCell.getMarginLeftOfLeftSide() + endCell.getPaddingLeftForIncomingRelationLines();

    // Vertical direction
    const directionY:'toUp'|'toDown' = idxFrom < idxTo ? 'toDown' : 'toUp';

    // Horizontal direction
    const directionX:'toLeft'|'beneath'|'toRight' = targetX > startX ? 'toRight' : targetX < startX ? 'toLeft' : 'beneath';

    // start
    if (!startCell) {
      return;
    }

    // Draw the first line next to the bar/milestone element
    const paddingRight = startCell.getPaddingRightForOutgoingRelationLines();
    const startLineWith = endCell.getPaddingLeftForIncomingRelationLines()
      + (paddingRight > 0 ? paddingRight : 0);
    this.container.append(newSegment(vp, e.classNames, idxFrom, 19, startX, startLineWith, 1, 'red'));
    const lastX = startX + startLineWith;
    // lastX += hookLength;

    // Draw vertical line between rows
    const height = Math.abs(idxTo - idxFrom);
    if (directionY === 'toDown') {
      if (directionX === 'toRight' || directionX === 'beneath') {
        this.container.append(newSegment(vp, e.classNames, idxFrom, 19, lastX, 1, height * 40, 'black'));
      } else if (directionX === 'toLeft') {
        this.container.append(newSegment(vp, e.classNames, idxFrom, 19, lastX, 1, (height * 40) - 10, 'black'));
      }
    } else if (directionY === 'toUp') {
      this.container.append(newSegment(vp, e.classNames, idxTo, 30, lastX, 1, (height * 40) - 10, 'black'));
    }

    // Draw end corner to the target
    if (directionX === 'toRight') {
      if (directionY === 'toDown') {
        this.container.append(newSegment(vp, e.classNames, idxTo, 19, lastX, targetX - lastX, 1, 'red'));
      } else if (directionY === 'toUp') {
        this.container.append(newSegment(vp, e.classNames, idxTo, 20, lastX, 1, 10, 'green'));
        this.container.append(newSegment(vp, e.classNames, idxTo, 20, lastX, targetX - lastX, 1, 'lightsalmon'));
      }
    } else if (directionX === 'toLeft') {
      if (directionY === 'toDown') {
        this.container.append(newSegment(vp, e.classNames, idxTo, 0, lastX, 1, 8, 'red'));
        this.container.append(newSegment(vp, e.classNames, idxTo, 8, targetX, lastX - targetX, 1, 'green'));
        this.container.append(newSegment(vp, e.classNames, idxTo, 8, targetX, 1, 11, 'blue'));
      } else if (directionY === 'toUp') {
        this.container.append(newSegment(vp, e.classNames, idxTo, 30, targetX + 1, lastX - targetX, 1, 'red'));
        this.container.append(newSegment(vp, e.classNames, idxTo, 19, targetX + 1, 1, 11, 'blue'));
      }
    }
  }
}