diff --git a/.gitignore b/.gitignore
index 5bd2d8af3276d30225062d5cddfe237c0578176d..d484f6a04277b6d27baea18c96717919acad33b7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,6 +7,7 @@
 /src/assets/docs
 /release
 /build
+/docs/pdf_build
 
 # dependencies
 /node_modules
diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index ed146e000f40c2e3b3874a3c1241896787708e44..92811cedb1fad95a05865e51acd895e050f929ce 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -79,19 +79,34 @@ test:
   script:
     - npm run e2e
 
-build:
+.build:
   stage: build
-  only:
-    - pushes
-    - tags
-    - schedules
-    - web
   artifacts:
     expire_in: 10 min
     paths:
       - dist/
   script:
-    - npm run build
+    # -baseref option is used by npm to set the npm_config_basehref environment variable
+    # used in package.json
+    - npm run build-href -basehref=$BASEHREF
+
+build-dev:
+  extends: .build
+  only:
+    - tags
+    - pushes
+    - schedules
+    - web
+  variables:
+    BASEHREF: "/cassiopee/$CI_COMMIT_REF_NAME/"
+
+build-prod:
+  extends: .build
+  only:
+    - tags
+    - devel
+  variables:
+    BASEHREF: "/"
 
 clean-stale-branches:
   stage: clean-stale-branches
@@ -109,7 +124,7 @@ deploy-dev:
     - tags
     - web
   dependencies:
-    - build
+    - build-dev
   script:
     # Copie de la branche / du tag
     - ./scripts/deploy-version.sh $CI_COMMIT_REF_NAME $DEV_LOGIN $DEV_HOST $DEV_PATH
@@ -120,7 +135,7 @@ deploy-ovh-dev:
   only:
     - devel
   dependencies:
-    - build
+    - build-prod
   script:
     # Copie de la branche / du tag
     - ./scripts/deploy-version.sh prod-devel $PROD_LOGIN $PROD_HOST $PROD_DEV_PATH
@@ -131,7 +146,7 @@ deploy-prod:
     variables:
       - $CI_COMMIT_REF_NAME == "stable"
   dependencies:
-    - build
+    - build-prod
   script:
     - ./scripts/deploy-version.sh prod $PROD_LOGIN $PROD_HOST $PROD_PATH
 
diff --git a/README.md b/README.md
index 5750c79b6ba0413eaaf5bd25fba416d4790817f8..0fc8a9725a79ebaa03c2684ef07a93917538361a 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ Requirements for developping Cassiopee can be achieved by manually install the r
  * npm
  * python3
  * pandoc ^2 (optional, for PDF documentation only)
- * texlive (optional, for PDF documentation only)
+ * texlive, texlive-bibtex-extra, texlive-latex-extra, latexmk (optional, for PDF documentation only)
 
 Building the HTML documentation requires MkDocs and some extensions:
 
diff --git a/e2e/calculate-linked-params.e2e-spec.ts b/e2e/calculate-linked-params.e2e-spec.ts
index 12e68d423b9cdda3373f74cabd60824086cfb91e..7c54f5af436fb313519b1919e95a5a6a923afd8a 100644
--- a/e2e/calculate-linked-params.e2e-spec.ts
+++ b/e2e/calculate-linked-params.e2e-spec.ts
@@ -5,6 +5,7 @@ import { Navbar } from "./navbar.po";
 import { SideNav } from "./sidenav.po";
 import { browser, by } from "protractor";
 import { PreferencesPage } from "./preferences.po";
+import { changeSelectValue } from "./util.po";
 
 /**
  * Uses an example configuration to calculate :
@@ -62,7 +63,7 @@ describe("ngHyd − calculate with linked parameters", () => {
         const Y = calcPage.getInputById("Y");
         await calcPage.setParamMode(Y, "link");
         const sel = await calcPage.getLinkedValueSelect(Y);
-        await calcPage.changeSelectValue(sel, 0);
+        await changeSelectValue(sel, 0);
 
         await computeAndCheckPresenceOfResults();
     });
@@ -79,7 +80,7 @@ describe("ngHyd − calculate with linked parameters", () => {
         const Y = calcPage.getInputById("Y");
         await calcPage.setParamMode(Y, "link");
         const sel = await calcPage.getLinkedValueSelect(Y);
-        await calcPage.changeSelectValue(sel, 0);
+        await changeSelectValue(sel, 0);
         // vary W
         const W = calcPage.getInputById("W");
         await calcPage.setParamMode(W, "var");
@@ -102,7 +103,7 @@ describe("ngHyd − calculate with linked parameters", () => {
         const Y2 = calcPage.getInputById("Y");
         await calcPage.setParamMode(Y2, "link");
         const sel = await calcPage.getLinkedValueSelect(Y2);
-        await calcPage.changeSelectValue(sel, 0);
+        await changeSelectValue(sel, 0);
 
         await computeAndCheckPresenceOfResults();
     });
@@ -122,7 +123,7 @@ describe("ngHyd − calculate with linked parameters", () => {
         const Y2 = calcPage.getInputById("Y");
         await calcPage.setParamMode(Y2, "link");
         const sel = await calcPage.getLinkedValueSelect(Y2);
-        await calcPage.changeSelectValue(sel, 0);
+        await changeSelectValue(sel, 0);
 
         await computeAndCheckPresenceOfResults();
     });
@@ -142,7 +143,7 @@ describe("ngHyd − calculate with linked parameters", () => {
         const Y2 = calcPage.getInputById("Y");
         await calcPage.setParamMode(Y2, "link");
         const sel = await calcPage.getLinkedValueSelect(Y2);
-        await calcPage.changeSelectValue(sel, 0);
+        await changeSelectValue(sel, 0);
         // vary W
         const W = calcPage.getInputById("W");
         await calcPage.setParamMode(W, "var");
@@ -168,7 +169,7 @@ describe("ngHyd − calculate with linked parameters", () => {
         const Y2 = calcPage.getInputById("Y");
         await calcPage.setParamMode(Y2, "link");
         const sel = await calcPage.getLinkedValueSelect(Y2);
-        await calcPage.changeSelectValue(sel, 0);
+        await changeSelectValue(sel, 0);
 
         await computeAndCheckPresenceOfResults();
     });
diff --git a/e2e/calculator.po.ts b/e2e/calculator.po.ts
index af1e1803ce378ba967def9837500ece6da58a434..12c5a8de42cace9dad88a7905d233e0091842aeb 100644
--- a/e2e/calculator.po.ts
+++ b/e2e/calculator.po.ts
@@ -331,13 +331,6 @@ export class CalculatorPage {
         return calcButton;
     }
 
-    async changeSelectValue(elt: ElementFinder, index: number) {
-        await elt.click();
-        const optionId = ".cdk-overlay-container mat-option:nth-of-type(" + (index + 1) + ")";
-        const option = element(by.css(optionId));
-        await option.click();
-    }
-
     // find parent element of elt having class "container"
     async findParentContainer(elt: ElementFinder): Promise<ElementFinder> {
         let i = 8; // garde fous
diff --git a/e2e/cloisons.e2e-spec.ts b/e2e/cloisons.e2e-spec.ts
index 2febdc57e4263fb9d97e1466736b8e2699942cf9..f251afabc8b8c7d4c8c81773ea3c8bcc8c56d277 100644
--- a/e2e/cloisons.e2e-spec.ts
+++ b/e2e/cloisons.e2e-spec.ts
@@ -3,6 +3,7 @@ import { CalculatorPage } from "./calculator.po";
 import { browser } from "protractor";
 import { Navbar } from "./navbar.po";
 import { PreferencesPage } from "./preferences.po";
+import { changeSelectValue } from "./util.po";
 
 /**
  * Cloisons - différents tests qui n'ont pas tant de rapport que ça avec les cloisons :)
@@ -44,7 +45,7 @@ describe("ngHyd − cloisons", () => {
         await browser.sleep(300);
 
         // 4. change LoiDebit
-        await calcPage.changeSelectValue(calcPage.getSelectById("select_loidebit"), 1);
+        await changeSelectValue(calcPage.getSelectById("select_loidebit"), 1);
         await browser.sleep(300);
 
         // 5. check number of inputs in CALC mode
diff --git a/e2e/clone-calc.e2e-spec.ts b/e2e/clone-calc.e2e-spec.ts
index 038a90e661a28a6526a9b143f407842f3bed4329..a63f565b0fb43630cb3c5f88fb4b91926629e528 100644
--- a/e2e/clone-calc.e2e-spec.ts
+++ b/e2e/clone-calc.e2e-spec.ts
@@ -4,7 +4,7 @@ import { CalculatorPage } from "./calculator.po";
 import { Navbar } from "./navbar.po";
 import { browser } from "protractor";
 import { PreferencesPage } from "./preferences.po";
-import { scrollPageToTop } from "./util.po";
+import { changeSelectValue, scrollPageToTop } from "./util.po";
 
 /**
  * Clone calculators
@@ -54,7 +54,7 @@ describe("ngHyd − clone a calculator", () => {
             k: 0.6,
             Ks: 42
         };
-        await calcPage.changeSelectValue(calcPage.getSelectById("select_section"), 3); // mode "parabolique"
+        await changeSelectValue(calcPage.getSelectById("select_section"), 3); // mode "parabolique"
         await calcPage.getInputById("k").clear();
         await calcPage.getInputById("k").sendKeys(sourceValues["k"]);
         await calcPage.getInputById("Ks").clear();
@@ -63,7 +63,7 @@ describe("ngHyd − clone a calculator", () => {
         const debitSP = calcPage.getInputById("Q");
         await calcPage.setParamMode(debitSP, "link");
         await browser.sleep(500);
-        await calcPage.changeSelectValue(calcPage.getSelectById("linked_Q"), 1); // "Courbe de remous"
+        await changeSelectValue(calcPage.getSelectById("linked_Q"), 1); // "Courbe de remous"
         await browser.sleep(500);
 
         // otherwise clickCloneCalcButton() fails with "Element is not clickable at point"
diff --git a/e2e/examples-empty-fields.e2e-spec.ts b/e2e/examples-empty-fields.e2e-spec.ts
index 08f40c467f44678244027dbcd7487e56c16dc6aa..3a8d3942cd6594c7bc3e367d9128597809980e77 100644
--- a/e2e/examples-empty-fields.e2e-spec.ts
+++ b/e2e/examples-empty-fields.e2e-spec.ts
@@ -3,6 +3,7 @@ import { CalculatorPage } from "./calculator.po";
 import { ListPage } from "./list.po";
 import { Navbar } from "./navbar.po";
 import { PreferencesPage } from "./preferences.po"
+import { changeSelectValue } from "./util.po";
 
 /**
  * check that fields are empty on creation
@@ -60,7 +61,7 @@ describe("ngHyd - Check that examples fields are not empty with 'empty fields on
 
         // modify 1st structure discharge law
         const dischargeSelect = calcPage.getSelectById("select_loidebit");
-        await calcPage.changeSelectValue(dischargeSelect, 1);
+        await changeSelectValue(dischargeSelect, 1);
         await browser.sleep(200);
 
         // open initial dialog
diff --git a/e2e/lechapt-calmon.e2e-spec.ts b/e2e/lechapt-calmon.e2e-spec.ts
index 91dea3194e5d9ffa11790e9d417080e556ea8ab2..f3be14a59a0fde635a339a8cc469c8048ffb23a5 100644
--- a/e2e/lechapt-calmon.e2e-spec.ts
+++ b/e2e/lechapt-calmon.e2e-spec.ts
@@ -3,6 +3,7 @@ import { browser, by } from "protractor";
 import { CalculatorPage } from "./calculator.po";
 import { PreferencesPage } from "./preferences.po";
 import { Navbar } from "./navbar.po";
+import { changeSelectValue } from "./util.po";
 
 /**
  * Check that created/cloned structures have empty fields when
@@ -45,7 +46,7 @@ describe("Lechapt&Calmon - ", () => {
 
         // select last material type
         const materialSelect = calcPage.getSelectById("select_material");
-        await calcPage.changeSelectValue(materialSelect, 8);
+        await changeSelectValue(materialSelect, 8);
         await browser.sleep(200);
 
         // run calculation
@@ -58,7 +59,7 @@ describe("Lechapt&Calmon - ", () => {
         const pl1 = await res1.all(by.css("td")).get(1).getText();
 
         // select first material type
-        await calcPage.changeSelectValue(materialSelect, 0);
+        await changeSelectValue(materialSelect, 0);
         await browser.sleep(200);
 
         // run calculation
diff --git a/e2e/linked-parameter-section-type.e2e-spec.ts b/e2e/linked-parameter-section-type.e2e-spec.ts
index b42c13d0f8ad8249514e3880592938bd726888bb..a863f273ed3f66ba5c4e19eeb2f14b02e2fd5134 100644
--- a/e2e/linked-parameter-section-type.e2e-spec.ts
+++ b/e2e/linked-parameter-section-type.e2e-spec.ts
@@ -3,6 +3,7 @@ import { ListPage } from "./list.po";
 import { Navbar } from "./navbar.po";
 import { PreferencesPage } from "./preferences.po";
 import { CalculatorPage } from "./calculator.po";
+import { changeSelectValue } from "./util.po";
 
 describe("linked parameter in calculator with section - ", () => {
     let listPage: ListPage;
@@ -42,7 +43,7 @@ describe("linked parameter in calculator with section - ", () => {
         await calcPage.setParamMode(inputQ, "link");
 
         // change section type
-        await calcPage.changeSelectValue(calcPage.getSelectById("select_section"), 3); // mode "parabolique"
+        await changeSelectValue(calcPage.getSelectById("select_section"), 3); // mode "parabolique"
 
         // check Q is still in linked mode
         expect(await calcPage.inputIsInLinkedMode(inputQ)).toBe(true);
diff --git a/e2e/load-save-session.e2e-spec.ts b/e2e/load-save-session.e2e-spec.ts
index 436058e6f6ffc4d54e716b1680c22b6b8dc9de18..8689c6f13a7510026cff21608aefa1ff663ee5cd 100644
--- a/e2e/load-save-session.e2e-spec.ts
+++ b/e2e/load-save-session.e2e-spec.ts
@@ -5,7 +5,7 @@ import { Navbar } from "./navbar.po";
 import { SideNav } from "./sidenav.po";
 import { browser, by, element } from "protractor";
 import { PreferencesPage } from "./preferences.po";
-import { expectNumber } from "./util.po";
+import { changeSelectValue, expectNumber } from "./util.po";
 
 const fs = require("fs");
 const path = require("path");
@@ -122,7 +122,7 @@ describe("ngHyd − save and load sessions", () => {
         await listPage.clickMenuEntryForCalcType(2); // Section paramétrée
         await browser.sleep(500);
 
-        await calcPage.changeSelectValue(calcPage.getSelectById("select_section"), 2); // mode "trapezoidal"
+        await changeSelectValue(calcPage.getSelectById("select_section"), 2); // mode "trapezoidal"
 
         await calcPage.getInputById("Ks").clear(); // coefficient de Strickler
         await browser.sleep(1000);
@@ -190,7 +190,7 @@ describe("ngHyd − save and load sessions", () => {
 
                         // select next select option (optionally looping)
                         const nextInd = (ind + 1) % optionCount;
-                        await calcPage.changeSelectValue(sel, nextInd);
+                        await changeSelectValue(sel, nextInd);
                         await browser.sleep(200);
 
                         // save session
diff --git a/e2e/ouvrages-empty-fields.e2e-spec.ts b/e2e/ouvrages-empty-fields.e2e-spec.ts
index 4a28316f8c5a2001f778241fd31c211da1d00fec..918107e2c8a980f573a5cced40dbfe6dbfa2ed67 100644
--- a/e2e/ouvrages-empty-fields.e2e-spec.ts
+++ b/e2e/ouvrages-empty-fields.e2e-spec.ts
@@ -4,6 +4,7 @@ import { CalculatorPage } from "./calculator.po";
 import { AppPage } from "./app.po";
 import { PreferencesPage } from "./preferences.po";
 import { Navbar } from "./navbar.po";
+import { changeSelectValue } from "./util.po";
 
 /**
  * Check that created/cloned structures have empty fields when
@@ -51,7 +52,7 @@ describe("ngHyd - check that created/cloned structures have empty fields - ", ()
 
         // change 1st structure type to rectangular weir
         const structSelect = calcPage.getSelectById("select_structure");
-        await calcPage.changeSelectValue(structSelect, 1);
+        await changeSelectValue(structSelect, 1);
         await browser.sleep(200);
 
         // check 1st structure empty fields
@@ -91,7 +92,7 @@ describe("ngHyd - check that created/cloned structures have empty fields - ", ()
 
         // change 1st structure type to rectangular weir
         const structSelect = calcPage.getSelectById("select_structure");
-        await calcPage.changeSelectValue(structSelect, 1);
+        await changeSelectValue(structSelect, 1);
         await browser.sleep(200);
 
         // copy structure
@@ -102,7 +103,7 @@ describe("ngHyd - check that created/cloned structures have empty fields - ", ()
         // change 2nd structure type to rectangular gate
         const selects = await element.all(by.css("mat-select#select_structure"));
         const structSelect2 = selects[1];
-        await calcPage.changeSelectValue(structSelect2, 5);
+        await changeSelectValue(structSelect2, 5);
         await browser.sleep(200);
 
         // check empty fields
@@ -135,12 +136,12 @@ describe("ngHyd - check that created/cloned structures have empty fields - ", ()
 
         // change 1st structure type to rectangular weir
         const structSelect = calcPage.getSelectById("select_structure");
-        await calcPage.changeSelectValue(structSelect, 1);
+        await changeSelectValue(structSelect, 1);
         await browser.sleep(200);
 
         // change discharge law to Larinier
         const dischargeSelect = calcPage.getSelectById("select_loidebit");
-        await calcPage.changeSelectValue(dischargeSelect, 3);
+        await changeSelectValue(dischargeSelect, 3);
         await browser.sleep(200);
 
         // check empty fields
diff --git a/e2e/pab.e2e-spec.ts b/e2e/pab.e2e-spec.ts
index 866f67fb57da855a5c8ce10d407efd3da5616ffc..edcdd97504ef4be2502413318d40266a8680beb5 100644
--- a/e2e/pab.e2e-spec.ts
+++ b/e2e/pab.e2e-spec.ts
@@ -5,7 +5,7 @@ import { browser, by, element } from "protractor";
 import { AppPage } from "./app.po";
 import { SideNav } from "./sidenav.po";
 import { PreferencesPage } from "./preferences.po";
-import { scrollPageToTop } from "./util.po";
+import { changeSelectValue, scrollPageToTop } from "./util.po";
 
 /**
  * Clone calculators
@@ -297,7 +297,7 @@ describe("ngHyd − Passe à Bassins", () => {
 
             // change iteration
             const pve = calcPage.getSelectById("pab-variating-element");
-            calcPage.changeSelectValue(pve, 3);
+            await changeSelectValue(pve, 3);
             await browser.sleep(300);
             // check absence of logs
             expect(await calcPage.nbLogEntries()).toBe(2);
diff --git a/e2e/preferences.po.ts b/e2e/preferences.po.ts
index 264ebb84fdc7f9ab570bb8524e7f61ccd26adb02..39604b8cbc5cf02b2644a058c342b032d61813da 100644
--- a/e2e/preferences.po.ts
+++ b/e2e/preferences.po.ts
@@ -1,4 +1,5 @@
 import { browser, by, element, ElementFinder } from "protractor";
+import { changeSelectValue } from "./util.po";
 
 export class PreferencesPage {
     navigateTo() {
@@ -43,10 +44,7 @@ export class PreferencesPage {
 
     async changeLanguage(index: number) {
         const select = this.getLanguageSelect();
-        await select.click();
-        const optionId = ".cdk-overlay-container mat-option#mat-option-" + index;
-        const option = element(by.css(optionId));
-        await option.click();
+        await changeSelectValue(select, index);
     }
 
     async enableEvilEmptyFields() {
@@ -65,6 +63,23 @@ export class PreferencesPage {
         }
     }
 
+    /**
+     * enable/disable option "empty fields on module creation"
+     * @param b true to enable "empty fields on module creation" option, false to disable it (fill fields with default values)
+     */
+    async setEmptyFields(b: boolean) {
+        await this.navigateTo();
+        await browser.sleep(200);
+
+        if (b) {
+            await this.enableEvilEmptyFields();
+        }
+        else {
+            await this.disableEvilEmptyFields();
+        }
+        await browser.sleep(200);
+    }
+
     async setIterationCount(n: number) {
         const input = this.getInputFromName("nmi");
         input.clear();
diff --git a/e2e/pressure-loss-empty-fields.e2e-spec.ts b/e2e/pressure-loss-empty-fields.e2e-spec.ts
index c4516a88bdeeb74f95eed96b4897f2641dd4f6a5..59eb87c641d01eeae71004f1b2cd07b5523625a5 100644
--- a/e2e/pressure-loss-empty-fields.e2e-spec.ts
+++ b/e2e/pressure-loss-empty-fields.e2e-spec.ts
@@ -3,6 +3,7 @@ import { Navbar } from "./navbar.po";
 import { browser } from "protractor";
 import { CalculatorPage } from "./calculator.po";
 import { PreferencesPage } from "./preferences.po";
+import { changeSelectValue } from "./util.po";
 
 describe("Check fields are empty in 'pressure loss' calculator when created with 'empty fields' option -", () => {
     let listPage: ListPage;
@@ -32,7 +33,7 @@ describe("Check fields are empty in 'pressure loss' calculator when created with
 
         // select Lechapt-Calmon pressure loss law
         const materialSelect = calcPage.getSelectById("select_pressurelosstype");
-        await calcPage.changeSelectValue(materialSelect, 0);
+        await changeSelectValue(materialSelect, 0);
         await browser.sleep(200);
 
         expect(calcPage.checkEmptyOrFilledFields(["Q", "D", "Lg", "Kloc"], [true, true, true, true]));
diff --git a/e2e/remous.e2e-spec.ts b/e2e/remous.e2e-spec.ts
index d13ccd48bd73cfea28a4b94f39b7243579f7b073..342d35edb63c03d52c0d0b8a60c86fcc00730d2d 100644
--- a/e2e/remous.e2e-spec.ts
+++ b/e2e/remous.e2e-spec.ts
@@ -4,6 +4,7 @@ import { browser } from "protractor";
 import { Navbar } from "./navbar.po";
 import { PreferencesPage } from "./preferences.po";
 import { SideNav } from "./sidenav.po";
+import { changeSelectValue } from "./util.po";
 
 /**
  * Remous
@@ -59,7 +60,7 @@ describe("ngHyd − remous", () => {
         await browser.sleep(300);
 
         // 2. Set to trapezoidal section with bank slope of 2m/m and 20 meter width bed
-        await calcPage.changeSelectValue(calcPage.getSelectById("select_section"), 2);
+        await changeSelectValue(calcPage.getSelectById("select_section"), 2);
         await browser.sleep(300);
         await calcPage.getInputById("LargeurFond").clear();
         await browser.sleep(300);
diff --git a/e2e/solveur.e2e-spec.ts b/e2e/solveur.e2e-spec.ts
index 89ca70d6ed4b357ddf9999ae61b08f9b4c4e5a9c..afe88721eb9b32753d9db0ec05fb40e0727c0a82 100644
--- a/e2e/solveur.e2e-spec.ts
+++ b/e2e/solveur.e2e-spec.ts
@@ -5,7 +5,7 @@ import { Navbar } from "./navbar.po";
 import { browser, by, element } from "protractor";
 import { SideNav } from "./sidenav.po";
 import { PreferencesPage } from "./preferences.po";
-import { scrollPageToTop } from "./util.po";
+import { changeSelectValue, scrollPageToTop } from "./util.po";
 
 /**
  * Clone calculators
@@ -76,7 +76,7 @@ describe("Solveur - ", () => {
         expect(hasResults).toBe(true);
 
         // change targetted Nub, check that targetted result changes too
-        await calcPage.changeSelectValue(ntc, 0);
+        await changeSelectValue(ntc, 0);
         const nttV2 = await calcPage.getSelectValueText(ntt);
         expect(nttV2).not.toContain("Puissance dissipée (PV)");
     });
@@ -111,9 +111,9 @@ describe("Solveur - ", () => {
         // Go back to Solveur
         await navbar.clickCalculatorTab(0);
 
-        await calcPage.changeSelectValue(calcPage.getSelectById("select_target_nub"), 1); // "Puissance / PV"
+        await changeSelectValue(calcPage.getSelectById("select_target_nub"), 1); // "Puissance / PV"
         await browser.sleep(500);
-        await calcPage.changeSelectValue(calcPage.getSelectById("select_searched_param"), 2); // "Chute / Z2"
+        await changeSelectValue(calcPage.getSelectById("select_searched_param"), 2); // "Chute / Z2"
         await browser.sleep(500);
         await calcPage.getInputById("Ytarget").sendKeys("318");
 
@@ -159,7 +159,7 @@ describe("Solveur - ", () => {
 
         // modify searched parameter
         const sel = calcPage.getSelectById("select_searched_param");
-        await calcPage.changeSelectValue(sel, 11);
+        await changeSelectValue(sel, 11);
         await browser.sleep(300);
         const selText = await calcPage.getSelectValueText(sel);
 
diff --git a/e2e/translation.e2e-spec.ts b/e2e/translation.e2e-spec.ts
new file mode 100644
index 0000000000000000000000000000000000000000..b0118eac98d93955aed19f0d537b59320f505001
--- /dev/null
+++ b/e2e/translation.e2e-spec.ts
@@ -0,0 +1,97 @@
+import { ListPage } from "./list.po";
+import { Navbar } from "./navbar.po";
+import { browser, by } from "protractor";
+import { CalculatorPage } from "./calculator.po";
+import { PreferencesPage } from "./preferences.po";
+import { SideNav } from "./sidenav.po";
+
+describe("Check translation", () => {
+    let listPage: ListPage;
+    let navBar: Navbar;
+    let calcPage: CalculatorPage;
+    let prefPage: PreferencesPage;
+    let sideNav: SideNav;
+
+    beforeAll(async () => {
+        listPage = new ListPage();
+        navBar = new Navbar();
+        calcPage = new CalculatorPage();
+        prefPage = new PreferencesPage();
+        sideNav = new SideNav();
+    });
+
+    beforeEach(async () => {
+        prefPage.setEmptyFields(false);
+    });
+
+    it("variables in results", async () => {
+        // *** results in french ***
+
+        prefPage.changeLanguage(1); // fr
+        await browser.sleep(200);
+
+        // open "fish ladder: fall" calculator
+        await navBar.clickNewCalculatorButton();
+        await listPage.clickMenuEntryForCalcType(12);
+        await browser.sleep(200);
+
+        // set Z2 to variated mode
+        const inpZ2 = calcPage.getInputById("Z2");
+        await calcPage.setParamMode(inpZ2, "var");
+
+        // run calculation
+        await calcPage.getCalculateButton().click();
+        await browser.sleep(500);
+
+        // "variable for X axis" select label
+        const selXaxis = calcPage.getSelectById("selectX");
+        expect(await calcPage.getMatselectCurrentOptionText(selXaxis)).toEqual("Cote aval");
+
+        // "variable for Y axis" select label
+        const selYaxis = calcPage.getSelectById("selectY");
+        expect(await calcPage.getMatselectCurrentOptionText(selYaxis)).toEqual("DH : Chute (m)");
+
+        // fixed results variables
+        const frr = calcPage.getAllFixedResultsRows();
+        let lbl1 = await frr.all(by.css("td")).get(0).getText();
+        expect(lbl1).toEqual("Cote amont (m)");
+
+        // variated results headers
+        const vrh = calcPage.getAllVariatedResultsTableHeaders();
+        let lbl2 = await vrh.get(0).getText();
+        expect(lbl2).toEqual("Cote aval");
+        let lbl3 = await vrh.get(1).getText();
+        expect(lbl3).toEqual("Chute (m)");
+
+        // *** results in english ***
+
+        // setup -> english
+        await navBar.clickMenuButton();
+        await browser.sleep(200);
+        const setupBtn = await sideNav.getSetupButton();
+        await setupBtn.click();
+        await browser.sleep(200);
+        await prefPage.changeLanguage(0); // en
+        await browser.sleep(200);
+
+        // back to calculator
+        await navBar.clickCalculatorTab(0);
+        await browser.sleep(200);
+
+        // "variable for X axis" select label
+        expect(await calcPage.getMatselectCurrentOptionText(selXaxis)).toEqual("Downstream elevation");
+
+        // "variable for Y axis" select label
+        expect(await calcPage.getMatselectCurrentOptionText(selYaxis)).toEqual("DH : Fall (m)");
+
+        // fixed results variables
+        lbl1 = await frr.all(by.css("td")).get(0).getText();
+        expect(lbl1).toEqual("Upstream elevation (m)");
+
+        // variated results headers
+        lbl2 = await vrh.get(0).getText();
+        expect(lbl2).toEqual("Downstream elevation");
+        lbl3 = await vrh.get(1).getText();
+        expect(lbl3).toEqual("Fall (m)");
+    });
+});
diff --git a/e2e/util.po.ts b/e2e/util.po.ts
index e772a28ce9c01e0c54f85d0bce84a01df90e7ac5..aded71c166c96b6914bff75af3f3fa4fe409185f 100644
--- a/e2e/util.po.ts
+++ b/e2e/util.po.ts
@@ -1,4 +1,4 @@
-import { ElementFinder, browser } from "protractor";
+import { ElementFinder, browser, by, element } from "protractor";
 
 /**
  * scroll page to make element visible
@@ -27,3 +27,11 @@ export function expectNumber(msg: string, val: number, expected: number) {
     }
     expect(val).toEqual(expected);
 }
+
+export async function changeSelectValue(elt: ElementFinder, index: number) {
+    await elt.click();
+    const optionId = ".cdk-overlay-container mat-option:nth-of-type(" + (index + 1) + ")";
+    const option = element(by.css(optionId));
+    await option.click();
+    await browser.sleep(200);
+}
diff --git a/package.json b/package.json
index 47e5d03490ed4a741f99a9d11dc7693f057d9ff7..b7cf7d4e8781930a18da8e1cc310cc48ecc3db55 100644
--- a/package.json
+++ b/package.json
@@ -20,7 +20,8 @@
     "preprocess": "mkdir -p build; node scripts/preprocessors.js; npm run service-worker-version; bash scripts/fix-chartjs-plugin-zoom-2.0.0.sh",
     "start": "npm run preprocess && npm run mkdocs && npm run ng serve -- --host 0.0.0.0 --poll 5000",
     "build-no-pdf": "npm run preprocess && npm run mkdocs && npm run ng build -- --configuration production",
-    "build": "npm run preprocess && npm run mkdocs && npm run ng build -- --configuration production && npm run mkdocs2pdf",
+    "build": "npm run build-href -basehref=/",
+    "build-href": "npm run preprocess && npm run mkdocs && npm run ng build -- --configuration production --base-href=$npm_config_basehref && npm run mkdocs2pdf",
     "update-dist-index-mimetypes": "node scripts/update-dist-index-mimetypes.js",
     "electron": "npm run update-dist-index-mimetypes && \"node_modules/.bin/electron\" .",
     "release-linux-nocompile": "npm run update-dist-index-mimetypes && \"node_modules/.bin/electron-builder\"",
diff --git a/scripts/deploy-version.sh b/scripts/deploy-version.sh
index 4221e281bf9a84801c54b96417c6fb24955543e7..4d5bd0687b2b11ebc1bce1b92ee80aa979e0f399 100755
--- a/scripts/deploy-version.sh
+++ b/scripts/deploy-version.sh
@@ -35,10 +35,6 @@ echo "$(basename $0): deploying version $VERSION in $LOGIN@$HOST:$DIR"
 if [[ $VERSION == "prod" || $VERSION == "prod-devel" ]]; then
   display_local_href
 
-  # Modification du dossier base href -> /
-  echo "updating index.html base href to /"
-  sed -i '/<base/s/href="[^"]*"/href="\/"/' $LOCAL_DIR/index.html
-
   # Copie de la branche production
   rsync -a --delete --exclude=cassiopee-releases -e "ssh -o StrictHostKeyChecking=no" $LOCAL_DIR/ ${LOGIN}@${HOST}:${DIR}/
 
@@ -46,10 +42,6 @@ if [[ $VERSION == "prod" || $VERSION == "prod-devel" ]]; then
 else
   display_local_href
 
-  # Modification du dossier base href -> /cassiopee/version/
-  echo "updating index.html base href to /cassiopee/$VERSION/"
-  sed -i "/<base/s/href=\"[^\"]*\"/href=\"\/cassiopee\/$VERSION\/\"/" $LOCAL_DIR/index.html
-
   # Copie de la branche / du tag
   rsync -a --delete --exclude=cassiopee-releases -e "ssh -o StrictHostKeyChecking=no" $LOCAL_DIR/ "$LOGIN@$HOST:$DIR/$VERSION"
 
diff --git a/scripts/mkdocs2pdf.py b/scripts/mkdocs2pdf.py
index 6e3cf68040aa15e43debd48019a955e3e13d9b2a..37deb6a8f52ef1d99b61ccf8f1b618790e3fa4a2 100644
--- a/scripts/mkdocs2pdf.py
+++ b/scripts/mkdocs2pdf.py
@@ -20,6 +20,9 @@ import yaml
 import re
 import shutil
 
+# verbose output
+verbose = False
+
 baseDir = os.getcwd()
 buildDir = os.path.join(baseDir, 'build')
 latexSourceDir = os.path.join(baseDir, 'docs/latex')
@@ -38,6 +41,21 @@ def runCommand(cmd):
     if os.waitstatus_to_exitcode(os.system(cmd)) != 0:
         raise RuntimeError("error executing:",cmd)
 
+# Create a symbolic link
+def createLink(src):
+    # check if destination already exists
+    dest = os.path.basename(src)
+    if os.path.exists(dest):
+        if not os.path.islink(dest):
+            raise Exception('{} exists but is not a symbolic link'.format(dest))
+    else:
+        runCommand('ln -s {}'.format(src))
+
+def createEmptyDir(path):
+    if os.path.exists(path):
+        shutil.rmtree(path)
+    os.makedirs(path)
+
 # Reads an MkDocs configuration file
 def readConfig(sYAML):
     f = open(sYAML, 'r')
@@ -130,9 +148,14 @@ def convertMdToTex(filePath):
 def getLatexModel():
     # Clone Git repository
     os.chdir(pdfBuildDir)
-    runCommand(
-        'git clone {} {}'.format(latexModelRepository, latexModelDir)
-    )
+    if os.path.isdir(latexModelDir):
+        # git directory exists, update it
+        os.chdir(latexModelDir)
+        runCommand('git pull')
+        # platform independent "cd .."
+        os.chdir(os.path.dirname(os.getcwd()))
+    else:
+        runCommand('git clone {} {}'.format(latexModelRepository, latexModelDir))
     # back to original working drectory
     os.chdir(baseDir)
 
@@ -146,35 +169,17 @@ def injectContentIntoModel(mergedDocFilenameTex, lang):
     # Symlink necessary resources
     os.chdir(modelDir)
     relPathToMergedTexDoc = os.path.join('..', mergedDocFilenameTex)
-    runCommand(
-        'ln -s {} .'.format(relPathToMergedTexDoc)
-    )
+    createLink(relPathToMergedTexDoc)
     latexTemplate = filenamePrefix + lang + '.tex'
     relPathToLatexTemplate = os.path.join(latexSourceDir, latexTemplate)
-    runCommand(
-        'ln -s {}'.format(relPathToLatexTemplate)
-    )
-    runCommand(
-        'ln -s {}'.format(os.path.join(latexSourceDir, 'logo_pole.png'))
-    )
-    runCommand(
-        'ln -s {}/schema_rugosite_fond.png'.format(os.path.join(baseDir, 'docs', lang, 'calculators', 'pam'))
-    )
-    runCommand(
-        'ln -s {}/bloc_cylindre.png'.format(os.path.join(baseDir, 'docs', lang, 'calculators', 'pam'))
-    )
-    runCommand(
-        'ln -s {}/bloc_face_arrondie.png'.format(os.path.join(baseDir, 'docs', lang, 'calculators', 'pam'))
-    )
-    runCommand(
-        'ln -s {}/bloc_base_carree.png'.format(os.path.join(baseDir, 'docs', lang, 'calculators', 'pam'))
-    )
-    runCommand(
-        'rm rapport_inrae/logos.tex'
-    )
-    runCommand(
-        'ln -s {} rapport_inrae/'.format(os.path.join(latexSourceDir, 'logos.tex'))
-    )
+    createLink(relPathToLatexTemplate)
+    createLink(os.path.join(latexSourceDir, 'logo_pole.png'))
+    createLink('{}/schema_rugosite_fond.png'.format(os.path.join(baseDir, 'docs', lang, 'calculators', 'pam')))
+    createLink('{}/bloc_cylindre.png'.format(os.path.join(baseDir, 'docs', lang, 'calculators', 'pam')))
+    createLink('{}/bloc_face_arrondie.png'.format(os.path.join(baseDir, 'docs', lang, 'calculators', 'pam')))
+    createLink('{}/bloc_base_carree.png'.format(os.path.join(baseDir, 'docs', lang, 'calculators', 'pam')))
+    runCommand('rm rapport_inrae/logos.tex')
+    createLink('{} rapport_inrae/'.format(os.path.join(latexSourceDir, 'logos.tex')))
     # back to original working drectory
     os.chdir(baseDir)
 
@@ -189,9 +194,11 @@ def buildPDF(lang):
     cvt = os.path.join(buildDir, 'cassiopee_version.tex')
     shutil.copy(cvt, modelDir)
 
-    os.system(
-        'latexmk -f -xelatex -pdf -interaction=nonstopmode {} > /dev/null 2>&1'.format(sourceTexFile)
-    )
+    if verbose:
+        os.system('latexmk -f -xelatex -pdf -interaction=nonstopmode {} > /dev/null'.format(sourceTexFile))
+    else:
+        os.system('latexmk -f -xelatex -pdf -interaction=nonstopmode {} > /dev/null 2>&1'.format(sourceTexFile))
+
     # copy generated PDF to release directory
     shutil.copy(outputPdfFile, outputDir)
     # back to original working drectory
@@ -201,9 +208,9 @@ def buildPDF(lang):
 def buildDocForLang(lang):
 
     # Prepare temporary build directory
-    os.makedirs(pdfBuildDir, exist_ok=True)
+    createEmptyDir(pdfBuildDir)
     # Prepare output directory
-    os.makedirs(outputDir, exist_ok=True)
+    createEmptyDir(outputDir)
 
     # Read config
     yamlPath = 'mkdocs/mkdocs-' + lang + '.yml'
diff --git a/src/app/app.component.ts b/src/app/app.component.ts
index d11194ecab2f9f8c1b5ed14ec25d6a671bc1bf23..0e1044ac651ec31ebd7d38d288ab52741aaea25c 100644
--- a/src/app/app.component.ts
+++ b/src/app/app.component.ts
@@ -22,7 +22,7 @@ import { DialogSaveSessionComponent } from "./components/dialog-save-session/dia
 import { QuicknavComponent } from "./components/quicknav/quicknav.component";
 import { NotificationsService } from "./services/notifications.service";
 
-import { decodeHtml } from "./util";
+import { decodeHtml } from "./util/util";
 
 import { HotkeysService, Hotkey } from "angular2-hotkeys";
 
@@ -33,6 +33,8 @@ 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({
@@ -85,13 +87,16 @@ export class AppComponent implements OnInit, OnDestroy, Observer {
         private confirmCloseCalcDialog: MatDialog,
         private hotkeysService: HotkeysService,
         private matomoTracker: MatomoTracker,
-        private serviceWorkerUpdateService: ServiceWorkerUpdateService
+        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
@@ -218,10 +223,24 @@ export class AppComponent implements OnInit, OnDestroy, Observer {
     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"])
@@ -670,6 +689,12 @@ export class AppComponent implements OnInit, OnDestroy, Observer {
         };
     }
 
+    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
@@ -809,4 +834,24 @@ export class AppComponent implements OnInit, OnDestroy, Observer {
             }
         }
     }
+
+    /**
+     * 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 });
+        });
+    }
 }
diff --git a/src/app/app.module.ts b/src/app/app.module.ts
index 15eac95408a49c29f830397b606d7f95990029fc..5c912764c5e680f134109936abe3681c7d0de46d 100644
--- a/src/app/app.module.ts
+++ b/src/app/app.module.ts
@@ -96,6 +96,7 @@ import { JetTrajectoryChartComponent } from "./components/jet-trajectory-chart/j
 import { SessionPropertiesComponent } from "./components/session-properties/session-properties.component";
 import { VerificateurResultsComponent } from "./components/verificateur-results/verificateur-results.component";
 
+import { DialogConfirmComponent } from "./components/dialog-confirm/dialog-confirm.component";
 import { DialogConfirmEmptySessionComponent } from "./components/dialog-confirm-empty-session/dialog-confirm-empty-session.component";
 import { DialogConfirmCloseCalcComponent } from "./components/dialog-confirm-close-calc/dialog-confirm-close-calc.component";
 import { DialogEditPabComponent } from "./components/dialog-edit-pab/dialog-edit-pab.component";
@@ -127,6 +128,7 @@ import { SelectSectionDetailsComponent } from "./components/select-section-detai
 import { ServiceWorkerModule } from '@angular/service-worker';
 import { environment } from '../environments/environment';
 import { ServiceWorkerUpdateService } from "./services/service-worker-update.service";
+import { UserConfirmationService } from "./services/user-confirmation.service";
 
 const appRoutes: Routes = [
     { path: "list/search", component: CalculatorListComponent },
@@ -208,6 +210,7 @@ const appRoutes: Routes = [
         CalculatorResultsComponent,
         DialogConfirmCloseCalcComponent,
         DialogConfirmEmptySessionComponent,
+        DialogConfirmComponent,
         DialogEditPabComponent,
         DialogEditParamComputedComponent,
         DialogEditParamValuesComponent,
@@ -282,7 +285,8 @@ const appRoutes: Routes = [
             provide: ErrorStateMatcher,
             useClass: ImmediateErrorStateMatcher
         },
-        ServiceWorkerUpdateService
+        ServiceWorkerUpdateService,
+        UserConfirmationService
     ],
     schemas: [NO_ERRORS_SCHEMA],
     bootstrap: [AppComponent]
diff --git a/src/app/components/dialog-confirm/dialog-confirm.component.html b/src/app/components/dialog-confirm/dialog-confirm.component.html
new file mode 100644
index 0000000000000000000000000000000000000000..1de1a1bae3d8e2d2cee34ed52dd36424283dc39f
--- /dev/null
+++ b/src/app/components/dialog-confirm/dialog-confirm.component.html
@@ -0,0 +1,12 @@
+<h1 mat-dialog-title [innerHTML]="uitextTitle"></h1>
+<div mat-dialog-content>
+    <p [innerHTML]="uitextBody"></p>
+</div>
+<div mat-dialog-actions [attr.align]="'end'">
+    <button id="cancel" mat-raised-button color="primary" [mat-dialog-close]="false" cdkFocusInitial>
+        {{ uitextNo }}
+    </button>
+    <button id="confirm" mat-raised-button color="warn" [mat-dialog-close]="true">
+        {{ uitextYes }}
+    </button>
+</div>
diff --git a/src/app/components/dialog-confirm/dialog-confirm.component.ts b/src/app/components/dialog-confirm/dialog-confirm.component.ts
new file mode 100644
index 0000000000000000000000000000000000000000..f8cefaa2526ec20077f992157001690ec99e5c16
--- /dev/null
+++ b/src/app/components/dialog-confirm/dialog-confirm.component.ts
@@ -0,0 +1,38 @@
+import { MatDialogRef, MAT_DIALOG_DATA } from "@angular/material/dialog";
+import { Inject, Component } from "@angular/core";
+import { I18nService } from "../../services/internationalisation.service";
+
+@Component({
+    selector: "dialog-confirm",
+    templateUrl: "dialog-confirm.component.html",
+})
+export class DialogConfirmComponent {
+
+    private _title: string;
+    private _text: string;
+
+    constructor(
+        public dialogRef: MatDialogRef<DialogConfirmComponent>,
+        private intlService: I18nService,
+        @Inject(MAT_DIALOG_DATA) public data: any
+    ) {
+        this._title = data.title;
+        this._text = data.text;
+    }
+
+    public get uitextYes() {
+        return this.intlService.localizeText("INFO_OPTION_YES");
+    }
+
+    public get uitextNo() {
+        return this.intlService.localizeText("INFO_OPTION_NO");
+    }
+
+    public get uitextTitle() {
+        return this._title;
+    }
+
+    public get uitextBody() {
+        return this._text;
+    }
+}
diff --git a/src/app/components/dialog-edit-param-values/dialog-edit-param-values.component.ts b/src/app/components/dialog-edit-param-values/dialog-edit-param-values.component.ts
index 16768e9c0715efc77bbf7b50d2c4d6b45d3e64ea..47790963b1c2fd3b83d065621b01b1c2326a026a 100644
--- a/src/app/components/dialog-edit-param-values/dialog-edit-param-values.component.ts
+++ b/src/app/components/dialog-edit-param-values/dialog-edit-param-values.component.ts
@@ -10,7 +10,7 @@ import { sprintf } from "sprintf-js";
 
 import { ParamValueMode, ExtensionStrategy } from "jalhyd";
 
-import { fv } from "../../util";
+import { fv } from "../../util/util";
 import { ServiceFactory } from "app/services/service-factory";
 
 @Component({
diff --git a/src/app/components/dialog-generate-par-simulation/dialog-generate-par-simulation.component.ts b/src/app/components/dialog-generate-par-simulation/dialog-generate-par-simulation.component.ts
index b7aaf61d72def9cd99d15fec5b95d62666de2614..554bc994357a9496566c3ad921f6ec8f4b8176f1 100644
--- a/src/app/components/dialog-generate-par-simulation/dialog-generate-par-simulation.component.ts
+++ b/src/app/components/dialog-generate-par-simulation/dialog-generate-par-simulation.component.ts
@@ -3,7 +3,7 @@ import { Inject, Component } from "@angular/core";
 
 import { I18nService } from "../../services/internationalisation.service";
 import { MultiDimensionResults } from "../../results/multidimension-results";
-import { fv, longestVarParam } from "../../util";
+import { fv, longestVarParam } from "../../util/util";
 
 @Component({
     selector: "dialog-generate-par-simulation",
diff --git a/src/app/components/field-set/field-set.component.ts b/src/app/components/field-set/field-set.component.ts
index 448d40a7dc63bfcd47dbec4e10bdb29ba884e857..62b39960f24fd4f2e18e1d4c95c9e5b8dda162c1 100644
--- a/src/app/components/field-set/field-set.component.ts
+++ b/src/app/components/field-set/field-set.component.ts
@@ -16,7 +16,7 @@ import { I18nService } from "../../services/internationalisation.service";
 import { sprintf } from "sprintf-js";
 
 import { capitalize } from "jalhyd";
-import { DefinedBoolean } from "app/definedvalue/definedboolean";
+import { DefinedBoolean } from "../../util/definedvalue/definedboolean";
 
 @Component({
     selector: "field-set",
diff --git a/src/app/components/fieldset-container/fieldset-container.component.ts b/src/app/components/fieldset-container/fieldset-container.component.ts
index 042df751799ed61418e5b9a869e2277cb627110d..e07c6514d5088587c89b402db5c6cca6088bc7e7 100644
--- a/src/app/components/fieldset-container/fieldset-container.component.ts
+++ b/src/app/components/fieldset-container/fieldset-container.component.ts
@@ -6,7 +6,7 @@ import { FieldSet } from "../../formulaire/elements/fieldset";
 import { FormulaireDefinition } from "../../formulaire/definition/form-definition";
 import { I18nService } from "../../services/internationalisation.service";
 import { ApplicationSetupService } from "../../services/app-setup.service";
-import { DefinedBoolean } from "app/definedvalue/definedboolean";
+import { DefinedBoolean } from "../../util/definedvalue/definedboolean";
 import { ParamValueMode } from "jalhyd";
 
 @Component({
diff --git a/src/app/components/fixedvar-results/results.component.ts b/src/app/components/fixedvar-results/results.component.ts
index ae9747dbe0e9f0b4843d4ccb9292105cf295e86f..18b08975073117647fc2b4e3fbfd1e94e5e81a1c 100644
--- a/src/app/components/fixedvar-results/results.component.ts
+++ b/src/app/components/fixedvar-results/results.component.ts
@@ -2,7 +2,7 @@ import screenfull from "screenfull";
 
 import { NgParameter } from "../../formulaire/elements/ngparam";
 import { ServiceFactory } from "../../services/service-factory";
-import { fv } from "../../util";
+import { fv } from "../../util/util";
 import { CalculatorResults } from "../../results/calculator-results";
 
 import { Directive, HostListener } from "@angular/core";
diff --git a/src/app/components/fixedvar-results/var-results.component.ts b/src/app/components/fixedvar-results/var-results.component.ts
index 7493401d2776c1760fbe9c616232aa62ceefecea..f48623acc22a3df02f2a972a5ba0a7b227c93683 100644
--- a/src/app/components/fixedvar-results/var-results.component.ts
+++ b/src/app/components/fixedvar-results/var-results.component.ts
@@ -1,14 +1,14 @@
-import { Component, ViewChild, ElementRef, Input } from "@angular/core";
+import { Component, ViewChild, ElementRef, Input, OnInit } from "@angular/core";
 
 import { MatDialog } from "@angular/material/dialog";
 
 import { VarResults } from "../../results/var-results";
-import { ResultElement, Message, MessageSeverity } from "jalhyd";
+import { ResultElement, Message, MessageSeverity, Observer } from "jalhyd";
 import { I18nService } from "../../services/internationalisation.service";
 import { ResultsComponentDirective } from "./results.component";
 import { DialogLogEntriesDetailsComponent } from "../dialog-log-entries-details/dialog-log-entries-details.component";
 import { AppComponent } from "../../app.component";
-import { longestVarParam } from "../../../app/util";
+import { longestVarParam } from "../../../app/util/util";
 
 @Component({
     selector: "var-results",
@@ -17,7 +17,7 @@ import { longestVarParam } from "../../../app/util";
         "./var-results.component.scss"
     ]
 })
-export class VarResultsComponent extends ResultsComponentDirective {
+export class VarResultsComponent extends ResultsComponentDirective implements Observer, OnInit {
 
     /** size of the longest variated parameter */
     public size: number;
@@ -42,12 +42,17 @@ export class VarResultsComponent extends ResultsComponentDirective {
         protected logEntriesDetailsDialog: MatDialog
     ) {
         super();
+        this.intlService.addObserver(this);
     }
 
     /** Refreshes results and builds the dataset */
     @Input()
     public set results(r: VarResults) {
         this._varResults = r;
+        this.updateResults();
+    }
+
+    private updateResults() {
         this._results = [];
         this._headers = [];
         this._messages = [];
@@ -190,4 +195,16 @@ export class VarResultsComponent extends ResultsComponentDirective {
             }
         );
     }
+
+    ngOnInit(): void {
+        this._varResults.updateCalculatedParameterHeader();
+    }
+
+    // Observer interface
+
+    update(sender: any, data: any): void {
+        if (sender instanceof I18nService) {
+            this._varResults.update();
+        }
+    }
 }
diff --git a/src/app/components/generic-calculator/calculator.component.ts b/src/app/components/generic-calculator/calculator.component.ts
index 49e81d946b6216bb123c23a0c64dea8c32cbe730..23f71a54d7850d590d8c0aff6729585eccb0ee69 100644
--- a/src/app/components/generic-calculator/calculator.component.ts
+++ b/src/app/components/generic-calculator/calculator.component.ts
@@ -28,7 +28,7 @@ import {
     ParallelStructure
 } from "jalhyd";
 
-import { generateValuesCombination, getUnformattedIthResult, getUnformattedIthValue } from "../../util";
+import { generateValuesCombination, getUnformattedIthResult, getUnformattedIthValue } from "../../util/util";
 
 import { AppComponent } from "../../app.component";
 import { FormulaireService } from "../../services/formulaire.service";
@@ -62,7 +62,7 @@ import { sprintf } from "sprintf-js";
 
 import * as XLSX from "xlsx";
 import { ServiceFactory } from "app/services/service-factory";
-import { DefinedBoolean } from "app/definedvalue/definedboolean";
+import { DefinedBoolean } from "../../util/definedvalue/definedboolean";
 import { FormulaireCourbeRemous } from "app/formulaire/definition/form-courbe-remous";
 import { RemousResults } from "app/results/remous-results";
 
@@ -1259,7 +1259,7 @@ export class GenericCalculatorComponent implements OnInit, DoCheck, AfterViewChe
                 const form = this._formulaire as FormulaireFixedVar;
                 const nub = (form.currentNub as Espece);
                 nub.loadPredefinedSpecies(result.selected);
-                form.refreshFieldsets();
+                form.refreshFieldsets(false);
             }
         });
     }
diff --git a/src/app/components/generic-input/generic-input.component.ts b/src/app/components/generic-input/generic-input.component.ts
index d95168143cb67edb5fff3ffe00586e65fe60ad4f..281a11332a7c288641ab2719a9f18a1d11c9ccf2 100644
--- a/src/app/components/generic-input/generic-input.component.ts
+++ b/src/app/components/generic-input/generic-input.component.ts
@@ -5,7 +5,7 @@ import { FormulaireDefinition } from "../../formulaire/definition/form-definitio
 import { NgParameter } from "../../formulaire/elements/ngparam";
 import { I18nService } from "../../services/internationalisation.service";
 import { ApplicationSetupService } from "../../services/app-setup.service";
-import { DefinedBoolean } from "app/definedvalue/definedboolean";
+import { DefinedBoolean } from "../../util/definedvalue/definedboolean";
 
 /**
  * classe de gestion générique d'un champ de saisie avec titre, validation et message d'erreur
diff --git a/src/app/components/jet-trajectory-chart/jet-trajectory-chart.component.ts b/src/app/components/jet-trajectory-chart/jet-trajectory-chart.component.ts
index 5bd87e2bb12d5177c109a493bcc92517bef823ce..bf4e084e134588c955fd4da70e1a8de96b82808c 100644
--- a/src/app/components/jet-trajectory-chart/jet-trajectory-chart.component.ts
+++ b/src/app/components/jet-trajectory-chart/jet-trajectory-chart.component.ts
@@ -5,7 +5,7 @@ import { BaseChartDirective } from "ng2-charts";
 import { I18nService } from "../../services/internationalisation.service";
 import { ResultsComponentDirective } from "../fixedvar-results/results.component";
 import { IYSeries } from "../../results/y-series";
-import { fv } from "../../util";
+import { fv } from "../../util/util";
 import { AppComponent } from "../../app.component";
 
 import { Jet, Result } from "jalhyd";
diff --git a/src/app/components/macrorugo-compound-results/macrorugo-compound-results.component.ts b/src/app/components/macrorugo-compound-results/macrorugo-compound-results.component.ts
index f388647dafbcf7d653fcffd263b87d3dfff40718..039681e3c0195eb26e976b2d3b6ab31fd3189534 100644
--- a/src/app/components/macrorugo-compound-results/macrorugo-compound-results.component.ts
+++ b/src/app/components/macrorugo-compound-results/macrorugo-compound-results.component.ts
@@ -2,7 +2,7 @@ import { Component, Input } from "@angular/core";
 
 import { Result, cLog, Message, MessageCode, MessageSeverity, MRCInclination } from "jalhyd";
 
-import { fv } from "../../../app/util";
+import { fv } from "../../../app/util/util";
 
 import { CalculatorResults } from "../../results/calculator-results";
 import { NgParameter } from "../../formulaire/elements/ngparam";
diff --git a/src/app/components/modules-diagram/modules-diagram.component.ts b/src/app/components/modules-diagram/modules-diagram.component.ts
index 0b607e51e10e25cb2f5505c3e1bdf0993dfcffe5..0ec435a0739a6691b60d18390aab60a0f4227fdc 100644
--- a/src/app/components/modules-diagram/modules-diagram.component.ts
+++ b/src/app/components/modules-diagram/modules-diagram.component.ts
@@ -33,7 +33,7 @@ import * as SvgPanZoom from "svg-pan-zoom";
 
 import { MatomoTracker } from "@ngx-matomo/tracker";
 
-import { fv } from "../../util";
+import { fv } from "../../util/util";
 
 @Component({
     selector: "modules-diagram",
diff --git a/src/app/components/pab-profile-chart/pab-profile-chart.component.ts b/src/app/components/pab-profile-chart/pab-profile-chart.component.ts
index ee664e43ad59b3c77bd6e000921cbb11d8c32ea6..11aca1b4046fb5c4b0119cf4a5c87b4a56f41b20 100644
--- a/src/app/components/pab-profile-chart/pab-profile-chart.component.ts
+++ b/src/app/components/pab-profile-chart/pab-profile-chart.component.ts
@@ -6,7 +6,7 @@ import { I18nService } from "../../services/internationalisation.service";
 import { ResultsComponentDirective } from "../fixedvar-results/results.component";
 import { PabResults } from "../../results/pab-results";
 import { IYSeries } from "../../results/y-series";
-import { fv, longestVarParam } from "../../util";
+import { fv, longestVarParam } from "../../util/util";
 import { AppComponent } from "../../app.component";
 
 import { CloisonAval, Cloisons, LoiDebit } from "jalhyd";
diff --git a/src/app/components/pab-results/pab-results-table.component.ts b/src/app/components/pab-results/pab-results-table.component.ts
index b541cdc52e332f6cde19abc74c9c9730db870fd8..b2b36a8368d34de4fa186b699152db63e9602442 100644
--- a/src/app/components/pab-results/pab-results-table.component.ts
+++ b/src/app/components/pab-results/pab-results-table.component.ts
@@ -6,7 +6,7 @@ import { PabResults } from "../../results/pab-results";
 import { I18nService } from "../../services/internationalisation.service";
 import { ResultsComponentDirective } from "../fixedvar-results/results.component";
 import { AppComponent } from "../../app.component";
-import { fv } from "../../util";
+import { fv } from "../../util/util";
 
 @Component({
     selector: "pab-results-table",
diff --git a/src/app/components/pab-table/pab-table.component.ts b/src/app/components/pab-table/pab-table.component.ts
index f06c1b536c27ea4cd2c0d434cd3d416723c16f1a..a3919284b434408c8eee278ea30c551f93d90996 100644
--- a/src/app/components/pab-table/pab-table.component.ts
+++ b/src/app/components/pab-table/pab-table.component.ts
@@ -28,7 +28,7 @@ import { PabTable } from "../../formulaire/elements/pab-table";
 import { DialogEditPabComponent } from "../dialog-edit-pab/dialog-edit-pab.component";
 import { AppComponent } from "../../app.component";
 import { NgParameter, ParamRadioConfig } from "../../formulaire/elements/ngparam";
-import { DefinedBoolean } from "app/definedvalue/definedboolean";
+import { DefinedBoolean } from "../../util/definedvalue/definedboolean";
 
 /**
  * The big editable data grid for calculator type "Pab" (component)
diff --git a/src/app/components/pb-results/pb-cloison-results.component.ts b/src/app/components/pb-results/pb-cloison-results.component.ts
index 0a59b6a8b1f0581e7ff07e706f24459e6e7df5ce..da3aae4ba32983e163a9096fdf1db300ea78aab3 100644
--- a/src/app/components/pb-results/pb-cloison-results.component.ts
+++ b/src/app/components/pb-results/pb-cloison-results.component.ts
@@ -2,7 +2,7 @@ import { Component, Input } from "@angular/core";
 
 import { FixedResultsComponent } from "../fixedvar-results/fixed-results.component";
 import { NgParameter } from "../../formulaire/elements/ngparam";
-import { getIthValue } from "../../util";
+import { getIthValue } from "../../util/util";
 import { PbCloisonResults } from "../../results/pb-cloison-results";
 
 import { Result, ResultElement } from "jalhyd";
diff --git a/src/app/components/pb-results/pb-results-table.component.ts b/src/app/components/pb-results/pb-results-table.component.ts
index 79084192dd3b7b5e0477157ea06f3e8a09b1e5c9..9e1315d86f6d1f8608f728389431494d1065d239 100644
--- a/src/app/components/pb-results/pb-results-table.component.ts
+++ b/src/app/components/pb-results/pb-results-table.component.ts
@@ -5,7 +5,7 @@ import { PreBarrage, PbBassin } from "jalhyd";
 import { I18nService } from "../../services/internationalisation.service";
 import { ResultsComponentDirective } from "../fixedvar-results/results.component";
 import { AppComponent } from "../../app.component";
-import { fv, getIthValue } from "../../util";
+import { fv, getIthValue } from "../../util/util";
 import { PrebarrageResults } from "../../results/prebarrage-results";
 
 @Component({
diff --git a/src/app/components/pb-schema/pb-schema.component.ts b/src/app/components/pb-schema/pb-schema.component.ts
index 6de70eecd8ddf7c49d2c45a200a9ae6a6cf0a009..ef424b22fd24c3c88efd3680c3d0250f38ba1512 100644
--- a/src/app/components/pb-schema/pb-schema.component.ts
+++ b/src/app/components/pb-schema/pb-schema.component.ts
@@ -18,9 +18,9 @@ import { GenericCalculatorComponent } from "../generic-calculator/calculator.com
 import { FormulairePrebarrage } from "../../formulaire/definition/form-prebarrage";
 import { AppComponent } from "../../app.component";
 
-import { fv } from "app/util";
+import { fv } from "app/util/util";
 import { ServiceFactory } from "app/services/service-factory";
-import { DefinedBoolean } from "app/definedvalue/definedboolean";
+import { DefinedBoolean } from "../../util/definedvalue/definedboolean";
 import { PrebarrageService, PrebarrageServiceEvents } from "app/services/prebarrage.service";
 
 /**
diff --git a/src/app/components/remous-results/remous-results.component.ts b/src/app/components/remous-results/remous-results.component.ts
index 1f5ea4b720bfcb4bbf0ccbd85f42bde4dc582540..2fd73ae1e13f3ecb6b1b9444b07e0800ca6435f8 100644
--- a/src/app/components/remous-results/remous-results.component.ts
+++ b/src/app/components/remous-results/remous-results.component.ts
@@ -9,7 +9,7 @@ import { FormulaireService } from "../../services/formulaire.service";
 import { ResultsComponentDirective } from "../fixedvar-results/results.component";
 import { AppComponent } from "../../app.component";
 import { LineData, ChartData } from "./line-and-chart-data";
-import { fv } from "../../util";
+import { fv } from "../../util/util";
 import { VarResults } from "../../results/var-results";
 
 import { BaseChartDirective } from "ng2-charts";
diff --git a/src/app/components/results-chart/chart-type.component.ts b/src/app/components/results-chart/chart-type.component.ts
index 39839c3a96cb2feea1c220ebbf415e2855263880..123657c5173e1f09900f327f140c4cdd6eb3dc65 100644
--- a/src/app/components/results-chart/chart-type.component.ts
+++ b/src/app/components/results-chart/chart-type.component.ts
@@ -4,7 +4,7 @@ import { I18nService } from "../../services/internationalisation.service";
 import { ChartType } from "../../results/chart-type";
 import { SelectFieldChartType } from "app/formulaire/elements/select/select-field-charttype";
 import { SelectEntry } from "app/formulaire/elements/select/select-entry";
-import { decodeHtml } from "../../util";
+import { decodeHtml } from "../../util/util";
 
 @Component({
     selector: "chart-type",
diff --git a/src/app/components/results-chart/results-chart.component.ts b/src/app/components/results-chart/results-chart.component.ts
index 0b33967f3c45ae7766943a382b9db32fc3593ed7..a52719212a258e15957e4f8d27e586345f77474a 100644
--- a/src/app/components/results-chart/results-chart.component.ts
+++ b/src/app/components/results-chart/results-chart.component.ts
@@ -11,7 +11,7 @@ import { ChartType } from "../../results/chart-type";
 import { ResultsComponentDirective } from "../fixedvar-results/results.component";
 import { IYSeries } from "../../results/y-series";
 import { VarResults } from "../../results/var-results";
-import { fv } from "../../util";
+import { fv } from "../../util/util";
 import { AppComponent } from "../../app.component";
 
 import zoomPlugin from 'chartjs-plugin-zoom';
@@ -182,6 +182,10 @@ export class ResultsChartComponent extends ResultsComponentDirective implements
     }
 
     public ngOnChanges() {
+        if (this._results instanceof VarResults) {
+            this._results.updateCalculatedParameterHeader();
+        }
+
         // redessiner le graphique chaque fois qu'une entrée change
         this.drawChart();
     }
diff --git a/src/app/components/select-field-line/select-field-line.component.ts b/src/app/components/select-field-line/select-field-line.component.ts
index c6b2474a0f08e795ac58514bb58486d127e4ecdb..7c4b067efcece001ba65882f713c0e286d660f48 100644
--- a/src/app/components/select-field-line/select-field-line.component.ts
+++ b/src/app/components/select-field-line/select-field-line.component.ts
@@ -4,7 +4,7 @@ import { SelectField } from "../../formulaire/elements/select/select-field";
 import { SelectEntry } from "../../formulaire/elements/select/select-entry";
 import { I18nService } from "../../services/internationalisation.service";
 import { ApplicationSetupService } from "../../services/app-setup.service";
-import { decodeHtml } from "../../util";
+import { decodeHtml } from "../../util/util";
 
 @Component({
     selector: "select-field-line",
diff --git a/src/app/components/select-section-details/select-section-details.component.ts b/src/app/components/select-section-details/select-section-details.component.ts
index 55c21b523cead45096bfc15f39d2d733043aad92..278ce4bdca3d2ead89a5354807f4266b1f4171a0 100644
--- a/src/app/components/select-section-details/select-section-details.component.ts
+++ b/src/app/components/select-section-details/select-section-details.component.ts
@@ -3,7 +3,7 @@ import { Router } from '@angular/router';
 import { FormulaireDefinition } from 'app/formulaire/definition/form-definition';
 import { FormulaireService } from 'app/services/formulaire.service';
 import { I18nService } from 'app/services/internationalisation.service';
-import { fv } from 'app/util';
+import { fv } from 'app/util/util';
 import { formattedValue } from 'jalhyd';
 
 /**
diff --git a/src/app/components/variable-results-selector/variable-results-selector.component.ts b/src/app/components/variable-results-selector/variable-results-selector.component.ts
index 0d68b44573e4177fb943019a8516944fdbc94162..50c4a0d968df136c66c0f25f9391c0c2a5688820 100644
--- a/src/app/components/variable-results-selector/variable-results-selector.component.ts
+++ b/src/app/components/variable-results-selector/variable-results-selector.component.ts
@@ -1,7 +1,7 @@
 import { Component, Input, OnChanges } from "@angular/core";
 
 import { I18nService } from "../../services/internationalisation.service";
-import { fv, longestVarParam } from "../../util";
+import { fv, longestVarParam } from "../../util/util";
 import { MultiDimensionResults } from "../../results/multidimension-results";
 import { VariatedDetails } from "jalhyd";
 import { CalculatorResults } from "../../results/calculator-results";
diff --git a/src/app/formulaire/definition/form-definition.ts b/src/app/formulaire/definition/form-definition.ts
index 9acb17e78dea09b3bf132dd208e12ffecbf7a4cb..c6b05ea1ba0dd4a3fe92354006e6d8eeb4a95a5d 100644
--- a/src/app/formulaire/definition/form-definition.ts
+++ b/src/app/formulaire/definition/form-definition.ts
@@ -346,11 +346,10 @@ export abstract class FormulaireDefinition extends FormulaireNode implements Obs
     }
 
     /**
-     * Trouve le Ngparameter correspondant au symbole "symbol", parmi tous les
-     * éléments du formulaire
-     * @param symbol string
+     * Trouve le NgParameter correspondant au paramètre Nub parmi tous les éléments du formulaire
+     * @param param paramètre critère de recherche
      */
-    public getParamFromSymbol(param: ParamDefinition): NgParameter {
+    private getNgparamFromNubParam(param: ParamDefinition): NgParameter {
         for (const p of this.allFormElements) {
             if (p instanceof NgParameter) {
                 if (p.symbol === param.symbol && p.paramDefinition.parentNub.uid === param.parentNub.uid) {
@@ -360,6 +359,14 @@ export abstract class FormulaireDefinition extends FormulaireNode implements Obs
         }
     }
 
+    public getOrCreateParam(nubParam: ParamDefinition, parent: FormulaireNode): NgParameter {
+        const res = this.getNgparamFromNubParam(nubParam);
+        if (res === undefined) {
+            return new NgParameter(nubParam, parent);
+        }
+        return res;
+    }
+
     public getFieldById(id: string): Field {
         const res = this.getFormulaireNodeById(id);
         if (res instanceof Field) {
@@ -532,14 +539,10 @@ export abstract class FormulaireDefinition extends FormulaireNode implements Obs
 
     protected getComputedParameter(): NgParameter {
         const cpd = this.currentNub.calculatedParam;
-        let ngparam: NgParameter;
         if (cpd !== undefined) {
-            ngparam = this.getParamFromSymbol(cpd);
-            if (ngparam === undefined) { // calculated parameter is not displayed on screen
-                ngparam = new NgParameter(cpd, this);
-            }
+            return this.getOrCreateParam(cpd, this);
         }
-        return ngparam;
+        return undefined;
     }
 
     /** find variated (possibly linked) parameters from model, and get their values at the same time */
@@ -552,7 +555,7 @@ export abstract class FormulaireDefinition extends FormulaireNode implements Obs
         const fixedParams: ParamDefinition[] = this._currentNub.findFixedParams();
         let fnp: NgParameter[] = [];
         for (const fp of fixedParams) {
-            fnp.push(this.getParamFromSymbol(fp));
+            fnp.push(this.getNgparamFromNubParam(fp));
         }
         fnp = fnp.filter((e) => e !== undefined);
         return fnp;
diff --git a/src/app/formulaire/definition/form-fixedvar.ts b/src/app/formulaire/definition/form-fixedvar.ts
index 8eb6917e75496d210925a4bd8ab355c6ece3e107..f20ce41fef290508de47d54f63fb72b826792e4d 100644
--- a/src/app/formulaire/definition/form-fixedvar.ts
+++ b/src/app/formulaire/definition/form-fixedvar.ts
@@ -82,8 +82,7 @@ export class FormulaireFixedVar extends FormulaireDefinition {
 
     protected compute() {
         this.runNubCalc(this.currentNub);
-        this.refreshFieldsets(); // important: before reaffectResultComponents() or it will break results components localization
-        // this.reaffectResultComponents();  // seems useless since called from runNubCalc()
+        this.reaffectResultComponents();
     }
 
     protected reaffectResultComponents() {
@@ -115,9 +114,9 @@ export class FormulaireFixedVar extends FormulaireDefinition {
     /**
      * Forces all fieldsets to update all their fields
      */
-    public refreshFieldsets() {
+    public refreshFieldsets(forceClear: boolean) {
         for (const fs of this.allFieldsets) {
-            fs.updateFields();
+            fs.updateFields(forceClear);
         }
         this.completeParse(false); // re-add observers that were destroyed by updateFields()
     }
@@ -130,7 +129,7 @@ export class FormulaireFixedVar extends FormulaireDefinition {
         if (data.action === "propertyChange") {
             this.reset();
             // reflect changes in GUI (who knows ?), for ex. show / hide dependent fields
-            this.refreshFieldsets();
+            this.refreshFieldsets(true);
         }
     }
 }
diff --git a/src/app/formulaire/definition/form-macrorugo-compound.ts b/src/app/formulaire/definition/form-macrorugo-compound.ts
index 306332cb56def101c15cd82afcb095fb06aa4fb3..5dd33e865c4dada32d3de547e0f9d7696423b6a3 100644
--- a/src/app/formulaire/definition/form-macrorugo-compound.ts
+++ b/src/app/formulaire/definition/form-macrorugo-compound.ts
@@ -84,7 +84,7 @@ export class FormulaireMacrorugoCompound extends FormulaireRepeatableFieldset {
      */
     public updateApronState(inclined: MRCInclination) {
         // show / hide dependent fields (read from model)
-        this.refreshFieldsets();
+        this.refreshFieldsets(false);
         // show / hide children list (GUI only)
         for (const elt of this.allFormElements) {
             if (elt instanceof FieldsetContainer) {
diff --git a/src/app/formulaire/definition/form-pab.ts b/src/app/formulaire/definition/form-pab.ts
index 85f423501196014b5198c27d9e5a6a243524e796..d27a620927df68c68c2789250112d8889d7dbd93 100644
--- a/src/app/formulaire/definition/form-pab.ts
+++ b/src/app/formulaire/definition/form-pab.ts
@@ -3,7 +3,7 @@ import { Pab, Result, VariatedDetails } from "jalhyd";
 import { FormulaireDefinition } from "./form-definition";
 import { PabResults } from "../../results/pab-results";
 import { NgParameter } from "../elements/ngparam";
-import { longestVarParam } from "../../util";
+import { longestVarParam } from "../../util/util";
 import { PabTable } from "../elements/pab-table";
 
 /**
diff --git a/src/app/formulaire/definition/form-prebarrage.ts b/src/app/formulaire/definition/form-prebarrage.ts
index c9a0d9e0db35efb7ff665b637520798032944bf6..fbccdec66c8b788bb59508949aa2bd2b7c5413a5 100644
--- a/src/app/formulaire/definition/form-prebarrage.ts
+++ b/src/app/formulaire/definition/form-prebarrage.ts
@@ -9,7 +9,7 @@ import { FieldsetContainer } from "../elements/fieldset-container";
 import { CalculatorResults } from "../../results/calculator-results";
 import { PrebarrageResults } from "../../results/prebarrage-results";
 import { NgParameter } from "../elements/ngparam";
-import { longestVarParam } from "../../util";
+import { longestVarParam } from "../../util/util";
 import { FormulaireNode } from "../elements/formulaire-node";
 
 /**
@@ -239,7 +239,7 @@ export class FormulairePrebarrage extends FormulaireFixedVar {
 
     protected compute() {
         this.runNubCalc(this.currentNub);
-        this.refreshFieldsets(); // important: before reaffectResultComponents() or it will break results components localization
+        this.refreshFieldsets(false); // important: before reaffectResultComponents() or it will break results components localization
         // reset variable index to avoid trying to access an index > 0 when nothing varies
         this._pbResults.variableIndex = 0;
 
diff --git a/src/app/formulaire/definition/form-pressureloss.ts b/src/app/formulaire/definition/form-pressureloss.ts
index 26c862355f9b7d5f572d022379d73c0f27e4e982..9260bc79ae47f9098291a57b6ef35efb2d3c3409 100644
--- a/src/app/formulaire/definition/form-pressureloss.ts
+++ b/src/app/formulaire/definition/form-pressureloss.ts
@@ -36,16 +36,23 @@ export class FormulairePressureLoss extends FormulaireFixedVar {
     }
 
     public update(sender: IObservable, data: any) {
-        // changement de propriété du FieldSet contenant le select de choix du type de perte de charge
-        if (sender instanceof FieldSet && sender.id === "fs_pressureloss_law" && data.action === "propertyChange") {
-            // replace underlying pressure loss law without replacing whole Nub
-            const newPLL = Session.getInstance().createPressureLossLaw(data.value, undefined, this.currentNub.getPropValue(Prop_NullParameters));
-            (this._currentNub as PressureLoss).setLaw(newPLL);
-            // show / hide dependent fields
-            this.refreshFieldsets();
-            this.reset();
+        // if (sender instanceof FieldSet && sender.id === "fs_pressureloss_law" && data.action === "propertyChange") {
+        if (data.action === "propertyChange") {
+            if (data.name === "pressurelosstype") {
+                // changement de propriété du FieldSet contenant le select de choix du type de perte de charge
+
+                // replace underlying pressure loss law without replacing whole Nub
+                const newPLL = Session.getInstance().createPressureLossLaw(data.value, undefined, this.currentNub.getPropValue(Prop_NullParameters));
+                (this._currentNub as PressureLoss).setLaw(newPLL);
+                // show / hide dependent fields
+                this.refreshFieldsets(true);
+                this.reset();
+            }
+            else if (data.name === "material") {
+                // changement de propriété du FieldSet contenant le select de choix du type de matériau
+                this.refreshFieldsets(false);
+                this.reset();
+            }
         }
-        else
-            super.update(sender, data);
     }
 }
diff --git a/src/app/formulaire/definition/form-section-parametree.ts b/src/app/formulaire/definition/form-section-parametree.ts
index 7f91f2fca963c76ba3e5bae49cfa8adc511afa8b..7ddfdefaf792c204256e0ec9cfebfc86e9d9588a 100644
--- a/src/app/formulaire/definition/form-section-parametree.ts
+++ b/src/app/formulaire/definition/form-section-parametree.ts
@@ -23,10 +23,6 @@ export class FormulaireSectionParametree extends FormulaireSection {
         this.reaffectResultComponents();
     }
 
-    protected runNubCalc(nub: Nub, computedParam?: ParamDefinition): Result {
-        return nub.CalcSerie();
-    }
-
     protected reaffectResultComponents() {
         this.resetFormResults(); // to avoid adding fixed parameters more than once (see below)
         const sectNub: SectionParametree = this.currentNub as SectionParametree;
diff --git a/src/app/formulaire/definition/form-section.ts b/src/app/formulaire/definition/form-section.ts
index bf06c16b257e578889469d86dc696f6ea15249a0..d2aa08b33f532607425315ddbf139662708d15bb 100644
--- a/src/app/formulaire/definition/form-section.ts
+++ b/src/app/formulaire/definition/form-section.ts
@@ -47,12 +47,7 @@ export class FormulaireSection extends FormulaireFixedVar {
             const newSect = Session.getInstance().createSection(data.value);
             (this._currentNub as SectionNub).setSection(newSect);
             // reflect changes in GUI
-            for (const fs of this.allFieldsets) {
-                // show / hide dependent fields
-                fs.updateFields();
-            }
-            // show / hide dependent fields
-            this.refreshFieldsets();
+            this.refreshFieldsets(true);
             this.reset();
         }
     }
diff --git a/src/app/formulaire/definition/form-solveur.ts b/src/app/formulaire/definition/form-solveur.ts
index 0a3d446efac3a99a2fa2165f3b0916462fcfeb5a..ce7be1797a493253662eb914bf8a6ae1d3495d82 100644
--- a/src/app/formulaire/definition/form-solveur.ts
+++ b/src/app/formulaire/definition/form-solveur.ts
@@ -63,7 +63,7 @@ export class FormulaireSolveur extends FormulaireFixedVar {
                 // refresh targetted result selector
                 const trSel = this.getFormulaireNodeById(this._targettedResultSelectId) as SelectField;
                 if (trSel) {
-                    (trSel.parent as FieldSet).updateFields();
+                    (trSel.parent as FieldSet).updateFields(true);
                     // trick to re-set observers
                     this.completeParse(false);
                 }
diff --git a/src/app/formulaire/elements/fieldset.ts b/src/app/formulaire/elements/fieldset.ts
index c1678a93ba7bbc72b6bbba20953523941dc1cf8f..7b709eb764cb73580a7f56d1c2b24ed17b2a4c97 100644
--- a/src/app/formulaire/elements/fieldset.ts
+++ b/src/app/formulaire/elements/fieldset.ts
@@ -10,7 +10,7 @@ import {
 import { FormulaireElement } from "./formulaire-element";
 import { Field } from "./field";
 import { SelectField } from "./select/select-field";
-import { NgParameter, ParamRadioConfig } from "./ngparam";
+import { NgParameter } from "./ngparam";
 import { FieldsetContainer } from "./fieldset-container";
 import { SelectFieldFactory } from "./select/select-field-factory";
 import { FormulaireFixedVar } from "../definition/form-fixedvar";
@@ -38,9 +38,8 @@ export class FieldSet extends FormulaireElement implements IProperties {
 
     public setNub(sn: Nub, update: boolean = true) {
         this._nub = sn;
-        this.setParentNubForAllFields();
         if (update) {
-            this.updateFields();
+            this.updateFields(true);
         }
     }
 
@@ -48,7 +47,9 @@ export class FieldSet extends FormulaireElement implements IProperties {
         if (!f) {
             throw new Error("FieldSet.addField() : argument incorrect (undefined)");
         }
-        this.kids.push(f);
+        if (this.kids.indexOf(f) === -1) {
+            this.kids.push(f);
+        }
     }
 
     /**
@@ -106,6 +107,15 @@ export class FieldSet extends FormulaireElement implements IProperties {
         }
     }
 
+    private getOrCreateSelect(json: {}): SelectField {
+        const id: string = json["id"];
+        const res = this.getFormulaireNodeById(id);
+        if (res === undefined || !(res instanceof SelectField)) {
+            return SelectFieldFactory.newSelectField(json, this);
+        }
+        return res;
+    }
+
     private parse_select(json: {}): SelectField {
         let ok: boolean = true;
         // in case select is associated to a property, check nub has the property
@@ -114,7 +124,7 @@ export class FieldSet extends FormulaireElement implements IProperties {
             ok = this.parentForm.getPropValue(p) !== undefined;
         }
         if (ok) {
-            const res: SelectField = SelectFieldFactory.newSelectField(json, this);
+            const res: SelectField = this.getOrCreateSelect(json);
             res.parseConfig(json);
             res.afterParseConfig();
             res.addObserver(this);
@@ -135,9 +145,6 @@ export class FieldSet extends FormulaireElement implements IProperties {
         return this._jsonConfig;
     }
 
-    private setParentNubForAllFields() {
-    }
-
     /**
      * crée un input
      * @param json definition de l'input, extrait du fichier de conf du module de calcul
@@ -151,7 +158,7 @@ export class FieldSet extends FormulaireElement implements IProperties {
         try {
             nubParam = this.getNubParamFromSymbol(input_id);
             if (nubParam.visible) {
-                res = new NgParameter(nubParam, this);
+                res = this.parentForm.getOrCreateParam(nubParam, this);
                 res.parseConfig(json);
             }
         } catch (e) {
@@ -161,8 +168,6 @@ export class FieldSet extends FormulaireElement implements IProperties {
     }
 
     private parseFields() {
-        // clear everything so that parseFields() is idempotent
-        this.clearFields();
         // parse children fields from config
         const fields = this._jsonConfig["fields"];
         for (const field_index in fields) {
@@ -207,7 +212,10 @@ export class FieldSet extends FormulaireElement implements IProperties {
     /**
      * Reloads the model values and properties, and reloads localisation strings
      */
-    public updateFields() {
+    public updateFields(forceClear: boolean) {
+        if (forceClear) {
+            this.clearFields();
+        }
         this.parseFields();
         this.updateLocalisation();
 
@@ -261,6 +269,7 @@ export class FieldSet extends FormulaireElement implements IProperties {
 
         // parse fields once, so that SelectField elements are present
         // when setting default properties below
+        this.clearFields();
         this.parseFields();
 
         // for all select fields known by the form, apply default value
@@ -280,32 +289,6 @@ export class FieldSet extends FormulaireElement implements IProperties {
         }
     }
 
-    public getNodeParameter(symbol: string): NgParameter {
-        for (const p of this.kids) {
-            if (p instanceof NgParameter) {
-                if (p.isDisplayed && p.symbol === symbol) {
-                    return p;
-                }
-            }
-        }
-    }
-
-    public getNodeParameterValue(symbol: string): number {
-        const p = this.getNodeParameter(symbol);
-        if (!p) {
-            throw new Error(`FieldSet.getNodeParameterValue() : pas de paramètre ${symbol} trouvé`);
-        }
-
-        switch (p.radioState) {
-            case ParamRadioConfig.FIX:
-                return p.getValue();
-
-            case ParamRadioConfig.VAR:
-            case ParamRadioConfig.CAL:
-                return undefined;
-        }
-    }
-
     /**
      * retourne la valeur actuellement sélectionnée d'un SelectField (qui doit être affiché)
      * @param selectFieldId id du SelectField
diff --git a/src/app/formulaire/elements/ngparam.ts b/src/app/formulaire/elements/ngparam.ts
index e4dc902cd4034702c761634d145c30781db189b2..a579f014146807fe4e091f7a7dd6b1d0050faa18 100644
--- a/src/app/formulaire/elements/ngparam.ts
+++ b/src/app/formulaire/elements/ngparam.ts
@@ -8,7 +8,7 @@ import { sprintf } from "sprintf-js";
 import { InputField } from "./input-field";
 import { ServiceFactory } from "../../services/service-factory";
 import { FormulaireNode } from "./formulaire-node";
-import { fv } from "../../util";
+import { fv } from "../../util/util";
 
 export enum ParamRadioConfig {
     /** pas de radio, paramètre modifiable à la main uniquement */
diff --git a/src/app/formulaire/elements/select/select-field-searched-param.ts b/src/app/formulaire/elements/select/select-field-searched-param.ts
index 339acbfe5702e623c3cc8041b2860ad7ef0fe3f6..8811ef81f441628e39164f0f46404fa8cf1a1d4c 100644
--- a/src/app/formulaire/elements/select/select-field-searched-param.ts
+++ b/src/app/formulaire/elements/select/select-field-searched-param.ts
@@ -1,5 +1,5 @@
 import { ServiceFactory } from "app/services/service-factory";
-import { decodeHtml } from "app/util";
+import { decodeHtml } from "app/util/util";
 import { acSection, Nub, Solveur } from "jalhyd";
 import { SelectEntry } from "./select-entry";
 import { SelectField } from "./select-field";
diff --git a/src/app/formulaire/elements/select/select-field-solveur-target.ts b/src/app/formulaire/elements/select/select-field-solveur-target.ts
index b98546e221fc7bf13c782ca43440e24df9f34320..3d752aa4368a5c45c5b0c4a4d36375ff8cb0d8b2 100644
--- a/src/app/formulaire/elements/select/select-field-solveur-target.ts
+++ b/src/app/formulaire/elements/select/select-field-solveur-target.ts
@@ -5,7 +5,7 @@
 */
 
 import { ServiceFactory } from "app/services/service-factory";
-import { decodeHtml } from "../../../util";
+import { decodeHtml } from "../../../util/util";
 import { Session, Solveur } from "jalhyd";
 import { SelectEntry } from "./select-entry";
 import { SelectField } from "./select-field";
diff --git a/src/app/formulaire/elements/select/select-field-target-pass.ts b/src/app/formulaire/elements/select/select-field-target-pass.ts
index 6383c02ffc159a14afa7f6a11dce014769c48af9..8438e0a0fe2c50a5b5b1c8b937543249def5a423 100644
--- a/src/app/formulaire/elements/select/select-field-target-pass.ts
+++ b/src/app/formulaire/elements/select/select-field-target-pass.ts
@@ -1,5 +1,5 @@
 import { ServiceFactory } from "app/services/service-factory";
-import { decodeHtml } from "app/util";
+import { decodeHtml } from "app/util/util";
 import { CalculatorType, FishPass, Session, Verificateur } from "jalhyd";
 import { FormulaireElement } from "../formulaire-element";
 import { FormulaireNode } from "../formulaire-node";
diff --git a/src/app/formulaire/elements/select/select-field.ts b/src/app/formulaire/elements/select/select-field.ts
index 2b9b65aa97fc770e1548718791597698decb7a09..b45f59c42d2581d536dc40659295cb86ed57cc62 100644
--- a/src/app/formulaire/elements/select/select-field.ts
+++ b/src/app/formulaire/elements/select/select-field.ts
@@ -1,6 +1,6 @@
 import { Field } from "../field";
 import { SelectEntry } from "./select-entry";
-import { arraysAreEqual } from "../../../util";
+import { arraysAreEqual } from "../../../util/util";
 import { FormulaireNode } from "../formulaire-node";
 import { ServiceFactory } from "app/services/service-factory";
 import { FormulaireDefinition } from "../../definition/form-definition";
diff --git a/src/app/results/param-calc-results.ts b/src/app/results/param-calc-results.ts
index eaca087d32dee78357630328c0c72508f7b00e44..9e22d98b4928284d5b72a4712e233bb551649a7c 100644
--- a/src/app/results/param-calc-results.ts
+++ b/src/app/results/param-calc-results.ts
@@ -12,7 +12,7 @@ export abstract class CalculatedParamResults extends CalculatorResults {
     protected _calculatedParam: NgParameter;
 
     /** titre de la colonne du paramètre calculé */
-    public calculatedParameterHeader: string;
+    private _calculatedParameterHeader: string;
 
     /** résultat du calcul sur le paramètre calculé */
     public result: Result;
@@ -22,7 +22,7 @@ export abstract class CalculatedParamResults extends CalculatorResults {
 
     public reset() {
         this._calculatedParam = undefined;
-        this.calculatedParameterHeader = undefined;
+        this._calculatedParameterHeader = undefined;
         this.result = undefined;
     }
 
@@ -32,7 +32,17 @@ export abstract class CalculatedParamResults extends CalculatorResults {
 
     public set calculatedParameter(p: NgParameter) {
         this._calculatedParam = p;
-        this.calculatedParameterHeader = CalculatorResults.paramLabel(this._calculatedParam, true);
+        this.updateCalculatedParameterHeader();
+    }
+
+    public updateCalculatedParameterHeader() {
+        if (this._calculatedParam !== undefined) {
+            this._calculatedParameterHeader = CalculatorResults.paramLabel(this._calculatedParam, true);
+        }
+    }
+
+    public get calculatedParameterHeader(): string {
+        return this._calculatedParameterHeader;
     }
 
     public get hasResults(): boolean {
diff --git a/src/app/results/var-results.ts b/src/app/results/var-results.ts
index 37ff757b971936d696f0b040060cc6b54f2688f7..c8c814d89776af9760d03c5e524297446330f92a 100644
--- a/src/app/results/var-results.ts
+++ b/src/app/results/var-results.ts
@@ -2,7 +2,7 @@ import { CalculatedParamResults } from "./param-calc-results";
 import { ServiceFactory } from "../services/service-factory";
 import { PlottableData } from "./plottable-data";
 import { ChartType } from "./chart-type";
-import { longestVarParam } from "../util";
+import { longestVarParam } from "../util/util";
 import { FormulaireDefinition } from "../formulaire/definition/form-definition";
 
 import { sprintf } from "sprintf-js";
diff --git a/src/app/services/internationalisation.service.ts b/src/app/services/internationalisation.service.ts
index ea1bec8c3d563f2e11b1a8cfdbf45ae95c67100f..deee494fecb63836be93eeb253b8253ed708742f 100644
--- a/src/app/services/internationalisation.service.ts
+++ b/src/app/services/internationalisation.service.ts
@@ -5,7 +5,7 @@ import { Message, MessageCode, Observable, Observer, Nub, CalculatorType, PreBar
 import { StringMap } from "../stringmap";
 import { ApplicationSetupService } from "./app-setup.service";
 import { HttpService } from "./http.service";
-import { fv, decodeHtml } from "../util";
+import { fv, decodeHtml } from "../util/util";
 import { ServiceFactory } from "./service-factory";
 import { FormulaireService } from "./formulaire.service";
 
diff --git a/src/app/services/service-factory.ts b/src/app/services/service-factory.ts
index e3ea55cae3c7ce504924688dac8ae1517f3268ba..668d5aea18b12ed1295f44b2885fdec97a1eeb56 100644
--- a/src/app/services/service-factory.ts
+++ b/src/app/services/service-factory.ts
@@ -4,6 +4,7 @@ import { I18nService } from "./internationalisation.service";
 import { HttpService } from "./http.service";
 import { NotificationsService } from "./notifications.service";
 import { PrebarrageService } from "./prebarrage.service";
+import { ServiceWorkerUpdateService } from "./service-worker-update.service";
 
 /**
  * A "Singleton" the TS way, that holds pointers to all services, to be accessed
@@ -17,11 +18,13 @@ export const ServiceFactory: {
     httpService: HttpService;
     notificationsService: NotificationsService;
     prebarrageService: PrebarrageService;
+    serviceWorkerUpdateService: ServiceWorkerUpdateService;
 } = {
     applicationSetupService: undefined,
     formulaireService: undefined,
     i18nService: undefined,
     httpService: undefined,
     notificationsService: undefined,
-    prebarrageService: undefined
+    prebarrageService: undefined,
+    serviceWorkerUpdateService: undefined
 };
diff --git a/src/app/services/service-worker-update.service.ts b/src/app/services/service-worker-update.service.ts
index 96b84426ac85dc1c4666d7d6b55ecc7e4aa46f29..163fd4a12e45f941abec56a389479a73c68b291c 100644
--- a/src/app/services/service-worker-update.service.ts
+++ b/src/app/services/service-worker-update.service.ts
@@ -1,36 +1,74 @@
-import { Injectable } from "@angular/core";
+import { Injectable, NgZone } from "@angular/core";
 import { SwUpdate } from '@angular/service-worker';
 import { I18nService } from "./internationalisation.service";
 import { NotificationsService } from "./notifications.service";
+import { UserConfirmationService } from "./user-confirmation.service";
+import { interval } from "rxjs";
 
 @Injectable()
 export class ServiceWorkerUpdateService {
     constructor(
         private swUpdate: SwUpdate,
         private notificationService: NotificationsService,
-        private i18nService: I18nService
+        private i18nService: I18nService,
+        private userConfirmationService: UserConfirmationService,
+        private ngZone: NgZone
     ) {
-        swUpdate.versionUpdates.subscribe(evt => {
+        if (this.swUpdate.isEnabled) {
+            this.ngZone.runOutsideAngular(() =>
+                interval(1000 * 60 * 60).subscribe(val => {
+                    console.log('ServiceWorkerUpdateService: checking for updates...')
+                    swUpdate.checkForUpdate().then(b => {
+                        console.log("ServiceWorkerUpdateService: " + (b ? "new version found" : "no new version"));
+                    });
+                })
+            );
+        } else {
+            console.log("ServiceWorkerUpdateService: SwUpdate is disabled");
+        }
+
+        this.swUpdate.versionUpdates.subscribe(evt => {
             switch (evt.type) {
                 case 'VERSION_DETECTED':
-                    let ver = evt.version.appData["version"];
-                    let msg = i18nService.localizeText("INFO_SERVICE_WORKER_VERSION_DETECTED", { "ver": ver });
-                    notificationService.notify(msg, 10000);
+                    let ver = (evt as any).version?.appData?.version ?? "<NA>";
+                    console.log("ServiceWorkerUpdateService: VERSION_DETECTED", ver);
+                    notificationService.notify(i18nService.localizeText("INFO_SERVICE_WORKER_VERSION_DETECTED", { "ver": ver }), 10000);
                     break;
 
                 case 'VERSION_READY':
-                    const newVer = evt.latestVersion.appData["version"];
-                    // const currVer = evt.currentVersion.appData["version"];
-                    msg = i18nService.localizeText("INFO_SERVICE_WORKER_VERSION_READY", { "ver": newVer });
-                    notificationService.notify(msg, 10000);
+                    const currVer = (evt as any).currentVersion?.appData?.version ?? "<NA>";
+                    const newVer = (evt as any).latestVersion?.appData?.version ?? "<NA>";
+                    console.log("ServiceWorkerUpdateService: VERSION_READY", currVer, "->", newVer);
+
+                    notificationService.notify(i18nService.localizeText("INFO_SERVICE_WORKER_VERSION_READY", { "ver": newVer }), 10000);
+                    // PLANTE si on stocke le message dans une variable !!!!
+                    // const msg = i18nService.localizeText("INFO_SERVICE_WORKER_VERSION_READY", { "ver": newVer });
+                    // notificationService.notify(msg, 10000);
+                    // -> ReferenceError: can't access lexical declaration 'xxx' before initialization
+                    // avec xxx qui varie d'une fois à l'autre !!!
+
+                    userConfirmationService.askUserConfirmation("Confirmation",
+                        i18nService.localizeText("INFO_SERVICE_WORKER_VERSION_READY", { "ver": newVer })).then(data => {
+                            if (data["confirm"]) {
+                                console.log("ServiceWorkerUpdateService: application update confirmed");
+                                window.location.reload();
+                            }
+                            else {
+                                console.log("ServiceWorkerUpdateService: application update canceled");
+                            }
+                        });
                     break;
 
                 case 'VERSION_INSTALLATION_FAILED':
-                    ver = evt.version.appData["version"];
-                    msg = i18nService.localizeText("ERROR_SERVICE_WORKER_INSTALL_FAILED", { "ver": ver });
-                    notificationService.notify(msg, 10000);
+                    ver = (evt as any).version?.appData?.version ?? "NA";
+                    console.log("ServiceWorkerUpdateService: VERSION_INSTALLATION_FAILED", ver);
+                    notificationService.notify(i18nService.localizeText("ERROR_SERVICE_WORKER_INSTALL_FAILED", { "ver": ver }), 10000);
                     break;
             }
         });
+        swUpdate.unrecoverable.subscribe(event => {
+            console.log("SwUpdate.unrecoverable reason", event.reason, "type", event.type);
+            notificationService.notify("SwUpdate: unrecoverable state. Reason=" + event.reason + ", type=" + event.type, 10000);
+        });
     }
 }
diff --git a/src/app/services/user-confirmation.service.ts b/src/app/services/user-confirmation.service.ts
new file mode 100644
index 0000000000000000000000000000000000000000..d33beecbbcb3516620ad36170922baeeca54a8b3
--- /dev/null
+++ b/src/app/services/user-confirmation.service.ts
@@ -0,0 +1,69 @@
+import { Injectable } from "@angular/core";
+
+import { BidirectionalSubject } from "app/util/bidir_subject";
+import { Observer } from "rxjs";
+
+/**
+ * This service enables any class (even another service) to display a confirmation dialog on GUI ans get the user answer
+ */
+@Injectable()
+export class UserConfirmationService {
+
+    // used to communicate with UI component in charge of displaying confirmation dialog
+    // direction 0 : this
+    // direction 1 : UI
+    private _userConfirm: BidirectionalSubject<{}>;
+
+    public constructor() {
+        this._userConfirm = new BidirectionalSubject<{}>();
+
+        // we choose communication canal 0, UI will use 1
+        this._userConfirm.selectPostingChannel(this, 0);
+    }
+
+    /**
+     * add subscription from UI
+     * @param source 
+     */
+    public subscribe(source: any) {
+        this._userConfirm.selectPostingChannel(source, 1);
+    }
+
+    /**
+     * remove UI subscription
+     * @param source 
+     */
+    public unsubscribe(source: any) {
+        this._userConfirm.unselectPostingChannel(source);
+    }
+
+    /**
+     * add a handler (provided bu UI) to confirmation request
+     * @param source normally, UI component
+     * @param obs processing function
+     */
+    public addHandler(source: any, obs: Observer<boolean>) {
+        this._userConfirm.addHandler(source, obs)
+    }
+
+    /**
+     * forward user confirmation from UI to requesting object
+     * @param confirm user confirmation status
+     */
+    public postConfirmation(source: any, confirm: {}) {
+        this._userConfirm.post(source, confirm);
+    }
+
+    /**
+     * forward to UI a request from source to ask a user confirmation with a dialog
+     * @param source object requesting confirmation
+     * @param title confirmation dialog title
+     * @param text confirmation dialog body text
+     * @returns a Promise resolving to a boolean holding user confirmation status
+     */
+    public askUserConfirmation(title: string, text: string): Promise<{}> {
+        const ret = this._userConfirm.getReceivePromise(this);
+        this._userConfirm.post(this, { title: title, body: text }); // false or true, we don't care
+        return ret;
+    }
+}
diff --git a/src/app/util/bidir_subject.ts b/src/app/util/bidir_subject.ts
new file mode 100644
index 0000000000000000000000000000000000000000..069e004e83e7040dff2b273fad42ba9aef3ddd0c
--- /dev/null
+++ b/src/app/util/bidir_subject.ts
@@ -0,0 +1,131 @@
+import { Observer, Subject, firstValueFrom, lastValueFrom } from "rxjs";
+
+/**
+ * bi-directional subject (see RxJS Subject)
+ * Allows two objects to exchange messages in both directions. Each object has to choose a posting channel
+ * (messages will receive from the other one).
+ *
+ *    source1 ----post-----> | channel 0 | --subscribe--> source2
+ *            <--subscribe-- | channel 1 | <-----post----
+ * 
+ * EventEmitter is not used since it is reserved to properties in Angular component with @Output annotation
+ */
+export class BidirectionalSubject<T> {
+
+    // communication channels
+    private _channel0: Subject<T>;
+    private _channel1: Subject<T>;
+
+    // array of "who chose which posting channel"
+    private _channel0Posters: any[] = [];
+    private _channel1Posters: any[] = [];
+
+    constructor() {
+        this._channel0 = new Subject();
+        this._channel1 = new Subject();
+    }
+
+    /**
+     * get posting channel index
+     * @param source object that chose one of the channels
+     */
+    private getPostingChannelIndex(source: any) {
+        if (this._channel0Posters.indexOf(source) !== -1) {
+            return 0;
+        }
+        if (this._channel1Posters.indexOf(source) !== -1) {
+            return 1;
+        }
+        return -1;
+    }
+
+    /**
+     * choose a posting channel
+     * @param source object that chooses the channel
+     * @param chan channel number
+     */
+    public selectPostingChannel(source: any, chan: number) {
+        switch (chan) {
+            case 0:
+                if (this.getPostingChannelIndex(source) !== -1) {
+                    throw new Error("object already has a selected channel");
+                }
+                this._channel0Posters.push(source);
+                break;
+
+            case 1:
+                if (this.getPostingChannelIndex(source) !== -1) {
+                    throw new Error("object already has a selected channel");
+                }
+                this._channel1Posters.push(source);
+                break;
+
+            default:
+                throw new Error(`invalid channel number ${chan}`);
+        }
+    }
+
+    /**
+     * remove a source from its channel
+     */
+    public unselectPostingChannel(source: any) {
+        this._channel0Posters = this._channel0Posters.filter(o => o != source);
+        this._channel1Posters = this._channel1Posters.filter(o => o != source);
+    }
+
+    /**
+     * used by a source to post a message to communication channel
+     */
+    public post(source: any, msg: T) {
+        switch (this.getPostingChannelIndex(source)) {
+            case 0:
+                this._channel0.next(msg);
+                break;
+
+            case 1:
+                this._channel1.next(msg);
+                break;
+
+            case -1:
+                throw new Error("must select a channel first");
+        }
+    }
+
+    /**
+     * create a Promise representing a received message (when posted by another source)
+     * @param source object that will use the Promise
+     */
+    public getReceivePromise(source: any): Promise<T> {
+        switch (this.getPostingChannelIndex(source)) {
+            case 0:
+                return firstValueFrom(this._channel1);
+
+            case 1:
+                return firstValueFrom(this._channel0);
+
+            case -1:
+                throw new Error("must select a channel first");
+        }
+    }
+
+    /**
+     * Add a message handler (provided by source) to process received messages
+     * (alternative to getReceivePromise())
+     * @param source object providing handler
+     * @param handler message processing function
+     */
+    public addHandler(source: any, handler: Observer<T>) {
+        switch (this.getPostingChannelIndex(source)) {
+            case 0:
+                this._channel1.subscribe(handler);
+                break;
+
+            case 1:
+                this._channel0.subscribe(handler);
+                break;
+
+            case -1:
+                throw new Error("must select a channel first");
+        }
+    }
+}
diff --git a/src/app/definedvalue/definedboolean.ts b/src/app/util/definedvalue/definedboolean.ts
similarity index 100%
rename from src/app/definedvalue/definedboolean.ts
rename to src/app/util/definedvalue/definedboolean.ts
diff --git a/src/app/definedvalue/definedvalue.ts b/src/app/util/definedvalue/definedvalue.ts
similarity index 100%
rename from src/app/definedvalue/definedvalue.ts
rename to src/app/util/definedvalue/definedvalue.ts
diff --git a/src/app/util.ts b/src/app/util/util.ts
similarity index 97%
rename from src/app/util.ts
rename to src/app/util/util.ts
index 763a425cbebe0a5b95596c0ae707666bfea8df12..06db46788e2d96870913a5efd1faf8c49db487dd 100644
--- a/src/app/util.ts
+++ b/src/app/util/util.ts
@@ -1,5 +1,5 @@
-import { NgParameter } from "./formulaire/elements/ngparam";
-import { ServiceFactory } from "./services/service-factory";
+import { NgParameter } from "../formulaire/elements/ngparam";
+import { ServiceFactory } from "../services/service-factory";
 
 import { formattedValue, Nub, VariatedDetails, ParamDefinition, ParamValueMode, Result } from "jalhyd";
 
@@ -13,7 +13,7 @@ export function logObject(obj: {}, m?: string) {
 }
 
 export function isNumber(s: string): boolean {
-    return Number(s) !== NaN;
+    return !Number.isNaN(Number(s));
 }
 
 /**
diff --git a/src/main.ts b/src/main.ts
index 066830176864449a04f0ba173614afdd06bde81e..28da08b5bff46969e78ef8737919a9ee7b67fc3e 100644
--- a/src/main.ts
+++ b/src/main.ts
@@ -11,5 +11,9 @@ if (environment.production) {
   enableProdMode();
 }
 
-platformBrowserDynamic().bootstrapModule(AppModule)
-  .catch(err => console.log(err));
+platformBrowserDynamic().bootstrapModule(AppModule).then(() => {
+  if ('serviceWorker' in navigator && environment.production) {
+    console.log("Registering ngsw-worker.js...");
+    navigator.serviceWorker.register('ngsw-worker.js');
+  }
+}).catch(err => console.log(err));