An error occurred while loading the file. Please try again.
-
Grand Francois authored
refs #522
fcfab555
import { Component, Input, Output, EventEmitter, OnInit, AfterViewInit, ViewChild, Inject, forwardRef, AfterContentInit } from "@angular/core";
import { MatDialog } from "@angular/material/dialog";
import screenfull from "screenfull";
import {
PbBassin, PbCloison, Observer, IObservable, MermaidUtil
} from "jalhyd";
import mermaid from "mermaid";
import { HotkeysService, Hotkey } from "angular2-hotkeys";
import { I18nService } from "../../services/internationalisation.service";
import { PbSchema } from "../../formulaire/elements/pb-schema";
import { DialogNewPbCloisonComponent } from "../dialog-new-pb-cloison/dialog-new-pb-cloison.component";
import { GenericCalculatorComponent } from "../generic-calculator/calculator.component";
import { FormulairePrebarrage } from "../../formulaire/definition/form-prebarrage";
import { AppComponent } from "../../app.component";
import { fv } from "app/util";
import { ServiceFactory } from "app/services/service-factory";
import { DefinedBoolean } from "app/definedvalue/definedboolean";
import { PrebarrageService } from "app/services/prebarrage.service";
/**
* The interactive schema for calculator type "PreBarrage" (component)
*/
@Component({
selector: "pb-schema",
templateUrl: "./pb-schema.component.html",
styleUrls: [
"./pb-schema.component.scss"
]
})
export class PbSchemaComponent implements AfterViewInit, AfterContentInit, OnInit, Observer {
@Input()
private pbSchema: PbSchema;
@ViewChild("schema", { static: true })
public schema: any;
/** handle on SVG container */
private nativeElement: any;
/** flag de validité du composant */
private _isValid: DefinedBoolean;
/** événément de changement de validité */
@Output()
private validChange = new EventEmitter();
/** événément de sélection d'un nœud du graphique Mermaid */
@Output()
private nodeSelected = new EventEmitter();
/** Latest clicked item: a PbCloison, a PbBassin or undefined if river "Upstream" or "Downstream" was clicked */
private _selectedItem: PbCloison | PbBassin;
/** Records existing walls as they are built, to detect if multiple walls connect the same pair of basins */
private existingWalls: { [key: string]: number };
public constructor(
@Inject(forwardRef(() => GenericCalculatorComponent)) private calculatorComponent: GenericCalculatorComponent,
private i18nService: I18nService,
private hotkeysService: HotkeysService,
private newPbCloisonDialog: MatDialog,
private predamService: PrebarrageService
) {
7172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
this.hotkeysService.add(new Hotkey("del", AppComponent.onHotkey(this.removeOnHotkey, this)));
this._isValid = new DefinedBoolean();
}
/** tracks the fullscreen state */
public get isFullscreen() {
if (screenfull.isEnabled) {
return screenfull.isFullscreen;
}
}
public async setFullscreen(element): Promise<void> {
if (screenfull.isEnabled) {
await screenfull.request(element);
this.fullscreenChange(true);
}
}
public async exitFullscreen(): Promise<void> {
if (screenfull.isEnabled) {
await screenfull.exit();
this.fullscreenChange(false);
}
}
/** called when fullscreen state changes */
public fullscreenChange(isFullscreen: boolean) { }
public ngAfterContentInit(): void {
mermaid.initialize({
flowchart: {
curve: "basis",
// useMaxWidth: true
}
});
this.nativeElement = this.schema.nativeElement;
this.render();
// restore previously selected item
this._selectedItem = this.pbSchema.form.selectedItem;
if (this._selectedItem !== undefined) {
// @WARNING clodo timeout to prevent ExpressionChangedAfterItHasBeenCheckedError
// and select schema node after schema is refreshed by ngAfterViewInit()
setTimeout(() => {
this.selectNodeOnSchema(this._selectedItem);
}, 20); // timeout has to be greater than the 10ms of ngAfterViewInit()
}
else {
// select upstream basin since it's form is already displayed (and not undisplayable...)
setTimeout(() => {
this.selectUpstreamBasin();
}, 20); // timeout has to be greater than the 10ms of ngAfterViewInit()
}
}
private render() {
this.nativeElement.innerHTML = ""; // or diagram goes blank when refreshing…
// generate graph description
const graphDefinition = this.graphDefinition();
// draw
try {
mermaid.render("graphDiv", graphDefinition, (svgCode, bindFunctions) => {
this.nativeElement.innerHTML = svgCode;
});
} catch (e) {
console.error(e);
}
this.highlightErrorItems(null);
}
/**
141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
* Builds the interactive schema from the PreBarrage model
*/
private refresh() {
this.render();
this.refreshEventListeners();
this.updateValidity();
}
public ngAfterViewInit(): void {
// subscribe to "refresh" event passed indirectly by FormulairePbCloison (change upstream/downstream basin)
this.pbSchema.addObserver(this);
// @WARNING clodo trick to prevent blank diagram when switching from a PreBarrage to another
setTimeout(() => {
this.refresh();
}, 10);
}
/** Add click listener on every node and link in the graph */
private refreshEventListeners() {
this.nativeElement.querySelectorAll("g.node").forEach(item => {
item.style.cursor = "pointer";
item.addEventListener("click", () => {
this.selectNode(item);
});
});
}
/**
* Builds a Mermaid graph text definition, using Nodes
* to represent basins as well as walls; sorts connexions
* to prevent lines crossings
*/
private graphDefinition() {
this.existingWalls = {};
this.pbSchema.wallsSuffixes = {};
const def: string[] = [ "graph TB" ];
const pbModel = this.predamService.model;
// river upstream / downstream
let upstreamLabel = this.i18nService.localizeText("INFO_LIB_AMONT");
let downstreamLabel = this.i18nService.localizeText("INFO_LIB_AVAL");
// add result data Z and Q, if any
if (
pbModel.result?.resultElements
&& pbModel.result.resultElements[0]?.ok
) {
// when a parameter is variating, index of the variating parameter
// values to build the data from
const form = this.calculatorComponent.formulaire as FormulairePrebarrage;
const idx = form.pbResults.variableIndex;
const qValue = pbModel.prms.Q.isCalculated
? pbModel.result.resultElements[idx].vCalc
: (
pbModel.prms.Q.hasMultipleValues
? pbModel.prms.Q.getInferredValuesList(pbModel.variatingLength())[idx]
: pbModel.prms.Q.singleValue
);
// upstream
upstreamLabel += "<br>";
upstreamLabel += "Q = " + fv(qValue);
upstreamLabel += "<br>";
upstreamLabel += "Z = " + fv(
pbModel.prms.Z1.isCalculated
? pbModel.result.resultElements[idx].vCalc
: (
pbModel.prms.Z1.hasMultipleValues
? pbModel.prms.Z1.getInferredValuesList(pbModel.variatingLength())[idx]
: pbModel.prms.Z1.singleValue
)
211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
);
// downstream
downstreamLabel += "<br>";
downstreamLabel += "Q = " + fv(qValue);
downstreamLabel += "<br>";
downstreamLabel += "Z = " + fv(
pbModel.prms.Z2.hasMultipleValues
? pbModel.prms.Z2.getInferredValuesList(pbModel.variatingLength())[idx]
: pbModel.prms.Z2.singleValue
);
}
// add to graph definition
def.push(`${this.predamService.upstreamId}("${upstreamLabel}")`);
def.push(`${this.predamService.downstreamId}("${downstreamLabel}")`);
// styles
def.push("classDef wall fill:#e8e8e8,stroke-width:0;");
def.push("classDef basin fill:#e0f3fb,stroke:#003A80;"); // irstea-ocean 50 / 500
def.push("classDef basin::first-line color:green,font-size:0.5em;");
def.push("classDef node-highlighted fill:#4DBBE9;"); // irstea-ocean (material "accent"), 300
def.push("classDef node-error fill:#ec7430;"); // irstea-rouille (material "accent"), 400
def.push("classDef node-highlighted-error fill:#d92f03;"); // irstea-rouille (material "accent"), 900
const sortedWalls: PbCloison[] = [];
for (const c of pbModel.children) {
if (c instanceof PbBassin) {
def.push(`${c.uid}("${this.itemDescriptionWithResultData(c)}")`); // rounded edges
def.push(`class ${c.uid} basin;`);
} else if (c instanceof PbCloison) {
// store, to draw later
sortedWalls.push(c);
}
}
// sort then draw walls
sortedWalls.sort(this.triCloisonsGaucheDroite);
for (const c of sortedWalls) {
const upstreamBasinId = c.bassinAmont === undefined ? this.predamService.upstreamId : c.bassinAmont.uid;
const downstreamBasinId = c.bassinAval === undefined ? this.predamService.downstreamId : c.bassinAval.uid;
// record this wall
const basinsPair = upstreamBasinId + "-" + downstreamBasinId;
if (! (basinsPair in this.existingWalls)) {
this.existingWalls[basinsPair] = 0;
}
// affect suffix if needed
if (this.existingWalls[basinsPair] > 0) {
this.pbSchema.wallsSuffixes[c.uid] = this.existingWalls[basinsPair];
}
this.existingWalls[basinsPair]++;
// draw wall Node
def.push(`${c.uid}["${this.itemDescriptionWithResultData(c)}"]`); // square edges
def.push(`class ${c.uid} wall;`);
// draw "arrow" with 2 lines
def.push(`${upstreamBasinId}---${c.uid}-->${downstreamBasinId}`);
}
return def.join("\n");
}
/** gauche d'abord, droite ensuite */
private triCloisonsGaucheDroite(a: PbCloison, b: PbCloison) {
// ultra-gauchistes
if (a.bassinAmont === undefined && a.bassinAval === undefined) {
return -1;
}
if (b.bassinAmont === undefined && b.bassinAval === undefined) {
return 1;
}
// si A est un super-gauchiste
if (a.bassinAmont === undefined || a.bassinAval === undefined) {
281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350
// B est-il aussi un super-gauchiste ?
if (b.bassinAmont === undefined || b.bassinAval === undefined) {
// comparer le bassin restant
const bassinA = (a.bassinAmont === undefined ? a.bassinAval : a.bassinAmont);
const bassinB = (b.bassinAmont === undefined ? b.bassinAval : b.bassinAmont);
return (bassinA.findPositionInParent() <= bassinB.findPositionInParent()) ? -1 : 1;
}
// sinon A gagne
return -1;
}
// si B est un super-gauchiste
if (b.bassinAmont === undefined || b.bassinAval === undefined) {
// B gagne (le cas de A super-gauchiste est éliminé avant)
return 1;
}
// sinon, aucun des deux n'est super-gauchiste, comparaison des bassins amont et aval
const sommeA = a.bassinAmont.findPositionInParent() + a.bassinAval.findPositionInParent();
const sommeB = b.bassinAmont.findPositionInParent() + b.bassinAval.findPositionInParent();
return (sommeA <= sommeB ? -1 : 1);
}
/**
* @param item DOM element
*/
private selectNode(item: any) {
// console.debug(`PbSchemaComponent.selectNode(${item?.id})`);
// highlight clicked element
this.clearHighlightedItems();
item.classList.add("node-highlighted");
// find what was clicked
this._selectedItem = this.predamService.findFromItemId(item.id);
this.highlightErrorItems(item.id);
// show proper form and hide results
this.nodeSelected.emit({
node: this._selectedItem === this.predamService.upstreamBassin ? undefined : this._selectedItem
});
// exit fullscreen
this.exitFullscreen();
}
// for debug only
public get graphDef(): string {
return this.graphDefinition();
}
public get title(): string {
return this.i18nService.localizeText("INFO_PB_SCHEMA");
}
/** Global Pb validity */
public get isValid() {
return this._isValid.value;
}
/** used for a cosmetics CSS trick only (mat-card-header right margin) */
public get showInputData(): boolean {
return this.calculatorComponent.showPBInputData;
}
public get prefixedItemDescription(): string {
let desc = this.itemDescription(this._selectedItem);
if (this._selectedItem instanceof PbCloison) {
desc = this.i18nService.localizeText("INFO_PB_CLOISON") + " " + desc;
}
if (desc !== "") {
desc += " : ";
}
return desc;
}
351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420
/**
* Lorsque la passe est calculée, ajoute aux nœuds du schéma les valeurs de :
* - PV et YMOY pour les bassins
* - DH et Q pour les cloisons
*/
private itemDescriptionWithResultData(item: PbCloison | PbBassin): string {
let iDesc: string;
if (item !== undefined) {
iDesc = this.itemDescription(item);
if (
item?.result?.resultElements
&& item.result.resultElements[0]?.ok
) {
// when a parameter is variating, index of the variating parameter
// values to build the data from
const form = this.calculatorComponent.formulaire as FormulairePrebarrage;
const idx = form.pbResults.variableIndex;
iDesc += "<br>";
if (item instanceof PbCloison) {
iDesc += "Q = " + fv(item.result.resultElements[idx].vCalc); // Q is always the vCalc of PbCloison
iDesc += "<br>";
iDesc += "DH = " + fv(item.result.resultElements[idx].values.DH);
} else if (item instanceof PbBassin) {
iDesc += "PV = " + fv(item.result.resultElements[idx].values.PV);
iDesc += "<br>";
iDesc += "YMOY = " + fv(item.result.resultElements[idx].values.YMOY);
}
}
}
return iDesc;
}
/** Returns a short description of the given item: wall or basin */
private itemDescription(item: PbCloison | PbBassin): string {
let desc = "";
if (item !== undefined) {
if (item.description !== undefined) {
desc = this.i18nService.localizeMessage(item.description);
}
if (item instanceof PbCloison) {
// there might be multiple walls between the same pair of basins
if (item.uid in this.pbSchema.wallsSuffixes) {
desc += " (" + this.pbSchema.wallsSuffixes[item.uid] + ")";
}
}
}
return desc;
}
/**
* Selects and highlights on the schema the given wall or basin
*/
private selectNodeOnSchema(element: PbBassin | PbCloison) {
this.nativeElement.querySelectorAll("g.node").forEach(item => {
if (element !== undefined && MermaidUtil.isMermaidEqualIds(element.uid, item.id)) {
this.selectNode(item);
}
});
}
/**
* select upstream basin on schema
*/
private selectUpstreamBasin() {
let done = false; // optimisation : simulate break in forEach
this.nativeElement.querySelectorAll("g.node").forEach(item => {
if (!done) {
if (MermaidUtil.isMermaidEqualIds("amont", item.id)) {
this.selectNode(item);
this._selectedItem = this.predamService.upstreamBassin;
421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490
done = true;
}
}
});
}
// at this time @Input data is supposed to be already populated
public ngOnInit() {
this.predamService.model = this.pbSchema.pb;
}
public get enableAddItems(): boolean {
return this.calculatorComponent.showPBInputData;
}
public get enableRemoveButton() {
if (this._selectedItem === this.predamService.upstreamBassin) {
return false;
}
// if deleting a PbCloison would replace it by a new one at
// the same place (@see onRemoveClick), make it not deletable
if (this._selectedItem instanceof PbCloison) {
if ((
this._selectedItem.bassinAmont !== undefined
&& this._selectedItem.bassinAmont.cloisonsAval.length === 1
&& this._selectedItem.bassinAval === undefined
) || (
this._selectedItem.bassinAval !== undefined
&& this._selectedItem.bassinAval.cloisonsAmont.length === 1
&& this._selectedItem.bassinAmont === undefined
)) {
return false;
}
}
return true;
}
/** Removes a basin or wall, and all related items */
public onRemoveClick() {
this.predamService.deleteSelected(this._selectedItem, ServiceFactory.applicationSetupService.enableEmptyFieldsOnFormInit);
this.clearResults();
this.unselect();
this.refreshWithSelection();
this.calculatorComponent.showPBInputData = true;
}
public get uitextRemove() {
return this.i18nService.localizeText("INFO_FIELDSET_REMOVE");
}
// listener for "del" hotkey
protected removeOnHotkey() {
if (this.enableRemoveButton) {
this.onRemoveClick();
}
}
public get enableCopyButton() {
return (this._selectedItem !== undefined && this._selectedItem instanceof PbCloison);
}
/** Copies a wall */
public onCopyClick() {
const wallCopy: PbCloison = this.predamService.copyWall(this._selectedItem as PbCloison, ServiceFactory.applicationSetupService.enableEmptyFieldsOnFormInit);
this.clearResults();
this.refreshWithSelection(wallCopy.uid);
this.calculatorComponent.showPBInputData = true;
}
public get uitextCopy() {
491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560
return this.i18nService.localizeText("INFO_FIELDSET_COPY");
}
/** Adds a new lone basin */
public onAddBasinClick() {
const newBasin = this.predamService.addBasin(ServiceFactory.applicationSetupService.enableEmptyFieldsOnFormInit);
this.clearResults();
this.refreshWithSelection(newBasin.uid);
this.calculatorComponent.showPBInputData = true;
}
public get uitextAddBasin() {
return this.i18nService.localizeText("INFO_PB_ADD_BASIN");
}
public get enableAddWallButton(): boolean {
return this.predamService.hasBasins;
}
/** Adds a new lone wall, opening a modal to choose connected basins */
public onAddWallClick() {
// open dialog
const dialogRef = this.newPbCloisonDialog.open(
DialogNewPbCloisonComponent,
{
data: {
basins: this.predamService.bassins
},
disableClose: true
}
);
// apply modifications
dialogRef.afterClosed().subscribe(result => {
if (result.up !== undefined && result.down !== undefined) {
const wall = this.predamService.addWall(result.up, result.down, ServiceFactory.applicationSetupService.enableEmptyFieldsOnFormInit);
this.clearResults();
this.refreshWithSelection(wall.uid);
this.calculatorComponent.showPBInputData = true;
}
});
}
public get uitextAddWall() {
return this.i18nService.localizeText("INFO_PB_ADD_WALL");
}
public get enableUpButton() {
return (
this._selectedItem instanceof PbBassin
&& this.predamService.findBasinPosition(this._selectedItem.uid) !== 0
&& this.predamService.isStandaloneBasin(this._selectedItem)
);
}
public onMoveBasinUpClick() {
if (this._selectedItem instanceof PbBassin) {
this.predamService.moveBasinUp(this._selectedItem.uid);
}
this.clearResults();
this.refreshWithSelection(this._selectedItem.uid);
this.calculatorComponent.showPBInputData = true;
}
public get uitextMoveBasinUp() {
return this.i18nService.localizeText("INFO_PB_MOVE_BASIN_UP");
}
public get enableDownButton() {
return (
this._selectedItem instanceof PbBassin
561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630
&& !this.predamService.isLastBasin(this._selectedItem.uid)
&& this.predamService.isStandaloneBasin(this._selectedItem)
);
}
public onMoveBasinDownClick() {
if (this._selectedItem instanceof PbBassin) {
this.predamService.moveBasinDown(this._selectedItem.uid);
}
this.clearResults();
this.refreshWithSelection(this._selectedItem.uid);
this.calculatorComponent.showPBInputData = true;
}
public get uitextMoveBasinDown() {
return this.i18nService.localizeText("INFO_PB_MOVE_BASIN_DOWN");
}
public get uitextEnterFSTitle() {
return this.i18nService.localizeText("INFO_CHART_BUTTON_TITLE_ENTER_FS");
}
public get uitextExitFSTitle() {
return this.i18nService.localizeText("INFO_CHART_BUTTON_TITLE_EXIT_FS");
}
public get uitextExportImageTitle() {
return this.i18nService.localizeText("INFO_CHART_BUTTON_TITLE_EXPORT_IMAGE");
}
// @see https://levelup.gitconnected.com/draw-an-svg-to-canvas-and-download-it-as-image-in-javascript-f7f7713cf81f
public exportAsImage(element: HTMLDivElement) {
const svgElement = element.querySelector("svg");
const { width, height } = svgElement.getBBox();
const clonedSvgElement = svgElement.cloneNode(true) as Element;
const outerHTML = clonedSvgElement.outerHTML;
// create BLOB URL for SVG image
// add </br> for XML validity; Mermaid removes the </br> if they are added in schema description…
const blob = new Blob([outerHTML.replace(/<br>/g, "<br></br>") ], { type: "image/svg+xml;charset=utf-8" });
const blobURL = window.URL.createObjectURL(blob);
// draw image to canvas
const image = new Image();
image.onload = () => {
const canvas = document.createElement("canvas");
canvas.width = width;
canvas.height = height;
const context = canvas.getContext("2d");
context.drawImage(image, 0, 0, width, height);
// export canvas the usual way
AppComponent.exportAsImage(canvas);
};
image.src = blobURL;
}
/**
* Computes the global Pab validity : validity of every cell of every row
*/
private updateValidity() {
// check that at least 1 basin is present and a route from river
// upstream to river downstream exists (2nd check includes 1st)
this._isValid.value = this.predamService.isValid();
if (this._isValid.changed) {
this.validChange.emit();
}
}
/**
* update all items validity rendering
*/
631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700
public updateItemsValidity() {
this.highlightErrorItems(this._selectedItem?.uid);
}
private clearHighlightedItems() {
this.nativeElement.querySelectorAll("g.node").forEach(item => {
item.classList.remove("node-highlighted");
});
}
private highlightErrorItems(selectedUid: string) {
this.nativeElement.querySelectorAll("g.node").forEach(item => {
item.classList.remove("node-error");
item.classList.remove("node-highlighted-error");
});
const invalidUids: string[] = this.pbSchema.form.checkParameters();
selectedUid = this.predamService.toNubUid(selectedUid);
if (invalidUids.length > 0) {
this.nativeElement.querySelectorAll("g.node").forEach(item => {
// in this case, item is a HTML node of the SVG schema which id is a nub uid
const itemId = this.predamService.toNubUid(item.id);
if (invalidUids.includes(itemId)) { // if current item is among invalid ones
if (selectedUid === itemId) { // if current item is the selected item
item.classList.add("node-highlighted-error");
} else {
item.classList.add("node-error");
}
}
});
}
}
private unselect() {
// console.debug(`PbSchemaComponent.unselect()`);
this._selectedItem = undefined;
this.clearHighlightedItems();
this.nodeSelected.emit({}); // nothing selected
}
/** clear all PB form results whenever the basins / walls layout is changed */
private clearResults() {
this.pbSchema.form.reset();
}
/**
* Refreshes the schema; if uid is given, selects the node having this
* nub uid, else keeps previous selection
*/
private refreshWithSelection(uid?: string) {
// console.debug(`PbSchemaComponent.refreshWithSelection(${uid})`);
// remember previously selected node
const selectedNodeUID = this._selectedItem?.uid;
this.refresh();
// select a specific node on the schema
if (uid !== undefined) {
this.selectNodeOnSchema(this.predamService.findChild(uid));
} else if (selectedNodeUID !== undefined) {
// re-select previously selected node
this.selectNodeOnSchema(this.predamService.findChild(selectedNodeUID));
}
}
// interface Observer
public update(sender: IObservable, data: any) {
// console.debug(`PbSchemaComponent.update:`, data);
if (sender instanceof PbSchema) {
if (data.action === "refresh") {
this.refreshWithSelection(data.value);
701702703704705706
}
}
}
}