pb-schema.component.ts 26.31 KiB
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
} } } }