import { Component, OnInit, OnDestroy, HostListener, ViewChild, ElementRef, isDevMode } from "@angular/core"; import { Router, Event, NavigationEnd, ActivationEnd } from "@angular/router"; import { MatDialog } from "@angular/material/dialog"; import { MatSidenav } from "@angular/material/sidenav"; import { MatToolbar } from "@angular/material/toolbar"; import { CdkDragDrop, moveItemInArray } from "@angular/cdk/drag-drop"; import { Observer, jalhydDateRev, jalhydVersion, CalculatorType, Session } from "jalhyd"; import { I18nService } from "./services/internationalisation.service"; import { FormulaireService } from "./services/formulaire.service"; import { FormulaireDefinition } from "./formulaire/definition/form-definition"; import { ServiceFactory } from "./services/service-factory"; import { HttpService } from "./services/http.service"; import { ApplicationSetupService } from "./services/app-setup.service"; import { nghydDateRev, nghydVersion } from "../date_revision"; import { DialogConfirmCloseCalcComponent } from "./components/dialog-confirm-close-calc/dialog-confirm-close-calc.component"; import { DialogConfirmEmptySessionComponent } from "./components/dialog-confirm-empty-session/dialog-confirm-empty-session.component"; import { DialogLoadSessionComponent } from "./components/dialog-load-session/dialog-load-session.component"; import { DialogSaveSessionComponent } from "./components/dialog-save-session/dialog-save-session.component"; import { QuicknavComponent } from "./components/quicknav/quicknav.component"; import { NotificationsService } from "./services/notifications.service"; import { decodeHtml } from "./util/util"; import { HotkeysService, Hotkey } from "angular2-hotkeys"; import { MatomoTracker } from "@ngx-matomo/tracker"; import { saveAs } from "file-saver"; import * as XLSX from "xlsx"; import * as pako from "pako"; import { DialogConfirmComponent } from "./components/dialog-confirm/dialog-confirm.component"; import { UserConfirmationService } from "./services/user-confirmation.service"; import { ServiceWorkerUpdateService } from "./services/service-worker-update.service"; @Component({ selector: "nghyd-app", templateUrl: "./app.component.html", styleUrls: ["./app.component.scss"] }) export class AppComponent implements OnInit, OnDestroy, Observer { @ViewChild("sidenav") public sidenav: MatSidenav; @ViewChild("navbar") public navbar: MatToolbar; /** current calculator, inferred from _currentFormId by setActiveCalc() (used for navbar menu) */ public currentCalc: any; /** shows or hides the progressbar under the navbar */ public showProgressBar = false; /** liste des modules de calcul ouverts */ private _calculators: Array<{ title: string, type: CalculatorType, uid: string, active?: boolean, latestAnchor?: string }> = []; /** * id du formulaire courant * on utilise pas directement FormulaireService.currentFormId pour éviter l'erreur * ExpressionChangedAfterItHasBeenCheckedError */ private _currentFormId: string; private _innerWidth: number; constructor( private intlService: I18nService, private appSetupService: ApplicationSetupService, private router: Router, private formulaireService: FormulaireService, private httpService: HttpService, private notificationsService: NotificationsService, private confirmEmptySessionDialog: MatDialog, private saveSessionDialog: MatDialog, private loadSessionDialog: MatDialog, private confirmCloseCalcDialog: MatDialog, private hotkeysService: HotkeysService, private matomoTracker: MatomoTracker, private confirmDialog: MatDialog, private serviceWorkerUpdateService: ServiceWorkerUpdateService, private userConfirmationService: UserConfirmationService ) { ServiceFactory.httpService = httpService; ServiceFactory.applicationSetupService = appSetupService; ServiceFactory.i18nService = intlService; ServiceFactory.formulaireService = formulaireService; ServiceFactory.notificationsService = notificationsService; ServiceFactory.serviceWorkerUpdateService = serviceWorkerUpdateService; if (!isDevMode()) { // évite de mettre en place un bandeau RGPD this.matomoTracker.disableCookies(); // Set custom dimension for Electron / pure Web browser this.matomoTracker.setCustomDimension(1, this.getRunningPlatform()); } this.router.events.subscribe((event: Event) => { // close side navigation when clicking a calculator tab if (event instanceof NavigationEnd) { this.sidenav.close(); window.scrollTo(0, 0); } // [de]activate calc tabs depending on loaded route if (event instanceof ActivationEnd) { const path = event.snapshot.url[0].path; if (path === "calculator") { const calcUid = event.snapshot.params.uid; if (this.calculatorExists(calcUid)) { this.setActiveCalc(calcUid); } else { // if required calculator does not exist, redirect to list page this.toList(); } } else { this.setActiveCalc(null); } } }); // hotkeys listeners this.hotkeysService.add(new Hotkey("alt+s", AppComponent.onHotkey(this.saveForm, this))); this.hotkeysService.add(new Hotkey("alt+o", AppComponent.onHotkey(this.loadSession, this))); this.hotkeysService.add(new Hotkey("alt+q", AppComponent.onHotkey(this.emptySession, this))); this.hotkeysService.add(new Hotkey("alt+n", AppComponent.onHotkey(this.toList, this))); this.hotkeysService.add(new Hotkey("alt+g", AppComponent.onHotkey(this.toDiagram, this))); } /** * Wrapper for hotkeys triggers, that executes given function only if * hotkeys are enabled in app preferences * @param func function to execute when hotkey is entered */ public static onHotkey(func: any, that: any) { return (event: KeyboardEvent): boolean => { if (ServiceFactory.applicationSetupService.enableHotkeys) { func.call(that); return false; // Prevent bubbling } else { console.log("Hotkeys are disabled in app preferences"); } }; } public static exportAsImage(canvas: HTMLCanvasElement) { canvas.toBlob((blob) => { AppComponent.download(blob, "chart.png", "image/png"); }); // defaults to image/png } /** * Exports a results data table to XLSX format, and removes "help" mentions * from the parameters names columns if needed * @param table results data table * @param namesInFirstCol if true, will look for parameters names in 1st column * (for fixed results), else in 1st row (variable results, by default) */ public static exportAsSpreadsheet(table: ElementRef, namesInFirstCol: boolean = false) { const ws: XLSX.WorkSheet = XLSX.utils.table_to_sheet(table); let regExCellKey; if (namesInFirstCol) { regExCellKey = new RegExp("^A\\d$"); } else { regExCellKey = new RegExp("^\\w1$"); } // browse all cells for (const key in ws) { // look for 1st row or 1st col if (regExCellKey.test(key) === true) { const regExCellName = new RegExp("help$"); const v: string = ws[key].v; // remove "help" from cell name's ending if (regExCellName.test(v) === true) { const newV = v.substr(0, v.length - 4); ws[key].v = newV; } } } AppComponent.downloadSpreadsheet(ws); } /** * Adds the given XLSX worksheet to a new XLSX workbook and triggers its download */ public static downloadSpreadsheet(ws: XLSX.WorkSheet) { const wb: XLSX.WorkBook = XLSX.utils.book_new(); XLSX.utils.book_append_sheet(wb, ws, "default"); const wopts: any = { bookType: "xlsx", bookSST: false, type: "array" }; const wbout = XLSX.write(wb, wopts); AppComponent.download( new Blob([wbout], { type: "application/octet-stream" }), "cassiopee-results.xlsx", "application/vnd.ms-excel" ); } /** * file download method * @param blob binary object to download * @param filename * @param mimeType */ public static download(blob: Blob, filename: string, mimeType: string) { saveAs(blob, filename); } /** * Triggered at app startup. * Preferences are loaded by app setup service * @see ApplicationSetupService.construct() */ ngOnInit() { this.formulaireService.addObserver(this); this._innerWidth = window.innerWidth; this.logRevisionInfo(); // Initialise communication with UserConfirmationService. // When receiving a message from it, open a dialog to ask user to confirm. // Will then reply to UserConfirmationService with a message holding confirmation status. this.userConfirmationService.subscribe(this); this.userConfirmationService.addHandler(this, { next: (data) => this.displayConfirmationDialog(data["title"], data["body"]), error: () => { }, complete: () => { }, }); } ngOnDestroy() { this.formulaireService.removeObserver(this); // cancel communication link with UserConfirmationService this.userConfirmationService.unsubscribe(this); } @HostListener("window:resize", ["$event"]) onResize(event) { // keep track of window size for navbar tabs arrangement this._innerWidth = window.innerWidth; } public get uitextSidenavNewCalc() { return this.intlService.localizeText("INFO_MENU_NOUVELLE_CALC"); } public get uitextSidenavParams() { return this.intlService.localizeText("INFO_SETUP_TITLE"); } public get uitextSidenavLoadSession() { return this.intlService.localizeText("INFO_MENU_LOAD_SESSION_TITLE"); } public get uitextSidenavSaveSession() { return this.intlService.localizeText("INFO_MENU_SAVE_SESSION_TITLE"); } public get uitextSidenavEmptySession() { return this.intlService.localizeText("INFO_MENU_EMPTY_SESSION_TITLE"); } public get uitextSidenavDiagram() { return this.intlService.localizeText("INFO_MENU_DIAGRAM_TITLE"); } public get uitextSidenavSessionProps() { return this.intlService.localizeText("INFO_MENU_SESSION_PROPS"); } public get uitextSidenavReportBug() { return this.intlService.localizeText("INFO_MENU_REPORT_BUG"); } public get uitextSidenavHelp() { return this.intlService.localizeText("INFO_MENU_HELP_TITLE"); } public get uitextSelectCalc() { return this.intlService.localizeText("INFO_MENU_SELECT_CALC"); } public get uitextSearch() { return this.intlService.localizeText("INFO_MENU_RECHERCHE_MODULES"); } public getCalculatorLabel(t: CalculatorType) { return decodeHtml(this.formulaireService.getLocalisedTitleFromCalculatorType(t)); } public getFullCalculatorTitle(calc: { title: string, type: CalculatorType, active?: boolean }): string { return decodeHtml(calc.title + " (" + this.getCalculatorLabel(calc.type) + ")"); } public get calculators() { return this._calculators; } public get currentFormId() { return this._currentFormId; } public get currentRoute(): string { return this.router.url; } public get currentCalcUid(): string { return this.currentCalc ? this.currentCalc.uid : undefined; } public setActiveCalc(uid: string) { this._calculators.forEach((calc) => { calc.active = (calc.uid === uid); }); // mark current calc for navbar menu const index = this.getCalculatorIndexFromId(uid); this.currentCalc = this._calculators[index]; } /** * Close calculator using middle click on tab */ public onMouseUp(event: any, uid: string) { if (event.which === 2) { const dialogRef = this.confirmCloseCalcDialog.open( DialogConfirmCloseCalcComponent, { data: { uid: uid }, disableClose: true } ); dialogRef.afterClosed().subscribe(result => { if (result) { this.formulaireService.requestCloseForm(uid); } }); } } /** * Returns true if sum of open calculator tabs witdh is lower than navbar * available space (ie. if navbar is not overflowing), false otherwise */ public get tabsFitInNavbar() { // manual breakpoints // @WARNING keep in sync with .calculator-buttons sizes in app.component.scss let tabsLimit = 0; if (this._innerWidth > 480) { tabsLimit = 3; } if (this._innerWidth > 640) { tabsLimit = 4; } if (this._innerWidth > 800) { tabsLimit = 6; } const fits = this._calculators.length <= tabsLimit; return fits; } public get enableHeaderDoc(): boolean { return this.currentRoute.includes("/list") && this._calculators.length === 0; } public get enableSaveSessionMenu(): boolean { return this._calculators.length > 0; } public get enableModulesDiagramMenu(): boolean { return this._calculators.length > 0; } public get enableSessionPropertiesMenu(): boolean { return this._calculators.length > 0; } public get enableEmptySessionMenu(): boolean { return this._calculators.length > 0; } // interface Observer public update(sender: any, data: any): void { if (sender instanceof FormulaireService) { switch (data["action"]) { case "createForm": // add newly created form to calculators list const f: FormulaireDefinition = data["form"]; this._calculators.push( { "title": f.calculatorName, "type": f.calculatorType, "uid": f.uid } ); // abonnement en tant qu'observateur du nouveau formulaire f.addObserver(this); break; case "invalidFormId": this.toList(); break; case "currentFormChanged": this._currentFormId = data["formId"]; break; case "saveForm": this.saveForm(data["form"]); break; case "closeForm": const form: FormulaireDefinition = data["form"]; this.closeCalculator(form); break; } } else if (sender instanceof FormulaireDefinition) { switch (data["action"]) { case "nameChanged": this.updateCalculatorTitle(sender, data["name"]); break; } } } /** * Returns true if a form having "formUid" as UID exists * @param formId UID to look for */ private calculatorExists(formId: string): boolean { return (this.getCalculatorIndexFromId(formId) > -1); } private getCalculatorIndexFromId(formId: string) { const index = this._calculators.reduce((resultIndex, calc, currIndex) => { if (resultIndex === -1 && calc["uid"] === formId) { resultIndex = currIndex; } return resultIndex; }, -1); return index; } private updateCalculatorTitle(f: FormulaireDefinition, title: string) { const formIndex = this.getCalculatorIndexFromId(f.uid); this._calculators[formIndex]["title"] = title; } /** * Saves a JSON serialised session file, for one or more calc modules * @param calcList modules to save * @param filename */ private saveSession(calcList: any[], filename: string) { const session: string = this.buildSessionFile(calcList); if (!isDevMode()) { this.matomoTracker.trackEvent("userAction", "saveSession"); } this.formulaireService.downloadTextFile(session, filename); } /** * Builds a session file including Nubs, GUI-specific Nubs metadata, * model settings, GUI settings * @param calcList Nubs to save */ private buildSessionFile(calcList: any[]): string { const serialiseOptions: { [key: string]: {} } = {}; for (const c of calcList) { if (c.selected) { serialiseOptions[c.uid] = { // GUI-dependent metadata to add to the session file title: c.title }; } } const settings = { precision: this.appSetupService.computePrecision, maxIterations: this.appSetupService.maxIterations, displayPrecision: this.appSetupService.displayPrecision, }; return Session.getInstance().serialise(serialiseOptions, settings); } /** * Supprime un module de calcul **de l'interface** * ATTENTION, ne supprime pas le module de calcul en mémoire ! * Pour cela, utiliser FormulaireService.requestCloseForm(form.uid); * @param form module de calcul à fermer */ private closeCalculator(form: FormulaireDefinition) { const formId: string = form.uid; // désabonnement en tant qu'observateur form.removeObserver(this); // recherche du module de calcul correspondant à formId const closedIndex = this.getCalculatorIndexFromId(formId); /* * détermination du nouveau module de calcul à afficher : * - celui après celui supprimé * - ou celui avant celui supprimé si on supprime le dernier */ let newId = null; const l = this._calculators.length; if (l > 1) { if (closedIndex === l - 1) { newId = this._calculators[closedIndex - 1]["uid"]; } else { newId = this._calculators[closedIndex + 1]["uid"]; } } // suppression this._calculators = this._calculators.filter(calc => { return formId !== calc["uid"]; }); // MAJ affichage if (newId === null) { this.toList(); this._currentFormId = null; } else { this.toCalc(newId); } } private toList() { this.router.navigate(["/list"]); } public toDiagram() { this.router.navigate(["/diagram"]); } public toNotes() { this.router.navigate(["/properties"]); } public toCalc(id: string) { this.router.navigate(["/calculator", id]); this.setActiveCalc(id); setTimeout(() => { // @WARNING clodo trick to wait for Angular refresh this.scrollToLatestQuicknav(id); }, 50); } /** * restarts a fresh session by closing all calculators */ public emptySession() { const dialogRef = this.confirmEmptySessionDialog.open( DialogConfirmEmptySessionComponent, { disableClose: true } ); dialogRef.afterClosed().subscribe(result => { if (result) { this.doEmptySession(); } }); } public doEmptySession() { // temporarily disable notifications const oldNotifState = this.appSetupService.enableNotifications; this.appSetupService.enableNotifications = false; // close all calculators for (const c of this._calculators) { const form = this.formulaireService.getFormulaireFromId(c.uid); this.formulaireService.requestCloseForm(form.uid); } // just to be sure, get rid of any Nub possibly stuck in session without any form attached Session.getInstance().clear(); Session.getInstance().documentation = ""; // just to be sure, get rid of any Formulaire possibly stuck in the service without any Nub attached this.formulaireService.clearFormulaires(); // restore notifications this.appSetupService.enableNotifications = oldNotifState; } public loadSession() { // création du dialogue de sélection des formulaires à sauver const dialogRef = this.loadSessionDialog.open( DialogLoadSessionComponent, { disableClose: false } ); dialogRef.afterClosed().subscribe(result => { if (result) { if (result.emptySession) { this.doEmptySession(); } this.loadSessionFile(result.file, result.calculators); } }); } public async loadSessionFile(f: File|string, info?: any) { // notes merge detection: was there already some notes ? const existingNotes = Session.getInstance().documentation; // load try { const data = await this.formulaireService.loadSession(f, info); if (data.hasErrors) { this.notificationsService.notify(this.intlService.localizeText("ERROR_PROBLEM_LOADING_SESSION"), 3500); } else { if (data.loaded && data.loaded.length > 0) { if (!isDevMode()) { this.matomoTracker.trackEvent("userAction", "loadSession"); } // notes merge detection: was there already some notes ? const currentNotes = Session.getInstance().documentation; if (existingNotes !== "" && currentNotes !== existingNotes) { this.notificationsService.notify(this.intlService.localizeText("WARNING_SESSION_LOAD_NOTES_MERGED"), 3500); } // go to calc or diagram depending on what was loaded if (data.loaded.length > 1 && currentNotes) { this.toNotes(); } else if(data.loaded.length > 1 && !currentNotes) { this.toDiagram() } else { this.toCalc(data.loaded[0]); } } } } catch (err) { this.notificationsService.notify(this.intlService.localizeText("ERROR_LOADING_SESSION"), 3500); console.error("error loading session - ", err); // rollback to ensure session is clean this.doEmptySession(); } } /** * Demande au client d'envoyer un email (génère un lien mailto:), pré-rempli * avec un texte standard, et le contenu de la session au format JSON */ public reportBug() { const recipient = "bug@cassiopee.g-eau.fr"; const subject = "[ISSUE] " + this.intlService.localizeText("INFO_REPORT_BUG_SUBJECT"); let body = this.intlService.localizeText("INFO_REPORT_BUG_BODY"); // add session description // get all forms const list = []; for (const c of this._calculators) { list.push({ title: c.title, uid: c.uid, selected: true }); } let session = this.buildSessionFile(list); // compress session = pako.deflate(session, { to: "string" }); // gzip (zlib) session = btoa(session); // base64 body += session + "\n"; body = encodeURIComponent(body); const mailtoURL = `mailto:${recipient}?subject=${subject}&body=${body}`; // temporarily disable tab closing alert, as tab won't be closed for real this.appSetupService.warnBeforeTabClose = false; window.location.href = mailtoURL; this.appSetupService.warnBeforeTabClose = true; } public get revisionInfo(): any { return { jalhyd: { date: jalhydDateRev, version: jalhydVersion, }, nghyd: { date: nghydDateRev, version: nghydVersion } }; } private logRevisionInfo() { const ri = this.revisionInfo; console.log("JaLHyd", ri.jalhyd.date, ri.jalhyd.version); console.log("ngHyd", ri.nghyd.date, ri.nghyd.version); } /** * sauvegarde du/des formulaires * @param form formulaire à sélectionner par défaut dans la liste */ public saveForm(form?: FormulaireDefinition) { // liste des formulaires const list = []; for (const c of this._calculators) { const uid = c["uid"]; const nub = Session.getInstance().findNubByUid(uid); let required = nub.getTargettedNubs().map((req) => { return req.uid; }); required = required.filter( (item, index) => required.indexOf(item) === index // deduplicate ); list.push({ "children": nub.getChildren().map((child) => { return child.uid; }), "requires": required, "selected": form ? (uid === form.uid) : true, "title": c["title"], "uid": uid }); } // dialogue de sélection des formulaires à sauver const dialogRef = this.saveSessionDialog.open( DialogSaveSessionComponent, { data: { calculators: list }, disableClose: false } ); dialogRef.afterClosed().subscribe(result => { if (result) { let name = result.filename; // ajout extension ".json" const re = /.+\.json/; const match = re.exec(name.toLowerCase()); if (match === null) { name = name + ".json"; } this.saveSession(result.calculators, name); } }); } /** * Moves the view to one of the Quicknav anchors in the page, and saves this anchor * as the latest visited, in _calculators list * @param itemId a Quicknav anchor id (ex: "input" or "results") */ public scrollToQuicknav(itemId: string, behavior: ScrollBehavior = "smooth") { const idx = this.getCalculatorIndexFromId(this.currentFormId); let succeeded = false; if (idx > -1) { const id = QuicknavComponent.prefix + itemId; // Scroll https://stackoverflow.com/a/56391657/5986614 const element = document.getElementById(id); if (element && element.offsetParent !== null) { // offsetParent is null when element is not visible const yCoordinate = element.getBoundingClientRect().top + window.pageYOffset; window.scrollTo({ top: yCoordinate - 60, // substract a little more than navbar height behavior: behavior }); succeeded = true; // Save position this._calculators[idx].latestAnchor = itemId; } } if (!succeeded) { // throw an error so that caller CalculatorComponent.scrollToResults() // switches to plan B, in case we're trying to scroll to results pane // after a module is calculated throw Error("unable to scroll to quicknav anchor"); } } /** * Moves the view to the latest known Quicknav anchor of the current module */ public scrollToLatestQuicknav(formId: string) { // Get position const idx = this.getCalculatorIndexFromId(formId); if (idx > -1) { const itemId = this._calculators[idx].latestAnchor; // Scroll if (itemId) { this.scrollToQuicknav(itemId, "auto"); } } } public dropCalcButton(event: CdkDragDrop<string[]>) { moveItemInArray(this.calculators, event.previousIndex, event.currentIndex); } public get docIndexPath(): string { return "assets/docs/" + this.appSetupService.language + "/index.html"; } /** * Returns a string representing the running platform : * "electron", or "browser" */ public getRunningPlatform(): string { let runningPlatform = "browser"; if (navigator.userAgent.toLowerCase().indexOf("electron") > -1) { runningPlatform = "electron"; } // console.log(">> running platform: ", runningPlatform); return runningPlatform; } /** * détection de la fermeture de la page/navigateur et demande de confirmation */ @HostListener("window:beforeunload", [ "$event" ]) confirmExit($event) { if ( this.appSetupService.warnBeforeTabClose && ! isDevMode() // otherwise prevents dev server to reload app after recompiling ) { // affecter une valeur différente de null provoque l'affichage d'un dialogue de confirmation, mais le texte n'est pas affiché $event.returnValue = "Your data will be lost !"; } } @HostListener("keydown", ["$event"]) onKeyDown($event: any) { if ($event.which === 38 || $event.which === 40) { // up / down arrow if ($event.srcElement.type === "number") { $event.preventDefault(); } } } /** * display a confirmation display upon request from UserConfirmationService */ private displayConfirmationDialog(title: string, text: string) { const dialogRef = this.confirmDialog.open( DialogConfirmComponent, { data: { title: title, text: text }, disableClose: true } ); dialogRef.afterClosed().subscribe(result => { // reply to UserConfirmationService this.userConfirmationService.postConfirmation(this, { "confirm": result }); }); } }