diff --git a/.dockerignore b/.dockerignore index 69e8d74..88cefd4 100644 --- a/.dockerignore +++ b/.dockerignore @@ -9,6 +9,7 @@ __pycache__/ db.sqlite3 cs_app/db.sqlite3 cs_app/media/ +cs_app/.cache/ cs_app/data/db/ docs/_build/ output_files/ diff --git a/README.md b/README.md index 0b50c48..3b940af 100644 --- a/README.md +++ b/README.md @@ -150,6 +150,8 @@ model = FeaturesSimulation(subcatchment_id=subcatchment_id, raw_file=raw_file) ts = model.calculate_timeseries() # ts is a DataFrame with DatetimeIndex and columns: # rainfall, runoff, infiltration_loss, evaporation_loss, runon +# rainfall and infiltration_loss are in mm/h (in/h for US-unit models), +# evaporation_loss in mm/day (in/day), runoff and runon in the model's FLOW_UNITS ``` #### Collect timeseries for varying subcatchment parameter values. diff --git a/cs_app/.env.example b/cs_app/.env.example index 6577d00..12e0878 100644 --- a/cs_app/.env.example +++ b/cs_app/.env.example @@ -2,6 +2,8 @@ SECRET_KEY=change-me-for-local-dev DEBUG=True ALLOWED_HOSTS=localhost,127.0.0.1 DATABASE_URL=sqlite:///db.sqlite3 +# Analysis results cache (file-based); defaults to cs_app/.cache/django +# DJANGO_CACHE_DIR= # E2E test credentials (optional — defaults are baked into conftest.py) # E2E_TEST_USERNAME= diff --git a/cs_app/cs_app/settings.py b/cs_app/cs_app/settings.py index 0a42ffe..39a08c1 100644 --- a/cs_app/cs_app/settings.py +++ b/cs_app/cs_app/settings.py @@ -46,7 +46,7 @@ "django.contrib.staticfiles", "import_export", "crispy_forms", - "crispy_bootstrap4", + "crispy_bootstrap5", "storages", "main.apps.MainConfig", "register.apps.RegisterConfig", @@ -94,6 +94,18 @@ "default": dj_database_url.parse(db_url, conn_max_age=600), } +# Analysis results are kept in the cache between page visits. The file backend survives +# a server restart (runserver's autoreload, a deploy) and is shared by every worker +# process on the host; the default in-memory cache is neither. A deployment running +# several hosts needs a shared backend instead (Redis, database). +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.filebased.FileBasedCache", + "LOCATION": os.environ.get("DJANGO_CACHE_DIR", os.path.join(BASE_DIR, ".cache", "django")), + "OPTIONS": {"MAX_ENTRIES": 1000}, + } +} + AUTH_PASSWORD_VALIDATORS = [ { "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", @@ -109,6 +121,9 @@ }, ] +# Replaces ModelBackend (not alongside it): login() without backend= needs exactly one. +AUTHENTICATION_BACKENDS = ["register.backends.UsernameOrEmailBackend"] + LOGIN_URL = "/accounts/login/" LANGUAGE_CODE = "en-us" @@ -121,7 +136,8 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -CRISPY_TEMPLATE_PACK = "bootstrap4" +CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5" +CRISPY_TEMPLATE_PACK = "bootstrap5" LOGIN_REDIRECT_URL = "/" LOGOUT_REDIRECT_URL = "/" @@ -148,8 +164,13 @@ # Production Security Settings (Fail-Closed) SECURE_SSL_REDIRECT = os.environ.get("SECURE_SSL_REDIRECT", "False").lower() == "true" -SESSION_COOKIE_SECURE = os.environ.get("SESSION_COOKIE_SECURE", "True").lower() == "true" -CSRF_COOKIE_SECURE = os.environ.get("CSRF_COOKIE_SECURE", "True").lower() == "true" +# Secure cookies unless DEBUG: the dev server speaks plain HTTP, and browsers +# that drop Secure cookies there (Safari) would reject every POST with a CSRF 403. +_SECURE_COOKIES_DEFAULT = "False" if DEBUG else "True" +SESSION_COOKIE_SECURE = ( + os.environ.get("SESSION_COOKIE_SECURE", _SECURE_COOKIES_DEFAULT).lower() == "true" +) +CSRF_COOKIE_SECURE = os.environ.get("CSRF_COOKIE_SECURE", _SECURE_COOKIES_DEFAULT).lower() == "true" SECURE_REFERRER_POLICY = "same-origin" diff --git a/cs_app/cs_app/templates/base.html b/cs_app/cs_app/templates/base.html index 7e52971..7774025 100644 --- a/cs_app/cs_app/templates/base.html +++ b/cs_app/cs_app/templates/base.html @@ -1,77 +1,125 @@ {% load static %} - - + - - + - - + {% block title %}{% endblock %} - - - + + - - - - - - - {% block title %}{% endblock %} - + - - - - - - + + + {% block head_scripts %}{% endblock %} {% block style %}{% endblock %} -
-
-
- - Logo + Skip to content + +
+
-
+
{% if messages %} -
+
{% for message in messages %} - - -
-
-
-

© Rafał Buczyński 2023

- - Logo - - -
+
+ +
+ + - - + + + + + + + + + + {% block additional_scripts %}{% endblock %} diff --git a/cs_app/cs_app/templates/registration/login.html b/cs_app/cs_app/templates/registration/login.html index c1ff34e..f5e1ed3 100644 --- a/cs_app/cs_app/templates/registration/login.html +++ b/cs_app/cs_app/templates/registration/login.html @@ -1,13 +1,25 @@ {% extends "base.html" %} -{% load crispy_forms_tags %} +{% load crispy_forms_tags static %} -{% block title %} Logowanie {% endblock %} +{% block title %}Log in — Catchment Simulation{% endblock %} + +{% block style %} + +{% endblock %} {% block content %} -
+
+

Welcome back

+

Log in

+

Log in to run simulations on your own SWMM models.

+ + {% csrf_token %} {{ form|crispy }} -
- + + -{% endblock %} \ No newline at end of file + +

New here? Create an account

+
+{% endblock %} diff --git a/cs_app/cs_app/test_settings.py b/cs_app/cs_app/test_settings.py index 6d16e6a..64733b3 100644 --- a/cs_app/cs_app/test_settings.py +++ b/cs_app/cs_app/test_settings.py @@ -35,7 +35,7 @@ "django.contrib.staticfiles", "import_export", "crispy_forms", - "crispy_bootstrap4", + "crispy_bootstrap5", "storages", "main.apps.MainConfig", "register.apps.RegisterConfig", @@ -80,6 +80,13 @@ } } +# Per-process and empty at start, so tests never see each other's (or a dev server's) results. +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + } +} + AUTH_PASSWORD_VALIDATORS = [ { @@ -96,6 +103,8 @@ }, ] +AUTHENTICATION_BACKENDS = ["register.backends.UsernameOrEmailBackend"] + LOGIN_URL = "/accounts/login/" LANGUAGE_CODE = "en-us" @@ -109,7 +118,8 @@ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" -CRISPY_TEMPLATE_PACK = "bootstrap4" +CRISPY_ALLOWED_TEMPLATE_PACKS = "bootstrap5" +CRISPY_TEMPLATE_PACK = "bootstrap5" LOGIN_REDIRECT_URL = "/" LOGOUT_REDIRECT_URL = "/" diff --git a/cs_app/cs_app/urls.py b/cs_app/cs_app/urls.py index 10154d9..e8ba4cc 100644 --- a/cs_app/cs_app/urls.py +++ b/cs_app/cs_app/urls.py @@ -1,14 +1,18 @@ -from django.contrib import admin -from django.urls import path, include -from django.conf.urls.static import static from django.conf import settings +from django.conf.urls.static import static +from django.contrib import admin +from django.contrib.auth.views import LoginView from django.contrib.staticfiles.urls import staticfiles_urlpatterns +from django.urls import include, path +from register.forms import LoginForm urlpatterns = ( [ path("admin/", admin.site.urls), path("", include("main.urls")), path("", include("register.urls")), + # Ahead of django.contrib.auth.urls so it wins the "login" name and path. + path("accounts/login/", LoginView.as_view(authentication_form=LoginForm), name="login"), path("accounts/", include("django.contrib.auth.urls")), ] + static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/cs_app/data/build_home_data.py b/cs_app/data/build_home_data.py new file mode 100644 index 0000000..6e8c599 --- /dev/null +++ b/cs_app/data/build_home_data.py @@ -0,0 +1,68 @@ +"""Regenerate the example data shown on the home page from ``example.inp``. + +Every chart on the home page is computed from the bundled example model +(subcatchment ``S1``), so the numbers match what users get from the code +snippets next to them. Run after changing ``example.inp`` or the package: + + cd cs_app && ../.venv/bin/python data/build_home_data.py + +Writes, next to this file: + df_slope.json, df_area.json, df_width.json parameter sweeps (total runoff volume) + example_hydrograph.json ``calculate_timeseries()`` records +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from catchment_simulation.catchment_features_simulation import FeaturesSimulation + +DATA_DIR = Path(__file__).resolve().parent +MODEL = DATA_DIR / "example.inp" +SUBCATCHMENT_ID = "S1" +SIGNIFICANT_DIGITS = 6 + +# Output file -> (x key in the records, sweep method, start, stop, step). +# Keep the ranges in sync with the snippets in main/templates/main/main_view.html. +SWEEPS = { + "df_slope.json": ("slope", "simulate_percent_slope", 1, 100, 1), + "df_area.json": ("area", "simulate_area", 1, 100, 1), + "df_width.json": ("width", "simulate_width", 10, 1000, 10), +} + + +def _round(value: float) -> float: + return float(f"{value:.{SIGNIFICANT_DIGITS}g}") + + +def _write(name: str, records: list[dict]) -> None: + (DATA_DIR / name).write_text(json.dumps(records, separators=(",", ":")), encoding="utf-8") + + +def main() -> None: + for name, (x_key, method, start, stop, step) in SWEEPS.items(): + with FeaturesSimulation(subcatchment_id=SUBCATCHMENT_ID, raw_file=str(MODEL)) as model: + frame = getattr(model, method)(start=start, stop=stop, step=step) + feature_column = frame.columns[-1] # the swept parameter is the last column + _write( + name, + [ + {x_key: _round(row[feature_column]), "runoff": _round(row["runoff"])} + for _, row in frame.iterrows() + ], + ) + + with FeaturesSimulation(subcatchment_id=SUBCATCHMENT_ID, raw_file=str(MODEL)) as model: + timeseries = model.calculate_timeseries() + _write( + "example_hydrograph.json", + [ + {"datetime": moment.isoformat(), **{key: _round(value) for key, value in row.items()}} + for moment, row in timeseries.iterrows() + ], + ) + + +if __name__ == "__main__": + main() diff --git a/cs_app/data/df_area.json b/cs_app/data/df_area.json index 531a7ce..d48497d 100644 --- a/cs_app/data/df_area.json +++ b/cs_app/data/df_area.json @@ -1 +1 @@ -[{"Unnamed: 0":0,"runoff":43.7143490025,"area":1},{"Unnamed: 0":1,"runoff":82.5854598568,"area":2},{"Unnamed: 0":2,"runoff":118.6683490505,"area":3},{"Unnamed: 0":3,"runoff":152.7121369686,"area":4},{"Unnamed: 0":4,"runoff":185.1407843889,"area":5},{"Unnamed: 0":5,"runoff":216.2335935081,"area":6},{"Unnamed: 0":6,"runoff":246.1907517416,"area":7},{"Unnamed: 0":7,"runoff":275.1636561128,"area":8},{"Unnamed: 0":8,"runoff":303.2712756888,"area":9},{"Unnamed: 0":9,"runoff":330.6095973487,"area":10},{"Unnamed: 0":10,"runoff":357.2577577371,"area":11},{"Unnamed: 0":11,"runoff":383.2822394987,"area":12},{"Unnamed: 0":12,"runoff":408.7395645796,"area":13},{"Unnamed: 0":13,"runoff":433.6784049317,"area":14},{"Unnamed: 0":14,"runoff":458.1410650203,"area":15},{"Unnamed: 0":15,"runoff":482.164640941,"area":16},{"Unnamed: 0":16,"runoff":505.7818433641,"area":17},{"Unnamed: 0":17,"runoff":529.0217858316,"area":18},{"Unnamed: 0":18,"runoff":551.9104841225,"area":19},{"Unnamed: 0":19,"runoff":574.4712088353,"area":20},{"Unnamed: 0":20,"runoff":596.7251451078,"area":21},{"Unnamed: 0":21,"runoff":618.6912959965,"area":22},{"Unnamed: 0":22,"runoff":640.3870180854,"area":23},{"Unnamed: 0":23,"runoff":661.8281887273,"area":24},{"Unnamed: 0":24,"runoff":683.0292876182,"area":25},{"Unnamed: 0":25,"runoff":704.0036129683,"area":26},{"Unnamed: 0":26,"runoff":724.7634206256,"area":27},{"Unnamed: 0":27,"runoff":745.3199758518,"area":28},{"Unnamed: 0":28,"runoff":765.6837047665,"area":29},{"Unnamed: 0":29,"runoff":785.8643061741,"area":30},{"Unnamed: 0":30,"runoff":805.8708070537,"area":31},{"Unnamed: 0":31,"runoff":825.7114605248,"area":32},{"Unnamed: 0":32,"runoff":845.3941660865,"area":33},{"Unnamed: 0":33,"runoff":864.9260745456,"area":34},{"Unnamed: 0":34,"runoff":884.3140931435,"area":35},{"Unnamed: 0":35,"runoff":903.5644989019,"area":36},{"Unnamed: 0":36,"runoff":922.6832627065,"area":37},{"Unnamed: 0":37,"runoff":941.676039339,"area":38},{"Unnamed: 0":38,"runoff":960.5480423251,"area":39},{"Unnamed: 0":39,"runoff":979.3042130804,"area":40},{"Unnamed: 0":40,"runoff":997.9492232591,"area":41},{"Unnamed: 0":41,"runoff":1016.4875039836,"area":42},{"Unnamed: 0":42,"runoff":1034.9231964895,"area":43},{"Unnamed: 0":43,"runoff":1053.2602216441,"area":44},{"Unnamed: 0":44,"runoff":1071.5022932745,"area":45},{"Unnamed: 0":45,"runoff":1089.652929493,"area":46},{"Unnamed: 0":46,"runoff":1107.7154650866,"area":47},{"Unnamed: 0":47,"runoff":1125.6930635958,"area":48},{"Unnamed: 0":48,"runoff":1143.5887288777,"area":49},{"Unnamed: 0":49,"runoff":1161.4053162712,"area":50},{"Unnamed: 0":50,"runoff":1179.1455441717,"area":51},{"Unnamed: 0":51,"runoff":1196.8120058161,"area":52},{"Unnamed: 0":52,"runoff":1214.4071385187,"area":53},{"Unnamed: 0":53,"runoff":1231.9332793013,"area":54},{"Unnamed: 0":54,"runoff":1249.3926649623,"area":55},{"Unnamed: 0":55,"runoff":1266.7874366393,"area":56},{"Unnamed: 0":56,"runoff":1284.1196579707,"area":57},{"Unnamed: 0":57,"runoff":1301.3912497859,"area":58},{"Unnamed: 0":58,"runoff":1318.6040431901,"area":59},{"Unnamed: 0":59,"runoff":1335.7598374599,"area":60},{"Unnamed: 0":60,"runoff":1352.8603805121,"area":61},{"Unnamed: 0":61,"runoff":1369.9072397118,"area":62},{"Unnamed: 0":62,"runoff":1386.9019766526,"area":63},{"Unnamed: 0":63,"runoff":1403.846150715,"area":64},{"Unnamed: 0":64,"runoff":1420.7411389733,"area":65},{"Unnamed: 0":65,"runoff":1437.588312861,"area":66},{"Unnamed: 0":66,"runoff":1454.3890677734,"area":67},{"Unnamed: 0":67,"runoff":1471.1445821684,"area":68},{"Unnamed: 0":68,"runoff":1487.8561285133,"area":69},{"Unnamed: 0":69,"runoff":1504.5248855905,"area":70},{"Unnamed: 0":70,"runoff":1521.1519243789,"area":71},{"Unnamed: 0":71,"runoff":1537.7384218465,"area":72},{"Unnamed: 0":72,"runoff":1554.2853158674,"area":73},{"Unnamed: 0":73,"runoff":1570.7937119384,"area":74},{"Unnamed: 0":74,"runoff":1587.2644886401,"area":75},{"Unnamed: 0":75,"runoff":1603.6986557349,"area":76},{"Unnamed: 0":76,"runoff":1620.0970464221,"area":77},{"Unnamed: 0":77,"runoff":1636.4605859844,"area":78},{"Unnamed: 0":78,"runoff":1652.7900553254,"area":79},{"Unnamed: 0":79,"runoff":1669.0863179547,"area":80},{"Unnamed: 0":80,"runoff":1685.3500897622,"area":81},{"Unnamed: 0":81,"runoff":1701.5821946323,"area":82},{"Unnamed: 0":82,"runoff":1717.7832751554,"area":83},{"Unnamed: 0":83,"runoff":1733.9541277867,"area":84},{"Unnamed: 0":84,"runoff":1750.09532762,"area":85},{"Unnamed: 0":85,"runoff":1766.2076353276,"area":86},{"Unnamed: 0":86,"runoff":1782.2915944911,"area":87},{"Unnamed: 0":87,"runoff":1798.3478755019,"area":88},{"Unnamed: 0":88,"runoff":1814.377058716,"area":89},{"Unnamed: 0":89,"runoff":1830.3796788453,"area":90},{"Unnamed: 0":90,"runoff":1846.3563714272,"area":91},{"Unnamed: 0":91,"runoff":1862.3075818122,"area":92},{"Unnamed: 0":92,"runoff":1878.2339118817,"area":93},{"Unnamed: 0":93,"runoff":1894.135833394,"area":94},{"Unnamed: 0":94,"runoff":1910.0137984413,"area":95},{"Unnamed: 0":95,"runoff":1925.8683668025,"area":96},{"Unnamed: 0":96,"runoff":1941.6999237495,"area":97},{"Unnamed: 0":97,"runoff":1957.5089330463,"area":98},{"Unnamed: 0":98,"runoff":1973.2958718584,"area":99}] \ No newline at end of file +[{"area":1.0,"runoff":832.609},{"area":2.0,"runoff":1563.6},{"area":3.0,"runoff":2218.85},{"area":4.0,"runoff":2813.91},{"area":5.0,"runoff":3360.36},{"area":6.0,"runoff":3867.14},{"area":7.0,"runoff":4341.18},{"area":8.0,"runoff":4787.94},{"area":9.0,"runoff":5211.77},{"area":10.0,"runoff":5616.13},{"area":11.0,"runoff":6003.84},{"area":12.0,"runoff":6377.21},{"area":13.0,"runoff":6738.14},{"area":14.0,"runoff":7088.19},{"area":15.0,"runoff":7428.7},{"area":16.0,"runoff":7760.78},{"area":17.0,"runoff":8085.36},{"area":18.0,"runoff":8403.27},{"area":19.0,"runoff":8715.18},{"area":20.0,"runoff":9021.71},{"area":21.0,"runoff":9323.38},{"area":22.0,"runoff":9620.63},{"area":23.0,"runoff":9913.86},{"area":24.0,"runoff":10203.4},{"area":25.0,"runoff":10489.6},{"area":26.0,"runoff":10772.8},{"area":27.0,"runoff":11053.1},{"area":28.0,"runoff":11330.7},{"area":29.0,"runoff":11606.0},{"area":30.0,"runoff":11879.0},{"area":31.0,"runoff":12149.8},{"area":32.0,"runoff":12418.8},{"area":33.0,"runoff":12685.9},{"area":34.0,"runoff":12951.3},{"area":35.0,"runoff":13215.1},{"area":36.0,"runoff":13477.4},{"area":37.0,"runoff":13738.3},{"area":38.0,"runoff":13997.9},{"area":39.0,"runoff":14256.2},{"area":40.0,"runoff":14513.3},{"area":41.0,"runoff":14769.3},{"area":42.0,"runoff":15024.2},{"area":43.0,"runoff":15278.0},{"area":44.0,"runoff":15531.0},{"area":45.0,"runoff":15783.0},{"area":46.0,"runoff":16034.1},{"area":47.0,"runoff":16284.4},{"area":48.0,"runoff":16533.8},{"area":49.0,"runoff":16782.5},{"area":50.0,"runoff":17030.5},{"area":51.0,"runoff":17277.8},{"area":52.0,"runoff":17524.4},{"area":53.0,"runoff":17770.3},{"area":54.0,"runoff":18015.6},{"area":55.0,"runoff":18260.3},{"area":56.0,"runoff":18504.4},{"area":57.0,"runoff":18748.0},{"area":58.0,"runoff":18991.0},{"area":59.0,"runoff":19233.5},{"area":60.0,"runoff":19475.4},{"area":61.0,"runoff":19716.9},{"area":62.0,"runoff":19957.9},{"area":63.0,"runoff":20198.5},{"area":64.0,"runoff":20438.6},{"area":65.0,"runoff":20678.2},{"area":66.0,"runoff":20917.4},{"area":67.0,"runoff":21156.2},{"area":68.0,"runoff":21394.7},{"area":69.0,"runoff":21632.7},{"area":70.0,"runoff":21870.3},{"area":71.0,"runoff":22107.6},{"area":72.0,"runoff":22344.5},{"area":73.0,"runoff":22581.0},{"area":74.0,"runoff":22817.2},{"area":75.0,"runoff":23053.1},{"area":76.0,"runoff":23288.6},{"area":77.0,"runoff":23523.8},{"area":78.0,"runoff":23758.7},{"area":79.0,"runoff":23993.3},{"area":80.0,"runoff":24227.6},{"area":81.0,"runoff":24461.5},{"area":82.0,"runoff":24695.2},{"area":83.0,"runoff":24928.6},{"area":84.0,"runoff":25161.7},{"area":85.0,"runoff":25394.5},{"area":86.0,"runoff":25627.1},{"area":87.0,"runoff":25859.4},{"area":88.0,"runoff":26091.4},{"area":89.0,"runoff":26323.2},{"area":90.0,"runoff":26554.7},{"area":91.0,"runoff":26785.9},{"area":92.0,"runoff":27016.9},{"area":93.0,"runoff":27247.7},{"area":94.0,"runoff":27478.2},{"area":95.0,"runoff":27708.5},{"area":96.0,"runoff":27938.5},{"area":97.0,"runoff":28168.3},{"area":98.0,"runoff":28397.9},{"area":99.0,"runoff":28627.3},{"area":100.0,"runoff":28856.4}] \ No newline at end of file diff --git a/cs_app/data/df_slope.json b/cs_app/data/df_slope.json index b58e9b1..8383798 100644 --- a/cs_app/data/df_slope.json +++ b/cs_app/data/df_slope.json @@ -1 +1 @@ -[{"runoff":1376.3921185939,"slope":1},{"runoff":1454.4855661212,"slope":2},{"runoff":1507.3032367051,"slope":3},{"runoff":1548.4483124306,"slope":4},{"runoff":1582.5912383385,"slope":5},{"runoff":1611.9777793015,"slope":6},{"runoff":1637.8850748541,"slope":7},{"runoff":1661.118190416,"slope":8},{"runoff":1682.2214378451,"slope":9},{"runoff":1701.5821946323,"slope":10},{"runoff":1719.4871057191,"slope":11},{"runoff":1736.1549269153,"slope":12},{"runoff":1751.7568421027,"slope":13},{"runoff":1766.429443334,"slope":14},{"runoff":1780.2839420057,"slope":15},{"runoff":1793.4118500663,"slope":16},{"runoff":1805.8897607359,"slope":17},{"runoff":1817.7823888939,"slope":18},{"runoff":1829.1448671277,"slope":19},{"runoff":1840.0246716392,"slope":20},{"runoff":1850.4630030675,"slope":21},{"runoff":1860.4958452753,"slope":22},{"runoff":1870.1548272527,"slope":23},{"runoff":1879.4679109954,"slope":24},{"runoff":1888.4599459773,"slope":25},{"runoff":1897.153120366,"slope":26},{"runoff":1905.5673471112,"slope":27},{"runoff":1913.7206086995,"slope":28},{"runoff":1921.6291026431,"slope":29},{"runoff":1929.3075173301,"slope":30},{"runoff":1936.769212193,"slope":31},{"runoff":1944.0264668056,"slope":32},{"runoff":1951.0905301399,"slope":33},{"runoff":1957.9715978777,"slope":34},{"runoff":1964.6791141958,"slope":35},{"runoff":1971.2219546847,"slope":36},{"runoff":1977.6080657383,"slope":37},{"runoff":1983.8449358402,"slope":38},{"runoff":1989.9396363014,"slope":39},{"runoff":1995.8984465182,"slope":40},{"runoff":2001.7275868463,"slope":41},{"runoff":2007.4325540005,"slope":42},{"runoff":2013.0186288089,"slope":43},{"runoff":2018.4908091217,"slope":44},{"runoff":2023.8535849113,"slope":45},{"runoff":2029.1114729272,"slope":46},{"runoff":2034.2683555301,"slope":47},{"runoff":2039.3282945961,"slope":48},{"runoff":2044.2946933008,"slope":49},{"runoff":2049.1711976746,"slope":50},{"runoff":2053.9608284951,"slope":51},{"runoff":2058.6668434651,"slope":52},{"runoff":2063.2919768447,"slope":53},{"runoff":2067.8390919642,"slope":54},{"runoff":2072.3107554981,"slope":55},{"runoff":2076.7093922358,"slope":56},{"runoff":2081.0375275496,"slope":57},{"runoff":2085.2971620493,"slope":58},{"runoff":2089.4906901179,"slope":59},{"runoff":2093.6199213137,"slope":60},{"runoff":2097.6869169208,"slope":61},{"runoff":2101.6935716037,"slope":62},{"runoff":2105.6414850271,"slope":63},{"runoff":2109.5325899771,"slope":64},{"runoff":2113.3682979042,"slope":65},{"runoff":2117.1502130202,"slope":66},{"runoff":2120.8799787577,"slope":67},{"runoff":2124.5587664728,"slope":68},{"runoff":2128.1881047733,"slope":69},{"runoff":2131.7693427558,"slope":70},{"runoff":2135.303549945,"slope":71},{"runoff":2138.7921250833,"slope":72},{"runoff":2142.2361923883,"slope":73},{"runoff":2145.6367370609,"slope":74},{"runoff":2148.995011794,"slope":75},{"runoff":2152.3120165886,"slope":76},{"runoff":2155.5886118334,"slope":77},{"runoff":2158.8259082811,"slope":78},{"runoff":2162.0248734406,"slope":79},{"runoff":2165.1862050193,"slope":80},{"runoff":2168.310885236,"slope":81},{"runoff":2171.3998732569,"slope":82},{"runoff":2174.453744918,"slope":83},{"runoff":2177.4733432165,"slope":84},{"runoff":2180.4595768707,"slope":85},{"runoff":2183.4130296602,"slope":86},{"runoff":2186.3343548223,"slope":87},{"runoff":2189.224341911,"slope":88},{"runoff":2192.0837379169,"slope":89},{"runoff":2194.9129652168,"slope":90},{"runoff":2197.7127058776,"slope":91},{"runoff":2200.4836903459,"slope":92},{"runoff":2203.2264391695,"slope":93},{"runoff":2205.9413968935,"slope":94},{"runoff":2208.6291872876,"slope":95},{"runoff":2211.2904708718,"slope":96},{"runoff":2213.9256315837,"slope":97},{"runoff":2216.53511094,"slope":98},{"runoff":2219.1194669211,"slope":99}] \ No newline at end of file +[{"slope":1.0,"runoff":2434.56},{"slope":2.0,"runoff":2715.56},{"slope":3.0,"runoff":2883.67},{"slope":4.0,"runoff":3002.17},{"slope":5.0,"runoff":3092.73},{"slope":6.0,"runoff":3165.4},{"slope":7.0,"runoff":3225.71},{"slope":8.0,"runoff":3276.97},{"slope":9.0,"runoff":3321.36},{"slope":10.0,"runoff":3360.36},{"slope":11.0,"runoff":3395.04},{"slope":12.0,"runoff":3426.17},{"slope":13.0,"runoff":3454.35},{"slope":14.0,"runoff":3480.04},{"slope":15.0,"runoff":3503.61},{"slope":16.0,"runoff":3525.33},{"slope":17.0,"runoff":3545.46},{"slope":18.0,"runoff":3564.18},{"slope":19.0,"runoff":3581.66},{"slope":20.0,"runoff":3598.04},{"slope":21.0,"runoff":3613.43},{"slope":22.0,"runoff":3627.93},{"slope":23.0,"runoff":3641.64},{"slope":24.0,"runoff":3654.61},{"slope":25.0,"runoff":3666.92},{"slope":26.0,"runoff":3678.62},{"slope":27.0,"runoff":3689.77},{"slope":28.0,"runoff":3700.41},{"slope":29.0,"runoff":3710.57},{"slope":30.0,"runoff":3720.3},{"slope":31.0,"runoff":3729.62},{"slope":32.0,"runoff":3738.57},{"slope":33.0,"runoff":3747.16},{"slope":34.0,"runoff":3755.43},{"slope":35.0,"runoff":3763.39},{"slope":36.0,"runoff":3771.06},{"slope":37.0,"runoff":3778.47},{"slope":38.0,"runoff":3785.62},{"slope":39.0,"runoff":3792.53},{"slope":40.0,"runoff":3799.21},{"slope":41.0,"runoff":3805.68},{"slope":42.0,"runoff":3811.95},{"slope":43.0,"runoff":3818.03},{"slope":44.0,"runoff":3823.93},{"slope":45.0,"runoff":3829.66},{"slope":46.0,"runoff":3835.22},{"slope":47.0,"runoff":3840.63},{"slope":48.0,"runoff":3845.89},{"slope":49.0,"runoff":3851.01},{"slope":50.0,"runoff":3855.99},{"slope":51.0,"runoff":3860.85},{"slope":52.0,"runoff":3865.58},{"slope":53.0,"runoff":3870.19},{"slope":54.0,"runoff":3874.69},{"slope":55.0,"runoff":3879.09},{"slope":56.0,"runoff":3883.38},{"slope":57.0,"runoff":3887.57},{"slope":58.0,"runoff":3891.66},{"slope":59.0,"runoff":3895.67},{"slope":60.0,"runoff":3899.58},{"slope":61.0,"runoff":3903.41},{"slope":62.0,"runoff":3907.16},{"slope":63.0,"runoff":3910.82},{"slope":64.0,"runoff":3914.42},{"slope":65.0,"runoff":3917.94},{"slope":66.0,"runoff":3921.38},{"slope":67.0,"runoff":3924.76},{"slope":68.0,"runoff":3928.08},{"slope":69.0,"runoff":3931.33},{"slope":70.0,"runoff":3934.51},{"slope":71.0,"runoff":3937.64},{"slope":72.0,"runoff":3940.71},{"slope":73.0,"runoff":3943.73},{"slope":74.0,"runoff":3946.69},{"slope":75.0,"runoff":3949.59},{"slope":76.0,"runoff":3952.45},{"slope":77.0,"runoff":3955.26},{"slope":78.0,"runoff":3958.02},{"slope":79.0,"runoff":3960.73},{"slope":80.0,"runoff":3963.4},{"slope":81.0,"runoff":3966.02},{"slope":82.0,"runoff":3968.6},{"slope":83.0,"runoff":3971.14},{"slope":84.0,"runoff":3973.64},{"slope":85.0,"runoff":3976.1},{"slope":86.0,"runoff":3978.52},{"slope":87.0,"runoff":3980.9},{"slope":88.0,"runoff":3983.25},{"slope":89.0,"runoff":3985.56},{"slope":90.0,"runoff":3987.84},{"slope":91.0,"runoff":3990.08},{"slope":92.0,"runoff":3992.29},{"slope":93.0,"runoff":3994.47},{"slope":94.0,"runoff":3996.62},{"slope":95.0,"runoff":3998.74},{"slope":96.0,"runoff":4000.82},{"slope":97.0,"runoff":4002.88},{"slope":98.0,"runoff":4004.92},{"slope":99.0,"runoff":4006.92},{"slope":100.0,"runoff":4008.9}] \ No newline at end of file diff --git a/cs_app/data/df_width.json b/cs_app/data/df_width.json index d8d332e..1ffec7e 100644 --- a/cs_app/data/df_width.json +++ b/cs_app/data/df_width.json @@ -1 +1 @@ -[{"runoff":38.9144818959,"width":1},{"runoff":75.7976436879,"width":2},{"runoff":110.7964074731,"width":3},{"runoff":144.0442920819,"width":4},{"runoff":175.6628794515,"width":5},{"runoff":205.7630949176,"width":6},{"runoff":234.4463281658,"width":7},{"runoff":261.8054173214,"width":8},{"runoff":287.9255151162,"width":9},{"runoff":312.8848531577,"width":10},{"runoff":336.7554178948,"width":11},{"runoff":359.6035498592,"width":12},{"runoff":381.4904760625,"width":13},{"runoff":402.4727840164,"width":14},{"runoff":422.6028446429,"width":15},{"runoff":441.929190335,"width":16},{"runoff":460.4968535705,"width":17},{"runoff":478.3476707571,"width":18},{"runoff":495.520555366,"width":19},{"runoff":512.0517438841,"width":20},{"runoff":527.9750176592,"width":21},{"runoff":543.3219033281,"width":22},{"runoff":558.1218541773,"width":23},{"runoff":572.4024145025,"width":24},{"runoff":586.189368779,"width":25},{"runoff":599.5068772425,"width":26},{"runoff":612.3775992893,"width":27},{"runoff":624.8228059439,"width":28},{"runoff":636.8624824795,"width":29},{"runoff":648.5154221636,"width":30},{"runoff":659.7993121378,"width":31},{"runoff":670.7308119717,"width":32},{"runoff":681.3256257348,"width":33},{"runoff":691.5985681669,"width":34},{"runoff":701.5636254989,"width":35},{"runoff":711.2340114146,"width":36},{"runoff":720.6222185983,"width":37},{"runoff":729.7400662648,"width":38},{"runoff":738.5987440275,"width":39},{"runoff":747.208852429,"width":40},{"runoff":755.5804404216,"width":41},{"runoff":763.7230400611,"width":42},{"runoff":771.6456986495,"width":43},{"runoff":779.3570085411,"width":44},{"runoff":786.865134806,"width":45},{"runoff":794.1778409267,"width":46},{"runoff":801.3025126869,"width":47},{"runoff":808.2461803993,"width":48},{"runoff":815.0155396023,"width":49},{"runoff":821.6169703475,"width":50},{"runoff":828.0565551879,"width":51},{"runoff":834.3400959652,"width":52},{"runoff":840.4731294914,"width":53},{"runoff":846.4609422152,"width":54},{"runoff":852.3085838425,"width":55},{"runoff":858.020880227,"width":56},{"runoff":863.6024454337,"width":57},{"runoff":869.0576929531,"width":58},{"runoff":874.3908462591,"width":59},{"runoff":879.6059487164,"width":60},{"runoff":884.7068728829,"width":61},{"runoff":889.6973292495,"width":62},{"runoff":894.5808744558,"width":63},{"runoff":899.3609190186,"width":64},{"runoff":904.0407346048,"width":65},{"runoff":908.6234608812,"width":66},{"runoff":913.1121119673,"width":67},{"runoff":917.5095825192,"width":68},{"runoff":921.8186534682,"width":69},{"runoff":926.0419974356,"width":70},{"runoff":930.1821838463,"width":71},{"runoff":934.2416837586,"width":72},{"runoff":938.2228744295,"width":73},{"runoff":942.1280436319,"width":74},{"runoff":945.9593937385,"width":75},{"runoff":949.7190455875,"width":76},{"runoff":953.4090421436,"width":77},{"runoff":957.0313519666,"width":78},{"runoff":960.5878725012,"width":79},{"runoff":964.0804332112,"width":80},{"runoff":967.5107984136,"width":81},{"runoff":970.8806701751,"width":82},{"runoff":974.1916910494,"width":83},{"runoff":977.4454465406,"width":84},{"runoff":980.6434675074,"width":85},{"runoff":983.7872324481,"width":86},{"runoff":986.8781696735,"width":87},{"runoff":989.9176593743,"width":88},{"runoff":992.9070355878,"width":89},{"runoff":995.8475880717,"width":90},{"runoff":998.7405640871,"width":91},{"runoff":1001.587170098,"width":92},{"runoff":1004.3885733916,"width":93},{"runoff":1007.1459036219,"width":94},{"runoff":1009.8602542824,"width":95},{"runoff":1012.532684112,"width":96},{"runoff":1015.1642184355,"width":97},{"runoff":1017.7558504438,"width":98},{"runoff":1020.3085424175,"width":99},{"runoff":1022.8232268942,"width":100},{"runoff":1025.3008077857,"width":101},{"runoff":1027.742161445,"width":102},{"runoff":1030.1481376876,"width":103},{"runoff":1032.5195607682,"width":104},{"runoff":1034.8572303178,"width":105},{"runoff":1037.1619222496,"width":106},{"runoff":1039.4343895697,"width":107},{"runoff":1041.6753630725,"width":108},{"runoff":1043.8855524179,"width":109},{"runoff":1046.065646746,"width":110},{"runoff":1048.216315395,"width":111},{"runoff":1050.3382085923,"width":112},{"runoff":1052.4319581174,"width":113},{"runoff":1054.4981779381,"width":114},{"runoff":1056.5374648201,"width":115},{"runoff":1058.5503989131,"width":116},{"runoff":1060.5375443128,"width":117},{"runoff":1062.4994496009,"width":118},{"runoff":1064.4366483634,"width":119},{"runoff":1066.349659689,"width":120},{"runoff":1068.2389886476,"width":121},{"runoff":1070.1051267506,"width":122},{"runoff":1071.9485523937,"width":123},{"runoff":1073.7697312816,"width":124},{"runoff":1075.5691168382,"width":125},{"runoff":1077.3471505996,"width":126},{"runoff":1079.1042625939,"width":127},{"runoff":1080.8408717056,"width":128},{"runoff":1082.5573860276,"width":129},{"runoff":1084.2542032,"width":130},{"runoff":1085.9317107373,"width":131},{"runoff":1087.5902863478,"width":132},{"runoff":1089.2302982638,"width":133},{"runoff":1090.8521053334,"width":134},{"runoff":1092.4560574608,"width":135},{"runoff":1094.0424959831,"width":136},{"runoff":1095.6117538281,"width":137},{"runoff":1097.1641557665,"width":138},{"runoff":1098.700018655,"width":139},{"runoff":1100.2196516704,"width":140},{"runoff":1101.7233565368,"width":141},{"runoff":1103.2114277434,"width":142},{"runoff":1104.6841527559,"width":143},{"runoff":1106.1418122201,"width":144},{"runoff":1107.5846801589,"width":145},{"runoff":1109.0130241624,"width":146},{"runoff":1110.4271055716,"width":147},{"runoff":1111.827179656,"width":148},{"runoff":1113.2134957852,"width":149},{"runoff":1114.5862975951,"width":150},{"runoff":1115.9458231483,"width":151},{"runoff":1117.292305089,"width":152},{"runoff":1118.6259707938,"width":153},{"runoff":1119.9470425168,"width":154},{"runoff":1121.2557375302,"width":155},{"runoff":1122.5522682614,"width":156},{"runoff":1123.8368424248,"width":157},{"runoff":1125.109663152,"width":158},{"runoff":1126.3709291208,"width":159},{"runoff":1127.6208347082,"width":160},{"runoff":1128.859569868,"width":161},{"runoff":1130.0873204379,"width":162},{"runoff":1131.3042683664,"width":163},{"runoff":1132.5105916987,"width":164},{"runoff":1133.7064646782,"width":165},{"runoff":1134.8920578456,"width":166},{"runoff":1136.0675381343,"width":167},{"runoff":1137.2330689638,"width":168},{"runoff":1138.3888103293,"width":169},{"runoff":1139.5349188895,"width":170},{"runoff":1140.6715480509,"width":171},{"runoff":1141.7988480505,"width":172},{"runoff":1142.9169660353,"width":173},{"runoff":1144.0260461396,"width":174},{"runoff":1145.1262295604,"width":175},{"runoff":1146.2176546304,"width":176},{"runoff":1147.3004568886,"width":177},{"runoff":1148.3747691491,"width":178},{"runoff":1149.4407215683,"width":179},{"runoff":1150.4984417095,"width":180},{"runoff":1151.5480546062,"width":181},{"runoff":1152.5896828234,"width":182},{"runoff":1153.623446518,"width":183},{"runoff":1154.6494634968,"width":184},{"runoff":1155.6678492751,"width":185},{"runoff":1156.6787171354,"width":186},{"runoff":1157.6821782013,"width":187},{"runoff":1158.6783414086,"width":188},{"runoff":1159.6673133617,"width":189},{"runoff":1160.6491988606,"width":190},{"runoff":1161.6241007191,"width":191},{"runoff":1162.5921198064,"width":192},{"runoff":1163.5533550913,"width":193},{"runoff":1164.5079036863,"width":194},{"runoff":1165.45586089,"width":195},{"runoff":1166.3973202281,"width":196},{"runoff":1167.3323734942,"width":197},{"runoff":1168.2611107881,"width":198},{"runoff":1169.1836205549,"width":199},{"runoff":1170.0999896212,"width":200},{"runoff":1171.0103032316,"width":201},{"runoff":1171.9146450841,"width":202},{"runoff":1172.8130973638,"width":203},{"runoff":1173.7057407769,"width":204},{"runoff":1174.5926545829,"width":205},{"runoff":1175.4739166263,"width":206},{"runoff":1176.3496033676,"width":207},{"runoff":1177.2197899139,"width":208},{"runoff":1178.0845500477,"width":209},{"runoff":1178.9439562563,"width":210},{"runoff":1179.7980797604,"width":211},{"runoff":1180.646990542,"width":212},{"runoff":1181.4907573739,"width":213},{"runoff":1182.3294478529,"width":214},{"runoff":1183.1631284677,"width":215},{"runoff":1183.9918643259,"width":216},{"runoff":1184.8157194061,"width":217},{"runoff":1185.6347567562,"width":218},{"runoff":1186.4490383535,"width":219},{"runoff":1187.2586251277,"width":220},{"runoff":1188.0635769817,"width":221},{"runoff":1188.8639528133,"width":222},{"runoff":1189.6598105356,"width":223},{"runoff":1190.451207097,"width":224},{"runoff":1191.2381985009,"width":225},{"runoff":1192.0208398249,"width":226},{"runoff":1192.7991852394,"width":227},{"runoff":1193.5732880261,"width":228},{"runoff":1194.3432005956,"width":229},{"runoff":1195.1089745047,"width":230},{"runoff":1195.870660474,"width":231},{"runoff":1196.6283084038,"width":232},{"runoff":1197.3819673909,"width":233},{"runoff":1198.1316857444,"width":234},{"runoff":1198.8775110012,"width":235},{"runoff":1199.6194899414,"width":236},{"runoff":1200.3576686031,"width":237},{"runoff":1201.0920922977,"width":238},{"runoff":1201.8228056241,"width":239},{"runoff":1202.5498524847,"width":240},{"runoff":1203.2732761008,"width":241},{"runoff":1203.9931190344,"width":242},{"runoff":1204.7094232476,"width":243},{"runoff":1205.4222298061,"width":244},{"runoff":1206.1315790943,"width":245},{"runoff":1206.8375110657,"width":246},{"runoff":1207.5400650607,"width":247},{"runoff":1208.2392798187,"width":248},{"runoff":1208.9351934893,"width":249},{"runoff":1209.6278436428,"width":250},{"runoff":1210.3172672815,"width":251},{"runoff":1211.0035008497,"width":252},{"runoff":1211.6865802446,"width":253},{"runoff":1212.3665408258,"width":254},{"runoff":1213.0434174256,"width":255},{"runoff":1213.7172443581,"width":256},{"runoff":1214.3880554295,"width":257},{"runoff":1215.0558839463,"width":258},{"runoff":1215.7207627253,"width":259},{"runoff":1216.3827241019,"width":260},{"runoff":1217.0417999391,"width":261},{"runoff":1217.6980216359,"width":262},{"runoff":1218.351420136,"width":263},{"runoff":1219.0020259355,"width":264},{"runoff":1219.6498690918,"width":265},{"runoff":1220.2949792313,"width":266},{"runoff":1220.937385558,"width":267},{"runoff":1221.5771168617,"width":268},{"runoff":1222.214201528,"width":269},{"runoff":1222.8486675509,"width":270},{"runoff":1223.480542562,"width":271},{"runoff":1224.1098537859,"width":272},{"runoff":1224.7366276767,"width":273},{"runoff":1225.3608906305,"width":274},{"runoff":1225.9826686959,"width":275},{"runoff":1226.6019875591,"width":276},{"runoff":1227.2188725493,"width":277},{"runoff":1227.833348645,"width":278},{"runoff":1228.4454404803,"width":279},{"runoff":1229.05517235,"width":280},{"runoff":1229.6625682161,"width":281},{"runoff":1230.2676517128,"width":282},{"runoff":1230.8704461525,"width":283},{"runoff":1231.4709745309,"width":284},{"runoff":1232.0692595322,"width":285},{"runoff":1232.6653235348,"width":286},{"runoff":1233.2591886157,"width":287},{"runoff":1233.8508765563,"width":288},{"runoff":1234.4404088466,"width":289},{"runoff":1235.0278066907,"width":290},{"runoff":1235.6130910111,"width":291},{"runoff":1236.1962824538,"width":292},{"runoff":1236.7774013929,"width":293},{"runoff":1237.3564679352,"width":294},{"runoff":1237.9335019254,"width":295},{"runoff":1238.5085229507,"width":296},{"runoff":1239.0815503469,"width":297},{"runoff":1239.6526032051,"width":298},{"runoff":1240.2217003839,"width":299},{"runoff":1240.7888605566,"width":300},{"runoff":1241.3541019829,"width":301},{"runoff":1241.9174424569,"width":302},{"runoff":1242.4788998452,"width":303},{"runoff":1243.0384917858,"width":304},{"runoff":1243.5962356915,"width":305},{"runoff":1244.1521487539,"width":306},{"runoff":1244.7062479465,"width":307},{"runoff":1245.2585500281,"width":308},{"runoff":1245.8090715465,"width":309},{"runoff":1246.3578288413,"width":310},{"runoff":1246.9048380474,"width":311},{"runoff":1247.4501150983,"width":312},{"runoff":1247.9936757288,"width":313},{"runoff":1248.5355354786,"width":314},{"runoff":1249.0757096947,"width":315},{"runoff":1249.6142135352,"width":316},{"runoff":1250.1510619713,"width":317},{"runoff":1250.6862697909,"width":318},{"runoff":1251.2198516011,"width":319},{"runoff":1251.7518218314,"width":320},{"runoff":1252.2821947361,"width":321},{"runoff":1252.8109843973,"width":322},{"runoff":1253.3382047282,"width":323},{"runoff":1253.8638694755,"width":324},{"runoff":1254.3879922232,"width":325},{"runoff":1254.9105863963,"width":326},{"runoff":1255.4316652662,"width":327},{"runoff":1255.9512419603,"width":328},{"runoff":1256.4693294976,"width":329},{"runoff":1256.9859406602,"width":330},{"runoff":1257.5010876933,"width":331},{"runoff":1258.0147830668,"width":332},{"runoff":1258.5270391104,"width":333},{"runoff":1259.0378680091,"width":334},{"runoff":1259.547281805,"width":335},{"runoff":1260.0552923996,"width":336},{"runoff":1260.5619115555,"width":337},{"runoff":1261.0671508986,"width":338},{"runoff":1261.5710219204,"width":339},{"runoff":1262.0735359792,"width":340},{"runoff":1262.5747043027,"width":341},{"runoff":1263.0745379894,"width":342},{"runoff":1263.573048011,"width":343},{"runoff":1264.0702452136,"width":344},{"runoff":1264.56614032,"width":345},{"runoff":1265.0607439312,"width":346},{"runoff":1265.5540665283,"width":347},{"runoff":1266.0461184744,"width":348},{"runoff":1266.536910016,"width":349},{"runoff":1267.0264512853,"width":350},{"runoff":1267.5147523014,"width":351},{"runoff":1268.0018229727,"width":352},{"runoff":1268.4876730985,"width":353},{"runoff":1268.9723123714,"width":354},{"runoff":1269.45575038,"width":355},{"runoff":1269.9379966122,"width":356},{"runoff":1270.4190604617,"width":357},{"runoff":1270.898951244,"width":358},{"runoff":1271.3776782536,"width":359},{"runoff":1271.8552502132,"width":360},{"runoff":1272.3316759203,"width":361},{"runoff":1272.8069642331,"width":362},{"runoff":1273.2811239128,"width":363},{"runoff":1273.7541636259,"width":364},{"runoff":1274.2260919449,"width":365},{"runoff":1274.6969173498,"width":366},{"runoff":1275.1666482292,"width":367},{"runoff":1275.6352928816,"width":368},{"runoff":1276.1028595169,"width":369},{"runoff":1276.5693562569,"width":370},{"runoff":1277.0347911373,"width":371},{"runoff":1277.4991721082,"width":372},{"runoff":1277.9625070357,"width":373},{"runoff":1278.4248037028,"width":374},{"runoff":1278.8860698106,"width":375},{"runoff":1279.3463129796,"width":376},{"runoff":1279.8055407506,"width":377},{"runoff":1280.2637605858,"width":378},{"runoff":1280.7209798703,"width":379},{"runoff":1281.1772059127,"width":380},{"runoff":1281.632445947,"width":381},{"runoff":1282.0867071334,"width":382},{"runoff":1282.5399965599,"width":383},{"runoff":1282.992321244,"width":384},{"runoff":1283.4436881348,"width":385},{"runoff":1283.8941041168,"width":386},{"runoff":1284.3435760159,"width":387},{"runoff":1284.7921106189,"width":388},{"runoff":1285.2397147134,"width":389},{"runoff":1285.68639448,"width":390},{"runoff":1286.1321563169,"width":391},{"runoff":1286.5770066743,"width":392},{"runoff":1287.0209519373,"width":393},{"runoff":1287.4639984268,"width":394},{"runoff":1287.9061524,"width":395},{"runoff":1288.3474200515,"width":396},{"runoff":1288.7878075141,"width":397},{"runoff":1289.2273208593,"width":398},{"runoff":1289.6659660983,"width":399},{"runoff":1290.1037491827,"width":400},{"runoff":1290.5406760055,"width":401},{"runoff":1290.9767524013,"width":402},{"runoff":1291.4119841477,"width":403},{"runoff":1291.8463769653,"width":404},{"runoff":1292.2799365194,"width":405},{"runoff":1292.7126684198,"width":406},{"runoff":1293.1445782221,"width":407},{"runoff":1293.5756714283,"width":408},{"runoff":1294.0059534877,"width":409},{"runoff":1294.4354297974,"width":410},{"runoff":1294.8641057034,"width":411},{"runoff":1295.2919865017,"width":412},{"runoff":1295.7190774389,"width":413},{"runoff":1296.1453837137,"width":414},{"runoff":1296.5709104791,"width":415},{"runoff":1296.9956628443,"width":416},{"runoff":1297.4196458807,"width":417},{"runoff":1297.8428646352,"width":418},{"runoff":1298.2653242035,"width":419},{"runoff":1298.6870292077,"width":420},{"runoff":1299.1079842335,"width":421},{"runoff":1299.5281940833,"width":422},{"runoff":1299.9476635143,"width":423},{"runoff":1300.3663972391,"width":424},{"runoff":1300.7843999264,"width":425},{"runoff":1301.2016762011,"width":426},{"runoff":1301.6182306451,"width":427},{"runoff":1302.0340677978,"width":428},{"runoff":1302.4491921565,"width":429},{"runoff":1302.8636081772,"width":430},{"runoff":1303.2773202745,"width":431},{"runoff":1303.6903328229,"width":432},{"runoff":1304.1026501566,"width":433},{"runoff":1304.5142765705,"width":434},{"runoff":1304.9252163203,"width":435},{"runoff":1305.3354736232,"width":436},{"runoff":1305.7450526586,"width":437},{"runoff":1306.1539575683,"width":438},{"runoff":1306.5621924569,"width":439},{"runoff":1306.9697613931,"width":440},{"runoff":1307.3766684095,"width":441},{"runoff":1307.7829175036,"width":442},{"runoff":1308.1885126387,"width":443},{"runoff":1308.5934577446,"width":444},{"runoff":1308.997756719,"width":445},{"runoff":1309.4014134294,"width":446},{"runoff":1309.8044317162,"width":447},{"runoff":1310.2068153996,"width":448},{"runoff":1310.6085683027,"width":449},{"runoff":1311.0096942784,"width":450},{"runoff":1311.4101965371,"width":451},{"runoff":1311.8100786185,"width":452},{"runoff":1312.2093441368,"width":453},{"runoff":1312.6079966747,"width":454},{"runoff":1313.0060397835,"width":455},{"runoff":1313.4034769837,"width":456},{"runoff":1313.800311765,"width":457},{"runoff":1314.1965475869,"width":458},{"runoff":1314.592187879,"width":459},{"runoff":1314.9872360412,"width":460},{"runoff":1315.3816954445,"width":461},{"runoff":1315.7755694306,"width":462},{"runoff":1316.1688613129,"width":463},{"runoff":1316.5615743765,"width":464},{"runoff":1316.9537118787,"width":465},{"runoff":1317.3452770493,"width":466},{"runoff":1317.7362730909,"width":467},{"runoff":1318.1267031793,"width":468},{"runoff":1318.5165704638,"width":469},{"runoff":1318.9058780678,"width":470},{"runoff":1319.2946290892,"width":471},{"runoff":1319.6828266004,"width":472},{"runoff":1320.0704736497,"width":473},{"runoff":1320.457573261,"width":474},{"runoff":1320.8441284353,"width":475},{"runoff":1321.2301421517,"width":476},{"runoff":1321.6156173688,"width":477},{"runoff":1322.0005570283,"width":478},{"runoff":1322.3849640611,"width":479},{"runoff":1322.7688414092,"width":480},{"runoff":1323.1521920743,"width":481},{"runoff":1323.5350184111,"width":482},{"runoff":1323.9173230694,"width":483},{"runoff":1324.2991088207,"width":484},{"runoff":1324.6803784137,"width":485},{"runoff":1325.0611345745,"width":486},{"runoff":1325.4413800072,"width":487},{"runoff":1325.8211173939,"width":488},{"runoff":1326.2003493948,"width":489},{"runoff":1326.5790786488,"width":490},{"runoff":1326.9573077735,"width":491},{"runoff":1327.3350393656,"width":492},{"runoff":1327.7122760007,"width":493},{"runoff":1328.0890202343,"width":494},{"runoff":1328.4652746013,"width":495},{"runoff":1328.8410416166,"width":496},{"runoff":1329.2163237755,"width":497},{"runoff":1329.5911235536,"width":498},{"runoff":1329.9654434073,"width":499},{"runoff":1330.3392857738,"width":500},{"runoff":1330.712653072,"width":501},{"runoff":1331.085547702,"width":502},{"runoff":1331.4579720461,"width":503},{"runoff":1331.8299284692,"width":504},{"runoff":1332.2014193187,"width":505},{"runoff":1332.5724469258,"width":506},{"runoff":1332.9430136063,"width":507},{"runoff":1333.3131216617,"width":508},{"runoff":1333.6827733816,"width":509},{"runoff":1334.0519710484,"width":510},{"runoff":1334.4207169491,"width":511},{"runoff":1334.7890134374,"width":512},{"runoff":1335.1568626055,"width":513},{"runoff":1335.5242661709,"width":514},{"runoff":1335.8912262943,"width":515},{"runoff":1336.25774512,"width":516},{"runoff":1336.623824776,"width":517},{"runoff":1336.9894673738,"width":518},{"runoff":1337.3546750092,"width":519},{"runoff":1337.7194497617,"width":520},{"runoff":1338.0837936955,"width":521},{"runoff":1338.4477088589,"width":522},{"runoff":1338.8111972851,"width":523},{"runoff":1339.1742609919,"width":524},{"runoff":1339.5369019822,"width":525},{"runoff":1339.8991222439,"width":526},{"runoff":1340.2609237503,"width":527},{"runoff":1340.6223084603,"width":528},{"runoff":1340.9832783183,"width":529},{"runoff":1341.3438352547,"width":530},{"runoff":1341.7039811858,"width":531},{"runoff":1342.0637180146,"width":532},{"runoff":1342.4230476303,"width":533},{"runoff":1342.781971909,"width":534},{"runoff":1343.140492714,"width":535},{"runoff":1343.4986118961,"width":536},{"runoff":1343.8563312941,"width":537},{"runoff":1344.2136527355,"width":538},{"runoff":1344.5705780372,"width":539},{"runoff":1344.9271090071,"width":540},{"runoff":1345.2832474468,"width":541},{"runoff":1345.6389951562,"width":542},{"runoff":1345.9943539478,"width":543},{"runoff":1346.3493257273,"width":544},{"runoff":1346.7039119964,"width":545},{"runoff":1347.0581140698,"width":546},{"runoff":1347.4119336457,"width":547},{"runoff":1347.7653724103,"width":548},{"runoff":1348.1184320373,"width":549},{"runoff":1348.471114189,"width":550},{"runoff":1348.8234205155,"width":551},{"runoff":1349.1753526554,"width":552},{"runoff":1349.5269122356,"width":553},{"runoff":1349.8781008717,"width":554},{"runoff":1350.2289201679,"width":555},{"runoff":1350.5793717173,"width":556},{"runoff":1350.9294571016,"width":557},{"runoff":1351.2791778921,"width":558},{"runoff":1351.6285356489,"width":559},{"runoff":1351.9775319214,"width":560},{"runoff":1352.3261682489,"width":561},{"runoff":1352.6744461598,"width":562},{"runoff":1353.0223671726,"width":563},{"runoff":1353.3699327959,"width":564},{"runoff":1353.717144528,"width":565},{"runoff":1354.0640038581,"width":566},{"runoff":1354.4105122657,"width":567},{"runoff":1354.7566712215,"width":568},{"runoff":1355.1024821876,"width":569},{"runoff":1355.4479466178,"width":570},{"runoff":1355.7930659589,"width":571},{"runoff":1356.1378416517,"width":572},{"runoff":1356.482275133,"width":573},{"runoff":1356.8263678398,"width":574},{"runoff":1357.1701212192,"width":575},{"runoff":1357.513536769,"width":576},{"runoff":1357.85661593,"width":577},{"runoff":1358.1993594761,"width":578},{"runoff":1358.5417687294,"width":579},{"runoff":1358.8838450352,"width":580},{"runoff":1359.2255897298,"width":581},{"runoff":1359.5670041404,"width":582},{"runoff":1359.9080895857,"width":583},{"runoff":1360.2488473753,"width":584},{"runoff":1360.5892788105,"width":585},{"runoff":1360.9293851838,"width":586},{"runoff":1361.2691677793,"width":587},{"runoff":1361.6086278728,"width":588},{"runoff":1361.9477667318,"width":589},{"runoff":1362.2865856156,"width":590},{"runoff":1362.6250857753,"width":591},{"runoff":1362.9632684542,"width":592},{"runoff":1363.3011348878,"width":593},{"runoff":1363.6386863037,"width":594},{"runoff":1363.9759239219,"width":595},{"runoff":1364.312848955,"width":596},{"runoff":1364.6494626083,"width":597},{"runoff":1364.9857660799,"width":598},{"runoff":1365.321760561,"width":599},{"runoff":1365.6574472362,"width":600},{"runoff":1365.9928272837,"width":601},{"runoff":1366.3279018757,"width":602},{"runoff":1366.6626721794,"width":603},{"runoff":1366.9971393574,"width":604},{"runoff":1367.3313045691,"width":605},{"runoff":1367.6651689737,"width":606},{"runoff":1367.9987337349,"width":607},{"runoff":1368.332000035,"width":608},{"runoff":1368.6649691527,"width":609},{"runoff":1368.99764202,"width":610},{"runoff":1369.3300192562,"width":611},{"runoff":1369.662101949,"width":612},{"runoff":1369.9938911795,"width":613},{"runoff":1370.325388022,"width":614},{"runoff":1370.6565935439,"width":615},{"runoff":1370.9875088064,"width":616},{"runoff":1371.3181348636,"width":617},{"runoff":1371.6484727637,"width":618},{"runoff":1371.978523548,"width":619},{"runoff":1372.3082882517,"width":620},{"runoff":1372.6377679036,"width":621},{"runoff":1372.9669635264,"width":622},{"runoff":1373.2958761366,"width":623},{"runoff":1373.6245067447,"width":624},{"runoff":1373.9528563553,"width":625},{"runoff":1374.280925967,"width":626},{"runoff":1374.6087165727,"width":627},{"runoff":1374.9362291596,"width":628},{"runoff":1375.2634647095,"width":629},{"runoff":1375.5904241986,"width":630},{"runoff":1375.9171085981,"width":631},{"runoff":1376.2435188738,"width":632},{"runoff":1376.5696559871,"width":633},{"runoff":1376.8955208946,"width":634},{"runoff":1377.2211145488,"width":635},{"runoff":1377.5464378988,"width":636},{"runoff":1377.871491891,"width":637},{"runoff":1378.1962774701,"width":638},{"runoff":1378.520795582,"width":639},{"runoff":1378.8450471779,"width":640},{"runoff":1379.1690332262,"width":641},{"runoff":1379.4927547621,"width":642},{"runoff":1379.8162127016,"width":643},{"runoff":1380.1394073209,"width":644},{"runoff":1380.4623394901,"width":645},{"runoff":1380.7850100895,"width":646},{"runoff":1381.1074199939,"width":647},{"runoff":1381.4295700734,"width":648},{"runoff":1381.7514611928,"width":649},{"runoff":1382.073094212,"width":650},{"runoff":1382.3944699859,"width":651},{"runoff":1382.7155893647,"width":652},{"runoff":1383.0364531936,"width":653},{"runoff":1383.357062313,"width":654},{"runoff":1383.6774175588,"width":655},{"runoff":1383.997519762,"width":656},{"runoff":1384.3173697493,"width":657},{"runoff":1384.6369683426,"width":658},{"runoff":1384.9563163595,"width":659},{"runoff":1385.2754146132,"width":660},{"runoff":1385.5942639127,"width":661},{"runoff":1385.9128650629,"width":662},{"runoff":1386.2312188643,"width":663},{"runoff":1386.5493261139,"width":664},{"runoff":1386.8671876047,"width":665},{"runoff":1387.1848041262,"width":666},{"runoff":1387.5021764647,"width":667},{"runoff":1387.8193054032,"width":668},{"runoff":1388.1361917226,"width":669},{"runoff":1388.4528362016,"width":670},{"runoff":1388.7692396181,"width":671},{"runoff":1389.0854027508,"width":672},{"runoff":1389.4013263822,"width":673},{"runoff":1389.7170113047,"width":674},{"runoff":1390.0324583398,"width":675},{"runoff":1390.3476684367,"width":676},{"runoff":1390.6626419345,"width":677},{"runoff":1390.9773792183,"width":678},{"runoff":1391.2918810135,"width":679},{"runoff":1391.6061480414,"width":680},{"runoff":1391.9201810195,"width":681},{"runoff":1392.2339806614,"width":682},{"runoff":1392.5475476768,"width":683},{"runoff":1392.8608827715,"width":684},{"runoff":1393.1739866478,"width":685},{"runoff":1393.4868600041,"width":686},{"runoff":1393.7995035352,"width":687},{"runoff":1394.1119179321,"width":688},{"runoff":1394.4241038826,"width":689},{"runoff":1394.7360620705,"width":690},{"runoff":1395.0477931766,"width":691},{"runoff":1395.359297878,"width":692},{"runoff":1395.6705768485,"width":693},{"runoff":1395.9816307586,"width":694},{"runoff":1396.2924602758,"width":695},{"runoff":1396.6030660643,"width":696},{"runoff":1396.9134487854,"width":697},{"runoff":1397.2236090974,"width":698},{"runoff":1397.5335476561,"width":699},{"runoff":1397.8432651147,"width":700},{"runoff":1398.1527621241,"width":701},{"runoff":1398.4620393331,"width":702},{"runoff":1398.7710973892,"width":703},{"runoff":1399.0799369389,"width":704},{"runoff":1399.3885586289,"width":705},{"runoff":1399.6969631074,"width":706},{"runoff":1400.0051510274,"width":707},{"runoff":1400.3131230527,"width":708},{"runoff":1400.6208798751,"width":709},{"runoff":1400.9284223146,"width":710},{"runoff":1401.235750653,"width":711},{"runoff":1401.5428650778,"width":712},{"runoff":1401.8497661903,"width":713},{"runoff":1402.1564545888,"width":714},{"runoff":1402.4629308686,"width":715},{"runoff":1402.769195622,"width":716},{"runoff":1403.0752494384,"width":717},{"runoff":1403.3810929041,"width":718},{"runoff":1403.6867266026,"width":719},{"runoff":1403.9921511146,"width":720},{"runoff":1404.2973670179,"width":721},{"runoff":1404.6023748876,"width":722},{"runoff":1404.9071752958,"width":723},{"runoff":1405.2117688123,"width":724},{"runoff":1405.5161560039,"width":725},{"runoff":1405.8203374349,"width":726},{"runoff":1406.1243136672,"width":727},{"runoff":1406.42808526,"width":728},{"runoff":1406.7316527702,"width":729},{"runoff":1407.0350167523,"width":730},{"runoff":1407.3381777586,"width":731},{"runoff":1407.6411363394,"width":732},{"runoff":1407.9438930426,"width":733},{"runoff":1408.2464484147,"width":734},{"runoff":1408.5488030005,"width":735},{"runoff":1408.8509573431,"width":736},{"runoff":1409.152911985,"width":737},{"runoff":1409.4546674682,"width":738},{"runoff":1409.7562243348,"width":739},{"runoff":1410.0575831287,"width":740},{"runoff":1410.3587443976,"width":741},{"runoff":1410.6597086966,"width":742},{"runoff":1410.9604765989,"width":743},{"runoff":1411.2610487298,"width":744},{"runoff":1411.5614257644,"width":745},{"runoff":1411.861607536,"width":746},{"runoff":1412.1615944428,"width":747},{"runoff":1412.4613869865,"width":748},{"runoff":1412.7609856665,"width":749},{"runoff":1413.0603909799,"width":750},{"runoff":1413.3596034215,"width":751},{"runoff":1413.6586234837,"width":752},{"runoff":1413.9574516568,"width":753},{"runoff":1414.2560884287,"width":754},{"runoff":1414.5545342851,"width":755},{"runoff":1414.8527897099,"width":756},{"runoff":1415.1508551843,"width":757},{"runoff":1415.4487311878,"width":758},{"runoff":1415.7464181978,"width":759},{"runoff":1416.0439166895,"width":760},{"runoff":1416.3412271362,"width":761},{"runoff":1416.6383500095,"width":762},{"runoff":1416.9352857788,"width":763},{"runoff":1417.232034912,"width":764},{"runoff":1417.528597875,"width":765},{"runoff":1417.8249751323,"width":766},{"runoff":1418.1211671468,"width":767},{"runoff":1418.41717438,"width":768},{"runoff":1418.7129972921,"width":769},{"runoff":1419.0086363424,"width":770},{"runoff":1419.3040919894,"width":771},{"runoff":1419.5993646914,"width":772},{"runoff":1419.8944549065,"width":773},{"runoff":1420.1893630941,"width":774},{"runoff":1420.4840897158,"width":775},{"runoff":1420.7786352374,"width":776},{"runoff":1421.0730001335,"width":777},{"runoff":1421.3671848972,"width":778},{"runoff":1421.6611900759,"width":779},{"runoff":1421.9550162699,"width":780},{"runoff":1422.2486632163,"width":781},{"runoff":1422.5421312303,"width":782},{"runoff":1422.8354207352,"width":783},{"runoff":1423.1285321524,"width":784},{"runoff":1423.4214659015,"width":785},{"runoff":1423.7142224003,"width":786},{"runoff":1424.0068020648,"width":787},{"runoff":1424.2992053094,"width":788},{"runoff":1424.5914325465,"width":789},{"runoff":1424.8834841869,"width":790},{"runoff":1425.1753606398,"width":791},{"runoff":1425.4670623127,"width":792},{"runoff":1425.7585896115,"width":793},{"runoff":1426.0499429404,"width":794},{"runoff":1426.3411227023,"width":795},{"runoff":1426.6321292982,"width":796},{"runoff":1426.9229631281,"width":797},{"runoff":1427.2136245903,"width":798},{"runoff":1427.5041140817,"width":799},{"runoff":1427.7944319982,"width":800},{"runoff":1428.0845787342,"width":801},{"runoff":1428.3745546831,"width":802},{"runoff":1428.6643602375,"width":803},{"runoff":1428.9539957889,"width":804},{"runoff":1429.2434617282,"width":805},{"runoff":1429.532758446,"width":806},{"runoff":1429.8218863326,"width":807},{"runoff":1430.110845779,"width":808},{"runoff":1430.399637177,"width":809},{"runoff":1430.6882609205,"width":810},{"runoff":1430.9767174071,"width":811},{"runoff":1431.2650070414,"width":812},{"runoff":1431.5531302413,"width":813},{"runoff":1431.8410874563,"width":814},{"runoff":1432.1288792744,"width":815},{"runoff":1432.4165057269,"width":816},{"runoff":1432.7039667115,"width":817},{"runoff":1432.9912625889,"width":818},{"runoff":1433.2783937183,"width":819},{"runoff":1433.5653604575,"width":820},{"runoff":1433.852163163,"width":821},{"runoff":1434.1388021896,"width":822},{"runoff":1434.4252778911,"width":823},{"runoff":1434.7115906196,"width":824},{"runoff":1434.997740726,"width":825},{"runoff":1435.2837285601,"width":826},{"runoff":1435.5695544699,"width":827},{"runoff":1435.8552188027,"width":828},{"runoff":1436.1407219041,"width":829},{"runoff":1436.4260641189,"width":830},{"runoff":1436.7112457903,"width":831},{"runoff":1436.9962672607,"width":832},{"runoff":1437.2811288713,"width":833},{"runoff":1437.5658309622,"width":834},{"runoff":1437.8503738727,"width":835},{"runoff":1438.1347579411,"width":836},{"runoff":1438.4189835047,"width":837},{"runoff":1438.7030509003,"width":838},{"runoff":1438.9869604639,"width":839},{"runoff":1439.2707125312,"width":840},{"runoff":1439.5543074376,"width":841},{"runoff":1439.8377455183,"width":842},{"runoff":1440.121027109,"width":843},{"runoff":1440.404152546,"width":844},{"runoff":1440.6871221671,"width":845},{"runoff":1440.9699363129,"width":846},{"runoff":1441.2525953278,"width":847},{"runoff":1441.5350995641,"width":848},{"runoff":1441.8174493884,"width":849},{"runoff":1442.0996452015,"width":850},{"runoff":1442.3816875497,"width":851},{"runoff":1442.6635763576,"width":852},{"runoff":1442.945311497,"width":853},{"runoff":1443.2268932768,"width":854},{"runoff":1443.5083220051,"width":855},{"runoff":1443.7895979888,"width":856},{"runoff":1444.0707215336,"width":857},{"runoff":1444.3516929441,"width":858},{"runoff":1444.632512524,"width":859},{"runoff":1444.9131805756,"width":860},{"runoff":1445.1936974005,"width":861},{"runoff":1445.4740632991,"width":862},{"runoff":1445.7542785708,"width":863},{"runoff":1446.0343435141,"width":864},{"runoff":1446.3142584265,"width":865},{"runoff":1446.5940236045,"width":866},{"runoff":1446.8736393438,"width":867},{"runoff":1447.1531059393,"width":868},{"runoff":1447.4324236849,"width":869},{"runoff":1447.711592874,"width":870},{"runoff":1447.9906137989,"width":871},{"runoff":1448.2694867517,"width":872},{"runoff":1448.5482120234,"width":873},{"runoff":1448.8267899051,"width":874},{"runoff":1449.1052206869,"width":875},{"runoff":1449.3835046591,"width":876},{"runoff":1449.6616421118,"width":877},{"runoff":1449.939633335,"width":878},{"runoff":1450.2174786195,"width":879},{"runoff":1450.4951782566,"width":880},{"runoff":1450.7727325394,"width":881},{"runoff":1451.0501417629,"width":882},{"runoff":1451.3274062261,"width":883},{"runoff":1451.6045262341,"width":884},{"runoff":1451.881502103,"width":885},{"runoff":1452.1583341721,"width":886},{"runoff":1452.4350228492,"width":887},{"runoff":1452.7115685293,"width":888},{"runoff":1452.9879707244,"width":889},{"runoff":1453.2642296377,"width":890},{"runoff":1453.5403455358,"width":891},{"runoff":1453.8163186841,"width":892},{"runoff":1454.0921493475,"width":893},{"runoff":1454.3678377899,"width":894},{"runoff":1454.6433842741,"width":895},{"runoff":1454.9187890624,"width":896},{"runoff":1455.1940524162,"width":897},{"runoff":1455.4691745959,"width":898},{"runoff":1455.7441558613,"width":899},{"runoff":1456.0189964714,"width":900},{"runoff":1456.2936966845,"width":901},{"runoff":1456.5682567581,"width":902},{"runoff":1456.8426769489,"width":903},{"runoff":1457.1169575133,"width":904},{"runoff":1457.3910987066,"width":905},{"runoff":1457.6651007839,"width":906},{"runoff":1457.9389639997,"width":907},{"runoff":1458.2126886077,"width":908},{"runoff":1458.4862748616,"width":909},{"runoff":1458.7597230144,"width":910},{"runoff":1459.033033319,"width":911},{"runoff":1459.3062060283,"width":912},{"runoff":1459.5792413947,"width":913},{"runoff":1459.8521396713,"width":914},{"runoff":1460.1249011113,"width":915},{"runoff":1460.3975259685,"width":916},{"runoff":1460.670014498,"width":917},{"runoff":1460.9423669564,"width":918},{"runoff":1461.2145836033,"width":919},{"runoff":1461.486664702,"width":920},{"runoff":1461.7586105226,"width":921},{"runoff":1462.0304213472,"width":922},{"runoff":1462.3020974823,"width":923},{"runoff":1462.5736393103,"width":924},{"runoff":1462.8450471607,"width":925},{"runoff":1463.1163204976,"width":926},{"runoff":1463.3874595072,"width":927},{"runoff":1463.6584644214,"width":928},{"runoff":1463.9293354715,"width":929},{"runoff":1464.2000728879,"width":930},{"runoff":1464.4706769005,"width":931},{"runoff":1464.7411477383,"width":932},{"runoff":1465.0114856298,"width":933},{"runoff":1465.2816908029,"width":934},{"runoff":1465.5517634849,"width":935},{"runoff":1465.8217039022,"width":936},{"runoff":1466.0915122809,"width":937},{"runoff":1466.3611888465,"width":938},{"runoff":1466.6307338239,"width":939},{"runoff":1466.9001474373,"width":940},{"runoff":1467.1694299108,"width":941},{"runoff":1467.4385814677,"width":942},{"runoff":1467.707602331,"width":943},{"runoff":1467.9764927234,"width":944},{"runoff":1468.2452528671,"width":945},{"runoff":1468.513882984,"width":946},{"runoff":1468.7823832961,"width":947},{"runoff":1469.0507540249,"width":948},{"runoff":1469.3189953921,"width":949},{"runoff":1469.5871076193,"width":950},{"runoff":1469.8550909286,"width":951},{"runoff":1470.1229455424,"width":952},{"runoff":1470.3906716838,"width":953},{"runoff":1470.658269577,"width":954},{"runoff":1470.9257394479,"width":955},{"runoff":1471.1930815246,"width":956},{"runoff":1471.4602960388,"width":957},{"runoff":1471.727383228,"width":958},{"runoff":1471.9943433387,"width":959},{"runoff":1472.261176635,"width":960},{"runoff":1472.5278834235,"width":961},{"runoff":1472.7944641663,"width":962},{"runoff":1473.0609185095,"width":963},{"runoff":1473.3272463129,"width":964},{"runoff":1473.5934477799,"width":965},{"runoff":1473.8595231135,"width":966},{"runoff":1474.1254725159,"width":967},{"runoff":1474.3912961892,"width":968},{"runoff":1474.6569943345,"width":969},{"runoff":1474.9225671526,"width":970},{"runoff":1475.1880148439,"width":971},{"runoff":1475.4533376082,"width":972},{"runoff":1475.7185356446,"width":973},{"runoff":1475.9836091522,"width":974},{"runoff":1476.2485583292,"width":975},{"runoff":1476.5133833737,"width":976},{"runoff":1476.7780844832,"width":977},{"runoff":1477.042661855,"width":978},{"runoff":1477.3071156859,"width":979},{"runoff":1477.5714461725,"width":980},{"runoff":1477.835653511,"width":981},{"runoff":1478.0997378974,"width":982},{"runoff":1478.3636995275,"width":983},{"runoff":1478.6275385972,"width":984},{"runoff":1478.8912553019,"width":985},{"runoff":1479.1548498373,"width":986},{"runoff":1479.4183223992,"width":987},{"runoff":1479.6816731836,"width":988},{"runoff":1479.9449023869,"width":989},{"runoff":1480.208010206,"width":990},{"runoff":1480.470996839,"width":991},{"runoff":1480.7338624848,"width":992},{"runoff":1480.9966073444,"width":993},{"runoff":1481.2592316213,"width":994},{"runoff":1481.5217355228,"width":995},{"runoff":1481.7841192619,"width":996},{"runoff":1482.0463830615,"width":997},{"runoff":1482.3085271626,"width":998},{"runoff":1482.5705518503,"width":999}] \ No newline at end of file +[{"width":10.0,"runoff":1703.05},{"width":20.0,"runoff":2097.93},{"width":30.0,"runoff":2393.38},{"width":40.0,"runoff":2623.65},{"width":50.0,"runoff":2808.06},{"width":60.0,"runoff":2958.95},{"width":70.0,"runoff":3084.59},{"width":80.0,"runoff":3190.79},{"width":90.0,"runoff":3281.69},{"width":100.0,"runoff":3360.36},{"width":110.0,"runoff":3429.11},{"width":120.0,"runoff":3489.71},{"width":130.0,"runoff":3543.51},{"width":140.0,"runoff":3591.61},{"width":150.0,"runoff":3634.88},{"width":160.0,"runoff":3674.01},{"width":170.0,"runoff":3709.58},{"width":180.0,"runoff":3742.05},{"width":190.0,"runoff":3771.82},{"width":200.0,"runoff":3799.21},{"width":210.0,"runoff":3824.51},{"width":220.0,"runoff":3847.95},{"width":230.0,"runoff":3869.74},{"width":240.0,"runoff":3890.04},{"width":250.0,"runoff":3909.0},{"width":260.0,"runoff":3926.76},{"width":270.0,"runoff":3943.43},{"width":280.0,"runoff":3959.11},{"width":290.0,"runoff":3973.88},{"width":300.0,"runoff":3987.84},{"width":310.0,"runoff":4001.03},{"width":320.0,"runoff":4013.53},{"width":330.0,"runoff":4025.4},{"width":340.0,"runoff":4036.67},{"width":350.0,"runoff":4047.93},{"width":360.0,"runoff":4057.61},{"width":370.0,"runoff":4067.35},{"width":380.0,"runoff":4076.67},{"width":390.0,"runoff":4085.58},{"width":400.0,"runoff":4094.11},{"width":410.0,"runoff":4102.29},{"width":420.0,"runoff":4110.14},{"width":430.0,"runoff":4117.68},{"width":440.0,"runoff":4124.92},{"width":450.0,"runoff":4131.88},{"width":460.0,"runoff":4138.58},{"width":470.0,"runoff":4145.03},{"width":480.0,"runoff":4151.25},{"width":490.0,"runoff":4157.26},{"width":500.0,"runoff":4163.05},{"width":510.0,"runoff":4168.65},{"width":520.0,"runoff":4174.07},{"width":530.0,"runoff":4179.32},{"width":540.0,"runoff":4184.4},{"width":550.0,"runoff":4189.32},{"width":560.0,"runoff":4194.09},{"width":570.0,"runoff":4198.72},{"width":580.0,"runoff":4203.19},{"width":590.0,"runoff":4207.54},{"width":600.0,"runoff":4211.77},{"width":610.0,"runoff":4215.88},{"width":620.0,"runoff":4219.87},{"width":630.0,"runoff":4223.75},{"width":640.0,"runoff":4227.53},{"width":650.0,"runoff":4231.21},{"width":660.0,"runoff":4234.8},{"width":670.0,"runoff":4238.29},{"width":680.0,"runoff":4241.69},{"width":690.0,"runoff":4245.0},{"width":700.0,"runoff":4248.24},{"width":710.0,"runoff":4251.39},{"width":720.0,"runoff":4254.47},{"width":730.0,"runoff":4257.48},{"width":740.0,"runoff":4260.41},{"width":750.0,"runoff":4263.28},{"width":760.0,"runoff":4266.08},{"width":770.0,"runoff":4268.81},{"width":780.0,"runoff":4271.49},{"width":790.0,"runoff":4274.12},{"width":800.0,"runoff":4276.69},{"width":810.0,"runoff":4279.2},{"width":820.0,"runoff":4281.66},{"width":830.0,"runoff":4284.06},{"width":840.0,"runoff":4286.42},{"width":850.0,"runoff":4288.73},{"width":860.0,"runoff":4291.0},{"width":870.0,"runoff":4293.21},{"width":880.0,"runoff":4295.38},{"width":890.0,"runoff":4297.51},{"width":900.0,"runoff":4299.62},{"width":910.0,"runoff":4301.68},{"width":920.0,"runoff":4303.7},{"width":930.0,"runoff":4305.67},{"width":940.0,"runoff":4307.62},{"width":950.0,"runoff":4309.51},{"width":960.0,"runoff":4311.39},{"width":970.0,"runoff":4313.22},{"width":980.0,"runoff":4315.02},{"width":990.0,"runoff":4316.79},{"width":1000.0,"runoff":4318.53}] \ No newline at end of file diff --git a/cs_app/data/example_hydrograph.json b/cs_app/data/example_hydrograph.json new file mode 100644 index 0000000..182c6ea --- /dev/null +++ b/cs_app/data/example_hydrograph.json @@ -0,0 +1 @@ +[{"datetime":"2022-06-17T00:01:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:02:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:03:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:04:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:05:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:06:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:07:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:08:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:09:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:10:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:11:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:12:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:13:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:14:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:15:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:16:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:17:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:18:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:19:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:20:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:21:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:22:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:23:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:24:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:25:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:26:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:27:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:28:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:29:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:30:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:31:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:32:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:33:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:34:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:35:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:36:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:37:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:38:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:39:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:40:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:41:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:42:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:43:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:44:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:45:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:46:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:47:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:48:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:49:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:50:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:51:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:52:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:53:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:54:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:55:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:56:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:57:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:58:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T00:59:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:00:00","rainfall":0.0,"runoff":0.0,"infiltration_loss":0.0,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:01:00","rainfall":1.0,"runoff":0.000195919,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:02:00","rainfall":1.0,"runoff":0.000195919,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:03:00","rainfall":1.0,"runoff":0.000195919,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:04:00","rainfall":1.0,"runoff":0.000195919,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:05:00","rainfall":1.0,"runoff":0.000195919,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:06:00","rainfall":1.0,"runoff":0.000876633,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:07:00","rainfall":1.0,"runoff":0.000876633,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:08:00","rainfall":1.0,"runoff":0.000876633,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:09:00","rainfall":1.0,"runoff":0.000876633,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:10:00","rainfall":1.0,"runoff":0.000876633,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:11:00","rainfall":1.0,"runoff":0.00163422,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:12:00","rainfall":1.0,"runoff":0.00163422,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:13:00","rainfall":1.0,"runoff":0.00163422,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:14:00","rainfall":1.0,"runoff":0.00163422,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:15:00","rainfall":1.0,"runoff":0.00163422,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:16:00","rainfall":1.0,"runoff":0.00225809,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:17:00","rainfall":1.0,"runoff":0.00225809,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:18:00","rainfall":1.0,"runoff":0.00225809,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:19:00","rainfall":1.0,"runoff":0.00225809,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:20:00","rainfall":1.0,"runoff":0.00225809,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:21:00","rainfall":1.0,"runoff":0.00270409,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:22:00","rainfall":1.0,"runoff":0.00270409,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:23:00","rainfall":1.0,"runoff":0.00270409,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:24:00","rainfall":1.0,"runoff":0.00270409,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:25:00","rainfall":1.0,"runoff":0.00270409,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:26:00","rainfall":1.0,"runoff":0.00299897,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:27:00","rainfall":1.0,"runoff":0.00299897,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:28:00","rainfall":1.0,"runoff":0.00299897,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:29:00","rainfall":1.0,"runoff":0.00299897,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:30:00","rainfall":1.0,"runoff":0.00299897,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:31:00","rainfall":1.0,"runoff":0.00318532,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:32:00","rainfall":1.0,"runoff":0.00318532,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:33:00","rainfall":1.0,"runoff":0.00318532,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:34:00","rainfall":1.0,"runoff":0.00318532,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:35:00","rainfall":1.0,"runoff":0.00318532,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:36:00","rainfall":1.0,"runoff":0.00330002,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:37:00","rainfall":1.0,"runoff":0.00330002,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:38:00","rainfall":1.0,"runoff":0.00330002,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:39:00","rainfall":1.0,"runoff":0.00330002,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:40:00","rainfall":1.0,"runoff":0.00330002,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:41:00","rainfall":1.0,"runoff":0.00336951,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:42:00","rainfall":1.0,"runoff":0.00336951,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:43:00","rainfall":1.0,"runoff":0.00336951,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:44:00","rainfall":1.0,"runoff":0.00336951,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:45:00","rainfall":1.0,"runoff":0.00336951,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:46:00","rainfall":1.0,"runoff":0.00341123,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:47:00","rainfall":1.0,"runoff":0.00341123,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:48:00","rainfall":1.0,"runoff":0.00341123,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:49:00","rainfall":1.0,"runoff":0.00341123,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:50:00","rainfall":1.0,"runoff":0.00341123,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:51:00","rainfall":1.0,"runoff":0.00343615,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:52:00","rainfall":1.0,"runoff":0.00343615,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:53:00","rainfall":1.0,"runoff":0.00343615,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:54:00","rainfall":1.0,"runoff":0.00343615,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:55:00","rainfall":1.0,"runoff":0.00343615,"infiltration_loss":0.75,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:56:00","rainfall":1.0,"runoff":0.00345097,"infiltration_loss":0.732955,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:57:00","rainfall":1.0,"runoff":0.00345097,"infiltration_loss":0.732955,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:58:00","rainfall":1.0,"runoff":0.00345097,"infiltration_loss":0.732955,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T01:59:00","rainfall":1.0,"runoff":0.00345097,"infiltration_loss":0.732955,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:00:00","rainfall":1.0,"runoff":0.00345097,"infiltration_loss":0.732955,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:01:00","rainfall":2.0,"runoff":0.00496575,"infiltration_loss":0.703926,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:02:00","rainfall":2.0,"runoff":0.00496575,"infiltration_loss":0.703926,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:03:00","rainfall":2.0,"runoff":0.00496575,"infiltration_loss":0.703926,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:04:00","rainfall":2.0,"runoff":0.00496575,"infiltration_loss":0.703926,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:05:00","rainfall":2.0,"runoff":0.00496575,"infiltration_loss":0.703926,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:06:00","rainfall":2.0,"runoff":0.00589845,"infiltration_loss":0.687727,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:07:00","rainfall":2.0,"runoff":0.00589845,"infiltration_loss":0.687727,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:08:00","rainfall":2.0,"runoff":0.00589845,"infiltration_loss":0.687727,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:09:00","rainfall":2.0,"runoff":0.00589845,"infiltration_loss":0.687727,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:10:00","rainfall":2.0,"runoff":0.00589845,"infiltration_loss":0.687727,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:11:00","rainfall":2.0,"runoff":0.00642393,"infiltration_loss":0.674333,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:12:00","rainfall":2.0,"runoff":0.00642393,"infiltration_loss":0.674333,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:13:00","rainfall":2.0,"runoff":0.00642393,"infiltration_loss":0.674333,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:14:00","rainfall":2.0,"runoff":0.00642393,"infiltration_loss":0.674333,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:15:00","rainfall":2.0,"runoff":0.00642393,"infiltration_loss":0.674333,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:16:00","rainfall":2.0,"runoff":0.0067128,"infiltration_loss":0.66306,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:17:00","rainfall":2.0,"runoff":0.0067128,"infiltration_loss":0.66306,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:18:00","rainfall":2.0,"runoff":0.0067128,"infiltration_loss":0.66306,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:19:00","rainfall":2.0,"runoff":0.0067128,"infiltration_loss":0.66306,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:20:00","rainfall":2.0,"runoff":0.0067128,"infiltration_loss":0.66306,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:21:00","rainfall":2.0,"runoff":0.00687577,"infiltration_loss":0.65343,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:22:00","rainfall":2.0,"runoff":0.00687577,"infiltration_loss":0.65343,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:23:00","rainfall":2.0,"runoff":0.00687577,"infiltration_loss":0.65343,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:24:00","rainfall":2.0,"runoff":0.00687577,"infiltration_loss":0.65343,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:25:00","rainfall":2.0,"runoff":0.00687577,"infiltration_loss":0.65343,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:26:00","rainfall":2.0,"runoff":0.0069752,"infiltration_loss":0.645104,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:27:00","rainfall":2.0,"runoff":0.0069752,"infiltration_loss":0.645104,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:28:00","rainfall":2.0,"runoff":0.0069752,"infiltration_loss":0.645104,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:29:00","rainfall":2.0,"runoff":0.0069752,"infiltration_loss":0.645104,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:30:00","rainfall":2.0,"runoff":0.0069752,"infiltration_loss":0.645104,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:31:00","rainfall":2.0,"runoff":0.00704412,"infiltration_loss":0.637827,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:32:00","rainfall":2.0,"runoff":0.00704412,"infiltration_loss":0.637827,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:33:00","rainfall":2.0,"runoff":0.00704412,"infiltration_loss":0.637827,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:34:00","rainfall":2.0,"runoff":0.00704412,"infiltration_loss":0.637827,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:35:00","rainfall":2.0,"runoff":0.00704412,"infiltration_loss":0.637827,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:36:00","rainfall":2.0,"runoff":0.00709945,"infiltration_loss":0.631408,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:37:00","rainfall":2.0,"runoff":0.00709945,"infiltration_loss":0.631408,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:38:00","rainfall":2.0,"runoff":0.00709945,"infiltration_loss":0.631408,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:39:00","rainfall":2.0,"runoff":0.00709945,"infiltration_loss":0.631408,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:40:00","rainfall":2.0,"runoff":0.00709945,"infiltration_loss":0.631408,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:41:00","rainfall":2.0,"runoff":0.0071497,"infiltration_loss":0.625701,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:42:00","rainfall":2.0,"runoff":0.0071497,"infiltration_loss":0.625701,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:43:00","rainfall":2.0,"runoff":0.0071497,"infiltration_loss":0.625701,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:44:00","rainfall":2.0,"runoff":0.0071497,"infiltration_loss":0.625701,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:45:00","rainfall":2.0,"runoff":0.0071497,"infiltration_loss":0.625701,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:46:00","rainfall":2.0,"runoff":0.00719912,"infiltration_loss":0.620589,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:47:00","rainfall":2.0,"runoff":0.00719912,"infiltration_loss":0.620589,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:48:00","rainfall":2.0,"runoff":0.00719912,"infiltration_loss":0.620589,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:49:00","rainfall":2.0,"runoff":0.00719912,"infiltration_loss":0.620589,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:50:00","rainfall":2.0,"runoff":0.00719912,"infiltration_loss":0.620589,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:51:00","rainfall":2.0,"runoff":0.00724976,"infiltration_loss":0.61598,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:52:00","rainfall":2.0,"runoff":0.00724976,"infiltration_loss":0.61598,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:53:00","rainfall":2.0,"runoff":0.00724976,"infiltration_loss":0.61598,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:54:00","rainfall":2.0,"runoff":0.00724976,"infiltration_loss":0.61598,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:55:00","rainfall":2.0,"runoff":0.00724976,"infiltration_loss":0.61598,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:56:00","rainfall":2.0,"runoff":0.00730259,"infiltration_loss":0.6118,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:57:00","rainfall":2.0,"runoff":0.00730259,"infiltration_loss":0.6118,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:58:00","rainfall":2.0,"runoff":0.00730259,"infiltration_loss":0.6118,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T02:59:00","rainfall":2.0,"runoff":0.00730259,"infiltration_loss":0.6118,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:00:00","rainfall":2.0,"runoff":0.00730259,"infiltration_loss":0.6118,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:01:00","rainfall":4.0,"runoff":0.0111719,"infiltration_loss":0.607989,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:02:00","rainfall":4.0,"runoff":0.0111719,"infiltration_loss":0.607989,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:03:00","rainfall":4.0,"runoff":0.0111719,"infiltration_loss":0.607989,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:04:00","rainfall":4.0,"runoff":0.0111719,"infiltration_loss":0.607989,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:05:00","rainfall":4.0,"runoff":0.0111719,"infiltration_loss":0.607989,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:06:00","rainfall":4.0,"runoff":0.013203,"infiltration_loss":0.612578,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:07:00","rainfall":4.0,"runoff":0.013203,"infiltration_loss":0.612578,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:08:00","rainfall":4.0,"runoff":0.013203,"infiltration_loss":0.612578,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:09:00","rainfall":4.0,"runoff":0.013203,"infiltration_loss":0.612578,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:10:00","rainfall":4.0,"runoff":0.013203,"infiltration_loss":0.612578,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:11:00","rainfall":4.0,"runoff":0.0142119,"infiltration_loss":0.616707,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:12:00","rainfall":4.0,"runoff":0.0142119,"infiltration_loss":0.616707,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:13:00","rainfall":4.0,"runoff":0.0142119,"infiltration_loss":0.616707,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:14:00","rainfall":4.0,"runoff":0.0142119,"infiltration_loss":0.616707,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:15:00","rainfall":4.0,"runoff":0.0142119,"infiltration_loss":0.616707,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:16:00","rainfall":4.0,"runoff":0.0147639,"infiltration_loss":0.62042,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:17:00","rainfall":4.0,"runoff":0.0147639,"infiltration_loss":0.62042,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:18:00","rainfall":4.0,"runoff":0.0147639,"infiltration_loss":0.62042,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:19:00","rainfall":4.0,"runoff":0.0147639,"infiltration_loss":0.62042,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:20:00","rainfall":4.0,"runoff":0.0147639,"infiltration_loss":0.62042,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:21:00","rainfall":4.0,"runoff":0.0151317,"infiltration_loss":0.623757,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:22:00","rainfall":4.0,"runoff":0.0151317,"infiltration_loss":0.623757,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:23:00","rainfall":4.0,"runoff":0.0151317,"infiltration_loss":0.623757,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:24:00","rainfall":4.0,"runoff":0.0151317,"infiltration_loss":0.623757,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:25:00","rainfall":4.0,"runoff":0.0151317,"infiltration_loss":0.623757,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:26:00","rainfall":4.0,"runoff":0.0154324,"infiltration_loss":0.626751,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:27:00","rainfall":4.0,"runoff":0.0154324,"infiltration_loss":0.626751,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:28:00","rainfall":4.0,"runoff":0.0154324,"infiltration_loss":0.626751,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:29:00","rainfall":4.0,"runoff":0.0154324,"infiltration_loss":0.626751,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:30:00","rainfall":4.0,"runoff":0.0154324,"infiltration_loss":0.626751,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:31:00","rainfall":4.0,"runoff":0.0157136,"infiltration_loss":0.629433,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:32:00","rainfall":4.0,"runoff":0.0157136,"infiltration_loss":0.629433,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:33:00","rainfall":4.0,"runoff":0.0157136,"infiltration_loss":0.629433,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:34:00","rainfall":4.0,"runoff":0.0157136,"infiltration_loss":0.629433,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:35:00","rainfall":4.0,"runoff":0.0157136,"infiltration_loss":0.629433,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:36:00","rainfall":4.0,"runoff":0.0159941,"infiltration_loss":0.63183,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:37:00","rainfall":4.0,"runoff":0.0159941,"infiltration_loss":0.63183,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:38:00","rainfall":4.0,"runoff":0.0159941,"infiltration_loss":0.63183,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:39:00","rainfall":4.0,"runoff":0.0159941,"infiltration_loss":0.63183,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:40:00","rainfall":4.0,"runoff":0.0159941,"infiltration_loss":0.63183,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:41:00","rainfall":4.0,"runoff":0.0162811,"infiltration_loss":0.633968,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:42:00","rainfall":4.0,"runoff":0.0162811,"infiltration_loss":0.633968,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:43:00","rainfall":4.0,"runoff":0.0162811,"infiltration_loss":0.633968,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:44:00","rainfall":4.0,"runoff":0.0162811,"infiltration_loss":0.633968,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:45:00","rainfall":4.0,"runoff":0.0162811,"infiltration_loss":0.633968,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:46:00","rainfall":4.0,"runoff":0.016577,"infiltration_loss":0.635866,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:47:00","rainfall":4.0,"runoff":0.016577,"infiltration_loss":0.635866,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:48:00","rainfall":4.0,"runoff":0.016577,"infiltration_loss":0.635866,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:49:00","rainfall":4.0,"runoff":0.016577,"infiltration_loss":0.635866,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:50:00","rainfall":4.0,"runoff":0.016577,"infiltration_loss":0.635866,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:51:00","rainfall":4.0,"runoff":0.0168821,"infiltration_loss":0.637546,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:52:00","rainfall":4.0,"runoff":0.0168821,"infiltration_loss":0.637546,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:53:00","rainfall":4.0,"runoff":0.0168821,"infiltration_loss":0.637546,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:54:00","rainfall":4.0,"runoff":0.0168821,"infiltration_loss":0.637546,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:55:00","rainfall":4.0,"runoff":0.0168821,"infiltration_loss":0.637546,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:56:00","rainfall":4.0,"runoff":0.0171962,"infiltration_loss":0.639024,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:57:00","rainfall":4.0,"runoff":0.0171962,"infiltration_loss":0.639024,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:58:00","rainfall":4.0,"runoff":0.0171962,"infiltration_loss":0.639024,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T03:59:00","rainfall":4.0,"runoff":0.0171962,"infiltration_loss":0.639024,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:00:00","rainfall":4.0,"runoff":0.0171962,"infiltration_loss":0.639024,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:01:00","rainfall":4.0,"runoff":0.0175188,"infiltration_loss":0.640317,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:02:00","rainfall":4.0,"runoff":0.0175188,"infiltration_loss":0.640317,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:03:00","rainfall":4.0,"runoff":0.0175188,"infiltration_loss":0.640317,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:04:00","rainfall":4.0,"runoff":0.0175188,"infiltration_loss":0.640317,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:05:00","rainfall":4.0,"runoff":0.0175188,"infiltration_loss":0.640317,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:06:00","rainfall":4.0,"runoff":0.0178493,"infiltration_loss":0.641439,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:07:00","rainfall":4.0,"runoff":0.0178493,"infiltration_loss":0.641439,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:08:00","rainfall":4.0,"runoff":0.0178493,"infiltration_loss":0.641439,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:09:00","rainfall":4.0,"runoff":0.0178493,"infiltration_loss":0.641439,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:10:00","rainfall":4.0,"runoff":0.0178493,"infiltration_loss":0.641439,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:11:00","rainfall":4.0,"runoff":0.018187,"infiltration_loss":0.642402,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:12:00","rainfall":4.0,"runoff":0.018187,"infiltration_loss":0.642402,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:13:00","rainfall":4.0,"runoff":0.018187,"infiltration_loss":0.642402,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:14:00","rainfall":4.0,"runoff":0.018187,"infiltration_loss":0.642402,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:15:00","rainfall":4.0,"runoff":0.018187,"infiltration_loss":0.642402,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:16:00","rainfall":4.0,"runoff":0.0185313,"infiltration_loss":0.643218,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:17:00","rainfall":4.0,"runoff":0.0185313,"infiltration_loss":0.643218,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:18:00","rainfall":4.0,"runoff":0.0185313,"infiltration_loss":0.643218,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:19:00","rainfall":4.0,"runoff":0.0185313,"infiltration_loss":0.643218,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:20:00","rainfall":4.0,"runoff":0.0185313,"infiltration_loss":0.643218,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:21:00","rainfall":4.0,"runoff":0.0188816,"infiltration_loss":0.643899,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:22:00","rainfall":4.0,"runoff":0.0188816,"infiltration_loss":0.643899,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:23:00","rainfall":4.0,"runoff":0.0188816,"infiltration_loss":0.643899,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:24:00","rainfall":4.0,"runoff":0.0188816,"infiltration_loss":0.643899,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:25:00","rainfall":4.0,"runoff":0.0188816,"infiltration_loss":0.643899,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:26:00","rainfall":4.0,"runoff":0.0192373,"infiltration_loss":0.644454,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:27:00","rainfall":4.0,"runoff":0.0192373,"infiltration_loss":0.644454,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:28:00","rainfall":4.0,"runoff":0.0192373,"infiltration_loss":0.644454,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:29:00","rainfall":4.0,"runoff":0.0192373,"infiltration_loss":0.644454,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:30:00","rainfall":4.0,"runoff":0.0192373,"infiltration_loss":0.644454,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:31:00","rainfall":4.0,"runoff":0.0195979,"infiltration_loss":0.644891,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:32:00","rainfall":4.0,"runoff":0.0195979,"infiltration_loss":0.644891,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:33:00","rainfall":4.0,"runoff":0.0195979,"infiltration_loss":0.644891,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:34:00","rainfall":4.0,"runoff":0.0195979,"infiltration_loss":0.644891,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:35:00","rainfall":4.0,"runoff":0.0195979,"infiltration_loss":0.644891,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:36:00","rainfall":4.0,"runoff":0.0199627,"infiltration_loss":0.645219,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:37:00","rainfall":4.0,"runoff":0.0199627,"infiltration_loss":0.645219,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:38:00","rainfall":4.0,"runoff":0.0199627,"infiltration_loss":0.645219,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:39:00","rainfall":4.0,"runoff":0.0199627,"infiltration_loss":0.645219,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:40:00","rainfall":4.0,"runoff":0.0199627,"infiltration_loss":0.645219,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:41:00","rainfall":4.0,"runoff":0.0203313,"infiltration_loss":0.645445,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:42:00","rainfall":4.0,"runoff":0.0203313,"infiltration_loss":0.645445,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:43:00","rainfall":4.0,"runoff":0.0203313,"infiltration_loss":0.645445,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:44:00","rainfall":4.0,"runoff":0.0203313,"infiltration_loss":0.645445,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:45:00","rainfall":4.0,"runoff":0.0203313,"infiltration_loss":0.645445,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:46:00","rainfall":4.0,"runoff":0.0207032,"infiltration_loss":0.645577,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:47:00","rainfall":4.0,"runoff":0.0207032,"infiltration_loss":0.645577,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:48:00","rainfall":4.0,"runoff":0.0207032,"infiltration_loss":0.645577,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:49:00","rainfall":4.0,"runoff":0.0207032,"infiltration_loss":0.645577,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:50:00","rainfall":4.0,"runoff":0.0207032,"infiltration_loss":0.645577,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:51:00","rainfall":4.0,"runoff":0.0210778,"infiltration_loss":0.64562,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:52:00","rainfall":4.0,"runoff":0.0210778,"infiltration_loss":0.64562,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:53:00","rainfall":4.0,"runoff":0.0210778,"infiltration_loss":0.64562,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:54:00","rainfall":4.0,"runoff":0.0210778,"infiltration_loss":0.64562,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:55:00","rainfall":4.0,"runoff":0.0210778,"infiltration_loss":0.64562,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:56:00","rainfall":4.0,"runoff":0.0214548,"infiltration_loss":0.645581,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:57:00","rainfall":4.0,"runoff":0.0214548,"infiltration_loss":0.645581,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:58:00","rainfall":4.0,"runoff":0.0214548,"infiltration_loss":0.645581,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T04:59:00","rainfall":4.0,"runoff":0.0214548,"infiltration_loss":0.645581,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:00:00","rainfall":4.0,"runoff":0.0214548,"infiltration_loss":0.645581,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:01:00","rainfall":12.0,"runoff":0.0420187,"infiltration_loss":0.645464,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:02:00","rainfall":12.0,"runoff":0.0420187,"infiltration_loss":0.645464,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:03:00","rainfall":12.0,"runoff":0.0420187,"infiltration_loss":0.645464,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:04:00","rainfall":12.0,"runoff":0.0420187,"infiltration_loss":0.645464,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:05:00","rainfall":12.0,"runoff":0.0420187,"infiltration_loss":0.645464,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:06:00","rainfall":12.0,"runoff":0.0503878,"infiltration_loss":0.662373,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:07:00","rainfall":12.0,"runoff":0.0503878,"infiltration_loss":0.662373,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:08:00","rainfall":12.0,"runoff":0.0503878,"infiltration_loss":0.662373,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:09:00","rainfall":12.0,"runoff":0.0503878,"infiltration_loss":0.662373,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:10:00","rainfall":12.0,"runoff":0.0503878,"infiltration_loss":0.662373,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:11:00","rainfall":12.0,"runoff":0.0540043,"infiltration_loss":0.678073,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:12:00","rainfall":12.0,"runoff":0.0540043,"infiltration_loss":0.678073,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:13:00","rainfall":12.0,"runoff":0.0540043,"infiltration_loss":0.678073,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:14:00","rainfall":12.0,"runoff":0.0540043,"infiltration_loss":0.678073,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:15:00","rainfall":12.0,"runoff":0.0540043,"infiltration_loss":0.678073,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:16:00","rainfall":12.0,"runoff":0.0563839,"infiltration_loss":0.692607,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:17:00","rainfall":12.0,"runoff":0.0563839,"infiltration_loss":0.692607,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:18:00","rainfall":12.0,"runoff":0.0563839,"infiltration_loss":0.692607,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:19:00","rainfall":12.0,"runoff":0.0563839,"infiltration_loss":0.692607,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:20:00","rainfall":12.0,"runoff":0.0563839,"infiltration_loss":0.692607,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:21:00","rainfall":12.0,"runoff":0.0584983,"infiltration_loss":0.706023,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:22:00","rainfall":12.0,"runoff":0.0584983,"infiltration_loss":0.706023,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:23:00","rainfall":12.0,"runoff":0.0584983,"infiltration_loss":0.706023,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:24:00","rainfall":12.0,"runoff":0.0584983,"infiltration_loss":0.706023,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:25:00","rainfall":12.0,"runoff":0.0584983,"infiltration_loss":0.706023,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:26:00","rainfall":12.0,"runoff":0.0605849,"infiltration_loss":0.718369,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:27:00","rainfall":12.0,"runoff":0.0605849,"infiltration_loss":0.718369,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:28:00","rainfall":12.0,"runoff":0.0605849,"infiltration_loss":0.718369,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:29:00","rainfall":12.0,"runoff":0.0605849,"infiltration_loss":0.718369,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:30:00","rainfall":12.0,"runoff":0.0605849,"infiltration_loss":0.718369,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:31:00","rainfall":12.0,"runoff":0.0626951,"infiltration_loss":0.729699,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:32:00","rainfall":12.0,"runoff":0.0626951,"infiltration_loss":0.729699,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:33:00","rainfall":12.0,"runoff":0.0626951,"infiltration_loss":0.729699,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:34:00","rainfall":12.0,"runoff":0.0626951,"infiltration_loss":0.729699,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:35:00","rainfall":12.0,"runoff":0.0626951,"infiltration_loss":0.729699,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:36:00","rainfall":12.0,"runoff":0.0648349,"infiltration_loss":0.740064,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:37:00","rainfall":12.0,"runoff":0.0648349,"infiltration_loss":0.740064,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:38:00","rainfall":12.0,"runoff":0.0648349,"infiltration_loss":0.740064,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:39:00","rainfall":12.0,"runoff":0.0648349,"infiltration_loss":0.740064,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:40:00","rainfall":12.0,"runoff":0.0648349,"infiltration_loss":0.740064,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:41:00","rainfall":12.0,"runoff":0.0669999,"infiltration_loss":0.749515,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:42:00","rainfall":12.0,"runoff":0.0669999,"infiltration_loss":0.749515,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:43:00","rainfall":12.0,"runoff":0.0669999,"infiltration_loss":0.749515,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:44:00","rainfall":12.0,"runoff":0.0669999,"infiltration_loss":0.749515,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:45:00","rainfall":12.0,"runoff":0.0669999,"infiltration_loss":0.749515,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:46:00","rainfall":12.0,"runoff":0.0691834,"infiltration_loss":0.758105,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:47:00","rainfall":12.0,"runoff":0.0691834,"infiltration_loss":0.758105,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:48:00","rainfall":12.0,"runoff":0.0691834,"infiltration_loss":0.758105,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:49:00","rainfall":12.0,"runoff":0.0691834,"infiltration_loss":0.758105,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:50:00","rainfall":12.0,"runoff":0.0691834,"infiltration_loss":0.758105,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:51:00","rainfall":12.0,"runoff":0.0713788,"infiltration_loss":0.765882,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:52:00","rainfall":12.0,"runoff":0.0713788,"infiltration_loss":0.765882,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:53:00","rainfall":12.0,"runoff":0.0713788,"infiltration_loss":0.765882,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:54:00","rainfall":12.0,"runoff":0.0713788,"infiltration_loss":0.765882,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:55:00","rainfall":12.0,"runoff":0.0713788,"infiltration_loss":0.765882,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:56:00","rainfall":12.0,"runoff":0.0735796,"infiltration_loss":0.772897,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:57:00","rainfall":12.0,"runoff":0.0735796,"infiltration_loss":0.772897,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:58:00","rainfall":12.0,"runoff":0.0735796,"infiltration_loss":0.772897,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T05:59:00","rainfall":12.0,"runoff":0.0735796,"infiltration_loss":0.772897,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:00:00","rainfall":12.0,"runoff":0.0735796,"infiltration_loss":0.772897,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:01:00","rainfall":13.0,"runoff":0.078709,"infiltration_loss":0.779196,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:02:00","rainfall":13.0,"runoff":0.078709,"infiltration_loss":0.779196,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:03:00","rainfall":13.0,"runoff":0.078709,"infiltration_loss":0.779196,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:04:00","rainfall":13.0,"runoff":0.078709,"infiltration_loss":0.779196,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:05:00","rainfall":13.0,"runoff":0.078709,"infiltration_loss":0.779196,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:06:00","rainfall":13.0,"runoff":0.0818254,"infiltration_loss":0.786499,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:07:00","rainfall":13.0,"runoff":0.0818254,"infiltration_loss":0.786499,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:08:00","rainfall":13.0,"runoff":0.0818254,"infiltration_loss":0.786499,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:09:00","rainfall":13.0,"runoff":0.0818254,"infiltration_loss":0.786499,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:10:00","rainfall":13.0,"runoff":0.0818254,"infiltration_loss":0.786499,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:11:00","rainfall":13.0,"runoff":0.0844515,"infiltration_loss":0.793046,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:12:00","rainfall":13.0,"runoff":0.0844515,"infiltration_loss":0.793046,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:13:00","rainfall":13.0,"runoff":0.0844515,"infiltration_loss":0.793046,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:14:00","rainfall":13.0,"runoff":0.0844515,"infiltration_loss":0.793046,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:15:00","rainfall":13.0,"runoff":0.0844515,"infiltration_loss":0.793046,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:16:00","rainfall":13.0,"runoff":0.0869517,"infiltration_loss":0.798886,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:17:00","rainfall":13.0,"runoff":0.0869517,"infiltration_loss":0.798886,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:18:00","rainfall":13.0,"runoff":0.0869517,"infiltration_loss":0.798886,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:19:00","rainfall":13.0,"runoff":0.0869517,"infiltration_loss":0.798886,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:20:00","rainfall":13.0,"runoff":0.0869517,"infiltration_loss":0.798886,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:21:00","rainfall":13.0,"runoff":0.0894078,"infiltration_loss":0.804062,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:22:00","rainfall":13.0,"runoff":0.0894078,"infiltration_loss":0.804062,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:23:00","rainfall":13.0,"runoff":0.0894078,"infiltration_loss":0.804062,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:24:00","rainfall":13.0,"runoff":0.0894078,"infiltration_loss":0.804062,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:25:00","rainfall":13.0,"runoff":0.0894078,"infiltration_loss":0.804062,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:26:00","rainfall":13.0,"runoff":0.0918353,"infiltration_loss":0.80862,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:27:00","rainfall":13.0,"runoff":0.0918353,"infiltration_loss":0.80862,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:28:00","rainfall":13.0,"runoff":0.0918353,"infiltration_loss":0.80862,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:29:00","rainfall":13.0,"runoff":0.0918353,"infiltration_loss":0.80862,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:30:00","rainfall":13.0,"runoff":0.0918353,"infiltration_loss":0.80862,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:31:00","rainfall":13.0,"runoff":0.0942343,"infiltration_loss":0.8126,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:32:00","rainfall":13.0,"runoff":0.0942343,"infiltration_loss":0.8126,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:33:00","rainfall":13.0,"runoff":0.0942343,"infiltration_loss":0.8126,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:34:00","rainfall":13.0,"runoff":0.0942343,"infiltration_loss":0.8126,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:35:00","rainfall":13.0,"runoff":0.0942343,"infiltration_loss":0.8126,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:36:00","rainfall":13.0,"runoff":0.0966021,"infiltration_loss":0.816043,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:37:00","rainfall":13.0,"runoff":0.0966021,"infiltration_loss":0.816043,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:38:00","rainfall":13.0,"runoff":0.0966021,"infiltration_loss":0.816043,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:39:00","rainfall":13.0,"runoff":0.0966021,"infiltration_loss":0.816043,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:40:00","rainfall":13.0,"runoff":0.0966021,"infiltration_loss":0.816043,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:41:00","rainfall":13.0,"runoff":0.0989353,"infiltration_loss":0.818985,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:42:00","rainfall":13.0,"runoff":0.0989353,"infiltration_loss":0.818985,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:43:00","rainfall":13.0,"runoff":0.0989353,"infiltration_loss":0.818985,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:44:00","rainfall":13.0,"runoff":0.0989353,"infiltration_loss":0.818985,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:45:00","rainfall":13.0,"runoff":0.0989353,"infiltration_loss":0.818985,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:46:00","rainfall":13.0,"runoff":0.101231,"infiltration_loss":0.821461,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:47:00","rainfall":13.0,"runoff":0.101231,"infiltration_loss":0.821461,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:48:00","rainfall":13.0,"runoff":0.101231,"infiltration_loss":0.821461,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:49:00","rainfall":13.0,"runoff":0.101231,"infiltration_loss":0.821461,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:50:00","rainfall":13.0,"runoff":0.101231,"infiltration_loss":0.821461,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:51:00","rainfall":13.0,"runoff":0.103486,"infiltration_loss":0.823506,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:52:00","rainfall":13.0,"runoff":0.103486,"infiltration_loss":0.823506,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:53:00","rainfall":13.0,"runoff":0.103486,"infiltration_loss":0.823506,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:54:00","rainfall":13.0,"runoff":0.103486,"infiltration_loss":0.823506,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:55:00","rainfall":13.0,"runoff":0.103486,"infiltration_loss":0.823506,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:56:00","rainfall":13.0,"runoff":0.105699,"infiltration_loss":0.82515,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:57:00","rainfall":13.0,"runoff":0.105699,"infiltration_loss":0.82515,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:58:00","rainfall":13.0,"runoff":0.105699,"infiltration_loss":0.82515,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T06:59:00","rainfall":13.0,"runoff":0.105699,"infiltration_loss":0.82515,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:00:00","rainfall":13.0,"runoff":0.105699,"infiltration_loss":0.82515,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:01:00","rainfall":11.0,"runoff":0.10189,"infiltration_loss":0.826422,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:02:00","rainfall":11.0,"runoff":0.10189,"infiltration_loss":0.826422,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:03:00","rainfall":11.0,"runoff":0.10189,"infiltration_loss":0.826422,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:04:00","rainfall":11.0,"runoff":0.10189,"infiltration_loss":0.826422,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:05:00","rainfall":11.0,"runoff":0.10189,"infiltration_loss":0.826422,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:06:00","rainfall":11.0,"runoff":0.102048,"infiltration_loss":0.824647,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:07:00","rainfall":11.0,"runoff":0.102048,"infiltration_loss":0.824647,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:08:00","rainfall":11.0,"runoff":0.102048,"infiltration_loss":0.824647,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:09:00","rainfall":11.0,"runoff":0.102048,"infiltration_loss":0.824647,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:10:00","rainfall":11.0,"runoff":0.102048,"infiltration_loss":0.824647,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:11:00","rainfall":11.0,"runoff":0.103112,"infiltration_loss":0.822762,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:12:00","rainfall":11.0,"runoff":0.103112,"infiltration_loss":0.822762,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:13:00","rainfall":11.0,"runoff":0.103112,"infiltration_loss":0.822762,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:14:00","rainfall":11.0,"runoff":0.103112,"infiltration_loss":0.822762,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:15:00","rainfall":11.0,"runoff":0.103112,"infiltration_loss":0.822762,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:16:00","rainfall":11.0,"runoff":0.104376,"infiltration_loss":0.820775,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:17:00","rainfall":11.0,"runoff":0.104376,"infiltration_loss":0.820775,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:18:00","rainfall":11.0,"runoff":0.104376,"infiltration_loss":0.820775,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:19:00","rainfall":11.0,"runoff":0.104376,"infiltration_loss":0.820775,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:20:00","rainfall":11.0,"runoff":0.104376,"infiltration_loss":0.820775,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:21:00","rainfall":11.0,"runoff":0.105664,"infiltration_loss":0.818698,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:22:00","rainfall":11.0,"runoff":0.105664,"infiltration_loss":0.818698,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:23:00","rainfall":11.0,"runoff":0.105664,"infiltration_loss":0.818698,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:24:00","rainfall":11.0,"runoff":0.105664,"infiltration_loss":0.818698,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:25:00","rainfall":11.0,"runoff":0.105664,"infiltration_loss":0.818698,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:26:00","rainfall":11.0,"runoff":0.106932,"infiltration_loss":0.816539,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:27:00","rainfall":11.0,"runoff":0.106932,"infiltration_loss":0.816539,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:28:00","rainfall":11.0,"runoff":0.106932,"infiltration_loss":0.816539,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:29:00","rainfall":11.0,"runoff":0.106932,"infiltration_loss":0.816539,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:30:00","rainfall":11.0,"runoff":0.106932,"infiltration_loss":0.816539,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:31:00","rainfall":11.0,"runoff":0.108168,"infiltration_loss":0.814308,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:32:00","rainfall":11.0,"runoff":0.108168,"infiltration_loss":0.814308,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:33:00","rainfall":11.0,"runoff":0.108168,"infiltration_loss":0.814308,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:34:00","rainfall":11.0,"runoff":0.108168,"infiltration_loss":0.814308,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:35:00","rainfall":11.0,"runoff":0.108168,"infiltration_loss":0.814308,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:36:00","rainfall":11.0,"runoff":0.109371,"infiltration_loss":0.812011,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:37:00","rainfall":11.0,"runoff":0.109371,"infiltration_loss":0.812011,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:38:00","rainfall":11.0,"runoff":0.109371,"infiltration_loss":0.812011,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:39:00","rainfall":11.0,"runoff":0.109371,"infiltration_loss":0.812011,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:40:00","rainfall":11.0,"runoff":0.109371,"infiltration_loss":0.812011,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:41:00","rainfall":11.0,"runoff":0.110539,"infiltration_loss":0.809657,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:42:00","rainfall":11.0,"runoff":0.110539,"infiltration_loss":0.809657,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:43:00","rainfall":11.0,"runoff":0.110539,"infiltration_loss":0.809657,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:44:00","rainfall":11.0,"runoff":0.110539,"infiltration_loss":0.809657,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:45:00","rainfall":11.0,"runoff":0.110539,"infiltration_loss":0.809657,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:46:00","rainfall":11.0,"runoff":0.111673,"infiltration_loss":0.807252,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:47:00","rainfall":11.0,"runoff":0.111673,"infiltration_loss":0.807252,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:48:00","rainfall":11.0,"runoff":0.111673,"infiltration_loss":0.807252,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:49:00","rainfall":11.0,"runoff":0.111673,"infiltration_loss":0.807252,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:50:00","rainfall":11.0,"runoff":0.111673,"infiltration_loss":0.807252,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:51:00","rainfall":11.0,"runoff":0.112773,"infiltration_loss":0.804802,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:52:00","rainfall":11.0,"runoff":0.112773,"infiltration_loss":0.804802,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:53:00","rainfall":11.0,"runoff":0.112773,"infiltration_loss":0.804802,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:54:00","rainfall":11.0,"runoff":0.112773,"infiltration_loss":0.804802,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:55:00","rainfall":11.0,"runoff":0.112773,"infiltration_loss":0.804802,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:56:00","rainfall":11.0,"runoff":0.113841,"infiltration_loss":0.802313,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:57:00","rainfall":11.0,"runoff":0.113841,"infiltration_loss":0.802313,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:58:00","rainfall":11.0,"runoff":0.113841,"infiltration_loss":0.802313,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T07:59:00","rainfall":11.0,"runoff":0.113841,"infiltration_loss":0.802313,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:00:00","rainfall":11.0,"runoff":0.113841,"infiltration_loss":0.802313,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:01:00","rainfall":20.0,"runoff":0.143065,"infiltration_loss":0.799791,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:02:00","rainfall":20.0,"runoff":0.143065,"infiltration_loss":0.799791,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:03:00","rainfall":20.0,"runoff":0.143065,"infiltration_loss":0.799791,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:04:00","rainfall":20.0,"runoff":0.143065,"infiltration_loss":0.799791,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:05:00","rainfall":20.0,"runoff":0.143065,"infiltration_loss":0.799791,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:06:00","rainfall":20.0,"runoff":0.152937,"infiltration_loss":0.807438,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:07:00","rainfall":20.0,"runoff":0.152937,"infiltration_loss":0.807438,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:08:00","rainfall":20.0,"runoff":0.152937,"infiltration_loss":0.807438,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:09:00","rainfall":20.0,"runoff":0.152937,"infiltration_loss":0.807438,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:10:00","rainfall":20.0,"runoff":0.152937,"infiltration_loss":0.807438,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:11:00","rainfall":20.0,"runoff":0.158318,"infiltration_loss":0.814325,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:12:00","rainfall":20.0,"runoff":0.158318,"infiltration_loss":0.814325,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:13:00","rainfall":20.0,"runoff":0.158318,"infiltration_loss":0.814325,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:14:00","rainfall":20.0,"runoff":0.158318,"infiltration_loss":0.814325,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:15:00","rainfall":20.0,"runoff":0.158318,"infiltration_loss":0.814325,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:16:00","rainfall":20.0,"runoff":0.162799,"infiltration_loss":0.820496,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:17:00","rainfall":20.0,"runoff":0.162799,"infiltration_loss":0.820496,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:18:00","rainfall":20.0,"runoff":0.162799,"infiltration_loss":0.820496,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:19:00","rainfall":20.0,"runoff":0.162799,"infiltration_loss":0.820496,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:20:00","rainfall":20.0,"runoff":0.162799,"infiltration_loss":0.820496,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:21:00","rainfall":20.0,"runoff":0.167034,"infiltration_loss":0.825991,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:22:00","rainfall":20.0,"runoff":0.167034,"infiltration_loss":0.825991,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:23:00","rainfall":20.0,"runoff":0.167034,"infiltration_loss":0.825991,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:24:00","rainfall":20.0,"runoff":0.167034,"infiltration_loss":0.825991,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:25:00","rainfall":20.0,"runoff":0.167034,"infiltration_loss":0.825991,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:26:00","rainfall":20.0,"runoff":0.171138,"infiltration_loss":0.830851,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:27:00","rainfall":20.0,"runoff":0.171138,"infiltration_loss":0.830851,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:28:00","rainfall":20.0,"runoff":0.171138,"infiltration_loss":0.830851,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:29:00","rainfall":20.0,"runoff":0.171138,"infiltration_loss":0.830851,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:30:00","rainfall":20.0,"runoff":0.171138,"infiltration_loss":0.830851,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:31:00","rainfall":20.0,"runoff":0.175126,"infiltration_loss":0.835114,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:32:00","rainfall":20.0,"runoff":0.175126,"infiltration_loss":0.835114,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:33:00","rainfall":20.0,"runoff":0.175126,"infiltration_loss":0.835114,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:34:00","rainfall":20.0,"runoff":0.175126,"infiltration_loss":0.835114,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:35:00","rainfall":20.0,"runoff":0.175126,"infiltration_loss":0.835114,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:36:00","rainfall":20.0,"runoff":0.179,"infiltration_loss":0.83882,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:37:00","rainfall":20.0,"runoff":0.179,"infiltration_loss":0.83882,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:38:00","rainfall":20.0,"runoff":0.179,"infiltration_loss":0.83882,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:39:00","rainfall":20.0,"runoff":0.179,"infiltration_loss":0.83882,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:40:00","rainfall":20.0,"runoff":0.179,"infiltration_loss":0.83882,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:41:00","rainfall":20.0,"runoff":0.182759,"infiltration_loss":0.842003,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:42:00","rainfall":20.0,"runoff":0.182759,"infiltration_loss":0.842003,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:43:00","rainfall":20.0,"runoff":0.182759,"infiltration_loss":0.842003,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:44:00","rainfall":20.0,"runoff":0.182759,"infiltration_loss":0.842003,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:45:00","rainfall":20.0,"runoff":0.182759,"infiltration_loss":0.842003,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:46:00","rainfall":20.0,"runoff":0.186399,"infiltration_loss":0.844699,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:47:00","rainfall":20.0,"runoff":0.186399,"infiltration_loss":0.844699,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:48:00","rainfall":20.0,"runoff":0.186399,"infiltration_loss":0.844699,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:49:00","rainfall":20.0,"runoff":0.186399,"infiltration_loss":0.844699,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:50:00","rainfall":20.0,"runoff":0.186399,"infiltration_loss":0.844699,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:51:00","rainfall":20.0,"runoff":0.189922,"infiltration_loss":0.846941,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:52:00","rainfall":20.0,"runoff":0.189922,"infiltration_loss":0.846941,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:53:00","rainfall":20.0,"runoff":0.189922,"infiltration_loss":0.846941,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:54:00","rainfall":20.0,"runoff":0.189922,"infiltration_loss":0.846941,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:55:00","rainfall":20.0,"runoff":0.189922,"infiltration_loss":0.846941,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:56:00","rainfall":20.0,"runoff":0.193325,"infiltration_loss":0.84876,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:57:00","rainfall":20.0,"runoff":0.193325,"infiltration_loss":0.84876,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:58:00","rainfall":20.0,"runoff":0.193325,"infiltration_loss":0.84876,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T08:59:00","rainfall":20.0,"runoff":0.193325,"infiltration_loss":0.84876,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:00:00","rainfall":20.0,"runoff":0.193325,"infiltration_loss":0.84876,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:01:00","rainfall":15.0,"runoff":0.180251,"infiltration_loss":0.850187,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:02:00","rainfall":15.0,"runoff":0.180251,"infiltration_loss":0.850187,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:03:00","rainfall":15.0,"runoff":0.180251,"infiltration_loss":0.850187,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:04:00","rainfall":15.0,"runoff":0.180251,"infiltration_loss":0.850187,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:05:00","rainfall":15.0,"runoff":0.180251,"infiltration_loss":0.850187,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:06:00","rainfall":15.0,"runoff":0.178481,"infiltration_loss":0.846404,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:07:00","rainfall":15.0,"runoff":0.178481,"infiltration_loss":0.846404,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:08:00","rainfall":15.0,"runoff":0.178481,"infiltration_loss":0.846404,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:09:00","rainfall":15.0,"runoff":0.178481,"infiltration_loss":0.846404,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:10:00","rainfall":15.0,"runoff":0.178481,"infiltration_loss":0.846404,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:11:00","rainfall":15.0,"runoff":0.178784,"infiltration_loss":0.842654,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:12:00","rainfall":15.0,"runoff":0.178784,"infiltration_loss":0.842654,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:13:00","rainfall":15.0,"runoff":0.178784,"infiltration_loss":0.842654,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:14:00","rainfall":15.0,"runoff":0.178784,"infiltration_loss":0.842654,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:15:00","rainfall":15.0,"runoff":0.178784,"infiltration_loss":0.842654,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:16:00","rainfall":15.0,"runoff":0.179486,"infiltration_loss":0.838939,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:17:00","rainfall":15.0,"runoff":0.179486,"infiltration_loss":0.838939,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:18:00","rainfall":15.0,"runoff":0.179486,"infiltration_loss":0.838939,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:19:00","rainfall":15.0,"runoff":0.179486,"infiltration_loss":0.838939,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:20:00","rainfall":15.0,"runoff":0.179486,"infiltration_loss":0.838939,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:21:00","rainfall":15.0,"runoff":0.180248,"infiltration_loss":0.835261,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:22:00","rainfall":15.0,"runoff":0.180248,"infiltration_loss":0.835261,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:23:00","rainfall":15.0,"runoff":0.180248,"infiltration_loss":0.835261,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:24:00","rainfall":15.0,"runoff":0.180248,"infiltration_loss":0.835261,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:25:00","rainfall":15.0,"runoff":0.180248,"infiltration_loss":0.835261,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:26:00","rainfall":15.0,"runoff":0.180998,"infiltration_loss":0.83162,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:27:00","rainfall":15.0,"runoff":0.180998,"infiltration_loss":0.83162,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:28:00","rainfall":15.0,"runoff":0.180998,"infiltration_loss":0.83162,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:29:00","rainfall":15.0,"runoff":0.180998,"infiltration_loss":0.83162,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:30:00","rainfall":15.0,"runoff":0.180998,"infiltration_loss":0.83162,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:31:00","rainfall":15.0,"runoff":0.181722,"infiltration_loss":0.828018,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:32:00","rainfall":15.0,"runoff":0.181722,"infiltration_loss":0.828018,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:33:00","rainfall":15.0,"runoff":0.181722,"infiltration_loss":0.828018,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:34:00","rainfall":15.0,"runoff":0.181722,"infiltration_loss":0.828018,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:35:00","rainfall":15.0,"runoff":0.181722,"infiltration_loss":0.828018,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:36:00","rainfall":15.0,"runoff":0.182418,"infiltration_loss":0.824454,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:37:00","rainfall":15.0,"runoff":0.182418,"infiltration_loss":0.824454,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:38:00","rainfall":15.0,"runoff":0.182418,"infiltration_loss":0.824454,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:39:00","rainfall":15.0,"runoff":0.182418,"infiltration_loss":0.824454,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:40:00","rainfall":15.0,"runoff":0.182418,"infiltration_loss":0.824454,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:41:00","rainfall":15.0,"runoff":0.183085,"infiltration_loss":0.82093,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:42:00","rainfall":15.0,"runoff":0.183085,"infiltration_loss":0.82093,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:43:00","rainfall":15.0,"runoff":0.183085,"infiltration_loss":0.82093,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:44:00","rainfall":15.0,"runoff":0.183085,"infiltration_loss":0.82093,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:45:00","rainfall":15.0,"runoff":0.183085,"infiltration_loss":0.82093,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:46:00","rainfall":15.0,"runoff":0.183725,"infiltration_loss":0.817445,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:47:00","rainfall":15.0,"runoff":0.183725,"infiltration_loss":0.817445,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:48:00","rainfall":15.0,"runoff":0.183725,"infiltration_loss":0.817445,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:49:00","rainfall":15.0,"runoff":0.183725,"infiltration_loss":0.817445,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:50:00","rainfall":15.0,"runoff":0.183725,"infiltration_loss":0.817445,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:51:00","rainfall":15.0,"runoff":0.184339,"infiltration_loss":0.814001,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:52:00","rainfall":15.0,"runoff":0.184339,"infiltration_loss":0.814001,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:53:00","rainfall":15.0,"runoff":0.184339,"infiltration_loss":0.814001,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:54:00","rainfall":15.0,"runoff":0.184339,"infiltration_loss":0.814001,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:55:00","rainfall":15.0,"runoff":0.184339,"infiltration_loss":0.814001,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:56:00","rainfall":15.0,"runoff":0.184928,"infiltration_loss":0.810598,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:57:00","rainfall":15.0,"runoff":0.184928,"infiltration_loss":0.810598,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:58:00","rainfall":15.0,"runoff":0.184928,"infiltration_loss":0.810598,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T09:59:00","rainfall":15.0,"runoff":0.184928,"infiltration_loss":0.810598,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:00:00","rainfall":15.0,"runoff":0.184928,"infiltration_loss":0.810598,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:01:00","rainfall":10.0,"runoff":0.169834,"infiltration_loss":0.807236,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:02:00","rainfall":10.0,"runoff":0.169834,"infiltration_loss":0.807236,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:03:00","rainfall":10.0,"runoff":0.169834,"infiltration_loss":0.807236,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:04:00","rainfall":10.0,"runoff":0.169834,"infiltration_loss":0.807236,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:05:00","rainfall":10.0,"runoff":0.169834,"infiltration_loss":0.807236,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:06:00","rainfall":10.0,"runoff":0.165055,"infiltration_loss":0.799665,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:07:00","rainfall":10.0,"runoff":0.165055,"infiltration_loss":0.799665,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:08:00","rainfall":10.0,"runoff":0.165055,"infiltration_loss":0.799665,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:09:00","rainfall":10.0,"runoff":0.165055,"infiltration_loss":0.799665,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:10:00","rainfall":10.0,"runoff":0.165055,"infiltration_loss":0.799665,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:11:00","rainfall":10.0,"runoff":0.162627,"infiltration_loss":0.792439,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:12:00","rainfall":10.0,"runoff":0.162627,"infiltration_loss":0.792439,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:13:00","rainfall":10.0,"runoff":0.162627,"infiltration_loss":0.792439,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:14:00","rainfall":10.0,"runoff":0.162627,"infiltration_loss":0.792439,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:15:00","rainfall":10.0,"runoff":0.162627,"infiltration_loss":0.792439,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:16:00","rainfall":10.0,"runoff":0.16086,"infiltration_loss":0.785536,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:17:00","rainfall":10.0,"runoff":0.16086,"infiltration_loss":0.785536,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:18:00","rainfall":10.0,"runoff":0.16086,"infiltration_loss":0.785536,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:19:00","rainfall":10.0,"runoff":0.16086,"infiltration_loss":0.785536,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:20:00","rainfall":10.0,"runoff":0.16086,"infiltration_loss":0.785536,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:21:00","rainfall":10.0,"runoff":0.159328,"infiltration_loss":0.778936,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:22:00","rainfall":10.0,"runoff":0.159328,"infiltration_loss":0.778936,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:23:00","rainfall":10.0,"runoff":0.159328,"infiltration_loss":0.778936,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:24:00","rainfall":10.0,"runoff":0.159328,"infiltration_loss":0.778936,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:25:00","rainfall":10.0,"runoff":0.159328,"infiltration_loss":0.778936,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:26:00","rainfall":10.0,"runoff":0.157913,"infiltration_loss":0.772621,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:27:00","rainfall":10.0,"runoff":0.157913,"infiltration_loss":0.772621,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:28:00","rainfall":10.0,"runoff":0.157913,"infiltration_loss":0.772621,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:29:00","rainfall":10.0,"runoff":0.157913,"infiltration_loss":0.772621,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:30:00","rainfall":10.0,"runoff":0.157913,"infiltration_loss":0.772621,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:31:00","rainfall":10.0,"runoff":0.156581,"infiltration_loss":0.766571,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:32:00","rainfall":10.0,"runoff":0.156581,"infiltration_loss":0.766571,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:33:00","rainfall":10.0,"runoff":0.156581,"infiltration_loss":0.766571,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:34:00","rainfall":10.0,"runoff":0.156581,"infiltration_loss":0.766571,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:35:00","rainfall":10.0,"runoff":0.156581,"infiltration_loss":0.766571,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:36:00","rainfall":10.0,"runoff":0.155321,"infiltration_loss":0.760773,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:37:00","rainfall":10.0,"runoff":0.155321,"infiltration_loss":0.760773,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:38:00","rainfall":10.0,"runoff":0.155321,"infiltration_loss":0.760773,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:39:00","rainfall":10.0,"runoff":0.155321,"infiltration_loss":0.760773,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:40:00","rainfall":10.0,"runoff":0.155321,"infiltration_loss":0.760773,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:41:00","rainfall":10.0,"runoff":0.154126,"infiltration_loss":0.755211,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:42:00","rainfall":10.0,"runoff":0.154126,"infiltration_loss":0.755211,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:43:00","rainfall":10.0,"runoff":0.154126,"infiltration_loss":0.755211,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:44:00","rainfall":10.0,"runoff":0.154126,"infiltration_loss":0.755211,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:45:00","rainfall":10.0,"runoff":0.154126,"infiltration_loss":0.755211,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:46:00","rainfall":10.0,"runoff":0.152992,"infiltration_loss":0.749871,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:47:00","rainfall":10.0,"runoff":0.152992,"infiltration_loss":0.749871,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:48:00","rainfall":10.0,"runoff":0.152992,"infiltration_loss":0.749871,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:49:00","rainfall":10.0,"runoff":0.152992,"infiltration_loss":0.749871,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:50:00","rainfall":10.0,"runoff":0.152992,"infiltration_loss":0.749871,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:51:00","rainfall":10.0,"runoff":0.151915,"infiltration_loss":0.744741,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:52:00","rainfall":10.0,"runoff":0.151915,"infiltration_loss":0.744741,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:53:00","rainfall":10.0,"runoff":0.151915,"infiltration_loss":0.744741,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:54:00","rainfall":10.0,"runoff":0.151915,"infiltration_loss":0.744741,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:55:00","rainfall":10.0,"runoff":0.151915,"infiltration_loss":0.744741,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:56:00","rainfall":10.0,"runoff":0.150892,"infiltration_loss":0.739809,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:57:00","rainfall":10.0,"runoff":0.150892,"infiltration_loss":0.739809,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:58:00","rainfall":10.0,"runoff":0.150892,"infiltration_loss":0.739809,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T10:59:00","rainfall":10.0,"runoff":0.150892,"infiltration_loss":0.739809,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:00:00","rainfall":10.0,"runoff":0.150892,"infiltration_loss":0.739809,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:01:00","rainfall":5.0,"runoff":0.135556,"infiltration_loss":0.735063,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:02:00","rainfall":5.0,"runoff":0.135556,"infiltration_loss":0.735063,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:03:00","rainfall":5.0,"runoff":0.135556,"infiltration_loss":0.735063,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:04:00","rainfall":5.0,"runoff":0.135556,"infiltration_loss":0.735063,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:05:00","rainfall":5.0,"runoff":0.135556,"infiltration_loss":0.735063,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:06:00","rainfall":5.0,"runoff":0.129043,"infiltration_loss":0.726673,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:07:00","rainfall":5.0,"runoff":0.129043,"infiltration_loss":0.726673,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:08:00","rainfall":5.0,"runoff":0.129043,"infiltration_loss":0.726673,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:09:00","rainfall":5.0,"runoff":0.129043,"infiltration_loss":0.726673,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:10:00","rainfall":5.0,"runoff":0.129043,"infiltration_loss":0.726673,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:11:00","rainfall":5.0,"runoff":0.12499,"infiltration_loss":0.718693,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:12:00","rainfall":5.0,"runoff":0.12499,"infiltration_loss":0.718693,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:13:00","rainfall":5.0,"runoff":0.12499,"infiltration_loss":0.718693,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:14:00","rainfall":5.0,"runoff":0.12499,"infiltration_loss":0.718693,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:15:00","rainfall":5.0,"runoff":0.12499,"infiltration_loss":0.718693,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:16:00","rainfall":5.0,"runoff":0.121841,"infiltration_loss":0.711098,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:17:00","rainfall":5.0,"runoff":0.121841,"infiltration_loss":0.711098,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:18:00","rainfall":5.0,"runoff":0.121841,"infiltration_loss":0.711098,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:19:00","rainfall":5.0,"runoff":0.121841,"infiltration_loss":0.711098,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:20:00","rainfall":5.0,"runoff":0.121841,"infiltration_loss":0.711098,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:21:00","rainfall":5.0,"runoff":0.1191,"infiltration_loss":0.703862,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:22:00","rainfall":5.0,"runoff":0.1191,"infiltration_loss":0.703862,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:23:00","rainfall":5.0,"runoff":0.1191,"infiltration_loss":0.703862,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:24:00","rainfall":5.0,"runoff":0.1191,"infiltration_loss":0.703862,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:25:00","rainfall":5.0,"runoff":0.1191,"infiltration_loss":0.703862,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:26:00","rainfall":5.0,"runoff":0.116591,"infiltration_loss":0.696962,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:27:00","rainfall":5.0,"runoff":0.116591,"infiltration_loss":0.696962,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:28:00","rainfall":5.0,"runoff":0.116591,"infiltration_loss":0.696962,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:29:00","rainfall":5.0,"runoff":0.116591,"infiltration_loss":0.696962,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:30:00","rainfall":5.0,"runoff":0.116591,"infiltration_loss":0.696962,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:31:00","rainfall":5.0,"runoff":0.114244,"infiltration_loss":0.690378,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:32:00","rainfall":5.0,"runoff":0.114244,"infiltration_loss":0.690378,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:33:00","rainfall":5.0,"runoff":0.114244,"infiltration_loss":0.690378,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:34:00","rainfall":5.0,"runoff":0.114244,"infiltration_loss":0.690378,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:35:00","rainfall":5.0,"runoff":0.114244,"infiltration_loss":0.690378,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:36:00","rainfall":5.0,"runoff":0.11203,"infiltration_loss":0.684091,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:37:00","rainfall":5.0,"runoff":0.11203,"infiltration_loss":0.684091,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:38:00","rainfall":5.0,"runoff":0.11203,"infiltration_loss":0.684091,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:39:00","rainfall":5.0,"runoff":0.11203,"infiltration_loss":0.684091,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:40:00","rainfall":5.0,"runoff":0.11203,"infiltration_loss":0.684091,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:41:00","rainfall":5.0,"runoff":0.109932,"infiltration_loss":0.678083,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:42:00","rainfall":5.0,"runoff":0.109932,"infiltration_loss":0.678083,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:43:00","rainfall":5.0,"runoff":0.109932,"infiltration_loss":0.678083,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:44:00","rainfall":5.0,"runoff":0.109932,"infiltration_loss":0.678083,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:45:00","rainfall":5.0,"runoff":0.109932,"infiltration_loss":0.678083,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:46:00","rainfall":5.0,"runoff":0.107941,"infiltration_loss":0.672338,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:47:00","rainfall":5.0,"runoff":0.107941,"infiltration_loss":0.672338,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:48:00","rainfall":5.0,"runoff":0.107941,"infiltration_loss":0.672338,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:49:00","rainfall":5.0,"runoff":0.107941,"infiltration_loss":0.672338,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:50:00","rainfall":5.0,"runoff":0.107941,"infiltration_loss":0.672338,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:51:00","rainfall":5.0,"runoff":0.106049,"infiltration_loss":0.666839,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:52:00","rainfall":5.0,"runoff":0.106049,"infiltration_loss":0.666839,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:53:00","rainfall":5.0,"runoff":0.106049,"infiltration_loss":0.666839,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:54:00","rainfall":5.0,"runoff":0.106049,"infiltration_loss":0.666839,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:55:00","rainfall":5.0,"runoff":0.106049,"infiltration_loss":0.666839,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:56:00","rainfall":5.0,"runoff":0.10425,"infiltration_loss":0.661573,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:57:00","rainfall":5.0,"runoff":0.10425,"infiltration_loss":0.661573,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:58:00","rainfall":5.0,"runoff":0.10425,"infiltration_loss":0.661573,"evaporation_loss":0.0,"runon":0.0},{"datetime":"2022-06-17T11:59:00","rainfall":5.0,"runoff":0.10425,"infiltration_loss":0.661573,"evaporation_loss":0.0,"runon":0.0}] \ No newline at end of file diff --git a/cs_app/e2e/conftest.py b/cs_app/e2e/conftest.py index da4626e..6568399 100644 --- a/cs_app/e2e/conftest.py +++ b/cs_app/e2e/conftest.py @@ -12,7 +12,7 @@ import pytest from django.contrib.auth.models import User from django.test import Client -from playwright.sync_api import Page +from playwright.sync_api import Browser, BrowserContext, Page # Allow Django ORM access from async Playwright threads. os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true") @@ -81,3 +81,25 @@ def auth_page(page: Page, live_server, test_user) -> Page: # Reload so the server sees the session cookie page.reload(wait_until="domcontentloaded") return page + + +def new_context_without_js(browser: Browser) -> BrowserContext: + """ + A browser context with JavaScript off, for testing the no-JS fallbacks. + + Reduced motion switches off Bootstrap's smooth scrolling: the scroll a click + starts would otherwise animate, and on a slow runner the click can keep finding + its target outside the viewport until it times out. + """ + return browser.new_context(java_script_enabled=False, reduced_motion="reduce") + + +def wait_for_styles(page: Page) -> None: + """ + Wait until the stylesheets and web fonts have been applied. + + Without JavaScript nothing holds DOMContentLoaded back for the stylesheets, + so the page first lays out unstyled and then shifts; act only after that. + """ + page.wait_for_load_state("load") + page.evaluate("document.fonts.ready.then(() => true)") diff --git a/cs_app/e2e/pages/about_page.py b/cs_app/e2e/pages/about_page.py index f30dfa2..c4ab182 100644 --- a/cs_app/e2e/pages/about_page.py +++ b/cs_app/e2e/pages/about_page.py @@ -14,7 +14,7 @@ def navigate_to(self) -> None: self.navigate(self.PATH) def get_main_heading(self) -> str | None: - return self.get_heading(level=2) + return self.get_heading(level=1) def get_body_text(self) -> str: return self.page.locator(".content-wrapper").inner_text() diff --git a/cs_app/e2e/pages/calculations_page.py b/cs_app/e2e/pages/calculations_page.py index c7fc341..2a40165 100644 --- a/cs_app/e2e/pages/calculations_page.py +++ b/cs_app/e2e/pages/calculations_page.py @@ -9,14 +9,16 @@ class CalculationsPage(BasePage): - """POM for the calculations page (``/calculations``). + """POM for the SWMM vs ANN comparison page (``/calculations``). - Note: This page does NOT require authentication — anonymous users - can access it, but the "Run Calculations" button redirects - unauthenticated users to login via JS (``data-authenticated``). + GET is public; running the comparison requires login. For anonymous users + the "Run Calculations" button redirects to login via JS + (``data-authenticated``). With JS the run is submitted by ``CS.asyncForm`` + and the results fragment replaces the contents of ``#calculations-output``. """ PATH = "/calculations" + RUN_TIMEOUT_MS = 60_000 def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) @@ -29,35 +31,104 @@ def navigate_to(self) -> None: # Content # ------------------------------------------------------------------ + def get_page_heading(self) -> str | None: + return self.get_heading(level=1) + def get_nn_heading(self) -> str | None: - """Return the ANN model heading text.""" - loc = self.page.locator("h3", has_text="Catchment Area Neural Network Model") + """Return the ANN model description heading text.""" + loc = self.page.locator("h2", has_text="Catchment Area Neural Network Model") if loc.count() > 0: return loc.inner_text() return None - def get_results_heading(self) -> str | None: - loc = self.page.locator("h4", has_text="Results Comparison") - if loc.count() > 0: - return loc.inner_text() - return None + @property + def results_heading(self) -> Locator: + return self.page.locator("#calculations-results-heading") + + @property + def validity_note(self) -> Locator: + return self.page.locator(".calc-note") + + @property + def empty_state(self) -> Locator: + return self.page.locator("#calculations-results .cs-empty-state") + + def model_details(self, title: str) -> Locator: + """The ``
`` section of the model description with *title*.""" + return self.page.locator( + "details.calc-details__item", has=self.page.locator("summary", has_text=title) + ) + + def has_nn_architecture_image(self) -> bool: + return self.page.get_by_role("img", name="Diagram of the neural network").is_visible() + + # ------------------------------------------------------------------ + # Run + # ------------------------------------------------------------------ @property def run_button(self) -> Locator: return self.page.locator("#run-calculations-button") + @property + def loading_state(self) -> Locator: + return self.page.locator("#calculations-loading-state") + + @property + def form_error(self) -> Locator: + return self.page.locator("#calculations-form .alert-danger") + def run_calculations(self) -> None: self.run_button.click() + def run_and_wait_for_results(self) -> None: + """Run the comparison and wait until the swapped-in results table is shown.""" + self.run_calculations() + self.results_table.wait_for(state="visible", timeout=self.RUN_TIMEOUT_MS) + + # ------------------------------------------------------------------ + # Results + # ------------------------------------------------------------------ + + @property + def results_table(self) -> Locator: + return self.page.locator("#calculations-table") + def has_results_table(self) -> bool: - return self.page.locator("table.table-striped").is_visible() + return self.results_table.is_visible() def get_results_columns(self) -> list[str]: """Return column headers of the results table.""" - return self.page.locator("table.table-striped thead th").all_inner_texts() + return [text.strip() for text in self.results_table.locator("thead th").all_inner_texts()] def get_results_row_count(self) -> int: - return self.page.locator("table.table-striped tbody tr").count() + return self.results_table.locator("tbody tr").count() - def has_nn_architecture_image(self) -> bool: - return self.page.locator("img[alt='Opis obrazu']").is_visible() + def column_values(self, index: int) -> list[str]: + """Text of every body cell in column *index* (0 = Name).""" + return [ + text.strip() + for text in self.results_table.locator( + f"tbody tr > :nth-child({index + 1})" + ).all_inner_texts() + ] + + def sort_by(self, column: str) -> None: + self.results_table.get_by_role("button", name=column, exact=True).click() + + def metric_value(self, label: str) -> str: + """Value text of the metric tile whose label is *label*.""" + tile = self.page.locator(".cs-metric", has=self.page.locator("dt", has_text=label)) + return tile.locator(".cs-metric__value").inner_text() + + @property + def parity_chart(self) -> Locator: + return self.page.locator("#calculations-parity-chart") + + @property + def bars_chart(self) -> Locator: + return self.page.locator("#calculations-bars-chart") + + @property + def copy_table_button(self) -> Locator: + return self.page.locator("#copy-calculations-table-button") diff --git a/cs_app/e2e/pages/contact_page.py b/cs_app/e2e/pages/contact_page.py index 696012a..73ccf10 100644 --- a/cs_app/e2e/pages/contact_page.py +++ b/cs_app/e2e/pages/contact_page.py @@ -10,11 +10,7 @@ class ContactPage(BasePage): """POM for the contact form page (``/contact``). - Form labels are in Polish: - - email → "Adres email" - - title → "Tytuł" - - content → "Treść" - - submit → "Wyślij" + Labels: "Email", "Subject", "Message", "Send me a copy"; submit "Send message". """ PATH = "/contact" @@ -44,7 +40,7 @@ def send_to_me_checkbox(self) -> Locator: @property def submit_button(self) -> Locator: - return self.page.get_by_role("button", name="Wyślij") + return self.page.get_by_role("button", name="Send message", exact=True) def fill_form(self, email: str, title: str, content: str, *, send_to_me: bool = False) -> None: self.email_input.fill(email) @@ -64,4 +60,4 @@ def has_form(self) -> bool: return self.email_input.is_visible() and self.title_input.is_visible() def get_error_messages(self) -> list[str]: - return self.page.locator(".errorlist li").all_inner_texts() + return self.page.locator(".invalid-feedback").all_inner_texts() diff --git a/cs_app/e2e/pages/home_page.py b/cs_app/e2e/pages/home_page.py index 61acaba..06cd828 100644 --- a/cs_app/e2e/pages/home_page.py +++ b/cs_app/e2e/pages/home_page.py @@ -2,11 +2,13 @@ from __future__ import annotations +from playwright.sync_api import Locator + from .base_page import BasePage class HomePage(BasePage): - """POM for the home page (``/``).""" + """POM for the home page (``/``): hero chart, tool cards and documentation.""" PATH = "/" @@ -14,20 +16,46 @@ def navigate_to(self) -> None: self.navigate(self.PATH) # ------------------------------------------------------------------ - # Content + # Hero # ------------------------------------------------------------------ def get_main_heading(self) -> str | None: - return self.get_heading(level=2) + return self.get_heading(level=1) + + @property + def hydrograph(self) -> Locator: + return self.page.locator("#plot-hydrograph") + + @property + def hero_metrics(self) -> Locator: + return self.page.locator("#hero-metrics .cs-metric") + + def wait_for_chart(self, chart: Locator) -> None: + """Wait until Plotly has drawn into *chart*.""" + chart.locator(".main-svg").first.wait_for(state="attached") + + # ------------------------------------------------------------------ + # Tools and documentation + # ------------------------------------------------------------------ + + def tool_link(self, name: str) -> Locator: + return self.page.locator(".home-tools").get_by_role("link", name=name, exact=True) - def get_plotly_charts(self): + def toc_link(self, name: str) -> Locator: + return self.page.locator("#docs-toc").get_by_role("link", name=name, exact=True) + + def get_plotly_charts(self) -> Locator: """Return locator for all Plotly chart containers.""" return self.page.locator("[id^='plot-']") - def get_code_examples(self): + def get_code_examples(self) -> Locator: """Return locator for code snippet blocks.""" return self.page.locator("pre.code-snippet") + def copy_button(self, example_id: str) -> Locator: + """Copy button of the snippet inside the ``#`` section.""" + return self.page.locator(f"#{example_id} .cs-code__copy") + def has_chart_data_script(self) -> bool: """Check whether the embedded chart-data JSON script tag exists.""" return self.page.locator("#chart-data").count() > 0 diff --git a/cs_app/e2e/pages/login_page.py b/cs_app/e2e/pages/login_page.py index 24cbe9d..3cacff1 100644 --- a/cs_app/e2e/pages/login_page.py +++ b/cs_app/e2e/pages/login_page.py @@ -29,7 +29,11 @@ def password_input(self) -> Locator: @property def submit_button(self) -> Locator: - return self.page.get_by_role("button", name="Login") + return self.page.get_by_role("button", name="Log in", exact=True) + + @property + def password_toggle(self) -> Locator: + return self.page.get_by_role("button", name="Show password", exact=True) def fill_credentials(self, username: str, password: str) -> None: self.username_input.fill(username) @@ -51,8 +55,5 @@ def has_form(self) -> bool: return self.username_input.is_visible() and self.password_input.is_visible() def get_error_messages(self) -> list[str]: - """Return non-field error messages shown by crispy forms. - - Uses ``all_inner_texts()`` to avoid race conditions. - """ - return self.page.locator(".errorlist li").all_inner_texts() + """Return the non-field error messages (wrong credentials) shown above the fields.""" + return self.page.locator("form .alert-danger li").all_inner_texts() diff --git a/cs_app/e2e/pages/nav_component.py b/cs_app/e2e/pages/nav_component.py index 6d71ddd..d9af8e5 100644 --- a/cs_app/e2e/pages/nav_component.py +++ b/cs_app/e2e/pages/nav_component.py @@ -1,4 +1,8 @@ -"""Navigation component POM — header navbar and footer.""" +"""Navigation component POM — header navbar, theme menu, account menu and footer. + +Below the ``lg`` breakpoint the navbar collapses behind a toggler; every +action opens it first, so callers do not need to care about the viewport. +""" from __future__ import annotations @@ -9,6 +13,8 @@ if TYPE_CHECKING: from playwright.sync_api import Page +THEME_MODES = ("light", "dark", "auto") + class NavComponent: """Header and footer navigation shared across all pages.""" @@ -17,49 +23,124 @@ def __init__(self, page: Page) -> None: self.page = page # ------------------------------------------------------------------ - # Header nav links + # Header structure # ------------------------------------------------------------------ @property def header(self) -> Locator: return self.page.locator("header") + @property + def toggler(self) -> Locator: + return self.header.locator(".navbar-toggler") + + @property + def menu(self) -> Locator: + return self.header.locator("#main-nav") + + def is_collapsed(self) -> bool: + """True when the hamburger is shown and the menu is closed.""" + return self.toggler.is_visible() and self.toggler.get_attribute("aria-expanded") != "true" + + def open_menu(self) -> None: + """Expand the collapsed navbar (no-op on wide viewports).""" + if self.is_collapsed(): + self.toggler.click() + self.header.locator("#main-nav.show").wait_for(state="visible") + + def link(self, name: str) -> Locator: + return self.header.get_by_role("link", name=name, exact=True) + + def current_link(self) -> Locator: + return self.header.locator("a[aria-current='page']") + + # ------------------------------------------------------------------ + # Header nav links + # ------------------------------------------------------------------ + + def _click_link(self, name: str) -> None: + self.open_menu() + self.link(name).click() + def click_home(self) -> None: - self.header.get_by_role("link", name="Home").click() + self._click_link("Home") def click_simulation(self) -> None: - self.header.get_by_role("link", name="Simulation").click() + self._click_link("Simulation") def click_timeseries(self) -> None: - self.header.get_by_role("link", name="Timeseries").click() + self._click_link("Timeseries") def click_calculations(self) -> None: - self.header.get_by_role("link", name="Calculations").click() + self._click_link("Calculations") def click_about(self) -> None: - self.header.get_by_role("link", name="About").click() + self._click_link("About") def click_logo(self) -> None: self.header.locator("a img[alt='Logo']").first.click() # ------------------------------------------------------------------ - # Auth buttons + # Auth / account # ------------------------------------------------------------------ + @property + def account_toggle(self) -> Locator: + return self.header.locator("#account-menu") + + @property + def logout_button(self) -> Locator: + """Logout submits a POST form, so it is a button rather than a link.""" + return self.header.get_by_role("button", name="Logout", exact=True) + + def open_account_menu(self) -> None: + self.open_menu() + if self.account_toggle.get_attribute("aria-expanded") != "true": + self.account_toggle.click() + self.logout_button.wait_for(state="visible") + def click_login(self) -> None: - self.header.get_by_role("link", name="Login").click() + self._click_link("Log in") def click_signup(self) -> None: - self.header.get_by_role("link", name="Sign-up").click() + self._click_link("Create account") + + def click_profile(self) -> None: + self.open_account_menu() + self.link("Profile").click() def click_logout(self) -> None: - self.header.get_by_role("link", name="Logout").click() + self.open_account_menu() + self.logout_button.click() def is_logged_in(self) -> bool: - return self.header.get_by_role("link", name="Logout").is_visible() + """Server rendered the account menu (independent of viewport/menu state).""" + return self.account_toggle.count() == 1 def is_logged_out(self) -> bool: - return self.header.get_by_role("link", name="Login").is_visible() + return self.link("Log in").count() == 1 and self.account_toggle.count() == 0 + + # ------------------------------------------------------------------ + # Theme + # ------------------------------------------------------------------ + + @property + def theme_toggle(self) -> Locator: + return self.header.locator("#theme-menu") + + def theme_option(self, mode: str) -> Locator: + return self.header.locator(f"[data-cs-theme-value='{mode}']") + + def set_theme(self, mode: str) -> None: + """Pick light/dark/auto from the theme menu.""" + if mode not in THEME_MODES: + raise ValueError(f"Unknown theme mode: {mode}") + self.open_menu() + self.theme_toggle.click() + self.theme_option(mode).click() + + def resolved_theme(self) -> str | None: + return self.page.locator("html").get_attribute("data-bs-theme") # ------------------------------------------------------------------ # Footer @@ -69,8 +150,11 @@ def is_logged_out(self) -> bool: def footer(self) -> Locator: return self.page.locator("footer") + def get_footer_link(self, name: str) -> Locator: + return self.footer.get_by_role("link", name=name, exact=True) + def get_footer_about_link(self) -> Locator: - return self.footer.get_by_role("link", name="About") + return self.get_footer_link("About") def get_footer_copyright(self) -> str: return self.footer.locator("p.text-muted").inner_text() diff --git a/cs_app/e2e/pages/register_page.py b/cs_app/e2e/pages/register_page.py index 1674eaf..9065b9b 100644 --- a/cs_app/e2e/pages/register_page.py +++ b/cs_app/e2e/pages/register_page.py @@ -46,7 +46,10 @@ def password2_input(self) -> Locator: @property def submit_button(self) -> Locator: - return self.page.get_by_role("button", name="Register") + return self.page.get_by_role("button", name="Create account", exact=True) + + def password_toggle(self, label: str = "password") -> Locator: + return self.page.get_by_role("button", name=f"Show {label}", exact=True) def fill_form( self, @@ -88,4 +91,5 @@ def has_form(self) -> bool: return self.username_input.is_visible() and self.password1_input.is_visible() def get_error_messages(self) -> list[str]: - return self.page.locator(".errorlist li").all_inner_texts() + """Inline field errors rendered under the inputs.""" + return self.page.locator(".invalid-feedback").all_inner_texts() diff --git a/cs_app/e2e/pages/simulation_page.py b/cs_app/e2e/pages/simulation_page.py index 51835f9..1982a98 100644 --- a/cs_app/e2e/pages/simulation_page.py +++ b/cs_app/e2e/pages/simulation_page.py @@ -7,11 +7,14 @@ from .base_page import BasePage from .upload_component import UploadComponent +WINDOW_MARKER = "__simulationPageMarker" + class SimulationPage(BasePage): """POM for the simulation page (``/simulation``).""" PATH = "/simulation" + RUN_TIMEOUT_MS = 120_000 def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) @@ -24,6 +27,10 @@ def navigate_to(self) -> None: # Form fields (public — tests assert on these) # ------------------------------------------------------------------ + @property + def form(self) -> Locator: + return self.page.locator("#simulation-form") + @property def option_select(self) -> Locator: return self.page.locator("#id_option") @@ -44,6 +51,22 @@ def step_input(self) -> Locator: def catchment_name_select(self) -> Locator: return self.page.locator("#id_catchment_name") + @property + def range_fields(self) -> Locator: + return self.page.locator("#simulation-range") + + @property + def range_hint(self) -> Locator: + return self.page.locator("#simulation-range-hint") + + @property + def literature_note(self) -> Locator: + return self.page.locator("#simulation-literature-note") + + @property + def model_hint(self) -> Locator: + return self.page.locator("#simulation-model-hint") + @property def run_button(self) -> Locator: return self.page.locator("#run-simulation-button") @@ -52,6 +75,58 @@ def run_button(self) -> Locator: def loading_spinner(self) -> Locator: return self.page.locator("#simulation-loading-state") + @property + def elapsed_counter(self) -> Locator: + return self.loading_spinner.locator("[data-cs-elapsed]") + + @property + def error_summary(self) -> Locator: + return self.form.locator(".alert-danger[role='alert']") + + # ------------------------------------------------------------------ + # Results canvas + # ------------------------------------------------------------------ + + @property + def canvas(self) -> Locator: + return self.page.locator("#simulation-canvas") + + @property + def empty_state(self) -> Locator: + return self.canvas.locator(".cs-empty-state") + + @property + def results_heading(self) -> Locator: + return self.page.locator("#simulation-results-heading") + + @property + def metric_tiles(self) -> Locator: + return self.page.locator("#simulation-metrics .cs-metric") + + @property + def chart(self) -> Locator: + return self.page.locator("#simulation-chart") + + @property + def results_table(self) -> Locator: + return self.page.locator("#simulation-table") + + @property + def results_rows(self) -> Locator: + return self.results_table.locator("tbody tr") + + @property + def download_button(self) -> Locator: + return self.page.locator("#download-simulation-results-button") + + @property + def download_png_button(self) -> Locator: + return self.page.locator("[data-sim-action='download-png']") + + @property + def copy_table_button(self) -> Locator: + return self.page.locator("[data-sim-action='copy-table']") + # ------------------------------------------------------------------ # Actions # ------------------------------------------------------------------ @@ -68,12 +143,22 @@ def set_stop(self, value: int | str) -> None: def set_step(self, value: int | str) -> None: self.step_input.fill(str(value)) + def set_range(self, start: int | str, stop: int | str, step: int | str) -> None: + self.set_start(start) + self.set_stop(stop) + self.set_step(step) + def set_catchment_name(self, value: str) -> None: self.catchment_name_select.select_option(value) def run_simulation(self) -> None: self.run_button.click() + def wait_for_results(self) -> None: + """Wait until an async run has swapped in the results fragment and drawn the chart.""" + self.results_heading.wait_for(timeout=self.RUN_TIMEOUT_MS) + self.chart.locator(".main-svg").first.wait_for(state="attached") + def wait_for_catchment_options(self) -> None: """Wait until the catchment dropdown has real options (not just placeholder).""" self.page.wait_for_function( @@ -81,6 +166,21 @@ def wait_for_catchment_options(self) -> None: timeout=10_000, ) + def load_sample_and_select_catchment(self) -> str: + """Load the bundled sample model and select its first subcatchment; return its id.""" + self.upload.click_sample_data() + self.wait_for_catchment_options() + catchment = next(option for option in self.get_catchment_options() if option) + self.set_catchment_name(catchment) + return catchment + + def mark_window(self) -> None: + """Tag the current document; the tag survives only if no full page load happens.""" + self.page.evaluate(f"window.{WINDOW_MARKER} = true") + + def window_is_marked(self) -> bool: + return self.page.evaluate(f"window.{WINDOW_MARKER} === true") + # ------------------------------------------------------------------ # Queries # ------------------------------------------------------------------ @@ -92,13 +192,13 @@ def is_loading(self) -> bool: return self.loading_spinner.is_visible() def has_results_table(self) -> bool: - return self.page.locator("table.table-bordered").is_visible() + return self.results_table.is_visible() def get_results_row_count(self) -> int: - return self.page.locator("table.table-bordered tbody tr").count() + return self.results_rows.count() def has_chart(self) -> bool: - return self.page.locator("#simulation-chart").is_visible() + return self.chart.is_visible() def has_download_button(self) -> bool: return self.page.get_by_role("button", name="Download Results").is_visible() @@ -110,3 +210,7 @@ def get_catchment_options(self) -> list[str]: def is_run_button_enabled(self) -> bool: return self.run_button.is_enabled() + + def column_values(self, index: int) -> list[str]: + """Text of the given column (0 = swept parameter) in current row order.""" + return self.results_rows.locator(f":scope > :nth-child({index + 1})").all_inner_texts() diff --git a/cs_app/e2e/pages/timeseries_page.py b/cs_app/e2e/pages/timeseries_page.py index 1b8b5f0..67da7c1 100644 --- a/cs_app/e2e/pages/timeseries_page.py +++ b/cs_app/e2e/pages/timeseries_page.py @@ -24,9 +24,17 @@ def navigate_to(self) -> None: # Form fields (public — tests assert on these) # ------------------------------------------------------------------ + def mode_radio(self, value: str) -> Locator: + """Radio of the 'single' / 'sweep' segmented control.""" + return self.page.locator(f"input[name='mode'][value='{value}']") + + @property + def sweep_fields(self) -> Locator: + return self.page.locator("#ts-sweep-fields") + @property - def mode_select(self) -> Locator: - return self.page.locator("#id_mode") + def runs_hint(self) -> Locator: + return self.page.locator("#ts-runs-hint") @property def feature_select(self) -> Locator: @@ -56,13 +64,50 @@ def run_button(self) -> Locator: def loading_spinner(self) -> Locator: return self.page.locator("#timeseries-loading-state") + @property + def canvas(self) -> Locator: + """Results region whose content the async run replaces.""" + return self.page.locator("#timeseries-canvas") + + @property + def results_heading(self) -> Locator: + return self.page.locator("#timeseries-results-heading") + + @property + def chart(self) -> Locator: + return self.page.locator("#timeseries-chart") + + @property + def empty_state(self) -> Locator: + return self.canvas.locator(".cs-empty-state") + + @property + def sweep_series_select(self) -> Locator: + return self.page.locator("#ts-sweep-series") + + @property + def sweep_table(self) -> Locator: + return self.page.locator("table[data-ts-table='sweep']") + + @property + def timestep_table(self) -> Locator: + return self.page.locator("table[data-ts-table='single']") + + @property + def timestep_toggle(self) -> Locator: + return self.page.locator("details[data-ts-data] > summary") + # ------------------------------------------------------------------ # Actions # ------------------------------------------------------------------ def set_mode(self, value: str) -> None: - """Set analysis mode: 'single' or 'sweep'.""" - self.mode_select.select_option(value) + """Set analysis mode: 'single' or 'sweep' (clicks the segmented control).""" + radio_id = self.mode_radio(value).get_attribute("id") + self.page.locator(f"label[for='{radio_id}']").click() + + def selected_mode(self) -> str | None: + return self.page.locator("input[name='mode']:checked").get_attribute("value") def set_feature(self, value: str) -> None: self.feature_select.select_option(value) @@ -83,6 +128,20 @@ def run_analysis(self) -> None: """Click 'Run Timeseries Analysis'.""" self.run_button.click() + def wait_for_results(self, timeout: float = 120_000) -> None: + """Wait until an async run has swapped in results and drawn the chart.""" + self.results_heading.wait_for(state="visible", timeout=timeout) + self.chart.locator(".main-svg").first.wait_for(state="attached", timeout=timeout) + + def load_sample_and_pick_catchment(self) -> str: + """Load the bundled sample model and select its first subcatchment.""" + self.upload.click_sample_data() + self.wait_for_catchment_options() + options = [o for o in self.get_catchment_options() if o] + assert options, "sample model has no subcatchments" + self.set_catchment_name(options[0]) + return options[0] + def wait_for_catchment_options(self) -> None: """Wait until the catchment dropdown has real options.""" self.page.wait_for_function( @@ -95,34 +154,48 @@ def wait_for_catchment_options(self) -> None: # ------------------------------------------------------------------ def has_form(self) -> bool: - return self.mode_select.is_visible() and self.run_button.is_visible() + return self.mode_radio("single").is_enabled() and self.run_button.is_visible() def is_loading(self) -> bool: return self.loading_spinner.is_visible() def has_chart(self) -> bool: - return self.page.locator("#timeseries-chart").is_visible() + """True once Plotly has drawn into the chart container.""" + return self.chart.locator(".main-svg").count() > 0 - def get_time_to_peak(self) -> str | None: - card = self.page.locator(".metric-card", has_text="Time to Peak") - if card.count() > 0: - return card.locator(".card-title").inner_text() + def get_metric(self, label: str) -> str | None: + """Value text (with unit) of the metric tile labelled *label*.""" + tile = self.page.locator( + ".cs-metric", has=self.page.locator(".cs-metric__label", has_text=label) + ) + if tile.count() > 0: + return tile.locator(".cs-metric__value").inner_text() return None + def get_time_to_peak(self) -> str | None: + return self.get_metric("Time to peak") + def get_runoff_volume(self) -> str | None: - card = self.page.locator(".metric-card", has_text="Runoff Volume") - if card.count() > 0: - return card.locator(".card-title").inner_text() - return None + return self.get_metric("Runoff volume") + + def get_peak_runoff(self) -> str | None: + return self.get_metric("Peak runoff") + + def sweep_legend_names(self) -> list[str]: + """Legend entries of the sweep chart, read from the Plotly traces ([] with a colour bar).""" + return self.chart.evaluate( + "el => el.layout.showlegend === false ? []" + " : el.data.filter(t => t.showlegend !== false).map(t => t.name)" + ) def has_download_results_button(self) -> bool: - return self.page.get_by_role("button", name="Download Results").is_visible() + return self.page.get_by_role("button", name="Download Results (.xlsx)").is_visible() def has_csv_download_button(self) -> bool: - return self.page.get_by_role("button", name="Export timeseries to CSV").is_visible() + return self.page.get_by_role("button", name="Download Results (.csv)").is_visible() def has_png_download_button(self) -> bool: - return self.page.get_by_role("button", name="Download chart as PNG").is_visible() + return self.page.get_by_role("button", name="Download PNG").is_visible() def get_catchment_options(self) -> list[str]: options = self.catchment_name_select.locator("option") diff --git a/cs_app/e2e/pages/upload_component.py b/cs_app/e2e/pages/upload_component.py index 832d9e6..526706c 100644 --- a/cs_app/e2e/pages/upload_component.py +++ b/cs_app/e2e/pages/upload_component.py @@ -1,12 +1,13 @@ """Reusable Upload Zone (Dropzone.js) component POM. -Used by Simulation, Timeseries, and Calculations pages. Upload clearing is -done via the Dropzone's × remove-link (``dz-remove``), not a standalone -"Clear" button. +Used by Simulation, Timeseries, and Calculations pages. The loaded model is +shown as a chip (``#upload-status``); clearing it is done via the chip's × +button (``.dz-remove``), not a standalone "Clear" button. """ from __future__ import annotations +import os from typing import TYPE_CHECKING from playwright.sync_api import Locator, expect @@ -41,11 +42,26 @@ def upload_status(self) -> Locator: def upload_status_text(self) -> Locator: return self.page.locator("#upload-status-text") + @property + def upload_status_meta(self) -> Locator: + """Size and subcatchment count shown in the model chip.""" + return self.page.locator("#upload-status-meta") + @property def dropzone_remove_link(self) -> Locator: - """The × remove link added by Dropzone ``addRemoveLinks: true``.""" + """The × button of the loaded-model chip (class ``dz-remove``).""" return self.page.locator(".dz-remove") + @property + def trigger(self) -> Locator: + """Keyboard-focusable button inside the drop target that opens the file dialog.""" + return self.page.locator("#my-dropzone .upload-trigger") + + @property + def error_preview_dismiss(self) -> Locator: + """Dismiss button of the failed upload preview.""" + return self.page.locator("#my-dropzone .dz-error .upload-preview-dismiss") + @property def file_input(self) -> Locator: """The hidden file input created by Dropzone.js (class ``dz-hidden-input``). @@ -71,17 +87,20 @@ def click_sample_data(self) -> None: def upload_file(self, path: str) -> None: """Upload a file by setting it on the hidden input. - Waits for the Dropzone success event (upload status becomes visible) - or for a Dropzone error element to appear. + Waits until the model chip shows this file (success) or a Dropzone + error element appears; matching the name keeps it correct when a + model is already loaded. """ self.file_input.set_input_files(path) - # Wait for async Dropzone processing to complete self.page.wait_for_function( - """() => { + """(name) => { const status = document.getElementById('upload-status'); - const error = document.querySelector('.dz-error'); - return (status && status.style.display !== 'none') || !!error; + const text = document.getElementById('upload-status-text'); + const loaded = status && status.style.display !== 'none' + && text && text.textContent.includes(name); + return loaded || !!document.querySelector('.dz-error'); }""", + arg=os.path.basename(path), timeout=15_000, ) @@ -96,6 +115,22 @@ def clear_upload(self) -> None: """Remove the file via Dropzone's × remove link.""" self.dropzone_remove_link.click() + def record_model_events(self) -> None: + """Store ``cs:model-changed`` / ``cs:model-cleared`` details on ``window`` for assertions.""" + self.page.evaluate( + """() => { + window.__csModelChanged = null; + window.__csModelCleared = false; + document.addEventListener('cs:model-changed', (e) => { window.__csModelChanged = e.detail; }); + document.addEventListener('cs:model-cleared', () => { window.__csModelCleared = true; }); + }""" + ) + + def wait_for_model_changed(self) -> dict: + """Wait for the next recorded ``cs:model-changed`` event and return its detail.""" + handle = self.page.wait_for_function("() => window.__csModelChanged", timeout=15_000) + return handle.json_value() + def wait_for_sample_button_ready(self) -> None: """Wait until the sample button is enabled (not loading).""" expect(self.sample_button).to_be_enabled(timeout=10_000) diff --git a/cs_app/e2e/pages/user_profile_page.py b/cs_app/e2e/pages/user_profile_page.py index e3d760c..a56bf5d 100644 --- a/cs_app/e2e/pages/user_profile_page.py +++ b/cs_app/e2e/pages/user_profile_page.py @@ -10,29 +10,36 @@ class UserProfilePage(BasePage): """POM for the user profile page (``/user//profile``). - The profile view is public for GET, but the form is read-only when - viewed by a user other than the owner (fields disabled, submit hidden). - Only the owner can POST updates. + The profile view is public for GET. The owner gets an editable bio form; + everyone else sees the bio as text, without a form or submit button. """ def navigate_to(self, user_id: int) -> None: self.navigate(f"/user/{user_id}/profile") # ------------------------------------------------------------------ - # Form fields + # Elements # ------------------------------------------------------------------ @property - def user_field(self) -> Locator: - return self.page.locator("#id_user") + def profile_name(self) -> Locator: + return self.page.locator("#profile-name") + + @property + def profile_meta(self) -> Locator: + return self.page.locator(".profile-card__meta") @property def bio_field(self) -> Locator: return self.page.locator("#id_bio") + @property + def bio_text(self) -> Locator: + return self.page.locator(".profile-card__bio") + @property def submit_button(self) -> Locator: - return self.page.get_by_role("button", name="Wyślij") + return self.page.get_by_role("button", name="Save profile", exact=True) # ------------------------------------------------------------------ # Actions @@ -52,8 +59,8 @@ def has_form(self) -> bool: return self.bio_field.is_visible() def is_read_only(self) -> bool: - """Check if the form is in read-only mode (fields disabled).""" - return self.bio_field.is_disabled() + """The bio is shown as text and there is no form to edit it.""" + return self.bio_field.count() == 0 and self.bio_text.is_visible() def has_submit_button(self) -> bool: return self.submit_button.count() > 0 and self.submit_button.is_visible() diff --git a/cs_app/e2e/test_about.py b/cs_app/e2e/test_about.py new file mode 100644 index 0000000..8618b1c --- /dev/null +++ b/cs_app/e2e/test_about.py @@ -0,0 +1,52 @@ +"""About page tests.""" + +from __future__ import annotations + +import re + +import pytest +from playwright.sync_api import Page, expect + +from .pages.about_page import AboutPage + +pytestmark = pytest.mark.e2e + + +@pytest.fixture() +def about(page: Page, live_server) -> AboutPage: + ap = AboutPage(page, live_server.url) + ap.navigate_to() + return ap + + +def test_heading_and_sections(about: AboutPage) -> None: + assert about.get_main_heading() == "About" + for heading in ("What it is", "Who it is for", "How it works", "Open source"): + expect(about.page.get_by_role("heading", level=2, name=heading, exact=True)).to_be_visible() + + +def test_open_source_links(about: AboutPage) -> None: + main = about.page.locator("main") + expected = { + "GitHub": "https://github.com/BuczynskiRafal/catchments_simulation", + "PyPI": "https://pypi.org/project/catchment-simulation/", + "Documentation": "https://catchments-simulation.readthedocs.io", + } + for name, href in expected.items(): + expect(main.get_by_role("link", name=re.compile(f"^{name}"))).to_have_attribute( + "href", href + ) + + +def test_contact_call_to_action(about: AboutPage) -> None: + about.page.locator("main").get_by_role("link", name="Get in touch", exact=True).click() + expect(about.page).to_have_url(re.compile(r".*/contact$")) + + +@pytest.mark.parametrize("scheme", ["light", "dark"]) +def test_no_serious_axe_violations(page: Page, live_server, scheme: str) -> None: + page.emulate_media(color_scheme=scheme) + ap = AboutPage(page, live_server.url) + ap.navigate_to() + report = ap.run_axe_audit() + assert not report.critical + report.serious, [v.id for v in report.critical + report.serious] diff --git a/cs_app/e2e/test_auth.py b/cs_app/e2e/test_auth.py index 1c50733..1fbf2fd 100644 --- a/cs_app/e2e/test_auth.py +++ b/cs_app/e2e/test_auth.py @@ -38,7 +38,7 @@ def test_register_success_redirects_to_home(self, page: Page, live_server, db) - password1=STRONG_ALT_PASSWORD, password2=STRONG_ALT_PASSWORD, ) - expect(page).to_have_url(re.compile(r".*/$")) + expect(page).to_have_url(f"{live_server.url}/") def test_register_mismatched_passwords_shows_error(self, page: Page, live_server, db) -> None: rp = RegisterPage(page, live_server.url) @@ -51,14 +51,8 @@ def test_register_mismatched_passwords_shows_error(self, page: Page, live_server password1=STRONG_ALT_PASSWORD, password2=OTHER_USER_PASSWORD, ) - page.wait_for_load_state("domcontentloaded") - # After invalid submit, we should still be on the register page - expect(page).to_have_url(re.compile(r".*/register/")) - # Page body should contain error feedback about passwords - body_text = page.locator("body").inner_text() - assert ( - "password" in body_text.lower() or "match" in body_text.lower() - ), "Expected password mismatch error feedback on page" + expect(page.locator("#error_1_id_password2")).to_contain_text("didn’t match") + expect(page).to_have_url(re.compile(r".*/register/$")) def test_register_duplicate_username_shows_error( self, page: Page, live_server, test_user @@ -74,10 +68,8 @@ def test_register_duplicate_username_shows_error( password1=STRONG_ALT_PASSWORD, password2=STRONG_ALT_PASSWORD, ) - page.wait_for_load_state("domcontentloaded") - expect(page).to_have_url(re.compile(r".*/register/")) - body_text = page.locator("body").inner_text() - assert "username" in body_text.lower() or "already" in body_text.lower() + expect(page.locator("#error_1_id_username")).to_contain_text("already exists") + expect(page).to_have_url(re.compile(r".*/register/$")) def test_register_weak_password_shows_error(self, page: Page, live_server, db) -> None: """Too simple password should be rejected by Django validators.""" @@ -91,15 +83,51 @@ def test_register_weak_password_shows_error(self, page: Page, live_server, db) - password1="123", password2="123", ) - page.wait_for_load_state("domcontentloaded") - expect(page).to_have_url(re.compile(r".*/register/")) - body_text = page.locator("body").inner_text() - assert "password" in body_text.lower() + expect(page.locator("#error_1_id_password2")).to_contain_text("too short") + expect(page).to_have_url(re.compile(r".*/register/$")) def test_register_page_title(self, page: Page, live_server) -> None: rp = RegisterPage(page, live_server.url) rp.navigate_to() - assert "Register" in rp.get_title() + assert "Create account" in rp.get_title() + assert rp.get_heading(level=1) == "Create account" + + def test_register_success_logs_the_user_in(self, page: Page, live_server, db) -> None: + rp = RegisterPage(page, live_server.url) + rp.navigate_to() + rp.register( + "fresh", "fresh@example.com", "Fresh", "User", STRONG_ALT_PASSWORD, STRONG_ALT_PASSWORD + ) + + expect(page.locator(".alert-success")).to_contain_text("Welcome, Fresh.") + assert NavComponent(page).is_logged_in() + + def test_invalid_fields_are_marked_inline_and_focused( + self, page: Page, live_server, db + ) -> None: + rp = RegisterPage(page, live_server.url) + rp.navigate_to() + rp.register( + "mismatch2", "m2@example.com", "Mis", "Match", STRONG_ALT_PASSWORD, OTHER_USER_PASSWORD + ) + + feedback = page.locator("#error_1_id_password2") + expect(feedback).to_be_visible() + expect(feedback).to_contain_text("didn’t match") + expect(rp.password2_input).to_have_attribute("aria-invalid", "true") + expect(rp.password2_input).to_have_attribute( + "aria-describedby", re.compile(r"\berror_1_id_password2\b") + ) + expect(rp.password2_input).to_be_focused() + # Valid fields keep their value and are not flagged. + expect(rp.email_input).to_have_value("m2@example.com") + expect(rp.email_input).not_to_have_attribute("aria-invalid", "true") + + def test_links_to_login(self, page: Page, live_server) -> None: + rp = RegisterPage(page, live_server.url) + rp.navigate_to() + page.locator("main").get_by_role("link", name="Log in", exact=True).click() + expect(page).to_have_url(re.compile(r".*/accounts/login/$")) class TestLogin: @@ -115,27 +143,105 @@ def test_login_success_redirects(self, page: Page, live_server, test_user) -> No lp.navigate_to() lp.login(TEST_USERNAME, TEST_PASSWORD) # Should redirect to home after successful login - expect(page).to_have_url(re.compile(r".*/$")) + expect(page).to_have_url(f"{live_server.url}/") def test_login_invalid_credentials_shows_error(self, page: Page, live_server, db) -> None: lp = LoginPage(page, live_server.url) lp.navigate_to() lp.login("nonexistent", "wrongpass") - page.wait_for_load_state("domcontentloaded") - # Should stay on login page - expect(page).to_have_url(re.compile(r".*/accounts/login/")) - # The login page should contain error feedback - content = page.locator(".content-wrapper").inner_text() - assert ( - "username" in content.lower() - or "password" in content.lower() - or "correct" in content.lower() + expect(page.locator("form .alert-danger")).to_contain_text( + "Please enter a correct username or email and password" ) + expect(page).to_have_url(re.compile(r".*/accounts/login/$")) def test_login_page_title(self, page: Page, live_server) -> None: lp = LoginPage(page, live_server.url) lp.navigate_to() - assert "Logowanie" in lp.get_title() + assert "Log in" in lp.get_title() + assert lp.get_heading(level=1) == "Log in" + + def test_wrong_credentials_error_is_tied_to_the_form(self, page: Page, live_server, db) -> None: + lp = LoginPage(page, live_server.url) + lp.navigate_to() + lp.login("nobody", "wrong-password") + + assert any("correct username or email and password" in m for m in lp.get_error_messages()) + error_id = page.locator("form .alert-danger").get_attribute("id") + expect(lp.username_input).to_have_attribute( + "aria-describedby", re.compile(rf"\b{error_id}\b") + ) + expect(lp.username_input).to_be_focused() + + def test_login_returns_to_next_page(self, page: Page, live_server, test_user) -> None: + page.goto(f"{live_server.url}/accounts/login/?next=/about") + LoginPage(page, live_server.url).login(TEST_USERNAME, TEST_PASSWORD) + expect(page).to_have_url(re.compile(r".*/about$")) + + def test_links_to_register(self, page: Page, live_server) -> None: + lp = LoginPage(page, live_server.url) + lp.navigate_to() + page.get_by_role("link", name="Create an account", exact=True).click() + expect(page).to_have_url(re.compile(r".*/register/$")) + + +class TestPasswordToggle: + def test_toggle_reveals_and_hides_password(self, page: Page, live_server) -> None: + lp = LoginPage(page, live_server.url) + lp.navigate_to() + lp.password_input.fill(STRONG_ALT_PASSWORD) + toggle = lp.password_toggle + expect(toggle).to_have_attribute("aria-pressed", "false") + expect(toggle).to_have_attribute("aria-controls", "id_password") + + toggle.click() + expect(lp.password_input).to_have_attribute("type", "text") + expect(toggle).to_have_attribute("aria-pressed", "true") + + toggle.press("Enter") + expect(lp.password_input).to_have_attribute("type", "password") + expect(toggle).to_have_attribute("aria-pressed", "false") + expect(lp.password_input).to_have_value(STRONG_ALT_PASSWORD) + + def test_each_register_password_gets_its_own_toggle(self, page: Page, live_server) -> None: + rp = RegisterPage(page, live_server.url) + rp.navigate_to() + rp.password_toggle("password confirmation").click() + + expect(rp.password2_input).to_have_attribute("type", "text") + expect(rp.password1_input).to_have_attribute("type", "password") + + def test_revealed_password_is_hidden_again_on_submit(self, page: Page, live_server) -> None: + lp = LoginPage(page, live_server.url) + lp.navigate_to() + lp.fill_credentials("someone", STRONG_ALT_PASSWORD) + lp.password_toggle.click() + # Registered after the page's handler, so it observes the state it leaves behind. + page.evaluate( + """() => document.querySelector("form[data-cs-form]").addEventListener("submit", (e) => { + e.preventDefault(); + window.__typeOnSubmit = document.getElementById("id_password").type; + })""" + ) + + lp.submit() + + assert page.evaluate("window.__typeOnSubmit") == "password" + expect(lp.password_toggle).to_have_attribute("aria-pressed", "false") + + +class TestAuthA11y: + @pytest.mark.parametrize("scheme", ["light", "dark"]) + @pytest.mark.parametrize("path", ["/accounts/login/", "/register/"]) + def test_no_serious_axe_violations( + self, page: Page, live_server, db, scheme: str, path: str + ) -> None: + page.emulate_media(color_scheme=scheme) + lp = LoginPage(page, live_server.url) + lp.navigate(path) + report = lp.run_axe_audit() + assert not report.critical + report.serious, [ + v.id for v in report.critical + report.serious + ] class TestLogout: diff --git a/cs_app/e2e/test_calculations.py b/cs_app/e2e/test_calculations.py index 8d45802..78ff834 100644 --- a/cs_app/e2e/test_calculations.py +++ b/cs_app/e2e/test_calculations.py @@ -1,7 +1,9 @@ -"""Calculations page tests.""" +"""Calculations (SWMM vs ANN comparison) page tests.""" from __future__ import annotations +import re + import pytest from playwright.sync_api import Page, expect @@ -9,84 +11,213 @@ pytestmark = pytest.mark.e2e +RESULT_COLUMNS = [ + "Name", + "SWMM Runoff [m³]", + "ANN Runoff [m³]", + "Difference [m³]", + "Difference [%]", +] + + +def _open(page: Page, live_server) -> CalculationsPage: + cp = CalculationsPage(page, live_server.url) + cp.navigate_to() + return cp + class TestCalculationsPageRendering: """Calculations page renders its content sections.""" + def test_page_heading_and_title(self, page: Page, live_server) -> None: + cp = _open(page, live_server) + assert cp.get_page_heading() == "SWMM vs neural network runoff" + assert "Calculations" in cp.get_title() + def test_nn_heading_visible(self, page: Page, live_server) -> None: - cp = CalculationsPage(page, live_server.url) - cp.navigate_to() + cp = _open(page, live_server) heading = cp.get_nn_heading() assert heading is not None assert "Neural Network" in heading - def test_results_comparison_heading(self, page: Page, live_server) -> None: - cp = CalculationsPage(page, live_server.url) - cp.navigate_to() - heading = cp.get_results_heading() - assert heading is not None - assert "Results Comparison" in heading - - def test_results_table_has_correct_columns(self, page: Page, live_server) -> None: - cp = CalculationsPage(page, live_server.url) - cp.navigate_to() - assert cp.has_results_table() - cols = cp.get_results_columns() - assert "Name" in cols - assert "SWMM Runoff [m3]" in cols - assert "ANN Runoff [m3]" in cols - - def test_nn_architecture_image(self, page: Page, live_server) -> None: - cp = CalculationsPage(page, live_server.url) - cp.navigate_to() + def test_empty_state_heading_before_first_run(self, page: Page, live_server) -> None: + cp = _open(page, live_server) + heading = cp.empty_state.get_by_role("heading", level=2) + expect(heading).to_have_text("No comparison yet") + # The results heading (the focus target after a run) comes with the results. + expect(cp.results_heading).to_have_count(0) + + def test_empty_state_before_first_run(self, page: Page, live_server) -> None: + cp = _open(page, live_server) + expect(cp.empty_state).to_be_visible() + expect(cp.empty_state).to_contain_text("No comparison yet") + expect(cp.results_table).to_have_count(0) + + def test_validity_note_visible(self, page: Page, live_server) -> None: + cp = _open(page, live_server) + expect(cp.validity_note).to_be_visible() + expect(cp.validity_note).to_contain_text("limited precipitation range") + + def test_model_details_expand_with_keyboard(self, page: Page, live_server) -> None: + cp = _open(page, live_server) + architecture = cp.model_details("Architecture") + assert not cp.has_nn_architecture_image() + + architecture.locator("summary").focus() + page.keyboard.press("Enter") + + expect(architecture).to_have_attribute("open", "") assert cp.has_nn_architecture_image() def test_upload_zone_present(self, page: Page, live_server) -> None: - cp = CalculationsPage(page, live_server.url) - cp.navigate_to() + cp = _open(page, live_server) assert cp.upload.is_visible() + expect(page.locator(".upload-zone-wrapper")).to_have_class( + re.compile("upload-zone--compact") + ) def test_run_calculations_button_present(self, page: Page, live_server) -> None: - cp = CalculationsPage(page, live_server.url) - cp.navigate_to() + cp = _open(page, live_server) expect(cp.run_button).to_be_visible() expect(cp.run_button).to_have_text("Run Calculations") - - def test_page_title(self, page: Page, live_server) -> None: - cp = CalculationsPage(page, live_server.url) - cp.navigate_to() - assert "Calculations" in cp.get_title() + expect(cp.loading_state).to_be_hidden() def test_anonymous_user_can_access_page(self, page: Page, live_server) -> None: """Calculations page does NOT require @login_required.""" - cp = CalculationsPage(page, live_server.url) - cp.navigate_to() - # Page should load OK, not redirect to login + cp = _open(page, live_server) assert "/calculations" in page.url - assert cp.has_results_table() + expect(cp.empty_state).to_be_visible() - def test_empty_results_show_placeholder(self, page: Page, live_server) -> None: - """Without running calculations, the table shows placeholder dashes.""" - cp = CalculationsPage(page, live_server.url) - cp.navigate_to() - first_cell = page.locator("table.table-striped tbody td").first - assert first_cell.inner_text().strip() == "-" + def test_anonymous_run_redirects_to_login(self, page: Page, live_server) -> None: + cp = _open(page, live_server) + cp.run_calculations() + page.wait_for_url("**/login/**", timeout=10_000) + assert "next=%2Fcalculations" in page.url + def test_no_horizontal_scroll_on_phone(self, page: Page, live_server) -> None: + page.set_viewport_size({"width": 360, "height": 780}) + _open(page, live_server) + overflow = page.evaluate( + "() => document.documentElement.scrollWidth - document.documentElement.clientWidth" + ) + assert overflow <= 0 -class TestCalculationsExecution: - """Running calculations (requires sample data + SWMM + ANN model).""" - @pytest.mark.slow - def test_full_calculation_run(self, auth_page: Page, live_server) -> None: - cp = CalculationsPage(auth_page, live_server.url) - cp.navigate_to() +class TestCalculationsAsyncErrors: + """Server errors are shown in the control panel without a page reload.""" - cp.upload.click_sample_data() + def test_run_without_model_shows_inline_error(self, auth_page: Page, live_server) -> None: + cp = _open(auth_page, live_server) + auth_page.evaluate("window.__csNoReload = true") + + cp.run_calculations() + expect(cp.form_error).to_have_text("Please upload a file first.") + expect(cp.form_error).to_have_attribute("role", "alert") + expect(cp.run_button).to_be_enabled() + expect(cp.run_button).to_be_focused() + expect(cp.loading_state).to_be_hidden() + expect(cp.empty_state).to_be_visible() + expect(auth_page.locator(".cs-toast--danger")).to_be_visible() + assert auth_page.evaluate("window.__csNoReload") is True + + def test_error_clears_on_next_run(self, auth_page: Page, live_server) -> None: + cp = _open(auth_page, live_server) cp.run_calculations() - auth_page.wait_for_load_state("domcontentloaded", timeout=60_000) + expect(cp.form_error).to_have_count(1) + + cp.run_calculations() + + # A repeated failure replaces the alert instead of stacking a second one. + expect(cp.form_error).to_have_count(1) + expect(cp.run_button).to_be_enabled() - assert cp.has_results_table() - # After running, rows should have actual values, not just dashes - row_count = cp.get_results_row_count() - assert row_count > 0 + +@pytest.mark.slow +class TestCalculationsExecution: + """Running calculations (requires sample data + SWMM + ANN model).""" + + @pytest.fixture() + def results_page(self, auth_page: Page, live_server) -> CalculationsPage: + cp = _open(auth_page, live_server) + cp.upload.click_sample_data() + auth_page.evaluate("window.__csNoReload = true") + cp.run_and_wait_for_results() + return cp + + def test_run_swaps_results_without_reload(self, results_page: CalculationsPage) -> None: + page = results_page.page + assert page.evaluate("window.__csNoReload") is True + expect(results_page.results_heading).to_be_focused() + expect(results_page.run_button).to_be_enabled() + expect(results_page.loading_state).to_be_hidden() + expect(results_page.empty_state).to_have_count(0) + expect(page.locator("#calculations-results")).to_have_count(1) + expect(page.locator(".cs-toast--success")).to_contain_text("Comparison finished") + + def test_results_table_columns_and_rows(self, results_page: CalculationsPage) -> None: + assert results_page.get_results_columns() == RESULT_COLUMNS + assert results_page.get_results_row_count() > 0 + assert results_page.column_values(0)[0] == "S1" + # SWMM reports 3.36 x 10^6 L for S1 of the sample model: shown in m³, as on the other tools. + assert results_page.column_values(1)[0] == "3360.00" + for value in results_page.column_values(1) + results_page.column_values(2): + assert re.fullmatch(r"-?[\d,]+\.\d{2}", value) + + def test_metric_tiles(self, results_page: CalculationsPage) -> None: + assert re.fullmatch(r"[\d,]+\.\d{2}m³", results_page.metric_value("Mean absolute error")) + assert re.fullmatch(r"[\d,]+\.\d{2}m³", results_page.metric_value("Root mean square error")) + assert results_page.metric_value("Subcatchments") == str( + results_page.get_results_row_count() + ) + + def test_charts_render_and_follow_theme(self, results_page: CalculationsPage) -> None: + page = results_page.page + for chart in (results_page.parity_chart, results_page.bars_chart): + expect(chart).to_have_attribute("role", "img") + expect(chart.locator(".main-svg").first).to_be_attached() + marker_color = ( + "() => document.getElementById('calculations-parity-chart').data[1].marker.color" + ) + light_color = page.evaluate(marker_color) + + results_page.nav.set_theme("dark") + + page.wait_for_function(f"({marker_color})() !== {light_color!r}") + expect(results_page.parity_chart.locator(".main-svg").first).to_be_attached() + expect(results_page.bars_chart.locator(".main-svg").first).to_be_attached() + + def test_table_sorts_by_difference(self, results_page: CalculationsPage) -> None: + header = results_page.results_table.locator("th", has_text="Difference [m³]") + + results_page.sort_by("Difference [m³]") + expect(header).to_have_attribute("aria-sort", "ascending") + ascending = [float(v.replace(",", "")) for v in results_page.column_values(3)] + assert ascending == sorted(ascending) + + results_page.sort_by("Difference [m³]") + expect(header).to_have_attribute("aria-sort", "descending") + descending = [float(v.replace(",", "")) for v in results_page.column_values(3)] + assert descending == sorted(ascending, reverse=True) + + def test_results_fit_phone_width(self, results_page: CalculationsPage) -> None: + page = results_page.page + page.set_viewport_size({"width": 360, "height": 780}) + # Plotly resizes the charts on a debounced window resize. + page.wait_for_function( + "() => document.documentElement.scrollWidth <= document.documentElement.clientWidth", + timeout=5_000, + ) + # The wide table scrolls inside its keyboard-focusable region instead. + expect(results_page.page.locator(".cs-table-wrap")).to_have_attribute("tabindex", "0") + + def test_rerun_replaces_results_in_place(self, results_page: CalculationsPage) -> None: + results_page.page.evaluate( + "document.getElementById('calculations-table').dataset.firstRun = '1'" + ) + results_page.run_calculations() + expect(results_page.results_table).not_to_have_attribute( + "data-first-run", "1", timeout=60_000 + ) + expect(results_page.results_table).to_have_count(1) + expect(results_page.results_heading).to_be_focused() diff --git a/cs_app/e2e/test_contact.py b/cs_app/e2e/test_contact.py index 483c16e..b007cf8 100644 --- a/cs_app/e2e/test_contact.py +++ b/cs_app/e2e/test_contact.py @@ -12,54 +12,66 @@ pytestmark = pytest.mark.e2e +@pytest.fixture() +def contact(page: Page, live_server) -> ContactPage: + cp = ContactPage(page, live_server.url) + cp.navigate_to() + return cp + + class TestContactForm: """Contact form rendering and submission.""" - def test_form_renders_with_polish_labels(self, page: Page, live_server) -> None: - cp = ContactPage(page, live_server.url) - cp.navigate_to() - assert cp.has_form() - # Labels are in Polish - expect(page.locator("label", has_text="Adres email")).to_be_visible() - expect(page.locator("label", has_text="Tytuł")).to_be_visible() - expect(page.locator("label", has_text="Treść")).to_be_visible() + def test_form_renders_with_english_labels(self, contact: ContactPage) -> None: + assert contact.has_form() + page = contact.page + expect(page.get_by_label("Email")).to_be_visible() + expect(page.get_by_label("Subject")).to_be_visible() + expect(page.get_by_label("Message")).to_be_visible() + expect(page.get_by_label("Send me a copy")).to_be_visible() - def test_submit_button_text_is_polish(self, page: Page, live_server) -> None: - cp = ContactPage(page, live_server.url) - cp.navigate_to() - expect(page.get_by_role("button", name="Wyślij")).to_be_visible() - - def test_valid_submission_posts_form(self, page: Page, live_server, db) -> None: - """After valid POST the view attempts ``HttpResponseRedirect(reverse('contact'))``. - - Note: The view has a bug — it uses ``reverse('contact')`` instead of - ``reverse('main:contact')``, which returns a 500 in test mode. This - test therefore only verifies the form submits without client-side - validation errors (i.e. all required fields are filled and the POST - is sent). Once the view bug is fixed, this test should be updated - to verify the redirect. - """ - cp = ContactPage(page, live_server.url) - cp.navigate_to() - cp.fill_form( - email="test@example.com", - title="Test Subject", - content="Test message body.", + def test_submit_button_text(self, contact: ContactPage) -> None: + expect(contact.submit_button).to_be_visible() + + def test_valid_submission_redirects_with_success_message( + self, contact: ContactPage, db + ) -> None: + contact.fill_form( + email="test@example.com", title="Test Subject", content="Test message body." ) - cp.submit() - page.wait_for_load_state("domcontentloaded") - # The form was submitted — we reached the server (not blocked by HTML5 validation) - # In current state this returns 500 due to the reverse() bug. + contact.submit() - def test_empty_submission_shows_validation_errors(self, page: Page, live_server) -> None: - cp = ContactPage(page, live_server.url) - cp.navigate_to() - cp.submit() - # HTML5 validation or crispy form errors should prevent empty submission - # The form should still be on the contact page - expect(page).to_have_url(re.compile(r".*/contact")) + expect(contact.page).to_have_url(re.compile(r".*/contact$")) + expect(contact.page.locator(".alert-success")).to_contain_text("Message sent.") + expect(contact.email_input).to_have_value("") + + def test_empty_submission_is_blocked_by_the_browser(self, contact: ContactPage) -> None: + contact.submit() + expect(contact.page).to_have_url(re.compile(r".*/contact$")) + expect(contact.page.locator(".alert-success")).to_have_count(0) + + def test_server_errors_are_inline_and_focused(self, contact: ContactPage, db) -> None: + """Server-side validation (the browser's is bypassed) marks and focuses the field.""" + page = contact.page + page.evaluate("() => document.querySelector('form[data-cs-form]').noValidate = true") + contact.fill_form(email="not-an-email", title="Subject", content="Body") + contact.submit() + + expect(page.locator("#error_1_id_email")).to_contain_text("Enter a valid email address.") + expect(contact.email_input).to_have_attribute("aria-invalid", "true") + expect(contact.email_input).to_be_focused() + expect(contact.content_input).to_have_value("Body") + + def test_page_title_and_heading(self, contact: ContactPage) -> None: + assert "Contact" in contact.get_title() + assert contact.get_heading(level=1) == "Contact" - def test_page_title(self, page: Page, live_server) -> None: + @pytest.mark.parametrize("scheme", ["light", "dark"]) + def test_no_serious_axe_violations(self, page: Page, live_server, scheme: str) -> None: + page.emulate_media(color_scheme=scheme) cp = ContactPage(page, live_server.url) cp.navigate_to() - assert "Kontakt" in cp.get_title() + report = cp.run_axe_audit() + assert not report.critical + report.serious, [ + v.id for v in report.critical + report.serious + ] diff --git a/cs_app/e2e/test_core_js.py b/cs_app/e2e/test_core_js.py new file mode 100644 index 0000000..a2b13cc --- /dev/null +++ b/cs_app/e2e/test_core_js.py @@ -0,0 +1,446 @@ +"""Contract tests for the shared core scripts (static/main/js/core/*). + +The run endpoints are mocked with Playwright routing so the client side of the +AJAX contract (DESIGN_SPEC §4) is exercised in isolation from the views. +""" + +from __future__ import annotations + +import json +import re + +import pytest +from playwright.sync_api import Page, Route, expect + +pytestmark = pytest.mark.e2e + +RUN_URL = "**/__cs_test_run__" +DOWNLOAD_URL = "**/__cs_test_download__" +TIMEOUT_TEXT = "The server did not finish the run." +ERROR_TOAST_TEXT = "The run did not complete." + +# Builds a run form on the current page and enhances it with CS.asyncForm. +MOUNT_FORM = """() => { + const main = document.getElementById("main-content"); + main.insertAdjacentHTML("beforeend", ` +
+ + + + + + +
+
`); + window.__events = []; + CS.asyncForm({ + form: document.getElementById("t-form"), + submitButton: document.getElementById("t-run"), + target: document.getElementById("t-results"), + loadingEl: document.getElementById("t-loading"), + successMessage: "Simulation finished", + onSuccess: (target) => window.__events.push(["success", target.id]), + onError: (error) => window.__events.push(["error", error.status, error.message]), + }); +}""" + + +def _mount(page: Page, live_server) -> None: + page.goto(f"{live_server.url}/about") + page.evaluate(MOUNT_FORM) + + +def _wait_for_route(page: Page, pending: list[Route]) -> None: + """Pump Playwright events until the intercepted request reaches the handler.""" + for _ in range(100): + if pending: + return + page.wait_for_timeout(20) + raise AssertionError("request was never routed") + + +def _json(route: Route, status: int, payload: dict) -> None: + route.fulfill(status=status, content_type="application/json", body=json.dumps(payload)) + + +class TestAsyncFormSuccess: + def test_busy_state_while_running_then_fragment_swapped_and_focused( + self, page: Page, live_server + ) -> None: + pending: list[Route] = [] + page.route(RUN_URL, lambda route: pending.append(route)) + _mount(page, live_server) + + page.locator("#t-run").click() + expect(page.locator("#t-run")).to_be_disabled() + expect(page.locator("#t-run")).to_have_attribute("aria-busy", "true") + expect(page.locator("#t-results")).to_have_attribute("aria-busy", "true") + expect(page.locator("#t-loading")).to_be_visible() + expect(page.locator("#t-loading [data-cs-elapsed]")).to_have_text(re.compile(r"^\d+ s$")) + + _wait_for_route(page, pending) + request = pending[0].request + assert request.method == "POST" + assert request.headers.get("x-requested-with") == "XMLHttpRequest" + assert 'name="csrfmiddlewaretoken"\r\n\r\nt\r\n' in (request.post_data or "") + pending[0].fulfill(status=200, content_type="text/html", body="

Results

42

") + + expect(page.locator("#t-results h3")).to_have_text("Results") + expect(page.locator("#t-results h3")).to_be_focused() + expect(page.locator("#t-run")).to_be_enabled() + expect(page.locator("#t-run")).not_to_have_attribute("aria-busy", "true") + expect(page.locator("#t-results")).not_to_have_attribute("aria-busy", "true") + expect(page.locator("#t-loading")).to_be_hidden() + assert page.evaluate("window.__events") == [["success", "t-results"]] + expect(page.locator(".toast-container .cs-toast--success")).to_have_text( + "Simulation finished" + ) + + def test_second_submit_while_running_is_ignored(self, page: Page, live_server) -> None: + pending: list[Route] = [] + page.route(RUN_URL, lambda route: pending.append(route)) + _mount(page, live_server) + + fetch_calls = page.evaluate( + """() => { + let calls = 0; + const realFetch = window.fetch; + window.fetch = (...args) => { calls += 1; return realFetch(...args); }; + const form = document.getElementById("t-form"); + form.requestSubmit(); + form.requestSubmit(); + return calls; + }""" + ) + assert fetch_calls == 1 + expect(page.locator("#t-run")).to_be_disabled() + _wait_for_route(page, pending) + pending[0].fulfill(status=200, content_type="text/html", body="

Done

") + expect(page.locator("#t-results h3")).to_have_text("Done") + + +class TestAsyncFormErrors: + def test_field_and_general_errors_render_inline_and_clear( + self, page: Page, live_server + ) -> None: + page.route( + RUN_URL, + lambda route: _json( + route, + 400, + { + "message": "Please correct the highlighted fields.", + "field_errors": {"start": ["Too large."], "__all__": ["Range too long."]}, + }, + ), + ) + _mount(page, live_server) + page.locator("#t-run").click() + + field = page.locator("#id_start") + expect(field).to_have_class(re.compile(r"\bis-invalid\b")) + expect(field).to_have_attribute("aria-invalid", "true") + expect(page.locator("#t-form .invalid-feedback")).to_have_text("Too large.") + expect(field).to_have_attribute("aria-describedby", "id_start-async-error") + alert = page.locator("#t-form .alert.alert-danger[role='alert']") + expect(alert).to_contain_text("Please correct the highlighted fields.") + expect(alert).to_contain_text("Range too long.") + expect(field).to_be_focused() + expect(page.locator("#t-run")).to_be_enabled() + expect(page.locator(".toast-container .cs-toast--danger")).to_contain_text(ERROR_TOAST_TEXT) + assert page.evaluate("window.__events") == [ + ["error", 400, "Please correct the highlighted fields."] + ] + + field.fill("2") + expect(field).not_to_have_class(re.compile(r"\bis-invalid\b")) + expect(page.locator("#t-form .invalid-feedback")).to_have_count(0) + + page.locator("#t-run").click() + expect(page.locator("#t-form .alert")).to_have_count(1) + + def test_field_errors_without_message_get_a_fields_summary( + self, page: Page, live_server + ) -> None: + page.route( + RUN_URL, + lambda route: _json(route, 400, {"message": "", "field_errors": {"start": ["Bad."]}}), + ) + _mount(page, live_server) + page.locator("#t-run").click() + + expect(page.locator("#t-form .alert-danger")).to_have_text( + "Please correct the highlighted fields." + ) + + @pytest.mark.parametrize( + "respond", + [ + lambda route: route.fulfill( + status=502, content_type="text/html", body="Bad gateway" + ), + lambda route: route.abort(), + ], + ids=["proxy-502-html", "network-failure"], + ) + def test_non_json_failures_show_timeout_message(self, page: Page, live_server, respond) -> None: + page.route(RUN_URL, respond) + _mount(page, live_server) + page.locator("#t-run").click() + + expect(page.locator("#t-form .alert-danger")).to_contain_text(TIMEOUT_TEXT) + expect(page.locator("#t-run")).to_be_enabled() + expect(page.locator("#t-run")).to_be_focused() + expect(page.locator("#t-loading")).to_be_hidden() + + @pytest.mark.parametrize( + ("content_type", "body"), + [ + ("application/json", json.dumps({"message": "Log in", "field_errors": {}})), + ("text/html", "

Unauthorized

"), + ], + ids=["json", "html"], + ) + def test_unauthenticated_redirects_to_login_with_next( + self, page: Page, live_server, content_type: str, body: str + ) -> None: + page.route( + RUN_URL, + lambda route: route.fulfill(status=401, content_type=content_type, body=body), + ) + _mount(page, live_server) + page.locator("#t-run").click() + + page.wait_for_url(re.compile(r".*/accounts/login/\?next=%2Fabout$")) + + +class TestAsyncFormReruns: + def test_failed_rerun_marks_kept_results_and_next_run_clears_previous_feedback( + self, page: Page, live_server + ) -> None: + responses = [ + lambda route: route.fulfill( + status=200, + content_type="text/html", + body="

First

", + ), + lambda route: _json(route, 400, {"message": "Bad range.", "field_errors": {}}), + lambda route: route.fulfill( + status=200, + content_type="text/html", + body="

Second

", + ), + ] + page.route(RUN_URL, lambda route: responses.pop(0)(route)) + _mount(page, live_server) + page.evaluate( + """() => document.getElementById("t-results").addEventListener( + "cs:beforeswap", (event) => window.__events.push(["beforeswap", event.target.id]))""" + ) + success_toasts = page.locator(".toast-container .cs-toast--success") + danger_toasts = page.locator(".toast-container .cs-toast--danger") + notice = page.locator("#t-results > .cs-stale-notice:first-child") + + page.locator("#t-run").click() + expect(page.locator("#t-results h3")).to_have_text("First") + expect(success_toasts).to_have_count(1) + expect(notice).to_have_count(0) + + page.locator("#t-run").click() + expect(notice).to_have_text(re.compile(r"^Showing results from the previous run")) + expect(page.locator("#t-results h3")).to_have_text("First") + expect(danger_toasts).to_have_count(1) + expect(success_toasts).to_have_count(0) + + page.locator("#t-run").click() + expect(page.locator("#t-results h3")).to_have_text("Second") + expect(page.locator("#t-results .cs-stale-notice")).to_have_count(0) + expect(danger_toasts).to_have_count(0) + expect(success_toasts).to_have_count(1) + assert [e for e in page.evaluate("window.__events") if e[0] == "beforeswap"] == [ + ["beforeswap", "t-results"], + ["beforeswap", "t-results"], + ] + + def test_failed_first_run_adds_no_stale_notice(self, page: Page, live_server) -> None: + page.route( + RUN_URL, lambda route: _json(route, 400, {"message": "Bad.", "field_errors": {}}) + ) + _mount(page, live_server) + page.locator("#t-run").click() + + expect(page.locator("#t-form .alert-danger")).to_have_text("Bad.") + expect(page.locator(".cs-stale-notice")).to_have_count(0) + + +class TestAsyncFormNativeSubmitters: + def test_download_button_submits_natively_without_loading( + self, page: Page, live_server + ) -> None: + run_calls: list[Route] = [] + page.route(RUN_URL, lambda route: run_calls.append(route)) + downloads: list[dict] = [] + + def fulfil_download(route: Route) -> None: + downloads.append( + { + "navigation": route.request.is_navigation_request(), + "xhr": route.request.headers.get("x-requested-with"), + } + ) + route.fulfill(status=200, content_type="text/html", body="

file

") + + page.route(DOWNLOAD_URL, fulfil_download) + _mount(page, live_server) + + page.locator("#t-download").click() + page.wait_for_url(re.compile(r".*/__cs_test_download__$")) + assert downloads == [{"navigation": True, "xhr": None}] + assert run_calls == [] + + +class TestAuthGuard: + def test_anonymous_action_button_redirects_to_login_with_next( + self, page: Page, live_server + ) -> None: + page.goto(f"{live_server.url}/about?tab=1") + page.evaluate( + """() => document.getElementById("main-content").insertAdjacentHTML( + "beforeend", '')""" + ) + page.get_by_role("button", name="Run", exact=True).click() + page.wait_for_url(re.compile(r".*/accounts/login/\?next=%2Fabout%3Ftab%3D1$")) + + +class TestToast: + def test_toast_renders_text_safely_and_can_be_dismissed(self, page: Page, live_server) -> None: + page.goto(f"{live_server.url}/about") + page.evaluate("CS.toast(' done', 'success')") + + toast = page.locator(".toast-container .toast.cs-toast--success") + expect(toast).to_be_visible() + expect(toast.locator(".toast-body")).to_have_text(" done") + expect(toast.locator("img")).to_have_count(0) + + toast.get_by_role("button", name="Close").click() + expect(page.locator(".toast-container .toast")).to_have_count(0) + + def test_unknown_level_falls_back_to_info(self, page: Page, live_server) -> None: + page.goto(f"{live_server.url}/about") + page.evaluate("CS.toast('hello', 'bogus')") + expect(page.locator(".toast-container .cs-toast--info")).to_be_visible() + + +class TestTableAndFormat: + MOUNT_TABLE = """() => { + document.getElementById("main-content").insertAdjacentHTML("beforeend", ` + + + + + + + +
NameRunoff
S102
S210
=SUM(A1), "x"-1.5
`); + CS.table.sortable(document.getElementById("t-table")); + }""" + + def _column(self, page: Page, index: int) -> list[str]: + return page.locator(f"#t-table tbody tr td:nth-child({index})").all_inner_texts() + + def test_numeric_aware_sorting_toggles_and_sets_aria_sort( + self, page: Page, live_server + ) -> None: + page.goto(f"{live_server.url}/about") + page.evaluate(self.MOUNT_TABLE) + runoff_header = page.locator("#t-table th").nth(1) + sort_button = runoff_header.get_by_role("button", name="Runoff") + + sort_button.click() + expect(runoff_header).to_have_attribute("aria-sort", "ascending") + assert self._column(page, 2) == ["-1.5", "2", "10"] + + sort_button.press("Enter") + expect(runoff_header).to_have_attribute("aria-sort", "descending") + assert self._column(page, 2) == ["10", "2", "-1.5"] + + name_header = page.locator("#t-table th").nth(0) + name_header.get_by_role("button", name="Name").click() + expect(name_header).to_have_attribute("aria-sort", "ascending") + expect(runoff_header).not_to_have_attribute("aria-sort", re.compile(".*")) + assert self._column(page, 1) == ['=SUM(A1), "x"', "S2", "S10"] + + def test_sortable_twice_binds_once(self, page: Page, live_server) -> None: + page.goto(f"{live_server.url}/about") + page.evaluate(self.MOUNT_TABLE) + page.evaluate("CS.table.sortable(document.getElementById('t-table'))") + header = page.locator("#t-table th").nth(1) + + header.get_by_role("button", name="Runoff").click() + expect(header).to_have_attribute("aria-sort", "ascending") + expect(header.locator(".cs-sort")).to_have_count(1) + + def test_csv_export_escapes_and_neutralises_formulas(self, page: Page, live_server) -> None: + page.goto(f"{live_server.url}/about") + page.evaluate(self.MOUNT_TABLE) + csv = page.evaluate("CS.table.toCSV(document.getElementById('t-table'))") + assert csv.split("\r\n") == [ + "Name,Runoff", + "S10,2", + "S2,10", + '"\'=SUM(A1), ""x""",-1.5', + ] + + def test_number_format(self, page: Page, live_server) -> None: + page.goto(f"{live_server.url}/about") + assert page.evaluate("CS.format.number(1234.5, 1)") == "1,234.5" + assert page.evaluate("CS.format.number('3.14159')") == "3.14" + assert page.evaluate("CS.format.number(null)") == "—" + + +class TestClipboard: + def test_copy_writes_text_and_confirms(self, page: Page, live_server) -> None: + page.context.grant_permissions(["clipboard-read", "clipboard-write"]) + page.goto(f"{live_server.url}/about") + + assert page.evaluate("CS.clipboard.copy('pip install catchment-simulation')") is True + assert page.evaluate("navigator.clipboard.readText()") == "pip install catchment-simulation" + expect(page.locator(".toast-container .cs-toast--success")).to_contain_text("Copied") + + +class TestChartsLifecycle: + """Charts about to be swapped out or already detached are purged (no leaked listeners).""" + + DRAW = """async () => { + const wrap = document.createElement("div"); + wrap.id = "t-charts"; + wrap.innerHTML = '
'; + document.getElementById("main-content").append(wrap); + const rows = [{x: 1, y: 2}, {x: 2, y: 3}]; + await CS.charts.line("t-a", rows, "x", "y"); + await CS.charts.line("t-b", rows, "x", "y"); + }""" + ATTACHED = "id => Boolean(document.getElementById(id)._responsiveChartHandler)" + + def test_before_swap_purges_charts_inside_the_target(self, page: Page, live_server) -> None: + page.goto(f"{live_server.url}/") + page.evaluate(self.DRAW) + assert page.evaluate(self.ATTACHED, "t-a") + + page.evaluate( + """() => document.getElementById("t-charts").dispatchEvent( + new CustomEvent("cs:beforeswap", {bubbles: true}))""" + ) + assert not page.evaluate(self.ATTACHED, "t-a") + assert not page.evaluate(self.ATTACHED, "t-b") + + def test_detached_chart_is_purged_on_next_render(self, page: Page, live_server) -> None: + page.goto(f"{live_server.url}/") + page.evaluate(self.DRAW) + detached = page.evaluate_handle( + """() => { const el = document.getElementById("t-a"); el.remove(); return el; }""" + ) + page.evaluate("""() => CS.charts.line("t-b", [{x: 1, y: 1}], "x", "y")""") + + assert not detached.evaluate("el => Boolean(el._responsiveChartHandler)") + assert page.evaluate(self.ATTACHED, "t-b") diff --git a/cs_app/e2e/test_home.py b/cs_app/e2e/test_home.py index b8ae2b2..258c758 100644 --- a/cs_app/e2e/test_home.py +++ b/cs_app/e2e/test_home.py @@ -1,49 +1,192 @@ -"""Home page tests.""" +"""Home page tests: hero hydrograph, tool cards, documentation and example charts.""" from __future__ import annotations +import re + import pytest from playwright.sync_api import Page, expect from .pages.home_page import HomePage +from .pages.nav_component import NavComponent pytestmark = pytest.mark.e2e +HERO_HEADING = "See how a subcatchment turns rain into runoff." +PHONE = {"width": 360, "height": 780} + + +@pytest.fixture() +def home(page: Page, live_server) -> HomePage: + hp = HomePage(page, live_server.url) + hp.navigate_to() + return hp + + +class TestHomeContent: + def test_heading_visible(self, home: HomePage) -> None: + assert home.get_main_heading() == HERO_HEADING + + def test_page_title(self, home: HomePage) -> None: + assert "Catchment Simulation" in home.get_title() + + def test_plotly_chart_containers_present(self, home: HomePage) -> None: + for chart_id in ("plot-hydrograph", "plot-slope", "plot-area", "plot-width"): + expect(home.page.locator(f"#{chart_id}")).to_have_count(1) + + def test_code_examples_present(self, home: HomePage) -> None: + expect(home.get_code_examples()).to_have_count(7) + + def test_chart_data_script_tag_exists(self, home: HomePage) -> None: + assert home.has_chart_data_script() + + def test_section_headings_are_present(self, home: HomePage) -> None: + expect(home.page.locator("h3", has_text="Simulation methods")).to_be_visible() + expect(home.page.locator("h3", has_text="Analysis functions")).to_be_visible() + + @pytest.mark.parametrize( + ("name", "path"), + [ + ("Parameter sweep", "/simulation"), + ("Hydrograph", "/timeseries"), + ("SWMM vs. neural network", "/calculations"), + ], + ) + def test_tool_cards_link_to_tools(self, home: HomePage, name: str, path: str) -> None: + expect(home.tool_link(name)).to_have_attribute("href", path) + + +class TestHeroChart: + def test_hydrograph_draws_rainfall_and_runoff(self, home: HomePage) -> None: + home.wait_for_chart(home.hydrograph) + + traces = home.page.evaluate( + "() => document.getElementById('plot-hydrograph').data.map(t => t.name)" + ) + assert "Rainfall Intensity [mm/h]" in traces + assert "Runoff Rate [CMS]" in traces + expect(home.hydrograph).to_have_attribute("role", "img") + expect(home.hydrograph).to_have_attribute("aria-describedby", "hero-metrics") + + def test_metrics_state_the_numbers_with_units(self, home: HomePage) -> None: + metrics = home.hero_metrics + expect(metrics).to_have_count(4) + expect(metrics.nth(0)).to_contain_text("Peak rainfall") + expect(metrics.nth(0)).to_contain_text("mm/h") + expect(metrics.nth(1)).to_contain_text("CMS") + expect(metrics.nth(2)).to_contain_text(re.compile(r"\d+\s*h\s*\d+\s*min")) + expect(metrics.nth(3)).to_contain_text("m³") + + def test_theme_switch_redraws_chart_in_place(self, page: Page, live_server) -> None: + page.emulate_media(color_scheme="light") + home = HomePage(page, live_server.url) + home.navigate_to() + home.wait_for_chart(home.hydrograph) + font_color = "() => document.getElementById('plot-hydrograph').layout.font.color" + light_ink = page.evaluate(font_color) + + NavComponent(page).set_theme("dark") + + expect(page.locator("html")).to_have_attribute("data-bs-theme", "dark") + page.wait_for_function(f"({font_color})() !== {light_ink!r}") + expect(home.hydrograph.locator(".main-svg").first).to_be_attached() + expect(home.hydrograph.locator(".cs-empty-state")).to_have_count(0) + + +class TestExampleCharts: + @pytest.mark.parametrize( + ("key", "x_label"), + [("slope", "Percent Slope [%]"), ("area", "Area [ha]"), ("width", "Width [m]")], + ) + def test_sweep_chart_uses_server_axis_labels( + self, home: HomePage, key: str, x_label: str + ) -> None: + chart = home.page.locator(f"#plot-{key}") + home.wait_for_chart(chart) + + expect(chart.locator(".xtitle")).to_have_text(x_label) + expect(chart.locator(".ytitle")).to_have_text("Total Runoff Volume [m³]") + + def test_each_chart_has_a_numeric_summary(self, home: HomePage) -> None: + summary = home.page.locator("#plot-slope-summary") + expect(home.page.locator("#plot-slope")).to_have_attribute( + "aria-describedby", "plot-slope-summary" + ) + expect(summary).to_contain_text("Percent Slope [%]") + expect(summary).to_contain_text("1 → 100") + + +class TestCodeCopy: + def test_copy_button_copies_snippet(self, page: Page, live_server) -> None: + page.context.grant_permissions(["clipboard-read", "clipboard-write"]) + home = HomePage(page, live_server.url) + home.navigate_to() + + home.copy_button("example-slope").click() + + expect(page.locator(".toast-container .cs-toast--success")).to_contain_text("copied") + copied = page.evaluate("navigator.clipboard.readText()") + assert copied.startswith("from catchment_simulation import FeaturesSimulation") + assert "simulate_percent_slope(start=1, stop=100, step=1)" in copied + + def test_copy_buttons_have_distinct_names(self, home: HomePage) -> None: + expect( + home.page.get_by_role("button", name="Copy the install command", exact=True) + ).to_be_visible() + expect( + home.page.get_by_role("button", name="Copy the width example", exact=True) + ).to_be_visible() + + +class TestTableOfContents: + def test_scrollspy_marks_the_visible_section(self, page: Page, live_server) -> None: + page.set_viewport_size({"width": 1366, "height": 900}) + home = HomePage(page, live_server.url) + home.navigate_to() + page.wait_for_load_state("load") + + page.locator("#analysis-functions").scroll_into_view_if_needed() + page.mouse.wheel(0, 200) + + expect(home.toc_link("Analysis functions")).to_have_class(re.compile(r"\bactive\b")) + expect(home.toc_link("The Python package")).not_to_have_class(re.compile(r"\bactive\b")) + + def test_toc_link_jumps_to_section(self, page: Page, live_server) -> None: + page.set_viewport_size({"width": 1366, "height": 900}) + home = HomePage(page, live_server.url) + home.navigate_to() + + home.toc_link("Width sweep").click() + + expect(page).to_have_url(re.compile(r"#example-width$")) + expect(page.locator("#example-width-title")).to_be_in_viewport() + + def test_toc_is_hidden_on_phones(self, page: Page, live_server) -> None: + page.set_viewport_size(PHONE) + home = HomePage(page, live_server.url) + home.navigate_to() + expect(page.locator("#docs-toc")).to_be_hidden() + + +class TestHomeLayoutAndA11y: + def test_no_horizontal_overflow_at_360px(self, page: Page, live_server) -> None: + page.set_viewport_size(PHONE) + home = HomePage(page, live_server.url) + home.navigate_to() + home.wait_for_chart(home.hydrograph) + assert page.evaluate( + "document.documentElement.scrollWidth <= window.innerWidth" + ), "home page scrolls horizontally at 360px" + + @pytest.mark.parametrize("scheme", ["light", "dark"]) + def test_no_serious_axe_violations(self, page: Page, live_server, scheme: str) -> None: + page.emulate_media(color_scheme=scheme) + home = HomePage(page, live_server.url) + home.navigate_to() + home.wait_for_chart(home.hydrograph) + + report = home.run_axe_audit() -class TestHomePage: - """Home / main_view page content tests.""" - - def test_heading_visible(self, page: Page, live_server) -> None: - hp = HomePage(page, live_server.url) - hp.navigate_to() - assert hp.get_main_heading() == "Catchment Simulation" - - def test_page_title(self, page: Page, live_server) -> None: - hp = HomePage(page, live_server.url) - hp.navigate_to() - assert "Catchment Simulation" in hp.get_title() - - def test_plotly_chart_containers_present(self, page: Page, live_server) -> None: - hp = HomePage(page, live_server.url) - hp.navigate_to() - charts = hp.get_plotly_charts() - # Should have at least 3 chart divs (slope, area, width) - assert charts.count() >= 3 - - def test_code_examples_present(self, page: Page, live_server) -> None: - hp = HomePage(page, live_server.url) - hp.navigate_to() - examples = hp.get_code_examples() - assert examples.count() >= 1 - - def test_chart_data_script_tag_exists(self, page: Page, live_server) -> None: - hp = HomePage(page, live_server.url) - hp.navigate_to() - assert hp.has_chart_data_script() - - def test_section_headings_are_present(self, page: Page, live_server) -> None: - hp = HomePage(page, live_server.url) - hp.navigate_to() - # Key section headings - expect(page.locator("h3", has_text="Simulation Methods")).to_be_visible() - expect(page.locator("h3", has_text="Analysis Functions")).to_be_visible() + assert not report.critical + report.serious, [ + v.id for v in report.critical + report.serious + ] diff --git a/cs_app/e2e/test_navigation.py b/cs_app/e2e/test_navigation.py index 71122d8..b77bc49 100644 --- a/cs_app/e2e/test_navigation.py +++ b/cs_app/e2e/test_navigation.py @@ -1,4 +1,4 @@ -"""Navigation tests — header, footer, logo links. +"""Navigation tests — header, footer, logo links, theme and account menus. Note: /simulation and /timeseries have @login_required, so clicking those links as anonymous navigates to /accounts/login/?next=/simulation. The nav @@ -33,7 +33,7 @@ def test_simulation_link_redirects_anonymous_to_login(self, page: Page, live_ser page.goto(f"{live_server.url}/") nav = NavComponent(page) nav.click_simulation() - page.wait_for_load_state("domcontentloaded") + page.wait_for_url(re.compile(r".*/accounts/login/\?")) parsed = urlparse(page.url) assert parsed.path == "/accounts/login/" assert parse_qs(parsed.query).get("next") == ["/simulation"] @@ -43,7 +43,7 @@ def test_timeseries_link_redirects_anonymous_to_login(self, page: Page, live_ser page.goto(f"{live_server.url}/") nav = NavComponent(page) nav.click_timeseries() - page.wait_for_load_state("domcontentloaded") + page.wait_for_url(re.compile(r".*/accounts/login/\?")) parsed = urlparse(page.url) assert parsed.path == "/accounts/login/" assert parse_qs(parsed.query).get("next") == ["/timeseries"] @@ -90,12 +90,33 @@ def test_anonymous_user_sees_login_and_signup(self, page: Page, live_server) -> page.goto(f"{live_server.url}/") nav = NavComponent(page) assert nav.is_logged_out() - expect(page.get_by_role("link", name="Sign-up")).to_be_visible() + expect(page.get_by_role("link", name="Create account")).to_be_visible() def test_authenticated_user_sees_logout(self, auth_page: Page, live_server) -> None: auth_page.goto(f"{live_server.url}/") nav = NavComponent(auth_page) assert nav.is_logged_in() + nav.open_account_menu() + expect(nav.logout_button).to_be_visible() + + def test_account_menu_links_to_own_profile( + self, auth_page: Page, live_server, test_user + ) -> None: + auth_page.goto(f"{live_server.url}/") + nav = NavComponent(auth_page) + expect(nav.account_toggle).to_contain_text(test_user.username) + nav.click_profile() + expect(auth_page).to_have_url(re.compile(rf".*/user/{test_user.id}/profile$")) + + def test_account_menu_is_keyboard_operable(self, auth_page: Page, live_server) -> None: + auth_page.goto(f"{live_server.url}/") + nav = NavComponent(auth_page) + nav.account_toggle.focus() + auth_page.keyboard.press("Enter") + expect(nav.account_toggle).to_have_attribute("aria-expanded", "true") + expect(nav.link("Profile")).to_be_visible() + auth_page.keyboard.press("Escape") + expect(nav.account_toggle).to_have_attribute("aria-expanded", "false") def test_login_button_navigates_to_login_page(self, page: Page, live_server) -> None: page.goto(f"{live_server.url}/") @@ -110,6 +131,117 @@ def test_signup_button_navigates_to_register_page(self, page: Page, live_server) expect(page).to_have_url(re.compile(r".*/register/$")) +class TestActivePage: + """The current section is exposed with aria-current, not only colour.""" + + @pytest.mark.parametrize( + ("path", "name"), + [("/", "Home"), ("/about", "About"), ("/calculations", "Calculations")], + ) + def test_current_link_is_marked(self, page: Page, live_server, path: str, name: str) -> None: + page.goto(f"{live_server.url}{path}") + nav = NavComponent(page) + expect(nav.current_link()).to_have_count(1) + expect(nav.current_link()).to_have_text(name) + + def test_authenticated_simulation_is_marked(self, auth_page: Page, live_server) -> None: + auth_page.goto(f"{live_server.url}/simulation") + expect(NavComponent(auth_page).current_link()).to_have_text("Simulation") + + +class TestSkipLink: + def test_skip_link_is_first_tab_stop_and_targets_main(self, page: Page, live_server) -> None: + page.goto(f"{live_server.url}/about") + page.keyboard.press("Tab") + skip = page.get_by_role("link", name="Skip to content") + expect(skip).to_be_focused() + expect(skip).to_be_visible() + page.keyboard.press("Enter") + expect(page).to_have_url(re.compile(r".*/about#main-content$")) + expect(page.locator("main#main-content")).to_be_focused() + + +class TestThemeToggle: + """Light/dark/auto colour modes, persistence and the cs:themechange contract.""" + + def test_dark_mode_applies_persists_and_marks_choice(self, page: Page, live_server) -> None: + page.emulate_media(color_scheme="light") + page.goto(f"{live_server.url}/about") + nav = NavComponent(page) + assert nav.resolved_theme() == "light" + + nav.set_theme("dark") + assert nav.resolved_theme() == "dark" + expect(nav.theme_option("dark")).to_have_attribute("aria-pressed", "true") + expect(nav.theme_option("light")).to_have_attribute("aria-pressed", "false") + + page.reload() + assert nav.resolved_theme() == "dark" + assert page.evaluate("localStorage.getItem('cs-theme')") == "dark" + + def test_keyboard_choice_returns_focus_to_the_toggle(self, page: Page, live_server) -> None: + page.goto(f"{live_server.url}/about") + toggle = page.locator("#theme-menu") + toggle.focus() + page.keyboard.press("Enter") + page.keyboard.press("ArrowDown") + page.keyboard.press("ArrowDown") + expect(NavComponent(page).theme_option("dark")).to_be_focused() + + page.keyboard.press("Enter") + + assert NavComponent(page).resolved_theme() == "dark" + expect(toggle).to_be_focused() + + def test_theme_change_event_reports_resolved_theme(self, page: Page, live_server) -> None: + page.emulate_media(color_scheme="light") + page.goto(f"{live_server.url}/about") + page.evaluate( + """() => { + window.__themeEvents = []; + document.addEventListener("cs:themechange", (e) => window.__themeEvents.push(e.detail.theme)); + }""" + ) + nav = NavComponent(page) + nav.set_theme("dark") + nav.set_theme("auto") + assert page.evaluate("window.__themeEvents") == ["dark", "light"] + + def test_auto_follows_system_preference_live(self, page: Page, live_server) -> None: + page.emulate_media(color_scheme="light") + page.goto(f"{live_server.url}/about") + nav = NavComponent(page) + nav.set_theme("auto") + assert page.evaluate("localStorage.getItem('cs-theme')") is None + page.evaluate( + """() => { + window.__themeEvents = []; + document.addEventListener("cs:themechange", (e) => window.__themeEvents.push(e.detail.theme)); + }""" + ) + + page.emulate_media(color_scheme="dark") + page.wait_for_function("document.documentElement.dataset.bsTheme === 'dark'") + assert page.evaluate("window.__themeEvents") == ["dark"] + + def test_stored_theme_applies_before_body_is_parsed(self, page: Page, live_server) -> None: + """theme-init.js runs in , so the theme is set before any content can paint.""" + page.emulate_media(color_scheme="light") + page.add_init_script( + """try { localStorage.setItem('cs-theme', 'dark'); } catch (e) {} + new MutationObserver((mutations, observer) => { + if (document.body) { + window.__themeAtBody = document.documentElement.dataset.bsTheme; + observer.disconnect(); + } + }).observe(document, { childList: true, subtree: true });""" + ) + page.goto(f"{live_server.url}/about") + assert page.evaluate("window.__themeAtBody") == "dark" + bg = page.evaluate("getComputedStyle(document.body).backgroundColor") + assert bg == "rgb(11, 22, 32)" + + class TestFooter: """Footer link and content tests.""" @@ -130,3 +262,14 @@ def test_footer_logo_navigates_home(self, page: Page, live_server) -> None: nav = NavComponent(page) nav.click_footer_logo() expect(page).to_have_url(re.compile(r".*/$")) + + def test_footer_project_links(self, page: Page, live_server) -> None: + page.goto(f"{live_server.url}/") + nav = NavComponent(page) + expect(nav.get_footer_link("Contact")).to_have_attribute("href", "/contact") + expect(nav.get_footer_link("GitHub")).to_have_attribute( + "href", "https://github.com/BuczynskiRafal/catchments_simulation" + ) + expect(nav.get_footer_link("PyPI")).to_have_attribute( + "href", "https://pypi.org/project/catchment-simulation/" + ) diff --git a/cs_app/e2e/test_profile.py b/cs_app/e2e/test_profile.py index 2f75782..9e73abb 100644 --- a/cs_app/e2e/test_profile.py +++ b/cs_app/e2e/test_profile.py @@ -1,71 +1,76 @@ """User profile page tests. Profile page (``/user//profile``) is public for GET, but only the -owner can edit and submit the form. Other users see a read-only view. +owner can edit the bio. Everyone else sees it as text. """ from __future__ import annotations +import re + import pytest -from playwright.sync_api import Page +from django.contrib.auth.models import User +from playwright.sync_api import Page, expect -from .conftest import OTHER_USER_PASSWORD +from .conftest import OTHER_USER_PASSWORD, TEST_EMAIL, TEST_FIRST_NAME, TEST_LAST_NAME from .pages.user_profile_page import UserProfilePage pytestmark = pytest.mark.e2e -class TestProfileRendering: - """Profile page renders for the owner.""" +@pytest.fixture() +def other_user(db) -> User: + return User.objects.create_user( + username="other_user", email="other@example.com", password=OTHER_USER_PASSWORD + ) + - def test_own_profile_shows_form(self, auth_page: Page, live_server, test_user) -> None: +class TestOwnProfile: + def test_shows_identity_and_editable_bio(self, auth_page: Page, live_server, test_user) -> None: pp = UserProfilePage(auth_page, live_server.url) pp.navigate_to(test_user.id) + + assert pp.get_heading(level=1) == "Profile" + expect(pp.profile_name).to_have_text(f"{TEST_FIRST_NAME} {TEST_LAST_NAME}") + expect(pp.profile_meta).to_contain_text(TEST_EMAIL) assert pp.has_form() + assert not pp.is_read_only() + assert pp.has_submit_button() + expect(auth_page.locator("#id_user")).to_have_count(0) - def test_own_profile_has_submit_button(self, auth_page: Page, live_server, test_user) -> None: + def test_save_bio_round_trip(self, auth_page: Page, live_server, test_user) -> None: pp = UserProfilePage(auth_page, live_server.url) pp.navigate_to(test_user.id) - assert pp.has_submit_button() - def test_own_profile_fields_are_editable(self, auth_page: Page, live_server, test_user) -> None: + pp.fill_bio("Urban drainage engineer.") + pp.submit() + + expect(auth_page).to_have_url(re.compile(rf".*/user/{test_user.id}/profile$")) + expect(pp.bio_field).to_have_value("Urban drainage engineer.") + + def test_page_title(self, auth_page: Page, live_server, test_user) -> None: pp = UserProfilePage(auth_page, live_server.url) pp.navigate_to(test_user.id) - assert not pp.is_read_only() + assert "Profile" in pp.get_title() -class TestProfileReadOnly: - """Other users see the profile in read-only mode.""" - +class TestOtherProfile: def test_other_user_profile_is_read_only( - self, auth_page: Page, live_server, test_user, db + self, auth_page: Page, live_server, test_user, other_user ) -> None: - """Create a second user and view their profile — should be read-only.""" - from django.contrib.auth.models import User - - other = User.objects.create_user( - username="other_user", - email="other@example.com", - password=OTHER_USER_PASSWORD, - ) pp = UserProfilePage(auth_page, live_server.url) - pp.navigate_to(other.id) - assert pp.has_form() + pp.navigate_to(other_user.id) + expect(pp.profile_name).to_have_text("other_user") assert pp.is_read_only() + expect(pp.bio_text).to_have_text("No bio yet.") - def test_other_user_profile_hides_submit( - self, auth_page: Page, live_server, test_user, db + def test_other_user_profile_hides_submit_and_email( + self, auth_page: Page, live_server, test_user, other_user ) -> None: - from django.contrib.auth.models import User - - other = User.objects.create_user( - username="other_user2", - email="other2@example.com", - password=OTHER_USER_PASSWORD, - ) pp = UserProfilePage(auth_page, live_server.url) - pp.navigate_to(other.id) + pp.navigate_to(other_user.id) assert not pp.has_submit_button() + expect(pp.profile_meta).not_to_contain_text("other@example.com") class TestProfileAnonymous: @@ -74,5 +79,17 @@ class TestProfileAnonymous: def test_anonymous_can_view_profile(self, page: Page, live_server, test_user) -> None: pp = UserProfilePage(page, live_server.url) pp.navigate_to(test_user.id) - assert pp.has_form() + expect(pp.profile_name).to_be_visible() assert pp.is_read_only() + + @pytest.mark.parametrize("scheme", ["light", "dark"]) + def test_no_serious_axe_violations( + self, page: Page, live_server, test_user, scheme: str + ) -> None: + page.emulate_media(color_scheme=scheme) + pp = UserProfilePage(page, live_server.url) + pp.navigate_to(test_user.id) + report = pp.run_axe_audit() + assert not report.critical + report.serious, [ + v.id for v in report.critical + report.serious + ] diff --git a/cs_app/e2e/test_responsive.py b/cs_app/e2e/test_responsive.py index 1ee7a81..e4c8e33 100644 --- a/cs_app/e2e/test_responsive.py +++ b/cs_app/e2e/test_responsive.py @@ -1,8 +1,8 @@ """Responsive layout tests. Verifies that the app works correctly at different viewport sizes. -The current nav uses ``flex-wrap`` (not a collapsible hamburger menu), -so we test that content wraps properly instead of checking for a toggler. +The navbar is ``navbar-expand-lg``: below 992px the links collapse behind a +toggler, at and above it they are always visible. Simulation/Timeseries pages require @login_required, so responsive tests for those pages use auth_page. @@ -10,10 +10,13 @@ from __future__ import annotations +import re + import pytest from playwright.sync_api import Page, expect from .pages.home_page import HomePage +from .pages.nav_component import NavComponent pytestmark = pytest.mark.e2e @@ -23,6 +26,7 @@ "desktop": {"width": 1920, "height": 1080}, "bootstrap_breakpoint": {"width": 992, "height": 768}, } +COLLAPSED = {"mobile", "tablet"} class TestResponsiveHome: @@ -46,15 +50,25 @@ def test_heading_visible_at_all_viewports( VIEWPORTS.items(), ids=VIEWPORTS.keys(), ) - def test_nav_links_visible_at_all_viewports( + def test_nav_links_reachable_at_all_viewports( self, page: Page, live_server, viewport_name: str, viewport: dict ) -> None: - """Nav uses flex-wrap, so all links should remain visible (wrapped, not collapsed).""" + """Links are visible on lg+, and one toggler click away below it.""" page.set_viewport_size(viewport) page.goto(f"{live_server.url}/") - # Use header-scoped selector to avoid ambiguity with footer "About" link - expect(page.locator("header").get_by_role("link", name="Home")).to_be_visible() - expect(page.locator("header").get_by_role("link", name="About")).to_be_visible() + nav = NavComponent(page) + + if viewport_name in COLLAPSED: + expect(nav.toggler).to_be_visible() + expect(nav.toggler).to_have_attribute("aria-expanded", "false") + expect(nav.link("Home")).to_be_hidden() + nav.open_menu() + expect(nav.toggler).to_have_attribute("aria-expanded", "true") + else: + expect(nav.toggler).to_be_hidden() + + for name in ("Home", "Simulation", "Timeseries", "Calculations", "About", "Log in"): + expect(nav.link(name)).to_be_visible() def test_no_horizontal_overflow_mobile(self, page: Page, live_server) -> None: page.set_viewport_size(VIEWPORTS["mobile"]) @@ -66,6 +80,69 @@ def test_no_horizontal_overflow_mobile(self, page: Page, live_server) -> None: body_width <= viewport_width + 5 ), f"Horizontal overflow detected: body={body_width}px > viewport={viewport_width}px" + def test_mobile_menu_navigates_and_closes(self, page: Page, live_server) -> None: + page.set_viewport_size(VIEWPORTS["mobile"]) + page.goto(f"{live_server.url}/") + nav = NavComponent(page) + nav.click_about() + expect(page).to_have_url(re.compile(r".*/about$")) + expect(nav.toggler).to_have_attribute("aria-expanded", "false") + + def test_mobile_theme_menu_is_usable(self, page: Page, live_server) -> None: + page.set_viewport_size(VIEWPORTS["mobile"]) + page.emulate_media(color_scheme="light") + page.goto(f"{live_server.url}/") + nav = NavComponent(page) + nav.set_theme("dark") + assert nav.resolved_theme() == "dark" + + def test_no_horizontal_overflow_mobile_with_menu_open( + self, auth_page: Page, live_server + ) -> None: + auth_page.set_viewport_size(VIEWPORTS["mobile"]) + auth_page.goto(f"{live_server.url}/") + nav = NavComponent(auth_page) + nav.open_account_menu() + overflow = auth_page.evaluate("document.body.scrollWidth - window.innerWidth") + assert overflow <= 5, f"Horizontal overflow with menu open: {overflow}px" + + +class TestNavLayout: + """On lg+ the links sit on the page's centre line and nothing in the bar wraps.""" + + # Two lines of the auth buttons (or of anything else) would make the bar taller. + MAX_HEADER_HEIGHT = 66 + + @pytest.mark.parametrize("width", [992, 1024, 1199, 1200, 1440, 1920]) + @pytest.mark.parametrize("signed_in", [False, True], ids=["anonymous", "signed-in"]) + def test_links_are_centred( + self, page: Page, live_server, request, width: int, signed_in: bool + ) -> None: + if signed_in: + page = request.getfixturevalue("auth_page") + page.set_viewport_size({"width": width, "height": 800}) + page.goto(f"{live_server.url}/about") + page.evaluate("document.fonts.ready.then(() => true)") + + box = page.evaluate( + """() => { + const rect = selector => document.querySelector(selector).getBoundingClientRect(); + const links = rect(".cs-nav-links"), bar = rect(".cs-header .navbar > .container"); + const tools = rect(".cs-nav-tools"), brand = rect(".cs-header .navbar-brand"); + return { + offset: (links.left + links.right) / 2 - (bar.left + bar.right) / 2, + header: rect(".cs-header").height, + gaps: [links.left - brand.right, tools.left - links.right], + }; + }""" + ) + # From xl the side columns have room to spare, so centring is exact; below it the + # tools column can be at its content width, which depends on font metrics. + tolerance = 2 if width >= 1200 else 8 + assert abs(box["offset"]) <= tolerance, f"links are {box['offset']:.1f}px off centre" + assert box["header"] <= self.MAX_HEADER_HEIGHT + assert min(box["gaps"]) >= 8, f"nav items crowd each other: {box['gaps']}" + class TestResponsiveSimulation: """Simulation page form layout at different viewports (requires auth).""" @@ -83,3 +160,28 @@ def test_simulation_form_usable( # Form and button should be visible expect(auth_page.locator("#id_option")).to_be_visible() expect(auth_page.locator("#run-simulation-button")).to_be_visible() + + +class TestWorkbenchPanel: + """The control panel sticks only while it fits; it never scrolls inside itself.""" + + @pytest.mark.parametrize( + ("height", "sticky"), [(1080, True), (600, False)], ids=["fits", "taller"] + ) + def test_panel_sticks_only_when_it_fits( + self, auth_page: Page, live_server, height: int, sticky: bool + ) -> None: + auth_page.set_viewport_size({"width": 1366, "height": height}) + auth_page.goto(f"{live_server.url}/simulation") + panel = auth_page.locator(".cs-panel") + + if sticky: + expect(panel).to_have_class(re.compile(r"\bis-sticky\b")) + else: + expect(panel).not_to_have_class(re.compile(r"\bis-sticky\b")) + assert panel.evaluate("el => el.scrollHeight <= el.clientHeight") + + def test_panel_is_not_sticky_below_lg(self, auth_page: Page, live_server) -> None: + auth_page.set_viewport_size({"width": 768, "height": 1024}) + auth_page.goto(f"{live_server.url}/simulation") + expect(auth_page.locator(".cs-panel")).not_to_have_class(re.compile(r"\bis-sticky\b")) diff --git a/cs_app/e2e/test_result_persistence.py b/cs_app/e2e/test_result_persistence.py new file mode 100644 index 0000000..0df340d --- /dev/null +++ b/cs_app/e2e/test_result_persistence.py @@ -0,0 +1,98 @@ +"""Results of every tool stay on its page while the user moves between tools. + +Each run's result is kept server-side for the session, so a tool's page renders its +latest results again when the user comes back to it. Loading the same model again +(the sample data, from another tool's page) keeps them too. +""" + +from __future__ import annotations + +import re + +import pytest +from playwright.sync_api import Page, expect + +from .pages.calculations_page import CalculationsPage +from .pages.nav_component import NavComponent +from .pages.simulation_page import SimulationPage +from .pages.timeseries_page import TimeseriesPage + +pytestmark = [pytest.mark.e2e, pytest.mark.slow] + +STALE_NOTICE = ".cs-stale-notice" + + +def _expect_simulation_results(page: Page, sp: SimulationPage) -> None: + expect(page).to_have_url(re.compile(r".*/simulation$")) + expect(sp.results_heading).to_be_visible() + sp.chart.locator(".main-svg").first.wait_for(state="attached") + assert sp.get_results_row_count() > 0 + expect(page.locator(STALE_NOTICE)).to_have_count(0) + + +def _expect_timeseries_results(page: Page, tp: TimeseriesPage) -> None: + expect(page).to_have_url(re.compile(r".*/timeseries$")) + expect(tp.results_heading).to_be_visible() + tp.chart.locator(".main-svg").first.wait_for(state="attached") + expect(page.locator(STALE_NOTICE)).to_have_count(0) + + +def test_results_survive_moving_between_tools(auth_page: Page, live_server) -> None: + nav = NavComponent(auth_page) + sp = SimulationPage(auth_page, live_server.url) + tp = TimeseriesPage(auth_page, live_server.url) + cp = CalculationsPage(auth_page, live_server.url) + + sp.navigate_to() + sp.load_sample_and_select_catchment() + sp.set_option("simulate_n_imperv") + sp.run_simulation() + sp.wait_for_results() + + nav.click_timeseries() + expect(auth_page).to_have_url(re.compile(r".*/timeseries$")) + tp.wait_for_catchment_options() + tp.set_catchment_name(next(o for o in tp.get_catchment_options() if o)) + tp.set_mode("single") + tp.run_analysis() + tp.wait_for_results() + + nav.click_calculations() + expect(auth_page).to_have_url(re.compile(r".*/calculations$")) + cp.run_and_wait_for_results() + + nav.click_simulation() + _expect_simulation_results(auth_page, sp) + nav.click_timeseries() + _expect_timeseries_results(auth_page, tp) + nav.click_calculations() + expect(auth_page).to_have_url(re.compile(r".*/calculations$")) + expect(cp.results_heading).to_be_visible() + assert cp.get_results_row_count() > 0 + + # The browser's back button renders the same results. + auth_page.go_back() + _expect_timeseries_results(auth_page, tp) + + +def test_loading_the_same_model_again_keeps_results(auth_page: Page, live_server) -> None: + sp = SimulationPage(auth_page, live_server.url) + sp.navigate_to() + sp.load_sample_and_select_catchment() + sp.set_option("simulate_n_imperv") + sp.run_simulation() + sp.wait_for_results() + + NavComponent(auth_page).click_timeseries() + expect(auth_page).to_have_url(re.compile(r".*/timeseries$")) + tp = TimeseriesPage(auth_page, live_server.url) + tp.load_sample_and_pick_catchment() + NavComponent(auth_page).click_simulation() + + _expect_simulation_results(auth_page, sp) + + # On the page itself, reloading the model it shows does not mark its results stale. + sp.upload.record_model_events() + sp.upload.sample_button.click() + assert sp.upload.wait_for_model_changed()["restored"] is True + expect(auth_page.locator(STALE_NOTICE)).to_have_count(0) diff --git a/cs_app/e2e/test_simulation.py b/cs_app/e2e/test_simulation.py index 883232d..6648543 100644 --- a/cs_app/e2e/test_simulation.py +++ b/cs_app/e2e/test_simulation.py @@ -6,20 +6,32 @@ from __future__ import annotations +import re +from pathlib import Path + import pytest -from playwright.sync_api import Page, expect +from playwright.sync_api import Page, Route, expect +from .pages.nav_component import NavComponent from .pages.simulation_page import SimulationPage pytestmark = pytest.mark.e2e +SIMULATION_ROUTE = re.compile(r".*/simulation$") +SAMPLE_INP = Path(__file__).resolve().parent.parent / "data" / "example.inp" + + +def _open(page: Page, live_server) -> SimulationPage: + sp = SimulationPage(page, live_server.url) + sp.navigate_to() + return sp + class TestSimulationFormRendering: """Form fields render correctly (requires authentication).""" def test_form_fields_present(self, auth_page: Page, live_server) -> None: - sp = SimulationPage(auth_page, live_server.url) - sp.navigate_to() + sp = _open(auth_page, live_server) assert sp.has_form() expect(sp.option_select).to_be_visible() expect(sp.start_input).to_be_visible() @@ -27,110 +39,344 @@ def test_form_fields_present(self, auth_page: Page, live_server) -> None: expect(sp.step_input).to_be_visible() expect(sp.catchment_name_select).to_be_visible() + def test_page_heading(self, auth_page: Page, live_server) -> None: + sp = _open(auth_page, live_server) + assert sp.get_heading(level=1) == "Parameter sweep" + def test_default_catchment_placeholder(self, auth_page: Page, live_server) -> None: - sp = SimulationPage(auth_page, live_server.url) - sp.navigate_to() - options = sp.get_catchment_options() - assert any("Upload a file first" in opt or opt == "" for opt in options) + sp = _open(auth_page, live_server) + expect(sp.catchment_name_select.locator("option")).to_have_text( + ["--- Upload a file first ---"] + ) + assert sp.get_catchment_options() == [""] def test_predefined_method_hides_range_fields(self, auth_page: Page, live_server) -> None: - """When a predefined method is selected, start/stop/step should be hidden.""" - sp = SimulationPage(auth_page, live_server.url) - sp.navigate_to() + """Literature-value methods need no range: the fields give way to a note.""" + sp = _open(auth_page, live_server) + expect(sp.range_fields).to_be_visible() + expect(sp.literature_note).to_be_hidden() + sp.set_option("simulate_n_imperv") - col = auth_page.locator("#id_start").locator( - "xpath=ancestor::div[contains(@class,'col-md-2')]" - ) - expect(col).to_be_hidden() + expect(sp.start_input).to_be_hidden() + expect(sp.range_fields).to_be_hidden() + expect(sp.literature_note).to_be_visible() + + sp.set_option("simulate_area") + expect(sp.start_input).to_be_visible() + expect(sp.literature_note).to_be_hidden() def test_run_button_text(self, auth_page: Page, live_server) -> None: - sp = SimulationPage(auth_page, live_server.url) - sp.navigate_to() + sp = _open(auth_page, live_server) expect(sp.run_button).to_have_text("Run Simulation") + def test_empty_state_before_first_run(self, auth_page: Page, live_server) -> None: + sp = _open(auth_page, live_server) + expect(sp.empty_state).to_be_visible() + expect(sp.empty_state).to_contain_text("No results yet") + expect(sp.results_heading).to_have_count(0) + expect(sp.loading_spinner).to_be_hidden() -class TestSimulationExecution: - """Full simulation execution tests (require sample data + SWMM).""" - @pytest.mark.slow - def test_full_simulation_run(self, auth_page: Page, live_server) -> None: - """Load sample → select catchment → run → verify results table and chart.""" - sp = SimulationPage(auth_page, live_server.url) +class TestSimulationRangeHint: + """Live run-count hint under the range fields.""" + + def test_hint_counts_runs(self, auth_page: Page, live_server) -> None: + sp = _open(auth_page, live_server) + sp.set_range(0, 100, 10) + expect(sp.range_hint).to_have_text("11 runs · 0 → 100, step 10") + expect(sp.range_hint).not_to_have_class(re.compile(r"\bis-warning\b")) + # The package sweeps np.arange(start, stop + step / 2, step): 0, 4, 8 and 12. + sp.set_range(0, 11, 4) + expect(sp.range_hint).to_have_text("4 runs · 0 → 11, step 4") + + def test_hint_warns_over_server_limit(self, auth_page: Page, live_server) -> None: + """Warns (without blocking) once (stop - start) / step reaches the form's limit of 100.""" + sp = _open(auth_page, live_server) + sp.set_range(0, 99, 1) + expect(sp.range_hint).to_have_text("100 runs · 0 → 99, step 1") + + sp.set_stop(100) + expect(sp.range_hint).to_contain_text("over the limit of 100 runs") + expect(sp.range_hint).to_have_class(re.compile(r"\bis-warning\b")) + expect(sp.run_button).to_be_enabled() + + def test_hint_flags_reversed_range(self, auth_page: Page, live_server) -> None: + sp = _open(auth_page, live_server) + sp.set_range(10, 1, 1) + expect(sp.range_hint).to_have_text("Stop must be greater than or equal to start.") + + def test_hidden_invalid_range_does_not_block_a_literature_run( + self, auth_page: Page, live_server + ) -> None: + """Leftovers in the hidden range are neither checked by the browser nor sent.""" + sp = _open(auth_page, live_server) + sp.load_sample_and_select_catchment() + sp.set_range(-5, 1, 1) + sp.set_option("simulate_n_imperv") + auth_page.route( + "**/simulation", + lambda route: route.fulfill( + status=400, json={"message": "Stubbed.", "field_errors": {}} + ), + ) + + with auth_page.expect_request(lambda request: request.method == "POST") as posted: + sp.run_simulation() + + assert 'name="start"' not in (posted.value.post_data or "") + expect(sp.error_summary).to_contain_text("Stubbed.") + + +class TestSimulationModelHint: + """Step 1 explains what to do while no model is loaded.""" + + def test_hint_follows_model_state(self, auth_page: Page, live_server) -> None: + sp = _open(auth_page, live_server) + expect(sp.model_hint).to_be_visible() + + sp.load_sample_and_select_catchment() + expect(sp.model_hint).to_be_hidden() + + sp.upload.clear_upload() + expect(sp.model_hint).to_be_visible() + + def test_hint_hidden_when_model_restored(self, auth_page: Page, live_server) -> None: + sp = _open(auth_page, live_server) + sp.load_sample_and_select_catchment() sp.navigate_to() + expect(sp.model_hint).to_be_hidden() + + def test_sample_button_keeps_keyboard_focus(self, auth_page: Page, live_server) -> None: + sp = _open(auth_page, live_server) + sample = auth_page.get_by_role("button", name="Try sample data") + sample.focus() + + auth_page.keyboard.press("Enter") - sp.upload.click_sample_data() sp.wait_for_catchment_options() + expect(sample).to_be_enabled() + expect(sample).to_be_focused() + + +class TestSimulationModelChange: + """Results belong to the model they were computed from.""" + + @pytest.mark.slow + def test_new_model_marks_results_stale_and_drops_them( + self, auth_page: Page, live_server, tmp_path + ) -> None: + sp = _open(auth_page, live_server) + sp.load_sample_and_select_catchment() + sp.set_range(1, 2, 1) + sp.run_simulation() + sp.wait_for_results() + stale = sp.canvas.locator(".cs-stale-notice") + + # The model restored on page load is the one the results came from. + sp.navigate_to() + sp.wait_for_catchment_options() + expect(sp.results_heading).to_be_visible() + expect(stale).to_have_count(0) + + # A different model (the sample with one more line); the same content again would + # keep the results (see test_result_persistence.py). + other_model = tmp_path / "other_model.inp" + other_model.write_bytes(SAMPLE_INP.read_bytes() + b"\n;; edited copy\n") + sp.upload.upload_file(str(other_model)) + expect(stale).to_contain_text("previously loaded model") + + sp.navigate_to() + expect(sp.empty_state).to_be_visible() + - options = sp.get_catchment_options() - real_options = [o for o in options if o and "Upload" not in o and "Select" not in o] - assert len(real_options) > 0, "No catchments available after sample upload" - sp.set_catchment_name(real_options[0]) +class TestSimulationValidation: + """Server-side validation errors are shown inline without reloading the page.""" + + def test_start_greater_than_stop_shows_field_error(self, auth_page: Page, live_server) -> None: + sp = _open(auth_page, live_server) + sp.load_sample_and_select_catchment() + sp.set_range(10, 1, 1) + sp.mark_window() + + sp.run_simulation() + + expect(sp.stop_input).to_have_class(re.compile(r"\bis-invalid\b")) + expect(sp.page.locator("#id_stop-async-error")).to_have_text( + "Stop must be greater than or equal to start." + ) + expect(sp.error_summary).to_contain_text("Please correct the highlighted fields.") + expect(sp.stop_input).to_be_focused() + expect(sp.run_button).to_be_enabled() + expect(sp.loading_spinner).to_be_hidden() + expect(sp.empty_state).to_be_visible() + assert sp.window_is_marked() + + def test_field_error_clears_on_input(self, auth_page: Page, live_server) -> None: + sp = _open(auth_page, live_server) + sp.load_sample_and_select_catchment() + sp.set_range(10, 1, 1) + sp.run_simulation() + expect(sp.stop_input).to_have_class(re.compile(r"\bis-invalid\b")) + + sp.set_stop(20) + expect(sp.stop_input).not_to_have_class(re.compile(r"\bis-invalid\b")) + + +class TestSimulationProgress: + """Loading state while the server works (response held back by a route).""" + + def test_loading_state_while_running(self, auth_page: Page, live_server) -> None: + sp = _open(auth_page, live_server) + sp.load_sample_and_select_catchment() + sp.set_range(1, 3, 1) + held: list[Route] = [] + auth_page.route( + SIMULATION_ROUTE, + lambda route: held.append(route) + if route.request.method == "POST" + else route.continue_(), + ) + + sp.run_simulation() + + expect(sp.loading_spinner).to_be_visible() + expect(sp.elapsed_counter).to_have_text(re.compile(r"^\d+ s$")) + expect(sp.run_button).to_be_disabled() + expect(sp.run_button).to_have_attribute("aria-busy", "true") + expect(sp.canvas).to_have_attribute("aria-busy", "true") + assert len(held) == 1 + assert held[0].request.headers.get("x-requested-with") == "XMLHttpRequest" + + held[0].fulfill( + status=500, + content_type="application/json", + body='{"message": "An error occurred while running the simulation.", "field_errors": {}}', + ) + expect(sp.loading_spinner).to_be_hidden() + expect(sp.run_button).to_be_enabled() + expect(sp.error_summary).to_contain_text("An error occurred while running the simulation.") + +class TestSimulationExecution: + """Full simulation execution tests (require sample data + SWMM).""" + + @pytest.mark.slow + def test_full_simulation_run(self, auth_page: Page, live_server) -> None: + """Load sample → select catchment → run → results swap in without a page load.""" + sp = _open(auth_page, live_server) + sp.load_sample_and_select_catchment() sp.set_option("simulate_percent_slope") - sp.set_start(1) - sp.set_stop(5) - sp.set_step(1) + sp.set_range(1, 5, 1) + sp.mark_window() sp.run_simulation() - auth_page.wait_for_load_state("domcontentloaded", timeout=60_000) + sp.wait_for_results() + assert sp.window_is_marked(), "the run reloaded the page" + expect(sp.results_heading).to_be_focused() + expect(auth_page.locator(".toast", has_text="Simulation finished")).to_be_visible() + expect(sp.empty_state).to_have_count(0) + expect(sp.loading_spinner).to_be_hidden() assert sp.has_results_table() - assert sp.get_results_row_count() > 0 + assert sp.get_results_row_count() == 5 assert sp.has_chart() + expect(sp.chart).to_have_attribute("role", "img") + expect(sp.metric_tiles).to_have_count(4) + expect(sp.metric_tiles.first).to_contain_text("Runoff at lowest value") assert sp.has_download_button() + expect(sp.copy_table_button).to_be_visible() + expect(sp.download_png_button).to_be_visible() @pytest.mark.slow - def test_loading_spinner_appears(self, auth_page: Page, live_server) -> None: - """Spinner should appear immediately after clicking Run Simulation.""" - sp = SimulationPage(auth_page, live_server.url) - sp.navigate_to() - sp.upload.click_sample_data() - sp.wait_for_catchment_options() + def test_results_table_sorts_and_copies(self, auth_page: Page, live_server) -> None: + auth_page.context.grant_permissions(["clipboard-read", "clipboard-write"]) + sp = _open(auth_page, live_server) + sp.load_sample_and_select_catchment() + sp.set_range(1, 3, 1) + sp.run_simulation() + sp.wait_for_results() - options = sp.get_catchment_options() - real_options = [o for o in options if o and "Upload" not in o and "Select" not in o] - if not real_options: - pytest.skip("No catchments available") - sp.set_catchment_name(real_options[0]) - sp.set_option("simulate_percent_slope") - sp.set_start(1) - sp.set_stop(3) - sp.set_step(1) - - # Prevent actual form submission so the page stays and the spinner - # (shown by the existing simulation.js submit handler) remains visible. - # Note: the page has two
elements (upload zone + simulation), - # so we target the simulation form via the run button's parent form. - auth_page.evaluate( - "document.getElementById('run-simulation-button').closest('form')" - ".addEventListener('submit', e => e.preventDefault())" - ) + swept_header = sp.results_table.locator("thead th").first + swept_header.locator(".cs-sort").click() + expect(swept_header).to_have_attribute("aria-sort", "ascending") + swept_header.locator(".cs-sort").click() + expect(swept_header).to_have_attribute("aria-sort", "descending") + assert sp.column_values(0) == ["3", "2", "1"] + + sp.copy_table_button.click() + expect(auth_page.locator(".toast", has_text="Table copied as CSV.")).to_be_visible() + csv = auth_page.evaluate("navigator.clipboard.readText()") + lines = csv.split("\r\n") + assert lines[0].startswith("Percent Slope [%],Total Runoff Volume") + assert [line.split(",")[0] for line in lines[1:]] == ["3", "2", "1"] + + @pytest.mark.slow + def test_download_does_not_start_a_run(self, auth_page: Page, live_server) -> None: + """Audit S2: the download submits its own form and leaves the run form idle.""" + sp = _open(auth_page, live_server) + sp.load_sample_and_select_catchment() + sp.set_range(1, 3, 1) sp.run_simulation() - expect(sp.loading_spinner).to_be_visible(timeout=2000) + sp.wait_for_results() + with auth_page.expect_download() as download_info: + sp.download_button.click() -class TestSimulationValidation: - """Form validation edge cases (require auth + sample data).""" + assert download_info.value.suggested_filename.endswith(".xlsx") + expect(sp.loading_spinner).to_be_hidden() + expect(sp.run_button).to_be_enabled() + expect(sp.results_heading).to_be_visible() @pytest.mark.slow - def test_start_greater_than_stop_shows_error(self, auth_page: Page, live_server) -> None: - """start > stop should show a validation error.""" - sp = SimulationPage(auth_page, live_server.url) - sp.navigate_to() - sp.upload.click_sample_data() - sp.wait_for_catchment_options() + def test_second_run_replaces_results(self, auth_page: Page, live_server) -> None: + sp = _open(auth_page, live_server) + sp.load_sample_and_select_catchment() + sp.set_range(1, 3, 1) + sp.run_simulation() + sp.wait_for_results() + sp.mark_window() - options = sp.get_catchment_options() - real_options = [o for o in options if o and "Upload" not in o and "Select" not in o] - if not real_options: - pytest.skip("No catchments available") - sp.set_catchment_name(real_options[0]) - sp.set_option("simulate_percent_slope") - sp.set_start(10) - sp.set_stop(1) - sp.set_step(1) + sp.set_option("simulate_area") + sp.set_range(1, 4, 1) + sp.run_simulation() + expect(sp.results_rows).to_have_count(4, timeout=SimulationPage.RUN_TIMEOUT_MS) + + assert sp.window_is_marked() + expect(sp.results_heading).to_have_count(1) + expect(sp.results_table.locator("thead th").first).to_contain_text("Area") + expect(sp.chart.locator(".main-svg").first).to_be_attached() + + @pytest.mark.slow + def test_theme_switch_keeps_chart(self, auth_page: Page, live_server) -> None: + sp = _open(auth_page, live_server) + NavComponent(auth_page).set_theme("light") + sp.load_sample_and_select_catchment() + sp.set_range(1, 3, 1) sp.run_simulation() - auth_page.wait_for_load_state("domcontentloaded") + sp.wait_for_results() + chart_font = "document.getElementById('simulation-chart').layout.font.color" + light_color = auth_page.evaluate(chart_font) + + NavComponent(auth_page).set_theme("dark") + + auth_page.wait_for_function(f"{chart_font} !== {light_color!r}") + expect(sp.chart.locator(".main-svg").first).to_be_attached() + expect(sp.results_rows).to_have_count(3) + + @pytest.mark.slow + @pytest.mark.parametrize("theme", ["light", "dark"]) + def test_results_have_no_serious_a11y_violations( + self, auth_page: Page, live_server, theme: str + ) -> None: + # No reveal animation: axe would measure contrast mid-fade. + auth_page.emulate_media(reduced_motion="reduce") + sp = _open(auth_page, live_server) + NavComponent(auth_page).set_theme(theme) + sp.load_sample_and_select_catchment() + sp.set_range(1, 3, 1) + sp.run_simulation() + sp.wait_for_results() + + report = sp.run_axe_audit() - errors = auth_page.locator(".invalid-feedback") - expect(errors.first).to_be_visible() + assert not report.critical + report.serious, report.critical + report.serious diff --git a/cs_app/e2e/test_smoke_auth_redirect.py b/cs_app/e2e/test_smoke_auth_redirect.py index af8c6fd..ad8fce6 100644 --- a/cs_app/e2e/test_smoke_auth_redirect.py +++ b/cs_app/e2e/test_smoke_auth_redirect.py @@ -11,23 +11,25 @@ def _assert_login_redirect(page: Page, expected_next: str) -> None: parsed = urlparse(page.url) assert parsed.path == "/accounts/login/" assert parse_qs(parsed.query).get("next") == [expected_next] - expect(page.get_by_role("button", name="Login")).to_be_visible() + expect(page.get_by_role("button", name="Log in", exact=True)).to_be_visible() def test_home_page_smoke(page: Page, live_server) -> None: page.goto(f"{live_server.url}/") - expect(page.get_by_role("heading", name="Catchment Simulation")).to_be_visible() - expect(page.get_by_role("link", name="Simulation")).to_be_visible() + expect(page.get_by_role("heading", level=1)).to_be_visible() + header = page.locator("header") + expect(header.get_by_role("link", name="Catchment Simulation home", exact=True)).to_be_visible() + expect(header.get_by_role("link", name="Simulation", exact=True)).to_be_visible() def test_navigation_to_about(page: Page, live_server) -> None: page.goto(f"{live_server.url}/") - page.get_by_role("link", name="About").first.click() + page.locator("header").get_by_role("link", name="About", exact=True).click() expect(page).to_have_url(re.compile(r".*/about$")) - expect(page.get_by_role("heading", name="About")).to_be_visible() + expect(page.get_by_role("heading", level=1, name="About")).to_be_visible() def test_simulation_redirects_anonymous_user_to_login(page: Page, live_server) -> None: diff --git a/cs_app/e2e/test_timeseries.py b/cs_app/e2e/test_timeseries.py index 18f4c6f..31af684 100644 --- a/cs_app/e2e/test_timeseries.py +++ b/cs_app/e2e/test_timeseries.py @@ -6,126 +6,364 @@ from __future__ import annotations +import re + import pytest -from playwright.sync_api import Page, expect +from playwright.sync_api import Browser, Page, expect +from .conftest import new_context_without_js, wait_for_styles from .pages.timeseries_page import TimeseriesPage pytestmark = pytest.mark.e2e +# Set on window before a run; a full page load would wipe it. +MARK_PAGE = "() => { window.__tsNoReload = true; }" +PAGE_NOT_RELOADED = "() => window.__tsNoReload === true" + + +def _open(auth_page: Page, live_server) -> TimeseriesPage: + tp = TimeseriesPage(auth_page, live_server.url) + tp.navigate_to() + return tp + class TestTimeseriesFormRendering: """Form fields render correctly (requires authentication).""" def test_form_fields_present(self, auth_page: Page, live_server) -> None: - tp = TimeseriesPage(auth_page, live_server.url) - tp.navigate_to() + tp = _open(auth_page, live_server) assert tp.has_form() - expect(tp.mode_select).to_be_visible() expect(tp.catchment_name_select).to_be_visible() + expect(auth_page.get_by_role("radio", name="Single run")).to_be_visible() + expect(auth_page.get_by_role("radio", name="Parameter sweep")).to_be_visible() + + def test_page_heading_is_level_one(self, auth_page: Page, live_server) -> None: + tp = _open(auth_page, live_server) + assert tp.get_heading(level=1) == "Timeseries analysis" def test_run_button_text(self, auth_page: Page, live_server) -> None: - tp = TimeseriesPage(auth_page, live_server.url) - tp.navigate_to() + tp = _open(auth_page, live_server) expect(tp.run_button).to_have_text("Run Timeseries Analysis") - def test_default_mode_is_single(self, auth_page: Page, live_server) -> None: - tp = TimeseriesPage(auth_page, live_server.url) - tp.navigate_to() - expect(tp.mode_select).to_have_value("single") + def test_default_mode_is_single_and_hides_sweep_range( + self, auth_page: Page, live_server + ) -> None: + tp = _open(auth_page, live_server) + assert tp.selected_mode() == "single" + expect(tp.sweep_fields).to_be_hidden() + # Disabled so hidden inputs are neither validated nor submitted. + expect(tp.start_input).to_be_disabled() def test_sweep_mode_shows_feature_field(self, auth_page: Page, live_server) -> None: - tp = TimeseriesPage(auth_page, live_server.url) - tp.navigate_to() + tp = _open(auth_page, live_server) tp.set_mode("sweep") expect(auth_page.locator("#feature-wrapper")).to_be_visible() + expect(tp.feature_select).to_be_enabled() def test_sweep_mode_shows_range_fields(self, auth_page: Page, live_server) -> None: - tp = TimeseriesPage(auth_page, live_server.url) - tp.navigate_to() + tp = _open(auth_page, live_server) tp.set_mode("sweep") expect(auth_page.locator("#start-wrapper")).to_be_visible() expect(auth_page.locator("#stop-wrapper")).to_be_visible() expect(auth_page.locator("#step-wrapper")).to_be_visible() + def test_mode_is_keyboard_operable(self, auth_page: Page, live_server) -> None: + tp = _open(auth_page, live_server) + tp.mode_radio("single").focus() + auth_page.keyboard.press("ArrowRight") + assert tp.selected_mode() == "sweep" + expect(tp.sweep_fields).to_be_visible() + + def test_empty_state_before_first_run(self, auth_page: Page, live_server) -> None: + tp = _open(auth_page, live_server) + expect(tp.empty_state).to_contain_text("No results yet") + expect(tp.loading_spinner).to_be_hidden() + expect(tp.results_heading).to_have_count(0) + + +class TestTimeseriesRunsHint: + """Live sweep hint mirrors the package's run count and the form's step limit.""" + + def test_hint_counts_runs(self, auth_page: Page, live_server) -> None: + tp = _open(auth_page, live_server) + tp.set_mode("sweep") + tp.set_start(0) + tp.set_stop(100) + tp.set_step(10) + expect(tp.runs_hint).to_have_text("11 runs · 0 → 100, step 10") + tp.set_stop(0) + expect(tp.runs_hint).to_have_text("1 run · 0 → 0, step 10") + + def test_hint_warns_about_limit_and_reversed_range(self, auth_page: Page, live_server) -> None: + tp = _open(auth_page, live_server) + tp.set_mode("sweep") + tp.set_start(0) + tp.set_stop(100) + tp.set_step(0.5) + expect(tp.runs_hint).to_contain_text("over the limit of 100 runs") + expect(tp.runs_hint).to_have_class(re.compile(r"\bis-warning\b")) + tp.set_step(10) + tp.set_start(200) + expect(tp.runs_hint).to_contain_text("Stop must be greater than or equal to start") + + +class TestTimeseriesValidation: + """Server-side validation errors come back inline without a page load.""" + + def test_sweep_start_greater_than_stop(self, auth_page: Page, live_server) -> None: + tp = _open(auth_page, live_server) + tp.load_sample_and_pick_catchment() + tp.set_mode("sweep") + tp.set_feature("PercSlope") + tp.set_start(100) + tp.set_stop(10) + tp.set_step(10) + auth_page.evaluate(MARK_PAGE) + + tp.run_analysis() + + error = auth_page.locator("#id_stop-async-error") + expect(error).to_have_text("Stop must be greater than or equal to start.") + expect(tp.stop_input).to_have_class(re.compile(r"\bis-invalid\b")) + expect(tp.stop_input).to_be_focused() + expect(auth_page.locator("#timeseries-form .alert-danger")).to_be_visible() + expect(tp.run_button).to_be_enabled() + expect(tp.loading_spinner).to_be_hidden() + expect(tp.empty_state).to_be_visible() + assert auth_page.evaluate(PAGE_NOT_RELOADED) + class TestTimeseriesExecution: """Full timeseries execution tests.""" @pytest.mark.slow def test_single_timeseries_run(self, auth_page: Page, live_server) -> None: - tp = TimeseriesPage(auth_page, live_server.url) - tp.navigate_to() - - tp.upload.click_sample_data() - tp.wait_for_catchment_options() - - options = tp.get_catchment_options() - real_options = [o for o in options if o and "Upload" not in o and "Select" not in o] - assert len(real_options) > 0 - tp.set_catchment_name(real_options[0]) + tp = _open(auth_page, live_server) + catchment = tp.load_sample_and_pick_catchment() tp.set_mode("single") + auth_page.evaluate(MARK_PAGE) tp.run_analysis() - auth_page.wait_for_load_state("domcontentloaded", timeout=60_000) + tp.wait_for_results() + + assert auth_page.evaluate(PAGE_NOT_RELOADED), "the run reloaded the page" + expect(tp.results_heading).to_be_focused() + expect(tp.results_heading).to_contain_text(catchment) + expect(auth_page.locator(".toast", has_text="Timeseries analysis finished")).to_be_visible() + expect(tp.run_button).to_be_enabled() + expect(tp.loading_spinner).to_be_hidden() assert tp.has_chart() - ttp = tp.get_time_to_peak() - vol = tp.get_runoff_volume() - assert ttp is not None or vol is not None, "Expected at least one metric card" + expect(tp.chart).to_have_attribute("role", "img") + # One unit per axis: infiltration shares the rainfall rate (mm/h), evaporation (mm/day) + # gets a panel of its own, hidden until its legend entry is selected. + axes = tp.chart.evaluate( + "el => Object.fromEntries(el.data.filter(t => t.uid).map(t => [t.uid, t.yaxis]))" + ) + assert axes["runoff"] == axes["runon"] == "y" + assert axes["rainfall"] == axes["infiltration_loss"] == "y2" + assert axes["evaporation_loss"] not in ("y", "y2") + evap_axis = "el.layout.yaxis" + axes["evaporation_loss"][1:] # "y3" -> el.layout.yaxis3 + assert "loss rates [mm/h]" in tp.chart.evaluate("el => el.layout.yaxis2.title.text") + assert tp.chart.evaluate(f"el => {evap_axis}.visible") is False + assert tp.chart.evaluate("el => el.layout.yaxis.domain") == [0, 1] + height = tp.chart.evaluate("el => el.layout.height") + + legend_item = tp.chart.locator(".legend .traces", has_text="Evaporation Loss [mm/day]") + legend_item.locator(".legendtoggle").click() + expect( + tp.chart.locator(".annotation-text", has_text="Evaporation Loss [mm/day]") + ).to_be_visible() + assert tp.chart.evaluate(f"el => {evap_axis}.visible") is True + assert tp.chart.evaluate(f"el => {evap_axis}.title.text") == "mm/day" + # The panel sits below the main plot, without overlapping it, and carries the time axis. + assert tp.chart.evaluate(f"el => {evap_axis}.domain[1] <= el.layout.yaxis.domain[0]") + assert tp.chart.evaluate("el => el.layout.xaxis.anchor") == axes["evaporation_loss"] + assert tp.chart.evaluate("el => el.layout.height") > height + + legend_item.locator(".legendtoggle").click() + expect( + tp.chart.locator(".annotation-text", has_text="Evaporation Loss [mm/day]") + ).to_be_hidden() + assert tp.chart.evaluate(f"el => {evap_axis}.visible") is False + assert tp.chart.evaluate("el => el.layout.yaxis.domain") == [0, 1] + assert tp.chart.evaluate("el => el.layout.xaxis.anchor") == "y" + assert tp.chart.evaluate("el => el.layout.height") == height + # The time axis spans exactly the data (the peak label must not widen it). + assert tp.chart.evaluate( + """el => { const t = el.data[0].x; return el.layout.xaxis.range[0] === t[0] + && el.layout.xaxis.range[1] === t[t.length - 1]; }""" + ) + assert re.fullmatch(r"[\d.,]+\s*CMS", tp.get_peak_runoff() or "") + assert re.fullmatch(r"[\d.,]+\s*m³", tp.get_runoff_volume() or "") + assert re.search(r"\d+ (d|h|min|s)\b", tp.get_time_to_peak() or "") + + tp.timestep_toggle.click() + expect(tp.timestep_table.locator("tbody tr").first).to_be_visible() assert tp.has_download_results_button() assert tp.has_csv_download_button() assert tp.has_png_download_button() @pytest.mark.slow - def test_sweep_timeseries_run(self, auth_page: Page, live_server) -> None: - tp = TimeseriesPage(auth_page, live_server.url) - tp.navigate_to() + def test_exports_do_not_trigger_the_run_state(self, auth_page: Page, live_server) -> None: + tp = _open(auth_page, live_server) + tp.load_sample_and_pick_catchment() + tp.run_analysis() + tp.wait_for_results() + + with auth_page.expect_download() as xlsx_download: + auth_page.get_by_role("button", name="Download Results (.xlsx)").click() + assert xlsx_download.value.suggested_filename.endswith(".xlsx") + expect(tp.loading_spinner).to_be_hidden() - tp.upload.click_sample_data() - tp.wait_for_catchment_options() + with auth_page.expect_download() as csv_download: + auth_page.get_by_role("button", name="Download Results (.csv)").click() + assert csv_download.value.suggested_filename.endswith(".csv") + expect(tp.loading_spinner).to_be_hidden() + expect(tp.run_button).to_be_enabled() - options = tp.get_catchment_options() - real_options = [o for o in options if o and "Upload" not in o and "Select" not in o] - assert len(real_options) > 0 - tp.set_catchment_name(real_options[0]) + with auth_page.expect_download() as png_download: + auth_page.get_by_role("button", name="Download PNG").click() + assert png_download.value.suggested_filename.endswith(".png") + expect(auth_page.locator("#timeseries-export-feedback")).to_be_hidden() + + @pytest.mark.slow + def test_theme_switch_keeps_chart(self, auth_page: Page, live_server) -> None: + tp = _open(auth_page, live_server) + tp.load_sample_and_pick_catchment() + tp.run_analysis() + tp.wait_for_results() + rain_fill = "() => document.querySelector('#timeseries-chart .bars .point path').style.fill" + light_fill = auth_page.evaluate(rain_fill) + + tp.nav.set_theme("dark") + + auth_page.wait_for_function(f"({rain_fill})() !== {light_fill!r}") + assert tp.has_chart() + @pytest.mark.slow + def test_second_run_replaces_results(self, auth_page: Page, live_server) -> None: + tp = _open(auth_page, live_server) + tp.load_sample_and_pick_catchment() + tp.run_analysis() + tp.wait_for_results() + auth_page.evaluate( + "() => { document.getElementById('timeseries-results').dataset.stale = '1'; }" + ) + + tp.run_analysis() + + expect(auth_page.locator("#timeseries-results[data-stale]")).to_have_count( + 0, timeout=60_000 + ) + tp.wait_for_results() + expect(auth_page.locator("#timeseries-results")).to_have_count(1) + expect(tp.results_heading).to_have_count(1) + + @pytest.mark.slow + def test_sweep_timeseries_run(self, auth_page: Page, live_server) -> None: + tp = _open(auth_page, live_server) + tp.load_sample_and_pick_catchment() tp.set_mode("sweep") tp.set_feature("PercImperv") tp.set_start(0) tp.set_stop(50) tp.set_step(25) + expect(tp.runs_hint).to_have_text("3 runs · 0 → 50, step 25") + auth_page.evaluate(MARK_PAGE) tp.run_analysis() - auth_page.wait_for_load_state("domcontentloaded", timeout=120_000) + tp.wait_for_results() + assert auth_page.evaluate(PAGE_NOT_RELOADED) assert tp.has_chart() - - -class TestTimeseriesValidation: - """Timeseries form validation (requires auth + sample data).""" + rows = tp.sweep_table.locator("tbody tr") + expect(rows).to_have_count(3) + expect(rows.first.locator("th")).to_have_text("0") + expect(tp.sweep_table.locator("thead")).to_contain_text("Peak runoff [CMS]") + expect(tp.sweep_table.locator("th button.cs-sort").first).to_be_visible() + assert tp.get_time_to_peak() is None, "sweep shows a summary table instead of tiles" + # Values only: the legend title names the parameter. + assert tp.sweep_legend_names() == ["0", "25", "50"] + assert tp.chart.evaluate("el => el.layout.legend.title.text") == "Impervious Area [%]" + + before = auth_page.evaluate( + "() => document.getElementById('timeseries-chart').layout.yaxis.title.text" + ) + tp.sweep_series_select.select_option("infiltration_loss") + auth_page.wait_for_function( + "(before) => document.getElementById('timeseries-chart').layout.yaxis.title.text !== before", + arg=before, + ) @pytest.mark.slow - def test_sweep_start_greater_than_stop(self, auth_page: Page, live_server) -> None: - tp = TimeseriesPage(auth_page, live_server.url) - tp.navigate_to() - tp.upload.click_sample_data() - tp.wait_for_catchment_options() - - options = tp.get_catchment_options() - real_options = [o for o in options if o and "Upload" not in o and "Select" not in o] - if not real_options: - pytest.skip("No catchments available") - tp.set_catchment_name(real_options[0]) + def test_leftover_invalid_sweep_values_do_not_block_a_single_run( + self, auth_page: Page, live_server + ) -> None: + tp = _open(auth_page, live_server) + tp.load_sample_and_pick_catchment() tp.set_mode("sweep") - tp.set_feature("PercSlope") - tp.set_start(100) - tp.set_stop(10) - tp.set_step(10) + tp.set_start(-5) + tp.set_mode("single") tp.run_analysis() - auth_page.wait_for_load_state("domcontentloaded") - errors = auth_page.locator(".invalid-feedback") - expect(errors.first).to_be_visible() + tp.wait_for_results() + expect(auth_page.locator("#timeseries-form .alert-danger")).to_have_count(0) + + +class TestTimeseriesWithoutJavaScript: + """Without JS the form posts natively (PRG) and the server renders every number.""" + + @pytest.mark.slow + def test_single_run_renders_metrics_after_redirect( + self, auth_page: Page, live_server, browser: Browser + ) -> None: + # Loading the model needs JS (the upload zone); the session then carries it over. + _open(auth_page, live_server).load_sample_and_pick_catchment() + context = new_context_without_js(browser) + context.add_cookies(auth_page.context.cookies()) + try: + tp = TimeseriesPage(context.new_page(), live_server.url) + tp.navigate_to() + wait_for_styles(tp.page) + tp.set_catchment_name(tp.get_catchment_options()[-1]) + + tp.run_analysis() + + expect(tp.results_heading).to_be_visible(timeout=120_000) + assert re.fullmatch(r"[\d.,]+\s*CMS", tp.get_peak_runoff() or "") + assert re.fullmatch(r"\d+ h( \d+ min)?", tp.get_time_to_peak() or "") + assert re.fullmatch(r"[\d.,]+\s*m³", tp.get_runoff_volume() or "") + finally: + context.close() + + +class TestTimeseriesResultsAccessibility: + """axe finds no critical or serious issues in the results, in either theme.""" + + @pytest.mark.slow + @pytest.mark.parametrize("mode", ["single", "sweep"]) + def test_results_have_no_serious_a11y_violations( + self, auth_page: Page, live_server, mode: str + ) -> None: + # axe would otherwise sample colours mid-way through the reveal and theme transitions. + auth_page.emulate_media(reduced_motion="reduce") + tp = _open(auth_page, live_server) + tp.load_sample_and_pick_catchment() + tp.set_mode(mode) + if mode == "sweep": + tp.set_start(0) + tp.set_stop(50) + tp.set_step(25) + tp.run_analysis() + tp.wait_for_results() + if mode == "single": + tp.timestep_toggle.click() + + for theme in ("light", "dark"): + tp.nav.set_theme(theme) + report = tp.run_axe_audit() + issues = [f"{v.id} ({v.nodes_count})" for v in report.critical + report.serious] + assert not issues, f"{theme}: {issues}" diff --git a/cs_app/e2e/test_upload.py b/cs_app/e2e/test_upload.py index cb8b585..f7f244e 100644 --- a/cs_app/e2e/test_upload.py +++ b/cs_app/e2e/test_upload.py @@ -9,8 +9,9 @@ import os import pytest -from playwright.sync_api import Page, expect +from playwright.sync_api import Browser, Page, expect +from .conftest import new_context_without_js, wait_for_styles from .pages.calculations_page import CalculationsPage from .pages.simulation_page import SimulationPage from .pages.timeseries_page import TimeseriesPage @@ -38,6 +39,17 @@ def test_calculations_has_upload_zone(self, page: Page, live_server) -> None: cp.navigate_to() assert cp.upload.is_visible() + def test_sample_button_needs_javascript(self, browser: Browser, live_server) -> None: + context = new_context_without_js(browser) + try: + cp = CalculationsPage(context.new_page(), live_server.url) + cp.navigate_to() + wait_for_styles(cp.page) + expect(cp.upload.sample_button).to_be_hidden() + expect(cp.page.locator("#upload-fallback-file")).to_be_visible() + finally: + context.close() + class TestSampleDataUpload: """'Try sample data' button loads sample file and updates UI.""" @@ -78,14 +90,6 @@ class TestRealFileUpload: not os.path.isfile(SAMPLE_INP_PATH), reason="example.inp not found", ) - @pytest.mark.xfail( - reason=( - "Server-side bug: upload view sets request.upload_handlers after " - "the upload has been processed (AttributeError). File reaches " - "the server correctly via Dropzone, but the view crashes." - ), - strict=False, - ) def test_upload_real_inp_file(self, auth_page: Page, live_server) -> None: """Upload the sample .inp file and verify status updates.""" sp = SimulationPage(auth_page, live_server.url) @@ -109,7 +113,111 @@ def test_wrong_extension_rejected(self, auth_page: Page, live_server, tmp_path) # Dropzone client-side validation rejects non-.inp files sp.upload.file_input.set_input_files(str(wrong_file)) - # Wait briefly for Dropzone to process - auth_page.wait_for_timeout(500) - # File should be rejected — either error element appears or no upload status - assert not sp.upload.has_upload_status() or sp.upload.has_dropzone_error() + expect(auth_page.locator(".dz-error")).to_be_visible() + assert sp.upload.get_dropzone_error_message() + assert not sp.upload.has_upload_status() + + +class TestModelEvents: + """Pages learn about the loaded model through document events.""" + + def test_sample_data_dispatches_model_changed(self, auth_page: Page, live_server) -> None: + sp = SimulationPage(auth_page, live_server.url) + sp.navigate_to() + sp.upload.record_model_events() + + sp.upload.click_sample_data() + detail = sp.upload.wait_for_model_changed() + + assert detail["filename"] == "example.inp" + assert detail["size"] > 0 + assert len(detail["subcatchments"]) > 0 + count = len(detail["subcatchments"]) + expect(sp.upload.upload_status_meta).to_contain_text(f"{count} subcatchment") + + def test_restored_model_dispatches_model_changed_once( + self, auth_page: Page, live_server + ) -> None: + sp = SimulationPage(auth_page, live_server.url) + sp.navigate_to() + sp.upload.click_sample_data() + + auth_page.add_init_script( + """window.__csModelEvents = 0; + document.addEventListener('cs:model-changed', () => { window.__csModelEvents += 1; });""" + ) + sp.navigate_to() + auth_page.wait_for_function("() => window.__csModelEvents > 0", timeout=15_000) + expect(sp.catchment_name_select).to_be_enabled() + + assert auth_page.evaluate("window.__csModelEvents") == 1 + assert "example.inp" in sp.upload.get_upload_status_text_value() + + def test_remove_dispatches_model_cleared(self, auth_page: Page, live_server) -> None: + sp = SimulationPage(auth_page, live_server.url) + sp.navigate_to() + sp.upload.click_sample_data() + sp.upload.record_model_events() + + sp.upload.clear_upload() + + auth_page.wait_for_function("() => window.__csModelCleared === true", timeout=5_000) + expect(sp.upload.trigger).to_be_focused() + assert sp.get_catchment_options() == [""] + + +class TestUploadZoneKeyboard: + """The drop target opens the file dialog from the keyboard.""" + + @pytest.mark.parametrize("key", ["Enter", " "]) + def test_trigger_opens_file_dialog(self, auth_page: Page, live_server, key: str) -> None: + sp = SimulationPage(auth_page, live_server.url) + # Listening for file choosers turns their interception on without waiting for it, + # so a key pressed right after expect_file_chooser() can reach the page first and + # open an unseen native dialog. Listen before the navigation instead. + auth_page.on("filechooser", lambda _chooser: None) + sp.navigate_to() + expect(sp.upload.trigger).to_have_attribute("aria-describedby", "upload-zone-hint") + + sp.upload.trigger.focus() + with auth_page.expect_file_chooser(timeout=5_000) as chooser_info: + auth_page.keyboard.press(key) + + assert not chooser_info.value.is_multiple() + + +class TestUploadErrorPreview: + """A rejected file never discards the model that is already loaded.""" + + def test_dismissing_rejected_file_keeps_loaded_model( + self, auth_page: Page, live_server, tmp_path + ) -> None: + wrong_file = tmp_path / "notes.txt" + wrong_file.write_text("not an inp file") + sp = SimulationPage(auth_page, live_server.url) + sp.navigate_to() + sp.upload.click_sample_data() + + sp.upload.file_input.set_input_files(str(wrong_file)) + expect(auth_page.locator(".dz-error [data-dz-errormessage]")).not_to_be_empty() + sp.upload.error_preview_dismiss.focus() + auth_page.keyboard.press("Enter") + + expect(auth_page.locator(".dz-error")).to_have_count(0) + expect(sp.upload.trigger).to_be_focused() + assert sp.upload.has_upload_status() + sp.navigate_to() + expect(sp.upload.upload_status_text).to_contain_text("example.inp") + + +class TestUploadRequiresLogin: + """Anonymous sample-data requests are redirected to the login page.""" + + def test_anonymous_sample_click_redirects_to_login(self, page: Page, live_server) -> None: + cp = CalculationsPage(page, live_server.url) + cp.navigate_to() + + cp.upload.sample_button.click() + + page.wait_for_url("**/login/**", timeout=10_000) + assert "next=" in page.url diff --git a/cs_app/main/forms.py b/cs_app/main/forms.py index 2440219..6c9b938 100644 --- a/cs_app/main/forms.py +++ b/cs_app/main/forms.py @@ -10,11 +10,14 @@ """ from crispy_forms.helper import FormHelper -from crispy_forms.layout import Submit +from crispy_forms.layout import Layout, Submit from django import forms from .models import UserProfile +# Same wording as the live range hint on the simulation and timeseries pages. +STOP_BEFORE_START_MESSAGE = "Stop must be greater than or equal to start." + class ContactForm(forms.Form): """ @@ -27,17 +30,23 @@ class ContactForm(forms.Form): send_to_me: A boolean field to indicate whether to send the message to the sender. """ - email = forms.EmailField(label="Adres email") - title = forms.CharField(label="Tytuł") - content = forms.CharField(widget=forms.Textarea, label="Treść") - send_to_me = forms.BooleanField(required=False, label="Prześlij") + # Length limits mirror main.schemas.ContactMessage. + email = forms.EmailField( + label="Email", widget=forms.EmailInput(attrs={"autocomplete": "email"}) + ) + title = forms.CharField(label="Subject", max_length=200) + content = forms.CharField( + label="Message", max_length=5000, widget=forms.Textarea(attrs={"rows": 6}) + ) + send_to_me = forms.BooleanField(required=False, label="Send me a copy") def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.helper = FormHelper() self.helper.form_method = "post" - self.helper.form_action = "contact" - self.helper.add_input(Submit("submit", "Wyślij")) + self.helper.form_action = "main:contact" + self.helper.attrs = {"data-cs-form": ""} # progressive enhancement hook (pages/forms.js) + self.helper.add_input(Submit("submit", "Send message")) class UserProfileForm(forms.ModelForm): @@ -52,13 +61,29 @@ class UserProfileForm(forms.ModelForm): class Meta: model = UserProfile fields = ["user", "bio"] + labels = {"bio": "Bio"} + help_texts = { + "bio": "A few words about you and your work. Anyone with the link can read it." + } + widgets = {"bio": forms.Textarea(attrs={"rows": 5})} def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + # The profile's account comes from the URL (via instance or initial), never + # from the request: the field is locked to that one account and not rendered. + initial_user = self.initial.get("user") + owner_pk = self.instance.user_id or getattr(initial_user, "pk", initial_user) + user_field = self.fields["user"] + user_field.disabled = True + user_field.queryset = user_field.queryset.filter(pk=owner_pk) + self.owner = user_field.queryset.first() + self.helper = FormHelper() + # No form_action: the form posts back to the profile URL it was rendered on. self.helper.form_method = "post" - self.helper.form_action = "userprofile" - self.helper.add_input(Submit("submit", "Wyślij")) + self.helper.attrs = {"data-cs-form": ""} # progressive enhancement hook (pages/forms.js) + self.helper.layout = Layout("bio") + self.helper.add_input(Submit("submit", "Save profile")) class SimulationForm(forms.Form): @@ -110,8 +135,13 @@ class SimulationForm(forms.Form): MAX_SWEEP_STEPS = 100 - option = forms.ChoiceField(choices=OPTIONS, widget=forms.Select(attrs={"class": "form-select"})) + option = forms.ChoiceField( + label="Parameter to vary", + choices=OPTIONS, + widget=forms.Select(attrs={"class": "form-select"}), + ) start = forms.IntegerField( + label="Start", min_value=0, max_value=10000, initial=1, @@ -119,6 +149,7 @@ class SimulationForm(forms.Form): widget=forms.NumberInput(attrs={"class": "form-control"}), ) stop = forms.IntegerField( + label="Stop", min_value=0, max_value=10000, initial=10, @@ -126,6 +157,7 @@ class SimulationForm(forms.Form): widget=forms.NumberInput(attrs={"class": "form-control"}), ) step = forms.IntegerField( + label="Step", min_value=1, max_value=10000, initial=1, @@ -133,40 +165,44 @@ class SimulationForm(forms.Form): widget=forms.NumberInput(attrs={"class": "form-control"}), ) catchment_name = forms.CharField( + label="Subcatchment", widget=forms.Select( choices=[("", "--- Upload a file first ---")], attrs={"class": "form-select"}, ), ) + RANGE_FIELDS = ("start", "stop", "step") + def __init__(self, *args, catchment_choices=None, **kwargs): super().__init__(*args, **kwargs) if catchment_choices is not None: self.fields["catchment_name"].widget.choices = catchment_choices - self.helper = FormHelper() - self.helper.form_method = "post" - self.helper.form_action = "simulation_view" - self.helper.add_input(Submit("submit", "Run Simulation")) def clean(self): cleaned_data = super().clean() - option = cleaned_data.get("option") - if option not in self.PREDEFINED_METHODS: - for field in ("start", "stop", "step"): - if cleaned_data.get(field) is None: - self.add_error(field, "This field is required for the selected method.") - start = cleaned_data.get("start") - stop = cleaned_data.get("stop") - step = cleaned_data.get("step") - if start is not None and stop is not None and start > stop: - self.add_error("stop", "Stop must be >= start.") - if start is not None and stop is not None and step and step > 0: - if (stop - start) / step >= self.MAX_SWEEP_STEPS: - self.add_error( - "step", - f"Too many steps (max {self.MAX_SWEEP_STEPS}). " - "Increase step size or reduce range.", - ) + if cleaned_data.get("option") in self.PREDEFINED_METHODS: + # Literature-value methods take no range, so values the user left in the + # range fields (even invalid ones) must not block the run. + for field in self.RANGE_FIELDS: + self.errors.pop(field, None) + return cleaned_data + + for field in self.RANGE_FIELDS: + if cleaned_data.get(field) is None and field not in self.errors: + self.add_error(field, "This field is required for the selected method.") + start = cleaned_data.get("start") + stop = cleaned_data.get("stop") + step = cleaned_data.get("step") + if start is not None and stop is not None and start > stop: + self.add_error("stop", STOP_BEFORE_START_MESSAGE) + if start is not None and stop is not None and step and step > 0: + if (stop - start) / step >= self.MAX_SWEEP_STEPS: + self.add_error( + "step", + f"Too many steps (max {self.MAX_SWEEP_STEPS}). " + "Increase step size or reduce range.", + ) return cleaned_data @@ -184,8 +220,8 @@ class TimeseriesForm(forms.Form): """ MODE_CHOICES = ( - ("single", "Single Timeseries"), - ("sweep", "Parameter Sweep Timeseries"), + ("single", "Single run"), + ("sweep", "Parameter sweep"), ) FEATURE_CHOICES = ( @@ -196,16 +232,21 @@ class TimeseriesForm(forms.Form): ("CurbLength", "Curb Length (m)"), ) + # Rendered as a segmented control (radios + button labels) by timeseries.html. mode = forms.ChoiceField( + label="Analysis mode", choices=MODE_CHOICES, - widget=forms.Select(attrs={"class": "form-select"}), + initial="single", + widget=forms.RadioSelect(attrs={"class": "btn-check", "autocomplete": "off"}), ) feature = forms.ChoiceField( + label="Parameter to vary", choices=FEATURE_CHOICES, required=False, widget=forms.Select(attrs={"class": "form-select"}), ) start = forms.FloatField( + label="Start", min_value=0, max_value=10000, initial=0, @@ -213,6 +254,7 @@ class TimeseriesForm(forms.Form): widget=forms.NumberInput(attrs={"class": "form-control"}), ) stop = forms.FloatField( + label="Stop", min_value=0, max_value=10000, initial=100, @@ -220,12 +262,14 @@ class TimeseriesForm(forms.Form): widget=forms.NumberInput(attrs={"class": "form-control"}), ) step = forms.FloatField( + label="Step", min_value=0.1, initial=10, required=False, widget=forms.NumberInput(attrs={"class": "form-control"}), ) catchment_name = forms.CharField( + label="Subcatchment", widget=forms.Select( choices=[("", "--- Upload a file first ---")], attrs={"class": "form-select"}, @@ -236,31 +280,32 @@ def __init__(self, *args, catchment_choices=None, **kwargs): super().__init__(*args, **kwargs) if catchment_choices is not None: self.fields["catchment_name"].widget.choices = catchment_choices - self.helper = FormHelper() - self.helper.form_method = "post" - self.helper.form_action = "timeseries" - self.helper.add_input(Submit("submit", "Run Analysis")) MAX_SWEEP_STEPS = 100 + SWEEP_FIELDS = ("feature", "start", "stop", "step") def clean(self): cleaned_data = super().clean() - if cleaned_data.get("mode") == "sweep": - if not cleaned_data.get("feature"): - self.add_error("feature", "Required for parameter sweep mode.") - for field in ("start", "stop", "step"): - if cleaned_data.get(field) is None: - self.add_error(field, "Required for parameter sweep mode.") - start = cleaned_data.get("start") - stop = cleaned_data.get("stop") - step = cleaned_data.get("step") - if start is not None and stop is not None and start > stop: - self.add_error("stop", "Stop must be >= start.") - if start is not None and stop is not None and step and step > 0: - if (stop - start) / step >= self.MAX_SWEEP_STEPS: - self.add_error( - "step", - f"Too many steps (max {self.MAX_SWEEP_STEPS}). " - "Increase step size or reduce range.", - ) + if cleaned_data.get("mode") != "sweep": + # The sweep range is not used outside sweep mode, so values the user left + # there (even invalid ones) must not block the run. + for field in self.SWEEP_FIELDS: + self.errors.pop(field, None) + return cleaned_data + + for field in self.SWEEP_FIELDS: + if cleaned_data.get(field) in (None, "") and field not in self.errors: + self.add_error(field, "Required for parameter sweep mode.") + start = cleaned_data.get("start") + stop = cleaned_data.get("stop") + step = cleaned_data.get("step") + if start is not None and stop is not None and start > stop: + self.add_error("stop", STOP_BEFORE_START_MESSAGE) + if start is not None and stop is not None and step and step > 0: + if (stop - start) / step >= self.MAX_SWEEP_STEPS: + self.add_error( + "step", + f"Too many steps (max {self.MAX_SWEEP_STEPS}). " + "Increase step size or reduce range.", + ) return cleaned_data diff --git a/cs_app/main/static/main/css/app.css b/cs_app/main/static/main/css/app.css new file mode 100644 index 0000000..6265fd1 --- /dev/null +++ b/cs_app/main/static/main/css/app.css @@ -0,0 +1,979 @@ +/* + * App shell and shared components. Tokens live in tokens.css. + * + * Shell + * .cs-skip-link "Skip to content" link (with .visually-hidden-focusable) + * .cs-header / .cs-brand sticky navbar; nav links mark the current page with aria-current="page" + * .cs-theme-toggle theme dropdown; items are [data-cs-theme-value] buttons with aria-pressed + * .content-wrapper
; grows so the footer sits at the bottom + * .cs-footer site footer + * .cs-toast toast created by CS.toast(); level via .cs-toast--success|info|warning|danger + * + * Page scaffolding + * .cs-page-header page intro block: .cs-eyebrow (small caps label), .cs-page-header__title + * (h1/h2), .cs-page-header__lead (one-line purpose) + * .cs-card surface card; .cs-card__title for its heading + * .cs-workbench two-column grid ≥ lg: .cs-panel (360px controls; sticky via .is-sticky from + * core/workbench.js while it fits the viewport) + .cs-canvas (results); + * stacks below lg + * .cs-step numbered panel section: .cs-step__header > .cs-step__num + .cs-step__title, + * then content; consecutive steps are divided by a rule + * .cs-hint muted note in a panel; --note adds a side rule, --count sets a live run count + * in mono; .is-warning turns it into an ochre warning + * .cs-segmented segmented control: .btn-check radios + .cs-segmented__option labels + * .cs-progress run progress row: spinner, .cs-progress__elapsed, [role=status] text + * + * Results + * .cs-metrics responsive grid of .cs-metric tiles + * .cs-metric tile: .cs-metric__label, .cs-metric__value (mono, tabular), .cs-metric__unit + * [data-cs-results] root of rendered results; dimmed while its async target is aria-busy + * .cs-stale-notice note above results kept after a failed re-run (added by CS.asyncForm) + * .cs-empty-state placeholder before the first run: .cs-empty-state__icon, __title, __text + * .cs-chart-card card for a chart: .cs-chart-card__toolbar (title + actions), .cs-chart-card__body + * .cs-table-wrap scroll container that keeps the .cs-table header sticky; shadows mark + * edges with more table to scroll to + * .cs-table data table (use with .table): sticky thead with wrapping body-font labels, + * .cs-num cells right-aligned (mono in the body); sortable headers hold a .cs-sort button, th[aria-sort] drives the indicator + * .cs-actions row of export/secondary actions under results + * .cs-reveal entrance animation for a freshly rendered results region; its .cs-metric + * tiles are staggered by 40ms + * + * Code + * .cs-code wrapper for a code block with an optional .cs-code__copy button slot + * .code-snippet
 code block; Prism token colours for both themes live here
+ *                            (only prism.js is loaded, no Prism theme stylesheet)
+ *
+ * Utilities
+ *   .cs-mono                 IBM Plex Mono with tabular numerals (file names, parameter values)
+ */
+
+/* ------------------------------------------------------------------ */
+/* Base                                                                */
+/* ------------------------------------------------------------------ */
+
+html {
+    scroll-padding-top: calc(var(--cs-header-h) + 1rem);
+}
+
+body {
+    display: flex;
+    flex-direction: column;
+    min-height: 100vh;
+    background-color: var(--cs-paper);
+    color: var(--cs-ink);
+    -webkit-font-smoothing: antialiased;
+}
+
+h1, h2, h3,
+.h1, .h2, .h3 {
+    font-family: var(--cs-font-display);
+    font-stretch: var(--cs-display-stretch);
+    font-weight: 650;
+    line-height: var(--cs-lh-display);
+    letter-spacing: -0.01em;
+}
+
+h1, .h1 { font-size: var(--cs-fs-h1); }
+h2, .h2 { font-size: var(--cs-fs-h2); }
+h3, .h3 { font-size: var(--cs-fs-h3); }
+
+h4, .h4 {
+    font-size: var(--cs-fs-h4);
+    font-weight: 600;
+}
+
+h5, .h5,
+h6, .h6 {
+    font-weight: 600;
+}
+
+small, .small {
+    font-size: var(--cs-fs-small);
+}
+
+a,
+.btn,
+.nav-link,
+.dropdown-item {
+    transition: color var(--cs-transition), background-color var(--cs-transition),
+        border-color var(--cs-transition);
+}
+
+code, kbd, samp, pre {
+    font-family: var(--cs-font-mono);
+}
+
+.cs-mono {
+    font-family: var(--cs-font-mono);
+    font-variant-numeric: tabular-nums;
+}
+
+/* One focus ring everywhere (spec §2.3). Bootstrap replaces the outline with
+   component box-shadows in its own :focus rules, so those are listed explicitly. */
+:focus-visible,
+.btn:focus-visible,
+.btn-close:focus-visible,
+.nav-link:focus-visible,
+.navbar-toggler:focus-visible,
+.dropdown-item:focus-visible,
+.accordion-button:focus-visible,
+.page-link:focus-visible,
+.form-control:focus-visible,
+.form-select:focus-visible,
+.form-check-input:focus-visible,
+.form-range:focus-visible {
+    outline: 3px solid var(--cs-focus-ring);
+    outline-offset: 2px;
+    box-shadow: none;
+}
+
+.btn-close:focus,
+.navbar-toggler:focus,
+.accordion-button:focus {
+    box-shadow: none;
+}
+
+.cs-skip-link:focus {
+    position: fixed !important;
+    top: 0.75rem;
+    left: 0.75rem;
+    z-index: 1090;
+    padding: 0.5rem 1rem;
+    border-radius: var(--cs-radius-sm);
+    background-color: var(--cs-water);
+    color: var(--cs-on-accent);
+    font-weight: 600;
+    text-decoration: none;
+}
+
+/* ------------------------------------------------------------------ */
+/* Header                                                              */
+/* ------------------------------------------------------------------ */
+
+.cs-header {
+    background-color: var(--cs-surface);
+    border-bottom: 1px solid var(--cs-line);
+}
+
+.cs-header .navbar {
+    --bs-navbar-color: var(--cs-ink-muted);
+    --bs-navbar-hover-color: var(--cs-ink);
+    --bs-navbar-active-color: var(--cs-ink);
+    --bs-navbar-brand-color: var(--cs-ink);
+    --bs-navbar-brand-hover-color: var(--cs-ink);
+    --bs-navbar-toggler-border-color: var(--cs-line);
+    min-height: var(--cs-header-h);
+}
+
+.cs-brand {
+    display: inline-flex;
+    align-items: center;
+    gap: 0.625rem;
+}
+
+.cs-brand img {
+    width: auto;
+    height: 2.25rem;
+}
+
+.cs-brand__wordmark {
+    font-family: var(--cs-font-display);
+    font-stretch: 125%;
+    font-weight: 700;
+    font-size: 1.0625rem;
+    letter-spacing: -0.01em;
+}
+
+.cs-header .nav-link {
+    position: relative;
+    font-weight: 500;
+}
+
+.cs-header .nav-link[aria-current="page"] {
+    color: var(--cs-ink);
+}
+
+/* Accent underline on the current page; a left bar in the collapsed menu. */
+.cs-header .nav-link[aria-current="page"]::after {
+    content: "";
+    position: absolute;
+    left: 0;
+    top: 0.5rem;
+    bottom: 0.5rem;
+    width: 2px;
+    border-radius: 2px;
+    background-color: var(--cs-water);
+}
+
+.cs-header .navbar-nav .nav-link[aria-current="page"] {
+    padding-left: 0.75rem;
+}
+
+@media (min-width: 992px) {
+    .cs-header .navbar-nav .nav-link,
+    .cs-header .navbar-nav .nav-link[aria-current="page"] {
+        padding-inline: 0.75rem;
+    }
+
+    .cs-header .nav-link[aria-current="page"]::after {
+        top: auto;
+        right: 0.75rem;
+        bottom: -0.2rem;
+        left: 0.75rem;
+        width: auto;
+        height: 2px;
+    }
+}
+
+.cs-nav-tools {
+    gap: 0.5rem;
+}
+
+.cs-nav-tools .btn {
+    white-space: nowrap;
+}
+
+/*
+ * Wide screens: brand | links | tools on a three-column grid whose side columns share
+ * the free space equally, so the links sit on the page's centre line whatever the width
+ * of the brand and of the tools (two auth buttons, or the account menu). The collapse
+ * wrapper steps out of the layout (display: contents) to make its two lists grid items.
+ */
+@media (min-width: 992px) {
+    .cs-header .navbar > .container {
+        display: grid;
+        grid-template-columns: minmax(max-content, 1fr) auto minmax(max-content, 1fr);
+        align-items: center;
+        column-gap: 1rem;
+    }
+
+    .cs-header .navbar-expand-lg .navbar-collapse {
+        display: contents !important;
+    }
+
+    .cs-header .navbar-brand {
+        justify-self: start;
+        margin-right: 0;
+    }
+
+    .cs-nav-links {
+        justify-self: center;
+    }
+
+    .cs-nav-tools {
+        justify-self: end;
+    }
+}
+
+/* Between lg and xl the row is only just wide enough: the wordmark stacks on two lines. */
+@media (min-width: 992px) and (max-width: 1199.98px) {
+    .cs-brand__wordmark {
+        width: min-content;
+        white-space: normal; /* .navbar-brand sets nowrap */
+        font-size: 0.9375rem;
+        line-height: 1.1;
+    }
+
+    .cs-header .navbar > .container {
+        column-gap: 0.75rem;
+    }
+
+    .cs-header .navbar-nav .nav-link,
+    .cs-header .navbar-nav .nav-link[aria-current="page"] {
+        padding-inline: 0.5rem;
+    }
+
+    .cs-header .nav-link[aria-current="page"]::after {
+        right: 0.5rem;
+        left: 0.5rem;
+    }
+}
+
+@media (max-width: 991.98px) {
+    /* The header is sticky, so an open menu taller than the viewport must scroll itself. */
+    .cs-header .navbar-collapse {
+        max-height: calc(100vh - var(--cs-header-h));
+        overflow-y: auto;
+        margin-inline: -0.375rem;
+    }
+
+    /* Room for the 5px focus ring, which the scroll container would clip. */
+    .cs-header .navbar-collapse > .navbar-nav {
+        padding-inline: 0.375rem;
+    }
+
+    .cs-header .navbar-collapse > .navbar-nav:first-child {
+        padding-top: 0.375rem;
+    }
+
+    .cs-nav-tools {
+        align-items: flex-start;
+        padding: 0.75rem 0 1rem;
+        border-top: 1px solid var(--cs-line);
+    }
+}
+
+.cs-theme-toggle {
+    display: inline-flex;
+    align-items: center;
+    gap: 0.5rem;
+}
+
+.cs-theme-toggle svg,
+.cs-theme-menu svg {
+    width: 1.125rem;
+    height: 1.125rem;
+    flex: none;
+}
+
+/* The icon alone carries the control on wide screens; the label stays for AT. */
+@media (min-width: 992px) {
+    .cs-theme-toggle__text {
+        position: absolute;
+        width: 1px;
+        height: 1px;
+        margin: -1px;
+        overflow: hidden;
+        clip: rect(0, 0, 0, 0);
+        white-space: nowrap;
+        border: 0;
+    }
+}
+
+.cs-theme-menu .dropdown-item {
+    display: flex;
+    align-items: center;
+    gap: 0.5rem;
+}
+
+.cs-theme-menu .cs-check {
+    margin-left: auto;
+    visibility: hidden;
+}
+
+.cs-theme-menu [aria-pressed="true"] {
+    font-weight: 600;
+}
+
+.cs-theme-menu [aria-pressed="true"] .cs-check {
+    visibility: visible;
+}
+
+/* ------------------------------------------------------------------ */
+/* Main, messages, footer, toasts                                      */
+/* ------------------------------------------------------------------ */
+
+.content-wrapper {
+    flex: 1 0 auto;
+    padding-top: 2rem;
+    padding-bottom: 4rem;
+}
+
+/* 
is only focusable as the skip-link target; it is not a control, so no ring. */ +#main-content:focus { + outline: none; +} + +.cs-messages .alert { + border-radius: var(--cs-radius); +} + +.alert { + --bs-alert-border-radius: var(--cs-radius); +} + +.cs-footer { + padding-block: 1.5rem; + border-top: 1px solid var(--cs-line); + background-color: var(--cs-surface); + font-size: var(--cs-fs-small); +} + +.cs-footer .nav-link { + --bs-nav-link-color: var(--cs-ink-muted); + --bs-nav-link-hover-color: var(--cs-water); + padding-inline: 0.5rem; +} + +.cs-footer__logo img { + width: auto; + height: 1.75rem; +} + +.cs-toast { + border-left: 4px solid var(--cs-toast-accent, var(--cs-water)); +} + +.cs-toast--success { --cs-toast-accent: var(--cs-success); } +.cs-toast--warning { --cs-toast-accent: var(--cs-ochre); } +.cs-toast--danger { --cs-toast-accent: var(--cs-danger); } + +.cs-toast .toast-body { + color: var(--cs-ink); +} + +/* ------------------------------------------------------------------ */ +/* Page scaffolding */ +/* ------------------------------------------------------------------ */ + +.cs-eyebrow { + margin-bottom: 0.5rem; + color: var(--cs-ink-muted); + font-family: var(--cs-font-body); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.cs-page-header { + margin-bottom: 2rem; +} + +.cs-page-header__title { + margin-bottom: 0.5rem; +} + +.cs-page-header__lead { + max-width: 64ch; + margin-bottom: 0; + color: var(--cs-ink-muted); + font-size: 1.0625rem; +} + +.cs-card { + padding: 1.5rem; + border: 1px solid var(--cs-line); + border-radius: var(--cs-radius); + background-color: var(--cs-surface); + box-shadow: var(--cs-shadow); +} + +.cs-card__title { + margin-bottom: 1rem; + font-size: var(--cs-fs-h4); +} + +.cs-workbench { + display: grid; + gap: 2rem; + align-items: start; +} + +.cs-panel, +.cs-canvas { + min-width: 0; +} + +.cs-panel { + padding: 1.5rem; + border: 1px solid var(--cs-line); + border-radius: var(--cs-radius); + background-color: var(--cs-surface); + box-shadow: var(--cs-shadow); +} + +@media (min-width: 992px) { + .cs-workbench { + grid-template-columns: 360px minmax(0, 1fr); + } + + /* Set by core/workbench.js only while the whole panel fits the viewport, so the + run button is never out of reach inside a scrolled panel. */ + .cs-panel.is-sticky { + position: sticky; + top: calc(var(--cs-header-h) + 1rem); + } +} + +.cs-canvas { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.cs-step + .cs-step { + margin-top: 1.5rem; + padding-top: 1.5rem; + border-top: 1px solid var(--cs-line); +} + +.cs-step__header { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.cs-step__num { + display: inline-grid; + place-items: center; + flex: none; + width: 1.75rem; + height: 1.75rem; + border: 1.5px solid var(--cs-water); + border-radius: 50%; + color: var(--cs-water); + font-family: var(--cs-font-mono); + font-size: var(--cs-fs-caption); + font-weight: 500; +} + +.cs-step__title { + margin: 0; + font-size: 1.0625rem; +} + +.cs-hint { + margin: 0 0 1rem; + color: var(--cs-ink-muted); + font-size: var(--cs-fs-caption); +} + +.cs-hint--note { + padding: 0.5rem 0.75rem; + border-left: 3px solid var(--cs-water); + border-radius: 0 var(--cs-radius-sm) var(--cs-radius-sm) 0; + background-color: var(--cs-surface-sunken); +} + +.cs-hint--count { + font-family: var(--cs-font-mono); + font-variant-numeric: tabular-nums; +} + +/* A live run count gives way to the server's field errors, which say the same thing. */ +fieldset:has(.is-invalid) > .cs-hint--count { + display: none; +} + +/* A warning, not an error: the server makes the final call. */ +.cs-hint.is-warning { + color: var(--cs-ochre-text); + font-family: var(--cs-font-body); + font-weight: 500; +} + +/* Number fields keep their spinner: in narrow range fields the invalid icon would crowd + it, and the red border and message already mark the error. */ +.form-control[type="number"].is-invalid { + padding-right: 0.75rem; + background-image: none; +} + +/* Segmented control: .btn-check radios, each followed by its .cs-segmented__option label. */ +.cs-segmented { + display: grid; + grid-auto-columns: minmax(0, 1fr); + grid-auto-flow: column; + padding: 0.25rem; + border: 1px solid var(--cs-line); + border-radius: var(--cs-radius); + background-color: var(--cs-surface-sunken); +} + +.cs-segmented__option { + --bs-btn-padding-y: 0.4rem; + --bs-btn-font-size: 0.9375rem; + --bs-btn-border-width: 0; + --bs-btn-color: var(--cs-ink-muted); + --bs-btn-hover-color: var(--cs-ink); + --bs-btn-active-color: var(--cs-ink); + --bs-btn-active-bg: var(--cs-surface); + border-radius: var(--cs-radius-sm); + font-weight: 500; +} + +.btn-check:checked + .cs-segmented__option { + background-color: var(--cs-surface); + color: var(--cs-ink); + box-shadow: var(--cs-shadow); +} + +.btn-check:focus-visible + .cs-segmented__option { + outline: 3px solid var(--cs-focus-ring); + outline-offset: 2px; + box-shadow: none; +} + +/* Run progress under the run button (toggled with [hidden] by CS.asyncForm). */ +.cs-progress { + display: flex; + align-items: flex-start; + gap: 0.625rem; + margin-top: 0.875rem; + color: var(--cs-ink-muted); + font-size: var(--cs-fs-caption); +} + +.cs-progress .spinner-border { + flex: none; + margin-top: 0.125rem; + color: var(--cs-water); +} + +/* Before the status text, so a wrapped sentence is never split by the counter. */ +.cs-progress__elapsed { + flex: none; + color: var(--cs-ink); + font-family: var(--cs-font-mono); + font-variant-numeric: tabular-nums; +} + +/* ------------------------------------------------------------------ */ +/* Results */ +/* ------------------------------------------------------------------ */ + +.cs-metrics { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(10rem, 1fr)); + gap: 1rem; +} + +.cs-metric { + padding: 1rem 1.25rem; + border: 1px solid var(--cs-line); + border-radius: var(--cs-radius); + background-color: var(--cs-surface); +} + +.cs-metric__label { + margin-bottom: 0.25rem; + color: var(--cs-ink-muted); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.cs-metric__value { + margin: 0; + color: var(--cs-ink); + font-family: var(--cs-font-mono); + font-size: 1.5rem; + font-weight: 500; + font-variant-numeric: tabular-nums; + line-height: 1.2; +} + +/* Phones fit two tiles per row; a smaller value keeps "8 h 55 min" on one line there. */ +@media (max-width: 575.98px) { + .cs-metric__value { + font-size: 1.25rem; + } +} + +.cs-metric__unit { + margin-left: 0.25rem; + color: var(--cs-ink-muted); + font-size: var(--cs-fs-caption); + font-weight: 400; +} + +/* Previous results ([data-cs-results]) stay readable but visibly stale during a re-run. */ +[aria-busy="true"] [data-cs-results] { + opacity: 0.55; + transition: opacity var(--cs-transition); +} + +/* Added by CS.asyncForm above results kept from an earlier run after a failed re-run. */ +.cs-stale-notice { + margin: 0 0 1rem; + padding: 0.5rem 0.75rem; + border-left: 3px solid var(--cs-ochre); + border-radius: 0 var(--cs-radius-sm) var(--cs-radius-sm) 0; + background-color: var(--cs-surface-sunken); + color: var(--cs-ink-muted); + font-size: var(--cs-fs-small); +} + +.cs-empty-state { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 0.5rem; + min-height: 18rem; + padding: 2.5rem 1.5rem; + border: 1.5px dashed var(--cs-line); + border-radius: var(--cs-radius-lg); + color: var(--cs-ink-muted); + text-align: center; +} + +.cs-empty-state__icon { + width: 2.5rem; + height: 2.5rem; + color: var(--cs-water); +} + +.cs-empty-state__title { + margin: 0; + color: var(--cs-ink); + font-size: var(--cs-fs-h4); +} + +.cs-empty-state__text { + max-width: 44ch; + margin: 0; +} + +.cs-chart-card { + border: 1px solid var(--cs-line); + border-radius: var(--cs-radius); + background-color: var(--cs-surface); + box-shadow: var(--cs-shadow); +} + +.cs-chart-card__toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem 1rem; + padding: 0.875rem 1.25rem; + border-bottom: 1px solid var(--cs-line); +} + +.cs-chart-card__title { + margin: 0; + font-size: 1.0625rem; +} + +.cs-chart-card__body { + padding: 0.75rem; +} + +/* + * Scroll cue: a shadow darkens each edge that has more table behind it. The + * surface-coloured covers scroll with the content (local) and hide the fixed + * (scroll) shadows once an end is reached; the cells are transparent (tokens.css). + */ +.cs-table-wrap { + max-height: 32rem; + overflow: auto; + border: 1px solid var(--cs-line); + border-radius: var(--cs-radius); + background: + linear-gradient(to right, var(--cs-surface) 30%, transparent) left center / 2.5rem 100% no-repeat local, + linear-gradient(to left, var(--cs-surface) 30%, transparent) right center / 2.5rem 100% no-repeat local, + radial-gradient(farthest-side at 0 50%, var(--cs-scroll-shadow), transparent) left center / 1rem 100% no-repeat scroll, + radial-gradient(farthest-side at 100% 50%, var(--cs-scroll-shadow), transparent) right center / 1rem 100% no-repeat scroll, + var(--cs-surface); +} + +.cs-table { + margin-bottom: 0; + font-size: var(--cs-fs-small); +} + +.cs-table thead th { + position: sticky; + top: 0; + z-index: 1; + background-color: var(--cs-surface-sunken); + color: var(--cs-ink-muted); + font-weight: 600; + vertical-align: bottom; + box-shadow: inset 0 -1px 0 var(--cs-line); +} + +/* Row headers (a name, a timestamp, a swept value) identify the row; they need no extra weight. */ +.cs-table tbody th { + font-weight: 500; +} + +.cs-table .cs-num { + text-align: right; +} + +/* Only values are set in mono; header labels ("Runoff volume [m³]") wrap in the body font. */ +.cs-table tbody .cs-num { + font-family: var(--cs-font-mono); + font-variant-numeric: tabular-nums; +} + +.cs-sort { + display: inline-flex; + align-items: center; + gap: 0.375rem; + width: 100%; + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + text-align: inherit; + cursor: pointer; +} + +.cs-num .cs-sort { + justify-content: flex-end; +} + +.cs-sort:hover { + color: var(--cs-ink); +} + +/* Sort indicator: both arrows faded when unsorted, the active one solid. + A mask (not text) so it never enters the button's accessible name. */ +.cs-sort::after { + content: ""; + flex: none; + width: 0.5rem; + height: 0.75rem; + background-color: currentColor; + -webkit-mask: var(--cs-sort-icon) center / contain no-repeat; + mask: var(--cs-sort-icon) center / contain no-repeat; + opacity: 0.35; + --cs-sort-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 12'%3e%3cpath d='M4 0 8 5H0zM4 12 0 7h8z'/%3e%3c/svg%3e"); +} + +th[aria-sort="ascending"] .cs-sort::after { + --cs-sort-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 12'%3e%3cpath d='M4 0 8 5H0z'/%3e%3c/svg%3e"); + opacity: 1; +} + +th[aria-sort="descending"] .cs-sort::after { + --cs-sort-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 12'%3e%3cpath d='M4 12 0 7h8z'/%3e%3c/svg%3e"); + opacity: 1; +} + +th[aria-sort] .cs-sort { + color: var(--cs-ink); +} + +.cs-actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + padding-top: 1rem; + border-top: 1px solid var(--cs-line); +} + +@keyframes cs-rise { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: none; + } +} + +.cs-reveal, +.cs-reveal .cs-metric { + animation: cs-rise 220ms ease-out both; +} + +.cs-reveal .cs-metric:nth-child(2) { animation-delay: 40ms; } +.cs-reveal .cs-metric:nth-child(3) { animation-delay: 80ms; } +.cs-reveal .cs-metric:nth-child(4) { animation-delay: 120ms; } +.cs-reveal .cs-metric:nth-child(5) { animation-delay: 160ms; } +.cs-reveal .cs-metric:nth-child(n + 6) { animation-delay: 200ms; } + +/* ------------------------------------------------------------------ */ +/* Code */ +/* ------------------------------------------------------------------ */ + +.cs-code { + position: relative; +} + +.cs-code__copy { + position: absolute; + top: 0.5rem; + right: 0.5rem; +} + +pre.code-snippet { + margin: 0 0 1rem; + padding: 1rem 1.25rem; + overflow: auto; + border: 1px solid var(--cs-line); + border-radius: var(--cs-radius); + background: var(--cs-surface-sunken); + color: var(--cs-ink); + font-family: var(--cs-font-mono); + font-size: 0.875rem; + line-height: 1.6; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.cs-code pre.code-snippet { + padding-right: 4.5rem; +} + +pre.code-snippet code { + background: none; + color: inherit; + font-family: inherit; + font-size: inherit; + white-space: inherit; +} + +.code-snippet .token.comment, +.code-snippet .token.prolog { + color: var(--cs-ink-muted); + font-style: italic; +} + +.code-snippet .token.keyword, +.code-snippet .token.builtin, +.code-snippet .token.boolean { + color: var(--cs-water-strong); + font-weight: 500; +} + +.code-snippet .token.string, +.code-snippet .token.triple-quoted-string { + color: var(--bs-success-text-emphasis); +} + +.code-snippet .token.number { + color: var(--cs-ochre-text); +} + +.code-snippet .token.function, +.code-snippet .token.class-name, +.code-snippet .token.decorator { + color: var(--cs-water); +} + +.code-snippet .token.operator, +.code-snippet .token.punctuation { + color: var(--cs-ink-muted); +} + +/* ------------------------------------------------------------------ */ +/* Motion preferences */ +/* ------------------------------------------------------------------ */ + +/* Set by core/theme.js for one frame while the colour mode changes. */ +.cs-theme-switching *, +.cs-theme-switching *::before, +.cs-theme-switching *::after { + transition: none !important; +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-delay: 0ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } + + .cs-reveal, + .cs-reveal .cs-metric { + animation: none; + transform: none; + } +} diff --git a/cs_app/main/static/main/css/base.css b/cs_app/main/static/main/css/base.css deleted file mode 100644 index 32b46ab..0000000 --- a/cs_app/main/static/main/css/base.css +++ /dev/null @@ -1,31 +0,0 @@ -html, -body { - height: 100%; - background-color: #fff; -} - -.content-wrapper { - min-height: calc(90% - 100px); -} - -/* Default dropzone reset. Upload page styles provide the full look. */ -.dropzone { - border-radius: 0.5rem; -} - -.dz-message { - border-radius: 0.25rem; -} - -.code-snippet { - border: 1px solid #ccc; - border-radius: 4px; - padding: 1em; - white-space: pre-wrap; - font-family: monospace; - background-color: #f0f0f0; -} - -pre[class*="code-snippet"] { - background-color: #f0f0f0; -} diff --git a/cs_app/main/static/main/css/calculations.css b/cs_app/main/static/main/css/calculations.css new file mode 100644 index 0000000..843045a --- /dev/null +++ b/cs_app/main/static/main/css/calculations.css @@ -0,0 +1,223 @@ +/* + * SWMM vs ANN comparison page. Builds on the shared components in app.css + * (.cs-workbench, .cs-step, .cs-metrics, .cs-chart-card, .cs-table, .cs-card). + */ + +/* ── Control panel ─────────────────────────────────────────────────── */ + +.calc-note { + display: flex; + gap: 0.625rem; + margin-top: 1.25rem; + padding: 0.75rem 0.875rem; + border-left: 3px solid var(--cs-ochre); + border-radius: var(--cs-radius-sm); + background-color: var(--cs-surface-sunken); + font-size: var(--cs-fs-small); +} + +.calc-note__icon { + flex: none; + width: 1.125rem; + height: 1.125rem; + margin-top: 0.125rem; + color: var(--cs-ochre-text); +} + +.calc-note__title { + margin-bottom: 0.125rem; + color: var(--cs-ochre-text); + font-weight: 600; +} + +.calc-note__text { + margin: 0; + color: var(--cs-ink-muted); +} + +/* ── Results ───────────────────────────────────────────────────────── */ + +.calc-results { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.calc-results__title { + margin: 0; + font-size: var(--cs-fs-h3); +} + +.calc-metrics { + margin: 0; +} + +/* Labels may wrap to two lines; keep every value on the tile's bottom edge. */ +.calc-metrics .cs-metric { + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.calc-metric__note { + margin: 0.375rem 0 0; + color: var(--cs-ink-muted); + font-size: var(--cs-fs-caption); +} + +.calc-charts { + display: grid; + gap: 1.5rem; +} + +/* Side by side only when each card is wide enough for a readable parity square. */ +@media (min-width: 1400px) { + .calc-charts { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +/* Reserve the chart height (charts.js defaults) so the swap does not jump. */ +.calc-chart--parity { + min-height: 420px; +} + +.calc-chart--bars { + min-height: 400px; +} + +.calc-chart__caption { + margin: 0.5rem 0.5rem 0.25rem; + color: var(--cs-ink-muted); + font-size: var(--cs-fs-caption); +} + +.calc-table__header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 0.5rem 1rem; + margin-bottom: 0.75rem; +} + +.calc-table__title { + margin: 0; + font-size: 1.0625rem; +} + +.calc-table__note { + margin: 0.5rem 0 0; + color: var(--cs-ink-muted); + font-size: var(--cs-fs-caption); +} + +/* ── Model description ─────────────────────────────────────────────── */ + +.calc-model__title { + margin-bottom: 0.5rem; +} + +.calc-model__lead { + max-width: 68ch; + color: var(--cs-ink-muted); +} + +.calc-details__item { + border-top: 1px solid var(--cs-line); +} + +.calc-details__item:last-child { + border-bottom: 1px solid var(--cs-line); +} + +.calc-details__item > summary { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + padding: 0.875rem 0.25rem; + border-radius: var(--cs-radius-sm); + list-style: none; + cursor: pointer; +} + +.calc-details__item > summary::-webkit-details-marker { + display: none; +} + +.calc-details__item > summary::after { + content: ""; + flex: none; + width: 0.75rem; + height: 0.75rem; + border-right: 2px solid var(--cs-ink-muted); + border-bottom: 2px solid var(--cs-ink-muted); + transform: translateY(-25%) rotate(45deg); + transition: transform var(--cs-transition); +} + +.calc-details__item[open] > summary::after { + transform: translateY(25%) rotate(-135deg); +} + +.calc-details__title { + font-weight: 600; + transition: color var(--cs-transition); +} + +.calc-details__title:hover { + color: var(--cs-water); +} + +.calc-details__body { + padding: 0 0.25rem 1.25rem; +} + +.calc-details__body > :last-child { + margin-bottom: 0; +} + +.calc-features { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 0.5rem 1.25rem; + margin: 0; +} + +.calc-features dt { + font-family: var(--cs-font-mono); + font-size: var(--cs-fs-small); + font-weight: 500; +} + +.calc-features dd { + margin: 0; + color: var(--cs-ink-muted); +} + +@media (max-width: 575.98px) { + .calc-features { + grid-template-columns: minmax(0, 1fr); + gap: 0; + } + + .calc-features dd { + margin-bottom: 0.625rem; + } +} + +.calc-model__diagram { + max-width: 30rem; + margin: 0 auto 1.25rem; +} + +.calc-model__diagram img { + width: 100%; + height: auto; +} + +/* The diagram is black line art on a transparent background. */ +[data-bs-theme="dark"] .calc-model__diagram img { + filter: invert(0.9); +} diff --git a/cs_app/main/static/main/css/content.css b/cs_app/main/static/main/css/content.css new file mode 100644 index 0000000..9973b5c --- /dev/null +++ b/cs_app/main/static/main/css/content.css @@ -0,0 +1,295 @@ +/* + * Content and account pages: About, Contact, Profile, Log in, Create account. + * Shared components (.cs-page-header, .cs-card, .cs-step__num) come from app.css. + */ + +/* ------------------------------------------------------------------ */ +/* Page frame */ +/* ------------------------------------------------------------------ */ + +.content-page { + max-width: 60rem; + margin-inline: auto; +} + +.content-page--form { + max-width: 38rem; +} + +.content-page__aside { + margin: 1.25rem 0 0; + color: var(--cs-ink-muted); + font-size: var(--cs-fs-small); +} + +/* Crispy renders the submit as input.btn inside div.mb-3; keep it snug under the fields. */ +[data-cs-form] .btn[type="submit"] { + min-width: 10rem; +} + +/* ------------------------------------------------------------------ */ +/* Log in / Create account */ +/* ------------------------------------------------------------------ */ + +.auth-card { + max-width: 28rem; + margin: 1rem auto 0; + padding: 2rem; +} + +@media (max-width: 575.98px) { + .auth-card { + margin-top: 0; + padding: 1.5rem 1.25rem; + } +} + +.auth-card__title { + margin-bottom: 0.5rem; + font-size: var(--cs-fs-h2); +} + +.auth-card__lead { + margin-bottom: 1.5rem; + color: var(--cs-ink-muted); +} + +.auth-card__form .form-text ul { + margin: 0.25rem 0 0; + padding-left: 1.125rem; +} + +.auth-card__switch { + margin: 1.5rem 0 0; + padding-top: 1.25rem; + border-top: 1px solid var(--cs-line); + color: var(--cs-ink-muted); + text-align: center; +} + +/* Password visibility toggle added by pages/forms.js. */ +.cs-password-toggle { + display: inline-flex; + align-items: center; + border-color: var(--cs-field-border); + color: var(--cs-ink-muted); +} + +.cs-password-toggle svg { + width: 1.125rem; + height: 1.125rem; +} + +.cs-password-toggle .cs-password-toggle__hide, +.cs-password-toggle[aria-pressed="true"] .cs-password-toggle__show { + display: none; +} + +.cs-password-toggle[aria-pressed="true"] .cs-password-toggle__hide { + display: inline; +} + +/* ------------------------------------------------------------------ */ +/* Profile */ +/* ------------------------------------------------------------------ */ + +.profile-card__identity { + display: flex; + align-items: center; + gap: 1rem; + margin-bottom: 1.5rem; + padding-bottom: 1.5rem; + border-bottom: 1px solid var(--cs-line); +} + +.profile-card__avatar { + display: inline-grid; + place-items: center; + flex: none; + width: 3.5rem; + height: 3.5rem; + border-radius: 50%; + background-color: var(--cs-water); + color: var(--cs-on-accent); + font-family: var(--cs-font-display); + font-size: 1.5rem; + font-weight: 700; +} + +.profile-card__name { + margin: 0 0 0.25rem; + font-size: var(--cs-fs-h3); + overflow-wrap: anywhere; +} + +.profile-card__meta { + display: flex; + flex-direction: column; + margin: 0; + color: var(--cs-ink-muted); + font-size: var(--cs-fs-small); + overflow-wrap: anywhere; +} + +/* One line with decorative separators where it fits; stacked on phones. */ +@media (min-width: 576px) { + .profile-card__meta { + flex-direction: row; + gap: 0.5rem; + } + + .profile-card__meta > span + span::before { + content: "·" / ""; + margin-right: 0.5rem; + } +} + +.profile-card__section-title { + margin-bottom: 0.5rem; + color: var(--cs-ink-muted); + font-family: var(--cs-font-body); + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.profile-card__bio { + margin: 0; + white-space: normal; + overflow-wrap: anywhere; +} + +.profile-card__bio--empty { + color: var(--cs-ink-muted); +} + +/* ------------------------------------------------------------------ */ +/* About */ +/* ------------------------------------------------------------------ */ + +.about-grid { + display: grid; + gap: 0 3rem; +} + +@media (min-width: 768px) { + .about-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.about-section { + margin-bottom: 2.5rem; +} + +.about-section--wide { + padding-top: 2.5rem; + border-top: 1px solid var(--cs-line); +} + +.about-section__title { + margin-bottom: 0.75rem; + font-size: var(--cs-fs-h3); +} + +.about-section p { + max-width: 68ch; +} + +.about-steps { + display: grid; + gap: 1.5rem; + margin: 1.25rem 0 0; + padding: 0; + list-style: none; +} + +@media (min-width: 768px) { + .about-steps { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.75rem 3rem; + } +} + +.about-steps__item { + display: flex; + gap: 1rem; +} + +.about-steps__title { + margin: 0.125rem 0 0.25rem; + font-family: var(--cs-font-body); + font-size: 1rem; + font-weight: 600; + font-stretch: normal; + letter-spacing: 0; +} + +.about-steps__item p { + margin: 0; + color: var(--cs-ink-muted); +} + +.about-links { + display: grid; + gap: 1rem; + margin: 1.25rem 0 0; + padding: 0; + list-style: none; +} + +@media (min-width: 768px) { + .about-links { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.about-links__item { + display: flex; + flex-direction: column; + gap: 0.25rem; + height: 100%; + padding: 1rem 1.25rem; + border: 1px solid var(--cs-line); + border-radius: var(--cs-radius); + background-color: var(--cs-surface); + color: var(--cs-ink); + text-decoration: none; +} + +.about-links__item:hover { + border-color: var(--cs-water); +} + +.about-links__name { + color: var(--cs-water-strong); + font-weight: 600; + text-decoration: underline; + text-underline-offset: 0.2em; +} + +.about-links__text { + color: var(--cs-ink-muted); + font-size: var(--cs-fs-small); + overflow-wrap: anywhere; +} + +.about-cta { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 1rem 2rem; + margin-top: 1rem; +} + +.about-cta__title { + margin-bottom: 0.25rem; + font-size: var(--cs-fs-h4); +} + +.about-cta__text { + margin: 0; + color: var(--cs-ink-muted); +} diff --git a/cs_app/main/static/main/css/home.css b/cs_app/main/static/main/css/home.css new file mode 100644 index 0000000..112b282 --- /dev/null +++ b/cs_app/main/static/main/css/home.css @@ -0,0 +1,324 @@ +/* + * Home page: hero with the example storm, tool cards, documentation with a + * sticky table of contents (≥ lg). Shared components come from app.css. + */ + +/* ------------------------------------------------------------------ */ +/* Hero */ +/* ------------------------------------------------------------------ */ + +.home-hero { + display: grid; + gap: 2rem; + align-items: center; + margin-bottom: 4rem; +} + +@media (min-width: 992px) { + .home-hero { + grid-template-columns: minmax(0, 5fr) minmax(0, 7fr); + gap: 3rem; + padding-top: 1rem; + } +} + +.home-hero__title { + max-width: 18ch; + margin-bottom: 1rem; +} + +.home-hero__lead { + max-width: 46ch; + margin-bottom: 1.75rem; + color: var(--cs-ink-muted); + font-size: 1.125rem; +} + +.home-hero__actions { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; +} + +.home-hero__chart { + margin: 0; +} + +.home-hero__source { + color: var(--cs-ink-muted); + font-size: var(--cs-fs-caption); +} + +/* Two by two until the card is wide enough for one row of four. */ +.home-hero__metrics { + margin: 0; + padding: 0 1.25rem 1.25rem; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.625rem; +} + +.home-hero__metrics .cs-metric { + padding: 0.75rem 0.875rem; + background-color: var(--cs-surface-sunken); + border-color: transparent; +} + +/* One row only where every tile fits its label and value ("8 h 55 min") on one line. */ +@media (min-width: 768px) and (max-width: 991.98px), (min-width: 1200px) { + .home-hero__metrics { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .home-hero__metrics .cs-metric { + padding-inline: 0.625rem; + } +} + +.home-hero__metrics .cs-metric__label { + letter-spacing: 0.04em; + white-space: nowrap; +} + +.home-hero__metrics dd { + margin: 0; +} + +.home-hero__metrics .cs-metric__value { + font-size: 1.125rem; +} + +.home-hero__metrics .cs-metric__unit { + margin-left: 0.125rem; +} + +.home-hero__empty { + min-height: 22rem; + margin: 1.25rem; +} + +/* Reserve the plot height so the page does not jump when Plotly draws. */ +.home-chart { + min-height: 320px; +} + +.home-chart--hero { + min-height: 360px; +} + +.home-chart__noscript { + margin: 0 0 0.5rem; + color: var(--cs-ink-muted); + font-size: var(--cs-fs-small); +} + +/* ------------------------------------------------------------------ */ +/* Tool cards */ +/* ------------------------------------------------------------------ */ + +.home-tools { + margin-bottom: 4rem; +} + +.home-tools__list { + display: grid; + gap: 1.25rem; + margin: 0; + padding: 0; + list-style: none; +} + +@media (min-width: 768px) { + .home-tools__list { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.home-tool { + position: relative; + transition: border-color var(--cs-transition); +} + +.home-tool:hover { + border-color: var(--cs-water); +} + +/* The whole card is the link target, so the ring goes around the card. */ +.home-tool:focus-within { + outline: 3px solid var(--cs-focus-ring); + outline-offset: 2px; +} + +.home-tool .stretched-link:focus-visible { + outline: none; +} + +.home-tool__icon { + width: 2rem; + height: 2rem; + margin-bottom: 1rem; + color: var(--cs-water); +} + +.home-tool .cs-eyebrow { + margin-bottom: 0.25rem; +} + +.home-tool__title { + margin-bottom: 0.5rem; + font-size: var(--cs-fs-h4); +} + +.home-tool__title a { + color: var(--cs-ink); + text-decoration: none; +} + +.home-tool:hover .home-tool__title a { + color: var(--cs-water-strong); +} + +.home-tool__text { + margin: 0; + color: var(--cs-ink-muted); +} + +/* ------------------------------------------------------------------ */ +/* Documentation */ +/* ------------------------------------------------------------------ */ + +.home-docs { + padding-top: 3rem; + border-top: 1px solid var(--cs-line); +} + +@media (min-width: 992px) { + .home-docs { + display: grid; + grid-template-columns: 13.5rem minmax(0, 1fr); + gap: 3.5rem; + align-items: start; + } +} + +.home-docs__body { + min-width: 0; +} + +.home-toc { + position: sticky; + top: calc(var(--cs-header-h) + 1.5rem); + max-height: calc(100vh - var(--cs-header-h) - 3rem); + overflow-y: auto; + /* Room for the focus ring, which the scroll container would clip. */ + padding: 0.25rem; +} + +.home-toc .nav { + flex-wrap: nowrap; +} + +.home-toc .nav-link { + padding: 0.3rem 0.75rem; + border-left: 2px solid var(--cs-line); + color: var(--cs-ink-muted); + font-size: var(--cs-fs-small); +} + +.home-toc .nav-link:hover { + color: var(--cs-ink); +} + +.home-toc .nav-link.active { + border-left-color: var(--cs-water); + color: var(--cs-ink); + font-weight: 600; +} + +.home-toc .nav .nav .nav-link { + padding-left: 1.5rem; +} + +.home-doc + .home-doc { + margin-top: 3.5rem; +} + +.home-doc > p { + max-width: 72ch; +} + +.home-doc h3 { + margin-bottom: 0.75rem; +} + +.home-methods__group { + margin: 2rem 0 0.75rem; + font-size: 1.0625rem; +} + +.home-methods { + max-width: 60rem; + margin: 0; + border-bottom: 1px solid var(--cs-line); +} + +.home-methods dt, +.home-methods dd { + margin: 0; + padding: 0.75rem 0; + border-top: 1px solid var(--cs-line); +} + +.home-methods dt { + font-weight: 400; +} + +.home-methods dt code { + overflow-wrap: anywhere; +} + +.home-methods dd { + padding-top: 0; + border-top: 0; + color: var(--cs-ink-muted); +} + +@media (min-width: 768px) { + .home-methods { + display: grid; + grid-template-columns: minmax(0, 2fr) minmax(0, 3fr); + column-gap: 2rem; + } + + .home-methods dd { + padding-top: 0.75rem; + border-top: 1px solid var(--cs-line); + } +} + +.home-example { + margin-top: 3rem; +} + +.home-example__chart { + margin: 0; +} + +.home-example__summary { + display: flex; + flex-wrap: wrap; + gap: 0.5rem 2.5rem; + margin: 0; + padding: 0.75rem 1.25rem; + border-top: 1px solid var(--cs-line); + font-size: var(--cs-fs-small); +} + +.home-example__summary dt { + color: var(--cs-ink-muted); + font-size: var(--cs-fs-caption); + font-weight: 500; +} + +.home-example__summary dd { + margin: 0; +} diff --git a/cs_app/main/static/main/css/simulation.css b/cs_app/main/static/main/css/simulation.css new file mode 100644 index 0000000..c7924df --- /dev/null +++ b/cs_app/main/static/main/css/simulation.css @@ -0,0 +1,103 @@ +/* + * Simulation page (main/simulation.html). Shared layout and result components + * live in app.css; this file only holds what the parameter sweep adds. + * + * .sim-range borderless fieldset with start/stop/step (.sim-range__fields) and the + * live run-count hint + * .sim-results results column: header, key figures, chart card, table, actions + * .sim-metrics
of .cs-metric tiles; .cs-metric__note names the swept value + * .sim-chart chart container; holds the no-JS fallback until Plotly draws + * .sim-empty empty state before the first run, with a list of what a run gives + */ + +/* ── Panel ────────────────────────────────────────────────────────── */ + +.sim-range { + min-width: 0; + margin: 0 0 1rem; + padding: 0; + border: 0; +} + +.sim-range__fields { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; +} + +/* crispy wraps each field in .mb-3; the grid gap spaces them instead. */ +.sim-range__fields > .mb-3 { + margin-bottom: 0 !important; +} + +/* ── Results ──────────────────────────────────────────────────────── */ + +.sim-results { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.sim-results__header { + display: flex; + flex-wrap: wrap; + align-items: baseline; + justify-content: space-between; + gap: 0.25rem 1rem; +} + +.sim-results__title { + margin: 0; + font-size: var(--cs-fs-h3); +} + +.sim-results__meta { + margin: 0; + color: var(--cs-ink-muted); + font-family: var(--cs-font-mono); + font-size: var(--cs-fs-caption); +} + +.sim-metrics { + margin: 0; +} + +.sim-metrics dd { + margin: 0; +} + +.sim-metrics .cs-metric__note { + margin-top: 0.375rem; + color: var(--cs-ink-muted); + font-size: var(--cs-fs-caption); + overflow-wrap: anywhere; +} + +.sim-chart { + min-height: 300px; +} + +.sim-chart__fallback { + margin: 0; + padding: 2rem 1rem; + color: var(--cs-ink-muted); + text-align: center; +} + +.sim-table__title { + margin: 0 0 0.75rem; + font-size: 1.0625rem; +} + +/* ── Empty state ──────────────────────────────────────────────────── */ + +.sim-empty__list { + margin: 0.75rem 0 0; + padding-left: 1.25rem; + font-size: var(--cs-fs-small); + text-align: left; +} + +.sim-empty__list li + li { + margin-top: 0.25rem; +} diff --git a/cs_app/main/static/main/css/timeseries.css b/cs_app/main/static/main/css/timeseries.css index 3203ea2..8a8cc56 100644 --- a/cs_app/main/static/main/css/timeseries.css +++ b/cs_app/main/static/main/css/timeseries.css @@ -1,9 +1,161 @@ -.metric-card { - border: 1px solid #e2e8f0; - border-radius: 12px; - transition: box-shadow 0.2s ease; +/* + * Timeseries page. Shared layout and result components live in app.css; this + * file only holds what is specific to the hydrograph workbench. + */ + +/* ── Run form ────────────────────────────────────────────────────── */ + +.ts-mode { + min-width: 0; +} + +/* Bootstrap sizes like a heading; here it is a field label. */ +.ts-mode > legend { + float: none; + margin-bottom: 0.5rem; + font-size: 1rem; +} + +.ts-mode__hint { + margin: 0.5rem 0 0; +} + +/* Visibility follows the checked radio, so it works without JS and never flashes. */ +.ts-form:has([name="mode"][value="single"]:checked) [data-ts-mode-hint="sweep"], +.ts-form:has([name="mode"][value="sweep"]:checked) [data-ts-mode-hint="single"], +.ts-form:has([name="mode"][value="single"]:checked) .ts-sweep { + display: none; +} + +.ts-sweep { + min-width: 0; + margin-bottom: 1rem; + padding: 1rem; + border: 1px solid var(--cs-line); + border-radius: var(--cs-radius); +} + +.ts-sweep__range { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0 0.75rem; +} + +/* ── Results canvas ──────────────────────────────────────────────── */ + +.ts-results__body { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.ts-results__header .cs-eyebrow { + margin-bottom: 0.25rem; +} + +.ts-results__title { + margin: 0; + font-size: var(--cs-fs-h3); +} + +.ts-results__title:focus { + outline: none; +} + +.ts-results__title:focus-visible { + outline: 3px solid var(--cs-focus-ring); + outline-offset: 4px; +} + +.ts-metric__note { + margin: 0.375rem 0 0; + color: var(--cs-ink-muted); + font-size: var(--cs-fs-caption); +} + +.ts-metric__note:empty { + display: none; } -.metric-card:hover { - box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06); +/* Reserves the chart's height (chartHeight() in timeseries.js) until Plotly draws. */ +.ts-chart { + min-height: 440px; +} + +.ts-chart-tools { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 1rem; +} + +.ts-series { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.ts-series__label { + color: var(--cs-ink-muted); + font-size: var(--cs-fs-small); +} + +.ts-series .form-select { + width: auto; + max-width: 16rem; +} + +.ts-chart-note { + margin: 0; + padding: 0 1.25rem 1rem; + color: var(--cs-ink-muted); + font-size: var(--cs-fs-caption); +} + +.ts-table-section__title { + margin-bottom: 0.75rem; + font-size: 1.0625rem; +} + +/* Timestamps and durations ("8 h 55 min") read as one value. */ +.ts-table-section .cs-table tbody > tr > * { + white-space: nowrap; +} + +.ts-data__summary { + width: fit-content; + margin-bottom: 0.75rem; + color: var(--cs-water); + font-weight: 600; + cursor: pointer; +} + +.ts-data__summary:hover { + color: var(--cs-water-strong); +} + +.ts-export-feedback { + margin: 0; + padding: 0 1.25rem 1rem; + color: var(--cs-danger); + font-size: var(--cs-fs-small); +} + +.ts-export-feedback:empty { + display: none; +} + +@media (max-width: 575.98px) { + .ts-chart { + min-height: 520px; + } + + /* Cells tighten so the columns fit a phone without sideways scrolling. */ + .ts-table-section .cs-table { + font-size: var(--cs-fs-caption); + } + + .ts-table-section .cs-table > :not(caption) > * > * { + padding-inline: 0.375rem; + } } diff --git a/cs_app/main/static/main/css/tokens.css b/cs_app/main/static/main/css/tokens.css new file mode 100644 index 0000000..b981cfe --- /dev/null +++ b/cs_app/main/static/main/css/tokens.css @@ -0,0 +1,436 @@ +/* + * Design tokens — "survey sheet" palette (docs/ui-modernization/DESIGN_SPEC.md §2). + * + * --cs-* tokens are the source of truth; the --bs-* block under each theme maps + * them onto Bootstrap 5.3 so stock components inherit the palette. Must load + * after bootstrap.min.css (same selectors, later wins). Theme is selected by + * , set before paint by core/theme-init.js. + */ + +:root, +[data-bs-theme="light"] { + /* Palette */ + --cs-ink: #0F2233; + --cs-ink-rgb: 15, 34, 51; + --cs-ink-muted: #4A5E6D; + --cs-ink-muted-rgb: 74, 94, 109; + --cs-paper: #F4F7F9; + --cs-paper-rgb: 244, 247, 249; + --cs-surface: #FFFFFF; + --cs-surface-sunken: #EBF1F5; + --cs-line: #D6E1E8; + --cs-water: #0B6E8A; + --cs-water-rgb: 11, 110, 138; + --cs-water-strong: #08566C; + --cs-water-strong-rgb: 8, 86, 108; + --cs-on-accent: #FFFFFF; /* text on water/danger/success fills */ + --cs-field-border: #808E99; /* 70% ink-muted over surface: 3:1 field edge */ + --cs-rain: #5A6FD6; + --cs-ochre: #B7791F; + --cs-ochre-text: #8A5A12; + --cs-danger: #B42318; + --cs-danger-rgb: 180, 35, 24; + --cs-success: #1F7A4D; + --cs-success-rgb: 31, 122, 77; + /* Chart-only categorical series (§2.1) */ + --cs-soil: #346400; + --cs-evap: #8A5A9E; + + /* Bootstrap mapping */ + --bs-body-color: var(--cs-ink); + --bs-body-color-rgb: var(--cs-ink-rgb); + --bs-body-bg: var(--cs-paper); + --bs-body-bg-rgb: var(--cs-paper-rgb); + --bs-emphasis-color: var(--cs-ink); + --bs-emphasis-color-rgb: var(--cs-ink-rgb); + --bs-heading-color: var(--cs-ink); + --bs-secondary-color: var(--cs-ink-muted); + --bs-secondary-color-rgb: var(--cs-ink-muted-rgb); + --bs-secondary-bg: var(--cs-surface-sunken); + --bs-tertiary-color: var(--cs-ink-muted); + --bs-tertiary-bg: var(--cs-surface-sunken); + --bs-tertiary-bg-rgb: 235, 241, 245; + --bs-border-color: var(--cs-line); + --bs-border-color-translucent: var(--cs-line); + + --bs-primary: var(--cs-water); + --bs-primary-rgb: var(--cs-water-rgb); + --bs-primary-text-emphasis: var(--cs-water-strong); + --bs-primary-bg-subtle: #E4F1F5; + --bs-primary-border-subtle: #B3D6E1; + --bs-link-color: var(--cs-water); + --bs-link-color-rgb: var(--cs-water-rgb); + --bs-link-hover-color: var(--cs-water-strong); + --bs-link-hover-color-rgb: var(--cs-water-strong-rgb); + --bs-code-color: var(--cs-ochre-text); + + --bs-danger: var(--cs-danger); + --bs-danger-rgb: var(--cs-danger-rgb); + --bs-danger-text-emphasis: #8F1C13; + --bs-danger-bg-subtle: #FDECEA; + --bs-danger-border-subtle: #F5C2BD; + --bs-success: var(--cs-success); + --bs-success-rgb: var(--cs-success-rgb); + --bs-success-text-emphasis: #175C3A; + --bs-success-bg-subtle: #E6F4EC; + --bs-success-border-subtle: #B5DCC6; + --bs-warning-text-emphasis: #6E480E; + --bs-warning-bg-subtle: #FBF1E1; + --bs-warning-border-subtle: #EBCB95; + --bs-info-text-emphasis: var(--cs-water-strong); + --bs-info-bg-subtle: #E4F1F5; + --bs-info-border-subtle: #B3D6E1; + + --bs-form-valid-color: var(--cs-success); + --bs-form-valid-border-color: var(--cs-success); + --bs-form-invalid-color: var(--cs-danger); + --bs-form-invalid-border-color: var(--cs-danger); + + --cs-shadow: 0 1px 2px rgb(15 34 51 / .06), 0 4px 12px rgb(15 34 51 / .05); + --cs-scroll-shadow: rgb(15 34 51 / .3); + --bs-box-shadow: var(--cs-shadow); + --bs-box-shadow-sm: 0 1px 2px rgb(15 34 51 / .06); +} + +[data-bs-theme="dark"] { + --cs-ink: #E3ECF2; + --cs-ink-rgb: 227, 236, 242; + --cs-ink-muted: #9DB1BF; + --cs-ink-muted-rgb: 157, 177, 191; + --cs-paper: #0B1620; + --cs-paper-rgb: 11, 22, 32; + --cs-surface: #122330; + --cs-surface-sunken: #0E1C27; + --cs-line: #23394A; + --cs-water: #3FB8D6; + --cs-water-rgb: 63, 184, 214; + --cs-water-strong: #6FCDE3; + --cs-water-strong-rgb: 111, 205, 227; + --cs-on-accent: #0B1620; + --cs-field-border: #738694; + --cs-rain: #8C9CF0; + --cs-ochre: #E0A546; + --cs-ochre-text: #E9B96A; + --cs-danger: #F97066; + --cs-danger-rgb: 249, 112, 102; + --cs-success: #4CC38A; + --cs-success-rgb: 76, 195, 138; + --cs-soil: #C1EB95; + --cs-evap: #C29AD6; + + --bs-body-color: var(--cs-ink); + --bs-body-color-rgb: var(--cs-ink-rgb); + --bs-body-bg: var(--cs-paper); + --bs-body-bg-rgb: var(--cs-paper-rgb); + --bs-emphasis-color: var(--cs-ink); + --bs-emphasis-color-rgb: var(--cs-ink-rgb); + --bs-heading-color: var(--cs-ink); + --bs-secondary-color: var(--cs-ink-muted); + --bs-secondary-color-rgb: var(--cs-ink-muted-rgb); + --bs-secondary-bg: var(--cs-surface-sunken); + --bs-tertiary-color: var(--cs-ink-muted); + --bs-tertiary-bg: var(--cs-surface-sunken); + --bs-tertiary-bg-rgb: 14, 28, 39; + --bs-border-color: var(--cs-line); + --bs-border-color-translucent: var(--cs-line); + + --bs-primary: var(--cs-water); + --bs-primary-rgb: var(--cs-water-rgb); + --bs-primary-text-emphasis: var(--cs-water-strong); + --bs-primary-bg-subtle: #0E2733; + --bs-primary-border-subtle: #1E4E61; + --bs-link-color: var(--cs-water); + --bs-link-color-rgb: var(--cs-water-rgb); + --bs-link-hover-color: var(--cs-water-strong); + --bs-link-hover-color-rgb: var(--cs-water-strong-rgb); + --bs-code-color: var(--cs-ochre-text); + + --bs-danger: var(--cs-danger); + --bs-danger-rgb: var(--cs-danger-rgb); + --bs-danger-text-emphasis: #FCA69F; + --bs-danger-bg-subtle: #2A1517; + --bs-danger-border-subtle: #6B2A26; + --bs-success: var(--cs-success); + --bs-success-rgb: var(--cs-success-rgb); + --bs-success-text-emphasis: #7FD9AE; + --bs-success-bg-subtle: #0F2A1F; + --bs-success-border-subtle: #1F5A3D; + --bs-warning-text-emphasis: var(--cs-ochre-text); + --bs-warning-bg-subtle: #2A2012; + --bs-warning-border-subtle: #6B4F1E; + --bs-info-text-emphasis: var(--cs-water-strong); + --bs-info-bg-subtle: #0E2733; + --bs-info-border-subtle: #1E4E61; + + --bs-form-valid-color: var(--cs-success); + --bs-form-valid-border-color: var(--cs-success); + --bs-form-invalid-color: var(--cs-danger); + --bs-form-invalid-border-color: var(--cs-danger); + + /* Dark mode relies on borders, not shadows (§2.3). */ + --cs-shadow: none; + /* A scroll cue, not elevation, so it stays in dark mode. */ + --cs-scroll-shadow: rgb(0 0 0 / .75); + --bs-box-shadow: none; + --bs-box-shadow-sm: none; +} + +/* + * Theme-independent tokens. Also matched on [data-bs-theme] so nested theme + * scopes keep the fonts (Bootstrap resets them there) and re-resolve the ring. + */ +:root, +[data-bs-theme] { + --cs-font-display: "Archivo", "Public Sans", system-ui, sans-serif; + --cs-font-body: "Public Sans", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + --cs-font-mono: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --cs-display-stretch: 118%; + + --cs-fs-caption: 0.8125rem; + --cs-fs-small: 0.9375rem; + --cs-fs-body: 1rem; + --cs-fs-h4: 1.25rem; + --cs-fs-h3: 1.5rem; + --cs-fs-h2: clamp(1.625rem, 1.3rem + 1.2vw, 2rem); + --cs-fs-h1: clamp(2rem, 1.4rem + 2.4vw, 2.75rem); + --cs-lh-body: 1.55; + --cs-lh-display: 1.15; + + --cs-radius-sm: 6px; + --cs-radius: 10px; + --cs-radius-lg: 16px; + + --cs-header-h: 4rem; + --cs-transition: 120ms ease; + + --bs-body-font-family: var(--cs-font-body); + --bs-body-font-size: var(--cs-fs-body); + --bs-body-line-height: var(--cs-lh-body); + --bs-font-sans-serif: var(--cs-font-body); + --bs-font-monospace: var(--cs-font-mono); + --bs-border-radius-sm: var(--cs-radius-sm); + --bs-border-radius: var(--cs-radius-sm); + --bs-border-radius-lg: var(--cs-radius); + --bs-border-radius-xl: var(--cs-radius-lg); + --bs-focus-ring-width: 3px; + --cs-focus-ring: color-mix(in srgb, var(--cs-water) 45%, transparent); + --bs-focus-ring-color: var(--cs-focus-ring); +} + +/* + * Bootstrap component mappings. These components compile the default blue + * into literals or component variables, so --bs-primary alone does not reach + * them. Values reference --cs-* tokens and therefore follow the theme. + */ +.btn-primary { + --bs-btn-color: var(--cs-on-accent); + --bs-btn-bg: var(--cs-water); + --bs-btn-border-color: var(--cs-water); + --bs-btn-hover-color: var(--cs-on-accent); + --bs-btn-hover-bg: var(--cs-water-strong); + --bs-btn-hover-border-color: var(--cs-water-strong); + --bs-btn-active-color: var(--cs-on-accent); + --bs-btn-active-bg: var(--cs-water-strong); + --bs-btn-active-border-color: var(--cs-water-strong); + --bs-btn-disabled-color: var(--cs-on-accent); + --bs-btn-disabled-bg: var(--cs-water); + --bs-btn-disabled-border-color: var(--cs-water); +} + +.btn-outline-primary { + --bs-btn-color: var(--cs-water); + --bs-btn-border-color: var(--cs-water); + --bs-btn-hover-color: var(--cs-on-accent); + --bs-btn-hover-bg: var(--cs-water); + --bs-btn-hover-border-color: var(--cs-water); + --bs-btn-active-color: var(--cs-on-accent); + --bs-btn-active-bg: var(--cs-water-strong); + --bs-btn-active-border-color: var(--cs-water-strong); + --bs-btn-disabled-color: var(--cs-water); + --bs-btn-disabled-border-color: var(--cs-water); +} + +.btn-outline-secondary { + --bs-btn-color: var(--cs-ink-muted); + --bs-btn-border-color: var(--cs-field-border); + --bs-btn-hover-color: var(--cs-ink); + --bs-btn-hover-bg: var(--cs-surface-sunken); + --bs-btn-hover-border-color: var(--cs-ink-muted); + --bs-btn-active-color: var(--cs-ink); + --bs-btn-active-bg: var(--cs-line); + --bs-btn-active-border-color: var(--cs-ink-muted); + --bs-btn-disabled-color: var(--cs-ink-muted); + --bs-btn-disabled-border-color: var(--cs-line); +} + +.btn-danger { + --bs-btn-color: var(--cs-on-accent); + --bs-btn-bg: var(--cs-danger); + --bs-btn-border-color: var(--cs-danger); + --bs-btn-hover-color: var(--cs-on-accent); + --bs-btn-hover-bg: var(--bs-danger-text-emphasis); + --bs-btn-hover-border-color: var(--bs-danger-text-emphasis); + --bs-btn-active-color: var(--cs-on-accent); + --bs-btn-active-bg: var(--bs-danger-text-emphasis); + --bs-btn-disabled-color: var(--cs-on-accent); + --bs-btn-disabled-bg: var(--cs-danger); + --bs-btn-disabled-border-color: var(--cs-danger); +} + +.btn-success { + --bs-btn-color: var(--cs-on-accent); + --bs-btn-bg: var(--cs-success); + --bs-btn-border-color: var(--cs-success); + --bs-btn-hover-color: var(--cs-on-accent); + --bs-btn-hover-bg: var(--bs-success-text-emphasis); + --bs-btn-hover-border-color: var(--bs-success-text-emphasis); + --bs-btn-active-color: var(--cs-on-accent); + --bs-btn-active-bg: var(--bs-success-text-emphasis); + --bs-btn-disabled-color: var(--cs-on-accent); + --bs-btn-disabled-bg: var(--cs-success); + --bs-btn-disabled-border-color: var(--cs-success); +} + +.text-bg-primary { + color: var(--cs-on-accent) !important; + background-color: var(--cs-water) !important; +} + +.form-control, +.form-select { + background-color: var(--cs-surface-sunken); + border-color: var(--cs-field-border); +} + +.form-control:focus, +.form-select:focus { + background-color: var(--cs-surface-sunken); + border-color: var(--cs-water); + box-shadow: none; +} + +.form-control:disabled, +.form-select:disabled { + background-color: var(--cs-line); +} + +.form-check-input { + background-color: var(--cs-surface-sunken); + border-color: var(--cs-field-border); +} + +.form-check-input:focus { + border-color: var(--cs-water); + box-shadow: none; +} + +.form-check-input:checked { + background-color: var(--cs-water); + border-color: var(--cs-water); +} + +/* Bootstrap draws white marks; on the light dark-theme water they need ink. */ +[data-bs-theme="dark"] .form-check-input:checked[type="checkbox"] { + --bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%230B1620' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e"); +} + +[data-bs-theme="dark"] .form-check-input:checked[type="radio"] { + --bs-form-check-bg-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%230B1620'/%3e%3c/svg%3e"); +} + +.form-range::-webkit-slider-thumb { + background-color: var(--cs-water); +} + +.form-range::-moz-range-thumb { + background-color: var(--cs-water); +} + +.form-range::-webkit-slider-thumb:active { + background-color: var(--cs-water-strong); +} + +.form-range::-moz-range-thumb:active { + background-color: var(--cs-water-strong); +} + +/* Kept as separate rules: one unknown vendor pseudo-element would void a shared selector list. + The ring is drawn on the range itself (app.css). */ +.form-range:focus::-webkit-slider-thumb { + box-shadow: none; +} + +.form-range:focus::-moz-range-thumb { + box-shadow: none; +} + +[data-bs-theme="dark"] .form-switch .form-check-input:checked { + --bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%230B1620'/%3e%3c/svg%3e"); +} + +.accordion { + --bs-accordion-bg: var(--cs-surface); + --bs-accordion-btn-focus-box-shadow: none; +} + +/* Chevrons are SVG data URIs, so the colours are the literal token values per theme. */ +.accordion-button::after { + --bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%230F2233' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); + --bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%2308566C' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); +} + +[data-bs-theme="dark"] .accordion-button::after { + --bs-accordion-btn-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23E3ECF2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); + --bs-accordion-btn-active-icon: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%236FCDE3' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='m2 5 6 6 6-6'/%3e%3c/svg%3e"); +} + +.dropdown-menu { + --bs-dropdown-bg: var(--cs-surface); + --bs-dropdown-border-color: var(--cs-line); + --bs-dropdown-link-color: var(--cs-ink); + --bs-dropdown-link-hover-color: var(--cs-ink); + --bs-dropdown-link-hover-bg: var(--cs-surface-sunken); + --bs-dropdown-link-active-color: var(--cs-on-accent); + --bs-dropdown-link-active-bg: var(--cs-water); + --bs-dropdown-box-shadow: var(--cs-shadow); +} + +.nav-pills { + --bs-nav-pills-link-active-color: var(--cs-on-accent); + --bs-nav-pills-link-active-bg: var(--cs-water); +} + +.pagination { + --bs-pagination-active-color: var(--cs-on-accent); + --bs-pagination-active-bg: var(--cs-water); + --bs-pagination-active-border-color: var(--cs-water); +} + +.progress, +.progress-stacked { + --bs-progress-bar-color: var(--cs-on-accent); + --bs-progress-bar-bg: var(--cs-water); +} + +.list-group { + --bs-list-group-active-color: var(--cs-on-accent); + --bs-list-group-active-bg: var(--cs-water); + --bs-list-group-active-border-color: var(--cs-water); +} + +.card { + --bs-card-bg: var(--cs-surface); + --bs-card-border-color: var(--cs-line); + --bs-card-cap-bg: var(--cs-surface-sunken); +} + +.table { + --bs-table-bg: transparent; +} + +.toast { + --bs-toast-bg: var(--cs-surface); + --bs-toast-border-color: var(--cs-line); + --bs-toast-header-bg: var(--cs-surface-sunken); + --bs-toast-header-color: var(--cs-ink); +} diff --git a/cs_app/main/static/main/css/upload_zone.css b/cs_app/main/static/main/css/upload_zone.css index e195d41..ed2b23d 100644 --- a/cs_app/main/static/main/css/upload_zone.css +++ b/cs_app/main/static/main/css/upload_zone.css @@ -1,480 +1,356 @@ -/* ── Upload Zone ─────────────────────────────────────── */ +/* ── Upload zone (main/_upload_zone.html) ───────────────────────────── + * Styled on design tokens only (tokens.css); the fallbacks are the light + * theme values. Dropzone's vendor stylesheet is intentionally not loaded, + * so every .dz-* piece used by the preview template is styled here. + * Modifier: .upload-zone--compact for the 360px workbench side panel. + * State: .has-model on the wrapper while a model is loaded (set by JS). + */ + .upload-zone-wrapper { + display: flex; + flex-direction: column; + gap: 0.75rem; max-width: 640px; margin: 0 auto 2rem; - font-family: 'Outfit', sans-serif; + color: var(--cs-ink, #0F2233); } .upload-zone-label { - display: flex; - align-items: center; - justify-content: center; - gap: 0.5rem; - margin-bottom: 0.75rem; - font-size: 0.8rem; - font-weight: 500; + margin: 0; + font-size: 0.75rem; + font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; - color: #64748b; + color: var(--cs-ink-muted, #4A5E6D); } -.upload-zone-label svg { - width: 16px; - height: 16px; - opacity: 0.6; -} - -/* Override ALL default dropzone styles */ -.dropzone.upload-dropzone, -.upload-dropzone.dropzone { - position: relative; - min-height: 220px !important; - padding: 0 !important; - background: #f8fafc; - border: 2px dashed #cbd5e1 !important; - border-radius: 16px !important; - cursor: pointer; - transition: all 0.4s cubic-bezier(0.22, 1, 0.36, 1); - overflow: hidden; - display: flex; - flex-direction: column; +/* ── Model chip: the currently loaded file ─────────────────────────── */ +.upload-chip { + display: none; align-items: center; - justify-content: center; + gap: 0.75rem; + padding: 0.625rem 0.5rem 0.625rem 0.875rem; + background: var(--cs-surface, #FFFFFF); + border: 1px solid var(--cs-line, #D6E1E8); + border-left: 3px solid var(--cs-water, #0B6E8A); + border-radius: var(--cs-radius, 10px); } -/* Animated gradient border on hover */ -.upload-dropzone::before { - content: ''; - position: absolute; - inset: -2px; - border-radius: 18px; - padding: 2px; - background: conic-gradient( - from 0deg, - #06b6d4, #0ea5e9, #6366f1, #8b5cf6, - #06b6d4, #0ea5e9, #6366f1, #8b5cf6, - #06b6d4 - ); - -webkit-mask: - linear-gradient(#fff 0 0) content-box, - linear-gradient(#fff 0 0); - -webkit-mask-composite: xor; - mask-composite: exclude; - opacity: 0; - transition: opacity 0.5s ease; - pointer-events: none; - z-index: 1; -} - -.upload-dropzone:hover::before, -.upload-dropzone.dz-drag-hover::before { - opacity: 1; - animation: borderSpin 4s linear infinite; -} - -@keyframes borderSpin { - to { --angle: 360deg; } -} - -.upload-dropzone:hover { - border-color: transparent !important; - background: #f1f5f9; - transform: translateY(-2px); - box-shadow: - 0 8px 30px rgba(6, 182, 212, 0.08), - 0 2px 8px rgba(0, 0, 0, 0.04); +.upload-zone-wrapper.has-model .upload-chip { + display: flex; } -.upload-dropzone.dz-drag-hover { - border-color: transparent !important; - background: #ecfeff; - transform: translateY(-2px) scale(1.005); - box-shadow: - 0 12px 40px rgba(6, 182, 212, 0.15), - 0 2px 8px rgba(0, 0, 0, 0.04); +.upload-chip-icon { + flex: none; + width: 1.25rem; + height: 1.25rem; + color: var(--cs-water, #0B6E8A); } -/* Custom message area */ -.upload-dropzone .dz-message { - margin: 0 !important; - padding: 2.5rem 2rem !important; +.upload-chip-body { display: flex; + flex: 1; flex-direction: column; - align-items: center; - gap: 1rem; - position: relative; - z-index: 2; + min-width: 0; +} + +.upload-chip-name { + overflow: hidden; + font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.9375rem; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--cs-ink, #0F2233); } -/* Background pattern */ -.upload-dropzone .dz-message::before { - content: ''; - position: absolute; - inset: 0; - background-image: - radial-gradient(circle at 1px 1px, #e2e8f0 1px, transparent 0); - background-size: 24px 24px; - opacity: 0.5; - pointer-events: none; +.upload-chip-meta { + font-size: 0.8125rem; + font-variant-numeric: tabular-nums; + color: var(--cs-ink-muted, #4A5E6D); } -.upload-icon-ring { - position: relative; - width: 72px; - height: 72px; - display: flex; +.upload-chip-remove, +.upload-preview-dismiss { + display: inline-flex; + flex: none; align-items: center; justify-content: center; - border-radius: 50%; - background: linear-gradient(135deg, #e0f2fe, #ecfeff); - box-shadow: - 0 0 0 1px rgba(6, 182, 212, 0.1), - 0 4px 12px rgba(6, 182, 212, 0.08); - transition: all 0.4s cubic-bezier(0.22, 1, 0.36, 1); + width: 2.25rem; + height: 2.25rem; + padding: 0; + color: var(--cs-ink-muted, #4A5E6D); + background: transparent; + border: 1px solid transparent; + border-radius: var(--cs-radius-sm, 6px); + transition: color 120ms ease, background-color 120ms ease, border-color 120ms ease; } -.upload-dropzone:hover .upload-icon-ring { - transform: translateY(-4px); - box-shadow: - 0 0 0 1px rgba(6, 182, 212, 0.2), - 0 8px 24px rgba(6, 182, 212, 0.12); - background: linear-gradient(135deg, #cffafe, #e0f2fe); +.upload-chip-remove svg { + width: 1rem; + height: 1rem; } -.upload-dropzone.dz-drag-hover .upload-icon-ring { - transform: translateY(-6px) scale(1.08); - background: linear-gradient(135deg, #a5f3fc, #bae6fd); - box-shadow: - 0 0 0 2px rgba(6, 182, 212, 0.3), - 0 12px 32px rgba(6, 182, 212, 0.2); +.upload-chip-remove:hover, +.upload-preview-dismiss:hover { + color: var(--cs-danger, #B42318); + background: var(--cs-surface-sunken, #EBF1F5); + border-color: var(--cs-line, #D6E1E8); } -.upload-icon-ring svg { - width: 28px; - height: 28px; - color: #0891b2; - transition: transform 0.3s ease; +/* ── Drop target ───────────────────────────────────────────────────── */ +.upload-dropzone { + position: relative; + display: flex; + flex-direction: column; + gap: 0.5rem; + padding: 0.5rem; + background: var(--cs-surface-sunken, #EBF1F5); + border: 1.5px dashed var(--cs-line, #D6E1E8); + border-radius: var(--cs-radius-lg, 16px); + transition: border-color 120ms ease, background-color 120ms ease; +} + +.upload-dropzone, +.upload-dropzone * { + box-sizing: border-box; } -.upload-dropzone:hover .upload-icon-ring svg { - transform: translateY(-2px); +.upload-dropzone:hover, +.upload-dropzone.dz-drag-hover { + border-color: var(--cs-water, #0B6E8A); } -.upload-dropzone.dz-drag-hover .upload-icon-ring svg { - animation: iconFloat 1.5s ease-in-out infinite; +.upload-dropzone.dz-drag-hover { + background: color-mix(in srgb, var(--cs-water, #0B6E8A) 8%, var(--cs-surface, #FFFFFF)); } -@keyframes iconFloat { - 0%, 100% { transform: translateY(0); } - 50% { transform: translateY(-4px); } +/* Without Dropzone (no JS or CDN failure) only the plain file input is shown. */ +.upload-dropzone:not(.dz-clickable) .dz-message { + display: none; } -.upload-text-group { +.upload-dropzone .dz-message { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; + margin: 0; text-align: center; - position: relative; - z-index: 1; } -.upload-text-primary { - font-family: 'Outfit', sans-serif; - font-size: 1.05rem; +.upload-trigger { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.625rem; + width: 100%; + min-height: 9.5rem; + justify-content: center; + padding: 1.25rem 1rem 0.5rem; + font: inherit; + font-size: 1rem; font-weight: 500; - color: #1e293b; - margin: 0 0 0.35rem; - line-height: 1.4; + color: var(--cs-ink, #0F2233); + background: transparent; + border: 0; + border-radius: var(--cs-radius, 10px); + cursor: pointer; } -.upload-text-primary strong { - color: #0891b2; - font-weight: 600; - position: relative; +.upload-trigger-icon { + width: 2rem; + height: 2rem; + color: var(--cs-water, #0B6E8A); } -.upload-text-primary strong::after { - content: ''; - position: absolute; - bottom: -1px; - left: 0; - right: 0; - height: 1.5px; - background: #0891b2; - opacity: 0; - transition: opacity 0.3s ease; +.upload-trigger code { + padding: 0 0.25em; + font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.9em; + color: inherit; + background: var(--cs-surface, #FFFFFF); + border-radius: 4px; } -.upload-dropzone:hover .upload-text-primary strong::after { - opacity: 0.4; +.upload-trigger-link { + color: var(--cs-water, #0B6E8A); + text-decoration: underline; + text-underline-offset: 0.2em; } -.upload-text-secondary { - font-family: 'Outfit', sans-serif; - font-size: 0.82rem; - color: #94a3b8; - font-weight: 300; - margin: 0; +.upload-trigger:hover .upload-trigger-link { + color: var(--cs-water-strong, #08566C); } -.upload-file-badge { - display: inline-flex; - align-items: center; - gap: 0.4rem; - padding: 0.3rem 0.85rem; - background: #0f172a; - color: #e2e8f0; - font-family: 'Outfit', monospace; - font-size: 0.72rem; - font-weight: 500; - letter-spacing: 0.04em; - border-radius: 100px; - position: relative; - z-index: 1; +.upload-trigger-text--replace, +.upload-zone-wrapper.has-model .upload-trigger-text--empty { + display: none; } -.upload-file-badge::before { - content: ''; - width: 6px; - height: 6px; - background: #06b6d4; - border-radius: 50%; - box-shadow: 0 0 6px rgba(6, 182, 212, 0.5); +.upload-zone-wrapper.has-model .upload-trigger-text--replace { + display: inline; } -/* ── Uploaded file preview override ──────────────────── */ -.upload-dropzone .dz-preview { - position: relative; - margin: 0.75rem auto !important; - width: calc(100% - 3rem); - max-width: 560px; - padding: 0.85rem 3.5rem 0.85rem 3rem; - background: #fff; - border: 1px solid #e2e8f0; - border-radius: 12px; - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04); - min-height: auto !important; - transition: all 0.3s ease; - z-index: 2; +.upload-zone-hint { + margin: 0 0 0.75rem; + font-size: 0.8125rem; + color: var(--cs-ink-muted, #4A5E6D); } -.upload-dropzone .dz-preview:hover { - border-color: #cbd5e1; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); +/* With a model loaded the drop target shrinks to a single "replace" row. */ +.upload-zone-wrapper.has-model .upload-trigger { + flex-direction: row; + min-height: 0; + padding: 0.75rem 1rem 0.25rem; + font-size: 0.9375rem; } -.upload-dropzone .dz-preview .dz-image { - display: none !important; +.upload-zone-wrapper.has-model .upload-trigger-icon { + width: 1.25rem; + height: 1.25rem; } -.upload-dropzone .dz-preview .dz-details { - position: relative !important; - opacity: 1 !important; - padding: 0 !important; - font-family: 'Outfit', sans-serif; - display: flex; - align-items: center; - gap: 0.5rem; - min-height: auto !important; +.upload-zone-wrapper.has-model .upload-zone-hint { + margin-bottom: 0.5rem; } -.upload-dropzone .dz-preview .dz-details .dz-filename { - word-wrap: break-word; +/* ── Previews: in-flight and failed uploads only ───────────────────── */ +.upload-dropzone .dz-preview { + position: relative; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 0.375rem 0.5rem; + padding: 0.625rem 0.5rem 0.625rem 0.875rem; + background: var(--cs-surface, #FFFFFF); + border: 1px solid var(--cs-line, #D6E1E8); + border-radius: var(--cs-radius, 10px); } -.upload-dropzone .dz-preview .dz-details .dz-filename span { - font-weight: 500; - font-size: 0.88rem; - color: #1e293b; - background: none !important; - border: none !important; - padding: 0 !important; -} - -.upload-dropzone .dz-preview .dz-details .dz-size { - margin-bottom: 0 !important; -} - -.upload-dropzone .dz-preview .dz-details .dz-size span { - font-size: 0.78rem; - color: #94a3b8; - background: none !important; - border: none !important; - padding: 0 !important; -} - -/* Progress bar */ -.upload-dropzone .dz-preview .dz-progress { - position: relative !important; - top: auto !important; - left: auto !important; - width: 100% !important; - height: 3px !important; - margin: 0.6rem 0 0 !important; - background: #f1f5f9 !important; - border-radius: 4px !important; - overflow: hidden; +/* A successful upload is represented by the model chip instead. */ +.upload-dropzone .dz-preview.dz-success { + display: none; } -.upload-dropzone .dz-preview .dz-progress .dz-upload { - background: linear-gradient(90deg, #06b6d4, #0ea5e9) !important; - border-radius: 4px !important; - height: 100%; - transition: width 0.3s ease; +.upload-dropzone .dz-details { + display: flex; + align-items: baseline; + gap: 0.5rem; + min-width: 0; } -/* Success state */ -.upload-dropzone .dz-preview.dz-success { - border-color: #a7f3d0; - background: #f0fdf4; +.upload-dropzone .dz-filename { + overflow: hidden; + font-family: "IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.875rem; + text-overflow: ellipsis; + white-space: nowrap; } -.upload-dropzone .dz-preview.dz-success .dz-progress { - opacity: 0; - height: 0 !important; - margin: 0 !important; +.upload-dropzone .dz-size { + flex: none; + font-size: 0.8125rem; + color: var(--cs-ink-muted, #4A5E6D); } -/* Success checkmark */ -.upload-dropzone .dz-preview .dz-success-mark { - position: absolute !important; - top: 50% !important; - left: 1rem !important; - right: auto !important; - margin-top: -13px !important; - opacity: 0; - transition: opacity 0.3s ease; - pointer-events: none; +.upload-dropzone .dz-size strong { + font-weight: 500; } -.upload-dropzone .dz-preview.dz-success .dz-success-mark { - opacity: 1; +.upload-dropzone .dz-preview .upload-preview-dismiss { + grid-column: 2; + grid-row: 1; + width: 2rem; + height: 2rem; + font-size: 1.25rem; + line-height: 1; } -.upload-dropzone .dz-preview .dz-success-mark svg { - width: 26px; - height: 26px; +.upload-dropzone .dz-progress { + grid-column: 1 / -1; + height: 3px; + overflow: hidden; + background: var(--cs-line, #D6E1E8); + border-radius: 3px; } -.upload-dropzone .dz-preview .dz-success-mark svg polygon { - fill: #10b981; +.upload-dropzone .dz-upload { + display: block; + width: 0; + height: 100%; + background: var(--cs-water, #0B6E8A); + transition: width 120ms linear; } -/* Error state */ -.upload-dropzone .dz-preview.dz-error { - border-color: #fecaca; - background: #fef2f2; +.upload-dropzone .dz-error-message { + display: none; + grid-column: 1 / -1; + margin: 0; + font-size: 0.8125rem; + color: var(--cs-danger, #B42318); } -.upload-dropzone .dz-preview .dz-error-mark { - position: absolute !important; - top: 50% !important; - left: 1rem !important; - right: auto !important; - margin-top: -13px !important; - opacity: 0; - transition: opacity 0.3s ease; - pointer-events: none; +.upload-dropzone .dz-preview.dz-error { + border-color: var(--cs-danger, #B42318); } -.upload-dropzone .dz-preview.dz-error .dz-error-mark { - opacity: 1; +.upload-dropzone .dz-preview.dz-error .dz-error-message { + display: block; } -.upload-dropzone .dz-preview .dz-error-mark svg { - width: 26px; - height: 26px; +.upload-dropzone .dz-preview.dz-error .dz-progress { + display: none; } -.upload-dropzone .dz-preview .dz-error-mark svg polygon { - fill: #ef4444; +.upload-sample-button { + width: 100%; } -.upload-dropzone .dz-preview .dz-error-message { - position: relative !important; - top: auto !important; - left: auto !important; - opacity: 1 !important; - margin-top: 0.5rem; - padding: 0.4rem 0.7rem !important; - background: #fef2f2 !important; - border: 1px solid #fecaca; - border-radius: 8px !important; - font-family: 'Outfit', sans-serif; - font-size: 0.78rem; - color: #dc2626 !important; - pointer-events: none; +/* ── Compact variant: workbench control panel ──────────────────────── */ +.upload-zone--compact { + max-width: none; + margin: 0; + gap: 0.5rem; } -.upload-dropzone .dz-preview .dz-error-message::after { - display: none !important; +/* The panel already titles this step ("① Model"). */ +.upload-zone--compact .upload-zone-label { + display: none; } -/* Remove button */ -.upload-dropzone .dz-preview .dz-remove { - position: absolute; - top: 50%; - right: 0.8rem; - transform: translateY(-50%); - font-family: 'Outfit', sans-serif; - font-size: 1.3rem; - line-height: 1; - width: 32px; - height: 32px; - display: flex; - align-items: center; - justify-content: center; - background: #f8fafc; - border: 1px solid #e2e8f0; - border-radius: 8px; - color: #64748b; - text-decoration: none !important; - transition: all 0.2s ease; - z-index: 20; - cursor: pointer; +/* Icon beside the text, as in the "replace" row, so the empty zone stays short. */ +.upload-zone--compact .upload-trigger { + flex-direction: row; + min-height: 0; + padding: 0.75rem 0.75rem 0.25rem; + font-size: 0.9375rem; } -.upload-dropzone .dz-preview .dz-remove:hover { - background: #fef2f2; - border-color: #fecaca; - color: #ef4444; +.upload-zone--compact .upload-trigger-icon { + flex: none; + width: 1.25rem; + height: 1.25rem; } -/* ── Entry animation ─────────────────────────────────── */ -@keyframes uploadZoneIn { - from { - opacity: 0; - transform: translateY(12px); - } - to { - opacity: 1; - transform: translateY(0); - } +.upload-zone--compact .upload-zone-hint { + margin-bottom: 0.5rem; } -.upload-zone-wrapper { - animation: uploadZoneIn 0.6s cubic-bezier(0.22, 1, 0.36, 1) forwards; +.upload-zone--compact .upload-chip { + padding-block: 0.5rem; } -/* ── File status bar ────────────────────────────────── */ -.upload-status-bar { - display: flex; - align-items: center; - justify-content: center; - gap: 0.4rem; - margin-top: 0.6rem; - padding: 0.35rem 0.9rem; - font-family: 'Outfit', sans-serif; - font-size: 0.8rem; - font-weight: 500; - color: #059669; - background: #ecfdf5; - border: 1px solid #a7f3d0; - border-radius: 8px; +.upload-zone--compact .upload-sample-button { + padding-block: 0.375rem; + font-size: 0.9375rem; } -.upload-sample-button { - width: 100%; - border-radius: 12px; - font-family: 'Outfit', sans-serif; - font-weight: 600; - letter-spacing: 0.01em; - padding: 0.7rem 1rem; +@media (prefers-reduced-motion: reduce) { + .upload-dropzone, + .upload-dropzone .dz-upload, + .upload-chip-remove, + .upload-preview-dismiss { + transition: none; + } } diff --git a/cs_app/main/static/main/js/base_ui.js b/cs_app/main/static/main/js/base_ui.js deleted file mode 100644 index 352c263..0000000 --- a/cs_app/main/static/main/js/base_ui.js +++ /dev/null @@ -1,20 +0,0 @@ -"use strict"; - -document.addEventListener("DOMContentLoaded", function () { - var body = document.body; - var loginUrl = body ? body.dataset.loginUrl : ""; - if (!loginUrl) { - return; - } - - var actionButtons = document.querySelectorAll("button[data-authenticated]"); - actionButtons.forEach(function (button) { - button.addEventListener("click", function (event) { - var isAuthenticated = button.getAttribute("data-authenticated") === "True"; - if (!isAuthenticated) { - event.preventDefault(); - window.location.href = loginUrl; - } - }); - }); -}); diff --git a/cs_app/main/static/main/js/charts.js b/cs_app/main/static/main/js/charts.js index 091b4eb..57a13fe 100644 --- a/cs_app/main/static/main/js/charts.js +++ b/cs_app/main/static/main/js/charts.js @@ -1,163 +1,1087 @@ "use strict"; /** - * Render a single line chart or subplot grid into the given container. + * Theme-aware Plotly charts: `window.CS.charts`. * - * @param {string} containerId - DOM id of the target div - * @param {Object[]} records - Array of row objects (from DataFrame.to_json orient="records")) - * @param {string} xField - Key used for the x-axis - * @param {string|string[]} yFields - Key(s) used for the y-axis - * @param {Object} [opts] - Extra options - * @param {string} [opts.title] - * @param {string} [opts.xLabel] - * @param {string} [opts.yLabel] - * @param {Object} [opts.yLabels] - Mapping: column -> axis label - * @param {number[]} [opts.xRange] - e.g. [0, 100] + * Colours are read from the design tokens (tokens.css) at render time and again + * on every `cs:themechange` event, when each chart still in the document is + * redrawn from the data it was given (no refetch). A constant `uirevision` + * keeps zoom and legend toggles across those redraws. Charts are purged (which + * frees Plotly's window listeners) when redrawn, when CS.asyncForm is about to + * replace them (`cs:beforeswap`), and once they have left the document. + * + * Every renderer takes a target element or its id, returns the Plotly promise, + * or `null` when nothing was drawn (missing element, or Plotly not loaded — the + * latter leaves a `.cs-empty-state` message in the container). + * + * The container gets `role="img"` (unless it already has a role) and an + * `aria-label` from `opts.ariaLabel`, else `opts.title`, else a generic label + * when it has none. Pages should pair each chart with a data table. */ -function renderLineChart(containerId, records, xField, yFields, opts) { - opts = opts || {}; - var yLabelMap = opts.yLabels || {}; - var cols = Array.isArray(yFields) ? yFields : [yFields]; - var el = document.getElementById(containerId); - if (!el) return; - el.innerHTML = ''; - - if (cols.length > 1) { - var nCols = 2; - var nRows = Math.ceil(cols.length / nCols); - var traces = []; - var layout = { - height: 350 * nRows, - title: {text: opts.title || '', x: 0.5, xanchor: 'center'}, - showlegend: false, - grid: {rows: nRows, columns: nCols, pattern: 'independent'} +(function () { + var CS = (window.CS = window.CS || {}); + + var FONT_UI = '"Public Sans", system-ui, -apple-system, "Segoe UI", sans-serif'; + var FONT_MONO = '"IBM Plex Mono", ui-monospace, SFMono-Regular, Menlo, monospace'; + var UNAVAILABLE_MESSAGE = "Charts could not be loaded. Check your connection and reload the page."; + var UI_REVISION = "cs-chart"; + var TRANSPARENT = "rgba(0,0,0,0)"; + var MARKER_POINT_LIMIT = 40; + var NARROW_WIDTH = 576; + // More swept values than this get a colour bar instead of one legend entry each. + var LEGEND_VALUE_LIMIT = 8; + // Rainfall (rate) axis max = 2.5 x visible peak, so the hanging bars fill the top ~40 %; + // flow axis max = 1.65 x visible peak, so the lines stay in the lower ~60 %. + var RAIN_AXIS_FACTOR = 2.5; + var FLOW_AXIS_FACTOR = 1.65; + // Hydrograph series in a third unit get a panel below the plot: its height and the gap + // above it (which holds the panel's header), in px, and its axis max = 1.2 x visible peak. + var PANEL_HEIGHT = 96; + var PANEL_GAP = 40; + var PANEL_AXIS_FACTOR = 1.2; + // Rough height outside the plot area (legend, ticks, range slider; measured ~200 px on desktop, + // ~265 px on phones), for turning those px into domain fractions. + var PLOT_MARGINS_ESTIMATE = 200; + + // DESIGN_SPEC §2.1 values, used when a token is not defined by tokens.css. + var FALLBACK_TOKENS = { + light: { + "--cs-ink": "#0F2233", + "--cs-ink-muted": "#4A5E6D", + "--cs-line": "#D6E1E8", + "--cs-surface": "#FFFFFF", + "--cs-water": "#0B6E8A", + "--cs-water-strong": "#08566C", + "--cs-rain": "#5A6FD6", + "--cs-ochre": "#B7791F", + "--cs-soil": "#346400", + "--cs-evap": "#8A5A9E", + }, + dark: { + "--cs-ink": "#E3ECF2", + "--cs-ink-muted": "#9DB1BF", + "--cs-line": "#23394A", + "--cs-surface": "#122330", + "--cs-water": "#3FB8D6", + "--cs-water-strong": "#6FCDE3", + "--cs-rain": "#8C9CF0", + "--cs-ochre": "#E0A546", + "--cs-soil": "#C1EB95", + "--cs-evap": "#C29AD6", + }, + }; + + // Categorical order (spec): water, rain, ochre, soil, evaporation. + var SERIES_ORDER = ["water", "rain", "ochre", "soil", "evap"]; + + // Hydrograph flow series: colour key + dash (secondary encoding, so identity is not colour-only). + var FLOW_STYLES = { + runoff: { color: "water", dash: "solid" }, + runon: { color: "waterStrong", dash: "dot" }, + infiltration_loss: { color: "soil", dash: "dash" }, + evaporation_loss: { color: "evap", dash: "dashdot" }, + }; + var DEFAULT_FLOW_FIELDS = ["runoff", "runon", "infiltration_loss", "evaporation_loss"]; + var EXTRA_DASHES = ["longdash", "longdashdot", "dot", "dash"]; + // Sweep lines listed in a legend also cycle through dashes: neighbouring ramp steps are close in colour. + var SWEEP_DASHES = ["solid", "dash", "dot", "dashdot"]; + // Mix amounts of the sequential ramp's ends per theme (see rampEnds). + var RAMP_MIX = { light: { low: 0.7, high: 0.85 }, dark: { low: 0.56, high: 0.9 } }; + + var registry = []; + + // ── Theme ───────────────────────────────────────────────────────── + + function isDarkTheme() { + var theme = document.documentElement.getAttribute("data-bs-theme"); + if (theme === "dark" || theme === "light") { + return theme === "dark"; + } + return Boolean(window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches); + } + + function readTheme() { + var styles = window.getComputedStyle(document.documentElement); + var mode = isDarkTheme() ? "dark" : "light"; + var fallback = FALLBACK_TOKENS[mode]; + function token(name) { + return styles.getPropertyValue(name).trim() || fallback[name]; + } + return { + mode: mode, + ink: token("--cs-ink"), + inkMuted: token("--cs-ink-muted"), + line: token("--cs-line"), + surface: token("--cs-surface"), + water: token("--cs-water"), + waterStrong: token("--cs-water-strong"), + rain: token("--cs-rain"), + ochre: token("--cs-ochre"), + soil: token("--cs-soil"), + evap: token("--cs-evap"), }; + } - cols.forEach(function(col, i) { - var axisIdx = i === 0 ? '' : String(i + 1); - traces.push({ - x: records.map(function(row) { return row[xField]; }), - y: records.map(function(row) { return row[col]; }), - type: 'scatter', - mode: 'lines', - name: col, - xaxis: 'x' + axisIdx, - yaxis: 'y' + axisIdx + function parseColor(value) { + var hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value); + if (hex) { + var digits = hex[1].length === 3 ? hex[1].replace(/./g, "$&$&") : hex[1]; + return [0, 2, 4].map(function (i) { + return parseInt(digits.substr(i, 2), 16); }); - layout['xaxis' + axisIdx] = {}; - if (opts.xLabel) { - layout['xaxis' + axisIdx].title = opts.xLabel; + } + var rgb = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(value); + return rgb ? [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])] : null; + } + + function mixColors(from, to, t) { + var a = parseColor(from); + var b = parseColor(to); + if (!a || !b) { + return to; + } + var mixed = a.map(function (channel, i) { + return Math.round(channel + (b[i] - channel) * t); + }); + return "rgb(" + mixed.join(", ") + ")"; + } + + /** + * Ends of the sequential teal ramp: low values are --cs-water mixed towards the + * surface only as far as keeps 3:1 contrast with it (per theme), high values move + * from --cs-water-strong most of the way to the ink, so the steps between spread + * over as much lightness as the theme allows. + */ + function rampEnds(theme) { + var mix = RAMP_MIX[theme.mode]; + return [ + mixColors(theme.surface, theme.water, mix.low), + mixColors(theme.waterStrong, theme.ink, mix.high), + ]; + } + + function seriesColor(theme, index) { + return theme[SERIES_ORDER[index % SERIES_ORDER.length]]; + } + + // ── Layout helpers ──────────────────────────────────────────────── + + function isNarrow(el) { + var width = el.clientWidth; + return width > 0 && width < NARROW_WIDTH; + } + + /** + * Shared layout. On narrow containers the in-chart title is dropped (the + * surrounding card and the aria-label carry it) because a wrapped legend + * would otherwise collide with it; the legend then pushes the top margin. + */ + function baseLayout(theme, opts, container, legendOnTop) { + var narrow = isNarrow(container); + var showTitle = Boolean(opts.title) && !narrow; + var titleSpace = showTitle ? 44 : 12; + var layout = { + font: { family: FONT_UI, size: 12, color: theme.ink }, + paper_bgcolor: TRANSPARENT, + plot_bgcolor: TRANSPARENT, + margin: { l: 16, r: 16, t: titleSpace + (legendOnTop ? 28 : 0), b: 16, pad: 0 }, + hoverlabel: { + bgcolor: theme.surface, + bordercolor: theme.line, + font: { family: FONT_UI, size: 12, color: theme.ink }, + }, + legend: { + font: { color: theme.ink }, + bgcolor: TRANSPARENT, + itemclick: "toggle", + itemdoubleclick: "toggleothers", + }, + modebar: { bgcolor: TRANSPARENT, color: theme.inkMuted, activecolor: theme.water }, + uirevision: UI_REVISION, + }; + if (legendOnTop || narrow) { + Object.assign(layout.legend, { orientation: "h", x: 0, xanchor: "left", y: 1.02, yanchor: "bottom" }); + } + if (showTitle) { + layout.title = { + text: opts.title, + font: { size: 15, color: theme.ink }, + x: 0, + xref: "paper", + xanchor: "left", + y: 1, + yref: "container", + yanchor: "top", + pad: { t: 12 }, + }; + } + return layout; + } + + function axis(theme, title, extra) { + return Object.assign( + { + title: { text: title || "", font: { size: 12, color: theme.inkMuted }, standoff: 8 }, + automargin: true, + gridcolor: theme.line, + linecolor: theme.line, + zerolinecolor: theme.line, + tickcolor: theme.line, + ticks: "outside", + ticklen: 4, + showline: true, + tickfont: { family: FONT_MONO, size: 11, color: theme.inkMuted }, + hoverformat: ".4~g", + }, + extra + ); + } + + /** One column on phone-width containers, two otherwise (small multiples). */ + function gridFor(el, panelCount) { + var columns = panelCount > 1 && !isNarrow(el) ? 2 : 1; + return { rows: Math.ceil(panelCount / columns), columns: columns }; + } + + function axisSuffix(index) { + return index === 0 ? "" : String(index + 1); + } + + function pluck(records, field) { + return records.map(function (row) { + return row[field]; + }); + } + + function finiteNumbers(values) { + return values.filter(function (value) { + return value !== null && value !== "" && isFinite(Number(value)); + }).map(Number); + } + + /** Largest finite value, or -Infinity when there is none. */ + function finiteMax(values) { + return finiteNumbers(values).reduce(function (max, value) { + return value > max ? value : max; + }, -Infinity); + } + + /** + * Keeps the axis of an all non-negative series (volumes, rates) at or above zero, + * so an all-zero series is drawn on 0…1 instead of Plotly's default −1…1. + */ + function nonNegativeRange(records, field) { + var negative = finiteNumbers(pluck(records, field)).some(function (value) { + return value < 0; + }); + return negative ? {} : { rangemode: "nonnegative" }; + } + + /** "Runoff [CMS]" → "CMS"; "" when the label has no unit. */ + function unitOf(label) { + var match = /\[([^\]]+)\]\s*$/.exec(label || ""); + return match ? match[1] : ""; + } + + function sanitizeFileName(fileName) { + return (fileName || "chart") + .replace(/[\r\n]+/g, "_") + .replace(/[<>:"/\\|?*]+/g, "_") + .replace(/\.png$/i, "") || "chart"; + } + + // ── Rendering core ──────────────────────────────────────────────── + + function resolveElement(target) { + return typeof target === "string" ? document.getElementById(target) : target; + } + + function showUnavailable(el) { + el.removeAttribute("role"); + el.removeAttribute("aria-label"); + el.innerHTML = ""; + var message = document.createElement("div"); + message.className = "cs-empty-state"; + message.textContent = UNAVAILABLE_MESSAGE; + el.appendChild(message); + } + + function labelChart(el, opts, defaultLabel) { + if (!el.hasAttribute("role")) { + el.setAttribute("role", "img"); + } + var label = opts.ariaLabel || opts.title; + if (label) { + el.setAttribute("aria-label", label); + } else if (!el.hasAttribute("aria-label")) { + el.setAttribute("aria-label", defaultLabel); + } + } + + function plotConfig(fileName) { + return { + responsive: true, + displaylogo: false, + // The stock camera button would export the transparent background; ours paints the surface. + modeBarButtonsToRemove: ["toImage", "lasso2d", "select2d", "autoScale2d", "toggleSpikelines"], + modeBarButtonsToAdd: [ + { + name: "downloadPng", + title: "Download chart as PNG", + icon: window.Plotly.Icons.camera, + click: function (gd) { + downloadPng(gd, fileName); + }, + }, + ], + toImageButtonOptions: { format: "png", filename: sanitizeFileName(fileName), scale: 2 }, + }; + } + + function draw(entry, plotFn) { + var spec = entry.build(readTheme(), entry.el); + return plotFn(entry.el, spec.data, spec.layout, plotConfig(entry.fileName)); + } + + /** + * @param {Element|string} target + * @param {function(Object, Element): {data: Object[], layout: Object}} build + * Pure function of (theme, element); re-run on every theme change. + */ + function render(target, build, opts, defaultLabel) { + var el = resolveElement(target); + if (!el) { + return null; + } + if (typeof window.Plotly === "undefined") { + showUnavailable(el); + return null; + } + releaseCharts(function (chart) { + return chart === el || !chart.isConnected; + }); + window.Plotly.purge(el); + el.innerHTML = ""; + labelChart(el, opts, defaultLabel); + + var entry = { el: el, build: build, fileName: opts.fileName || opts.title || "chart" }; + registry.push(entry); + return draw(entry, window.Plotly.newPlot); + } + + /** Purges and forgets every registered chart for which isReleased(element) is true. */ + function releaseCharts(isReleased) { + registry = registry.filter(function (item) { + if (!isReleased(item.el)) { + return true; } - layout['yaxis' + axisIdx] = {title: yLabelMap[col] || col}; - if (opts.xRange) { - layout['xaxis' + axisIdx].range = opts.xRange; + window.Plotly.purge(item.el); + return false; + }); + } + + document.addEventListener("cs:themechange", function () { + if (typeof window.Plotly === "undefined") { + return; + } + releaseCharts(function (chart) { + return !chart.isConnected; + }); + registry.forEach(function (item) { + draw(item, window.Plotly.react); + }); + }); + + document.addEventListener("cs:beforeswap", function (event) { + if (typeof window.Plotly === "undefined") { + return; + } + releaseCharts(function (chart) { + return event.target.contains(chart); + }); + }); + + // ── Public renderers ────────────────────────────────────────────── + + /** + * Line chart of one y field, or small multiples (two-column grid, one column + * on narrow containers) when several fields are given. + * + * @param {Element|string} el + * @param {Object[]} records Row objects (DataFrame `orient="records"`). + * @param {string} xField + * @param {string|string[]} yFields + * @param {Object} [opts] + * @param {string} [opts.title] + * @param {string} [opts.xLabel] + * @param {string} [opts.yLabel] Axis label of a single-field chart. + * @param {Object} [opts.yLabels] Field → axis label. + * @param {number[]} [opts.xRange] + * @param {number} [opts.height] Default 420, or 300 per grid row. + * @param {string} [opts.ariaLabel] + * @param {string} [opts.fileName] PNG export name (defaults to the title). + * @returns {Promise|null} + */ + function line(el, records, xField, yFields, opts) { + opts = opts || {}; + var fields = [].concat(yFields); + var yLabels = opts.yLabels || {}; + var multiple = fields.length > 1; + var x = pluck(records, xField); + var mode = records.length <= MARKER_POINT_LIMIT ? "lines+markers" : "lines"; + + return render(el, function (theme, container) { + var grid = gridFor(container, fields.length); + var layout = baseLayout(theme, opts, container, false); + layout.showlegend = false; + layout.hovermode = "x unified"; + layout.height = opts.height || (multiple ? 300 * grid.rows : 420); + if (multiple) { + layout.grid = { rows: grid.rows, columns: grid.columns, pattern: "independent", xgap: 0.3, ygap: 0.3 }; + } + + var data = fields.map(function (field, i) { + var suffix = axisSuffix(i); + var yLabel = multiple ? yLabels[field] || field : yLabels[field] || opts.yLabel; + layout["xaxis" + suffix] = axis(theme, opts.xLabel, opts.xRange ? { range: opts.xRange } : {}); + layout["yaxis" + suffix] = axis(theme, yLabel, nonNegativeRange(records, field)); + return { + type: "scatter", + mode: mode, + name: yLabels[field] || field, + x: x, + y: pluck(records, field), + xaxis: "x" + suffix, + yaxis: "y" + suffix, + line: { color: theme.water, width: 2 }, + marker: { color: theme.water, size: 6 }, + }; + }); + return { data: data, layout: layout }; + }, opts, "Line chart"); + } + + function compareParameterValues(a, b) { + var numberA = parseFloat(a); + var numberB = parseFloat(b); + var finiteA = isFinite(numberA); + var finiteB = isFinite(numberB); + if (finiteA && finiteB) { + return numberA - numberB; + } + if (finiteA !== finiteB) { + return finiteA ? -1 : 1; + } + return String(a).localeCompare(String(b)); + } + + /** + * Where each value of an ascending list sits on the colour ramp (0…1): + * proportional to the value when all are finite, by rank otherwise. + */ + function rampPositions(numbers) { + var low = numbers[0]; + var high = numbers[numbers.length - 1]; + var proportional = numbers.every(isFinite) && high > low; + return numbers.map(function (number, i) { + if (proportional) { + return (number - low) / (high - low); } + return numbers.length > 1 ? i / (numbers.length - 1) : 1; }); + } - Plotly.newPlot(el, traces, layout, {responsive: true}); - } else { - var yCol = cols[0]; - var traceData = [{ - x: records.map(function(row) { return row[xField]; }), - y: records.map(function(row) { return row[yCol]; }), - type: 'scatter', - mode: 'lines', - name: yCol - }]; - var singleLayout = { - height: 450, - title: {text: opts.title || '', x: 0.5, xanchor: 'center'} + /** Colour bar that replaces the legend of a long numeric sweep; horizontal above narrow plots. */ + function sweepColorBar(theme, feature, narrow) { + var colorBar = { + title: { text: feature, side: "top", font: { size: 12, color: theme.inkMuted } }, + thickness: 12, + outlinewidth: 0, + tickfont: { family: FONT_MONO, size: 11, color: theme.inkMuted }, + tickcolor: theme.line, }; - var resolvedYLabel = yLabelMap[yCol] || opts.yLabel; - if (opts.xLabel) singleLayout.xaxis = {title: opts.xLabel}; - if (resolvedYLabel) singleLayout.yaxis = {title: resolvedYLabel}; - if (opts.xRange) { - singleLayout.xaxis = singleLayout.xaxis || {}; - singleLayout.xaxis.range = opts.xRange; + if (narrow) { + Object.assign(colorBar, { orientation: "h", x: 0, xanchor: "left", y: 1.02, yanchor: "bottom", len: 1 }); } - Plotly.newPlot(el, traceData, singleLayout, {responsive: true}); + return colorBar; } -} -/** - * Render a sweep overlay chart with subplots. - * - * @param {string} containerId - * @param {Object} sweepData - {paramValue: [{datetime:…, col1:…, col2:…}, …], …} - * @param {string[]} columns - Timeseries column names to plot - * @param {string} feature - Name of the swept parameter - * @param {string} catchmentName - * @param {Object} [opts] - * @param {string} [opts.xLabel] - * @param {Object} [opts.yLabels] - Mapping: column -> axis label - */ -function renderSweepChart(containerId, sweepData, columns, feature, catchmentName, opts) { - opts = opts || {}; - var yLabelMap = opts.yLabels || {}; - var el = document.getElementById(containerId); - if (!el) return; - el.innerHTML = ''; - - var nCols = 2; - var nRows = Math.ceil(columns.length / nCols); - var traces = []; - var layout = { - height: 350 * nRows, - title: { - text: 'Timeseries sweep: ' + feature + ' for ' + catchmentName, - x: 0.5, - xanchor: 'center' - }, - grid: {rows: nRows, columns: nCols, pattern: 'independent'} - }; + /** + * Parameter sweep: one line per parameter value, coloured on a sequential + * teal ramp in value order, one panel per column. The legend lists the values + * under the parameter name, and the lines also differ in dash; above + * LEGEND_VALUE_LIMIT numeric values a colour bar takes their place. + * + * @param {Element|string} el + * @param {Object} sweepData {parameterValue: [row, ...], ...} + * @param {string[]} columns Fields to plot, one panel each. + * @param {string} feature Swept parameter name (legend and colour bar title). + * @param {string} [catchment] + * @param {Object} [opts] + * @param {string} [opts.title] Default "Timeseries sweep: for ". + * @param {string} [opts.xField] Default "datetime". + * @param {string} [opts.xLabel] + * @param {Object} [opts.yLabels] Column → axis label. + * @param {number} [opts.height] Default 300 per grid row (min 360). + * @param {string} [opts.ariaLabel] + * @param {string} [opts.fileName] + * @returns {Promise|null} + */ + function sweep(el, sweepData, columns, feature, catchment, opts) { + opts = Object.assign({}, opts); + if (opts.title === undefined) { + opts.title = "Timeseries sweep: " + feature + (catchment ? " for " + catchment : ""); + } + var xField = opts.xField || "datetime"; + var yLabels = opts.yLabels || {}; + var values = Object.keys(sweepData).sort(compareParameterValues); + var numbers = values.map(parseFloat); + var positions = rampPositions(numbers); + var colorBarScale = values.length > LEGEND_VALUE_LIMIT && numbers.every(isFinite); + + return render(el, function (theme, container) { + var grid = gridFor(container, columns.length); + var ends = rampEnds(theme); + var layout = baseLayout(theme, opts, container, false); + layout.hovermode = "closest"; + layout.height = opts.height || Math.max(360, 300 * grid.rows); + layout.showlegend = !colorBarScale; + layout.legend.title = { text: feature, font: { color: theme.inkMuted } }; + if (columns.length > 1) { + layout.grid = { rows: grid.rows, columns: grid.columns, pattern: "independent", xgap: 0.3, ygap: 0.3 }; + } + columns.forEach(function (column, i) { + var suffix = axisSuffix(i); + layout["xaxis" + suffix] = axis(theme, opts.xLabel, {}); + layout["yaxis" + suffix] = axis(theme, yLabels[column] || column, {}); + }); - var paramValues = Object.keys(sweepData); - paramValues.forEach(function(paramVal) { - var rows = sweepData[paramVal]; - columns.forEach(function(col, i) { - var axisIdx = i === 0 ? '' : String(i + 1); - traces.push({ - x: rows.map(function(r) { return r.datetime; }), - y: rows.map(function(r) { return r[col]; }), - type: 'scatter', - mode: 'lines', - name: feature + '=' + paramVal, - legendgroup: paramVal, - showlegend: i === 0, - xaxis: 'x' + axisIdx, - yaxis: 'y' + axisIdx + var data = []; + values.forEach(function (value, valueIndex) { + var rows = sweepData[value] || []; + var x = pluck(rows, xField); + columns.forEach(function (column, i) { + var suffix = axisSuffix(i); + data.push({ + type: "scatter", + mode: "lines", + name: value, + legendgroup: value, + showlegend: i === 0, + x: x, + y: pluck(rows, column), + xaxis: "x" + suffix, + yaxis: "y" + suffix, + hovertemplate: "%{x}
" + (yLabels[column] || column) + ": %{y:.4~g}" + + "" + feature + " = " + value + "", + line: { + color: mixColors(ends[0], ends[1], positions[valueIndex]), + width: 1.75, + dash: colorBarScale ? "solid" : SWEEP_DASHES[valueIndex % SWEEP_DASHES.length], + }, + }); + }); }); - layout['xaxis' + axisIdx] = layout['xaxis' + axisIdx] || {}; - if (opts.xLabel) { - layout['xaxis' + axisIdx].title = opts.xLabel; + if (colorBarScale) { + // A point-less trace that only carries the colour bar (Plotly interpolates in RGB, as mixColors does). + data.push({ + type: "scatter", + mode: "markers", + x: [null], + y: [null], + hoverinfo: "skip", + showlegend: false, + marker: { + color: [numbers[0], numbers[numbers.length - 1]], + cmin: numbers[0], + cmax: numbers[numbers.length - 1], + colorscale: [[0, ends[0]], [1, ends[1]]], + showscale: true, + colorbar: sweepColorBar(theme, feature, isNarrow(container)), + }, + }); + } + return { data: data, layout: layout }; + }, opts, "Parameter sweep chart"); + } + + /** + * [low, factor x peak] over the visible traces on one y axis ("y" or "y2"). The + * factors leave the flow lines the lower part of the plot and the hanging rain + * bars the upper part. + */ + function visibleRange(data, yaxis, factor) { + var values = []; + data.forEach(function (trace) { + if (trace.yaxis === yaxis && isShown(trace)) { + values = values.concat(finiteNumbers(trace.y)); } - layout['yaxis' + axisIdx] = layout['yaxis' + axisIdx] || {}; - layout['yaxis' + axisIdx].title = yLabelMap[col] || col; }); - }); + var peak = finiteMax(values); + var low = values.reduce(function (min, value) { + return value < min ? value : min; + }, 0); + return [low, peak > 0 ? peak * factor : 1]; + } - Plotly.newPlot(el, traces, layout, {responsive: true}); -} + /** The reversed rate axis: zero at the top, so its bars and lines hang down. */ + function rateAxisRange(data) { + return visibleRange(data, "y2", RAIN_AXIS_FACTOR).reverse(); + } -/** - * Download a rendered Plotly chart as PNG. - * - * @param {string} containerId - * @param {string} fileName - */ -function downloadChartAsPng(containerId, fileName) { - var chartEl = document.getElementById(containerId); - if (!chartEl || typeof Plotly === "undefined" || typeof Plotly.downloadImage !== "function") { - return false; - } - var safeFileName = (fileName || "chart") - .replace(/[\r\n]+/g, "_") - .replace(/[<>:"/\\|?*]+/g, "_") - .replace(/\.png$/i, ""); - Plotly.downloadImage(chartEl, { - format: "png", - filename: safeFileName || "chart", - scale: 2 - }); - return true; -} + function previousVisibility(el, uid, fallback) { + var previous = (el.data || []).filter(function (trace) { + return trace.uid === uid; + })[0]; + return previous && previous.visible !== undefined ? previous.visible : fallback; + } + + function isShown(trace) { + return trace.visible === undefined || trace.visible === true; + } + + /** + * Hyetograph + hydrograph: rainfall bars hanging from a reversed top axis + * (y2, right) over flow lines on the bottom axis (y, left), with the runoff + * peak marked. Every axis holds one unit, taken from the labels: series in + * the first flow field's unit share its axis, series in the rainfall unit + * (loss rates such as infiltration) share the rainfall axis, and each other + * unit (evaporation, reported per day) gets a panel of its own below the + * plot, on the same time axis, shown while one of its series is visible. + * Only the first flow field is visible initially; the rest toggle from the + * legend and the axes rescale to the visible series. + * + * @param {Element|string} el + * @param {Object[]} records + * @param {Object} [opts] + * @param {string} [opts.timeField="datetime"] + * @param {string} [opts.rainField="rainfall"] + * @param {string[]} [opts.flowFields] Default: runoff, runon, infiltration_loss, + * evaporation_loss — whichever exist in the records. + * @param {Object} [opts.yLabels] Field → label with its unit, "Runoff Rate [CMS]" + * (also names the series; the unit picks the axis). + * @param {string} [opts.rainLabel] Rainfall axis/series label. + * @param {string} [opts.flowLabel] Flow axis label (default: label of the first flow field). + * @param {string} [opts.xLabel] + * @param {string} [opts.title] + * @param {number} [opts.height=440] Without panels; each shown panel adds its own height. + * @param {boolean} [opts.rangeSlider=false] + * @param {string} [opts.ariaLabel] + * @param {string} [opts.fileName] + * @returns {Promise|null} + */ + function hydrograph(el, records, opts) { + opts = opts || {}; + var timeField = opts.timeField || "datetime"; + var rainField = opts.rainField || "rainfall"; + var yLabels = opts.yLabels || {}; + var sample = records[0] || {}; + var flowFields = (opts.flowFields || DEFAULT_FLOW_FIELDS).filter(function (field) { + return field in sample; + }); + var hasRain = rainField in sample; + var times = pluck(records, timeField); + var rain = hasRain ? pluck(records, rainField) : []; + var rainLabel = opts.rainLabel || yLabels[rainField] || "Rainfall"; + var rainUnit = unitOf(rainLabel); + var mainField = flowFields[0]; + var mainValues = mainField ? pluck(records, mainField) : []; + var peakValue = finiteMax(mainValues); + var peakIndex = isFinite(peakValue) ? mainValues.map(Number).indexOf(peakValue) : -1; + var mainUnit = unitOf(yLabels[mainField]); + var peakAnnotations = peakIndex !== -1 ? 1 : 0; + + // Series → axis by unit; panels: [{axis: "y3", unit, fields}], one per unit off both main axes. + var axisByField = {}; + var panels = []; + flowFields.forEach(function (field) { + var unit = unitOf(yLabels[field]); + if (field === mainField || unit === mainUnit) { + axisByField[field] = "y"; + } else if (hasRain && rainUnit !== "" && unit === rainUnit) { + axisByField[field] = "y2"; + } else { + var panel = panels.filter(function (candidate) { + return candidate.unit === unit; + })[0]; + if (!panel) { + panel = { axis: "y" + (panels.length + 3), unit: unit, fields: [] }; + panels.push(panel); + } + panel.fields.push(field); + axisByField[field] = panel.axis; + } + }); + var hasRateSeries = flowFields.some(function (field) { + return axisByField[field] === "y2"; + }); + + function layoutKey(axisId) { + return axisId.replace("y", "yaxis"); + } + + /** + * Height, domains and x-axis anchor for the panels with a visible series, from + * the traces alone, so a redraw (theme change) and a legend toggle agree. Hidden + * panels overlay the main plot, where they take neither room nor drag area. + */ + function panelLayout(data) { + var shown = panels.filter(function (panel) { + return data.some(function (trace) { + return trace.yaxis === panel.axis && isShown(trace); + }); + }); + var height = (opts.height || 440) + shown.length * (PANEL_HEIGHT + PANEL_GAP); + var px = 1 / (height - PLOT_MARGINS_ESTIMATE); + var slot = (PANEL_HEIGHT + PANEL_GAP) * px; + var placement = { + height: height, + mainDomain: [shown.length * slot, 1], + xAnchor: shown.length ? shown[shown.length - 1].axis : "y", + panels: {}, + }; + panels.forEach(function (panel) { + var index = shown.indexOf(panel); + var bottom = (shown.length - 1 - index) * slot; + // overlaying "free" (not overlaid) lets a shown panel take its own domain. + placement.panels[panel.axis] = index === -1 + ? { visible: false, overlaying: "y" } + : { visible: true, overlaying: "free", domain: [bottom, bottom + PANEL_HEIGHT * px] }; + }); + return placement; + } + + function panelHeaderY(placement, panel) { + var domain = placement.panels[panel.axis].domain; + return domain ? domain[1] : 0; + } + + var result = render(el, function (theme, container) { + var layout = baseLayout(theme, opts, container, true); + layout.hovermode = "x unified"; + // Unified hover also lists the panels' series, which share the time axis. + layout.hoversubplots = "axis"; + layout.bargap = 0.1; + + var data = []; + if (hasRain) { + data.push({ + type: "bar", + uid: rainField, + name: rainLabel, + visible: previousVisibility(container, rainField, true), + x: times, + y: rain, + yaxis: "y2", + marker: { color: theme.rain, opacity: 0.85, line: { width: 0 } }, + }); + } + + flowFields.forEach(function (field, i) { + var style = FLOW_STYLES[field] || { color: SERIES_ORDER[(i + 1) % SERIES_ORDER.length], dash: EXTRA_DASHES[i % EXTRA_DASHES.length] }; + data.push({ + type: "scatter", + mode: "lines", + uid: field, + name: yLabels[field] || field, + legendgroup: field, + visible: previousVisibility(container, field, i === 0 ? true : "legendonly"), + x: times, + y: pluck(records, field), + yaxis: axisByField[field], + line: { color: theme[style.color], width: i === 0 ? 2.25 : 1.75, dash: style.dash }, + }); + }); + + var placement = panelLayout(data); + layout.height = placement.height; + // An explicit range: autorange would also make room for the peak label and stretch the axis past the data. + layout.xaxis = axis(theme, opts.xLabel, { + type: "date", + anchor: placement.xAnchor, + range: times.length > 1 ? [times[0], times[times.length - 1]] : undefined, + hoverformat: "%Y-%m-%d %H:%M", + rangeslider: opts.rangeSlider + ? { visible: true, thickness: 0.08, bgcolor: TRANSPARENT, bordercolor: theme.line, borderwidth: 1 } + : { visible: false }, + }); + + layout.annotations = []; + var mainVisible = mainField ? previousVisibility(container, mainField, true) === true : false; + if (peakAnnotations) { + var peakLabel = "Peak " + Number(peakValue.toPrecision(4)) + (mainUnit ? " " + mainUnit : ""); + // Label on the side with more room, so the fixed x range never clips it. + var labelLeft = peakIndex > times.length / 2; + data.push({ + type: "scatter", + mode: "markers", + uid: "peak", + name: peakLabel, + legendgroup: mainField, + showlegend: false, + visible: mainVisible, + x: [times[peakIndex]], + y: [peakValue], + yaxis: "y", + hoverinfo: "skip", + marker: { symbol: "diamond", size: 11, color: theme.ochre, line: { color: theme.surface, width: 2 } }, + }); + layout.annotations.push({ + x: times[peakIndex], + y: peakValue, + xref: "x", + yref: "y", + text: peakLabel, + visible: mainVisible, + showarrow: true, + arrowhead: 0, + arrowwidth: 1, + arrowcolor: theme.ochre, + ax: labelLeft ? -28 : 28, + ay: -28, + xanchor: labelLeft ? "right" : "left", + font: { family: FONT_MONO, size: 11, color: theme.ink }, + bgcolor: theme.surface, + bordercolor: theme.line, + borderpad: 3, + }); + } + + layout.yaxis = axis(theme, opts.flowLabel || yLabels[mainField] || mainField, { + domain: placement.mainDomain, + range: visibleRange(data, "y", FLOW_AXIS_FACTOR), + rangemode: "tozero", + }); + if (hasRain) { + var rateTitle = hasRateSeries ? "Rainfall and loss rates [" + rainUnit + "]" : rainLabel; + layout.yaxis2 = axis(theme, rateTitle, { + overlaying: "y", + side: "right", + range: rateAxisRange(data), + showgrid: false, + zeroline: false, + }); + } + panels.forEach(function (panel) { + // The header names the series (with the unit, as in the legend); the axis title is the unit. + layout[layoutKey(panel.axis)] = axis(theme, panel.unit, Object.assign({ + anchor: "x", + range: visibleRange(data, panel.axis, PANEL_AXIS_FACTOR), + rangemode: "tozero", + nticks: 4, + }, placement.panels[panel.axis])); + layout.annotations.push({ + text: panel.fields.map(function (field) { + return yLabels[field] || field; + }).join(" · "), + visible: placement.panels[panel.axis].visible, + xref: "paper", + yref: "paper", + x: 0, + y: panelHeaderY(placement, panel), + xanchor: "left", + yanchor: "bottom", + yshift: 4, + showarrow: false, + font: { size: 12, color: theme.ink }, + }); + }); + return { data: data, layout: layout }; + }, opts, "Rainfall hyetograph and runoff hydrograph"); + + if (result) { + var container = resolveElement(el); + result.then(function () { + // Legend toggles fire plotly_restyle; relayout does not, so this cannot loop. + container.on("plotly_restyle", function () { + var data = container.data; + var placement = panelLayout(data); + var update = { + height: placement.height, + "xaxis.anchor": placement.xAnchor, + "yaxis.domain": placement.mainDomain, + "yaxis.range": visibleRange(data, "y", FLOW_AXIS_FACTOR), + }; + if (hasRain) { + update["yaxis2.range"] = rateAxisRange(data); + } + if (peakAnnotations) { + var peak = data.filter(function (trace) { + return trace.uid === mainField; + })[0]; + update["annotations[0].visible"] = Boolean(peak) && isShown(peak); + } + panels.forEach(function (panel, i) { + var key = layoutKey(panel.axis); + var panelPlacement = placement.panels[panel.axis]; + Object.keys(panelPlacement).forEach(function (name) { + update[key + "." + name] = panelPlacement[name]; + }); + update[key + ".range"] = visibleRange(data, panel.axis, PANEL_AXIS_FACTOR); + update["annotations[" + (peakAnnotations + i) + "].visible"] = panelPlacement.visible; + update["annotations[" + (peakAnnotations + i) + "].y"] = panelHeaderY(placement, panel); + }); + window.Plotly.relayout(container, update); + }); + }); + } + return result; + } + + /** + * Parity (observed vs predicted) scatter with a dashed 1:1 reference line + * and equal axis ranges. + * + * @param {Element|string} el + * @param {Object[]} rows + * @param {Object} [opts] + * @param {string} [opts.xField="SWMM_Runoff"] Observed / reference value. + * @param {string} [opts.yField="ANN_Runoff"] Predicted value. + * @param {string} [opts.nameField="Name"] Shown on hover. + * @param {string} [opts.xLabel] + * @param {string} [opts.yLabel] + * @param {string} [opts.title] + * @param {number} [opts.height=420] + * @param {string} [opts.ariaLabel] + * @param {string} [opts.fileName] + * @returns {Promise|null} + */ + function parity(el, rows, opts) { + opts = opts || {}; + var xField = opts.xField || "SWMM_Runoff"; + var yField = opts.yField || "ANN_Runoff"; + var nameField = opts.nameField || "Name"; + var xLabel = opts.xLabel || xField; + var yLabel = opts.yLabel || yField; + var points = rows.filter(function (row) { + return isFinite(parseFloat(row[xField])) && isFinite(parseFloat(row[yField])); + }); + var xs = points.map(function (row) { return Number(row[xField]); }); + var ys = points.map(function (row) { return Number(row[yField]); }); + var all = xs.concat(ys); + var low = all.reduce(function (min, value) { return value < min ? value : min; }, 0); + var high = all.reduce(function (max, value) { return value > max ? value : max; }, 0); + var padding = (high - low || 1) * 0.05; + var range = [low - (low < 0 ? padding : 0), high + padding]; + + return render(el, function (theme, container) { + var layout = baseLayout(theme, opts, container, true); + layout.hovermode = "closest"; + layout.height = opts.height || 420; + layout.xaxis = axis(theme, xLabel, { range: range, constrain: "domain" }); + layout.yaxis = axis(theme, yLabel, { range: range, scaleanchor: "x", scaleratio: 1, constrain: "domain" }); + var data = [ + { + type: "scatter", + mode: "lines", + name: "1:1 line", + x: range, + y: range, + hoverinfo: "skip", + line: { color: theme.inkMuted, width: 1, dash: "dash" }, + }, + { + type: "scatter", + mode: "markers", + name: opts.pointsLabel || "Subcatchments", + x: xs, + y: ys, + text: points.map(function (row) { return String(row[nameField]); }), + hovertemplate: "%{text}
" + xLabel + ": %{x:.4~g}
" + yLabel + ": %{y:.4~g}", + marker: { color: theme.water, size: 10, line: { color: theme.surface, width: 1.5 } }, + }, + ]; + return { data: data, layout: layout }; + }, opts, "Parity chart of observed versus predicted values"); + } + + /** + * Grouped bars: one bar per series for every category (e.g. SWMM vs ANN per + * subcatchment). + * + * @param {Element|string} el + * @param {Object[]} rows + * @param {Object} [opts] + * @param {string} [opts.categoryField="Name"] + * @param {{field: string, label?: string, color?: string}[]} [opts.series] + * `color` is a palette key: water|rain|ochre|soil|evap. Default: + * SWMM_Runoff (water) and ANN_Runoff (ochre). + * @param {string} [opts.xLabel] + * @param {string} [opts.yLabel] + * @param {string} [opts.title] + * @param {number} [opts.height=400] + * @param {string} [opts.ariaLabel] + * @param {string} [opts.fileName] + * @returns {Promise|null} + */ + function bars(el, rows, opts) { + opts = opts || {}; + var categoryField = opts.categoryField || "Name"; + var series = opts.series || [ + { field: "SWMM_Runoff", label: "SWMM", color: "water" }, + { field: "ANN_Runoff", label: "ANN", color: "ochre" }, + ]; + var categories = rows.map(function (row) { return String(row[categoryField]); }); + + return render(el, function (theme, container) { + var layout = baseLayout(theme, opts, container, true); + layout.hovermode = "x unified"; + layout.height = opts.height || 400; + layout.barmode = "group"; + layout.bargap = 0.25; + layout.bargroupgap = 0.08; + layout.xaxis = axis(theme, opts.xLabel, { type: "category", showgrid: false }); + layout.yaxis = axis(theme, opts.yLabel, { rangemode: "tozero" }); + var data = series.map(function (item, i) { + var color = theme[item.color] || seriesColor(theme, i); + return { + type: "bar", + name: item.label || item.field, + x: categories, + y: pluck(rows, item.field), + marker: { color: color, line: { color: theme.surface, width: 1 } }, + }; + }); + return { data: data, layout: layout }; + }, opts, "Grouped bar chart"); + } + + /** + * Download a rendered chart as a 2x PNG painted on the current surface colour. + * + * @param {Element|string} el + * @param {string} [fileName] Sanitized; a trailing ".png" is dropped. + * @returns {boolean} false when there is no rendered chart or Plotly is missing. + */ + function downloadPng(el, fileName) { + var chartEl = resolveElement(el); + if (!chartEl || !chartEl.data || typeof window.Plotly === "undefined" + || typeof window.Plotly.downloadImage !== "function") { + return false; + } + var surface = readTheme().surface; + var figure = { + data: chartEl.data, + layout: Object.assign({}, chartEl.layout, { paper_bgcolor: surface, plot_bgcolor: surface }), + }; + window.Plotly.downloadImage(figure, { + format: "png", + filename: sanitizeFileName(fileName), + width: chartEl.offsetWidth || null, + height: chartEl.layout.height || chartEl.offsetHeight || null, + scale: 2, + }).catch(function (error) { + console.warn("chart export failed:", error); + }); + return true; + } + + CS.charts = { + line: line, + sweep: sweep, + hydrograph: hydrograph, + parity: parity, + bars: bars, + downloadPng: downloadPng, + }; +})(); diff --git a/cs_app/main/static/main/js/core/async-form.js b/cs_app/main/static/main/js/core/async-form.js new file mode 100644 index 0000000..e2ac8da --- /dev/null +++ b/cs_app/main/static/main/js/core/async-form.js @@ -0,0 +1,350 @@ +/* + * CS.asyncForm(options) — progressive enhancement for run forms (DESIGN_SPEC §4). + * + * options: + * form to enhance (without JS it keeps its native POST) + * submitButton the run button; only submits from it (or implicit/requestSubmit + * submits without a submitter) are intercepted, so download buttons + * with formaction keep submitting natively and never show loading + * target element whose innerHTML receives the 200 results fragment; focus then + * moves to its first heading (tabindex=-1 added when needed). A bubbling + * `cs:beforeswap` event is dispatched on it just before the swap (charts.js + * releases the charts about to be replaced) + * loadingEl optional progress element, toggled with the `hidden` attribute; + * a descendant [data-cs-elapsed] shows elapsed seconds + * successMessage optional toast text shown after a successful run ("Simulation finished") + * onSuccess optional (target) => void, called after the fragment is swapped in + * onError optional ({status, message, field_errors}) => void, after the inline + * errors and the error toast are shown (do not toast again) + * + * Error responses: 4xx/5xx JSON {message, field_errors} render inline Bootstrap + * field errors plus a role="alert" summary in the form, and a short danger toast; + * 401 redirects to login; anything else (non-JSON body, network failure) shows + * TIMEOUT_MESSAGE. Results kept from an earlier run (a [data-cs-results] element in + * target) get a .cs-stale-notice above them. Every new run dismisses the toasts and + * the notice left by the previous one. + * + * Loading another model or removing it (the upload zone's cs:model-changed, unless + * `restored`, and cs:model-cleared) marks the results shown as stale too, and a run + * still in flight is discarded when it returns: both belong to the previous model. + */ +(function (CS) { + "use strict"; + + var TIMEOUT_MESSAGE = + "The server did not finish the run. It may have timed out — try a smaller range " + + "or reload the page to check for results."; + var FIELDS_MESSAGE = "Please correct the highlighted fields."; + var FAILED_MESSAGE = "The run could not be completed. Please try again."; + var ERROR_TOAST = "The run did not complete. See the form for details."; + var STALE_MESSAGE = "Showing results from the previous run: the latest run did not complete."; + var MODEL_STALE_MESSAGE = "These results are for the previously loaded model. Run again to update them."; + var DISCARDED_MESSAGE = "A run for the previously loaded model finished; its results were discarded."; + var ERROR_MARK = "data-cs-async-error"; + var INVALID_MARK = "data-cs-async-invalid"; + var STALE_CLASS = "cs-stale-notice"; + + function startElapsed(loadingEl) { + if (!loadingEl) { + return function () {}; + } + var counter = loadingEl.querySelector("[data-cs-elapsed]"); + var startedAt = Date.now(); + function render() { + if (counter) { + counter.textContent = Math.floor((Date.now() - startedAt) / 1000) + " s"; + } + } + render(); + loadingEl.hidden = false; + var timer = window.setInterval(render, 1000); + return function stop() { + window.clearInterval(timer); + loadingEl.hidden = true; + }; + } + + function focusResults(container) { + var focusTarget = container.querySelector("h1, h2, h3, h4, h5, h6") || container; + if (!focusTarget.hasAttribute("tabindex")) { + focusTarget.setAttribute("tabindex", "-1"); + } + focusTarget.focus(); + } + + function fieldFor(form, name) { + var field = form.elements.namedItem(name); + if (field instanceof RadioNodeList) { + return field[field.length - 1] || null; + } + return field; + } + + function clearFieldError(field) { + var feedbackId = field.getAttribute(INVALID_MARK); + if (feedbackId === null) { + return; + } + var feedback = document.getElementById(feedbackId); + if (feedback) { + feedback.remove(); + } + var describedBy = (field.getAttribute("aria-describedby") || "") + .split(/\s+/) + .filter(function (id) { + return id && id !== feedbackId; + }); + if (describedBy.length) { + field.setAttribute("aria-describedby", describedBy.join(" ")); + } else { + field.removeAttribute("aria-describedby"); + } + field.classList.remove("is-invalid"); + field.removeAttribute("aria-invalid"); + field.removeAttribute(INVALID_MARK); + } + + function clearErrors(form) { + form.querySelectorAll("[" + INVALID_MARK + "]").forEach(clearFieldError); + form.querySelectorAll("[" + ERROR_MARK + "]").forEach(function (el) { + el.remove(); + }); + } + + function showFieldError(field, messages) { + var feedbackId = (field.id || field.name) + "-async-error"; + var feedback = document.createElement("div"); + feedback.id = feedbackId; + feedback.className = "invalid-feedback"; + feedback.setAttribute(ERROR_MARK, ""); + feedback.textContent = messages.join(" "); + field.insertAdjacentElement("afterend", feedback); + + field.classList.add("is-invalid"); + field.setAttribute("aria-invalid", "true"); + field.setAttribute(INVALID_MARK, feedbackId); + var describedBy = field.getAttribute("aria-describedby"); + field.setAttribute("aria-describedby", describedBy ? describedBy + " " + feedbackId : feedbackId); + } + + function showSummary(form, message, details) { + var alert = document.createElement("div"); + alert.className = "alert alert-danger"; + alert.setAttribute("role", "alert"); + alert.setAttribute(ERROR_MARK, ""); + + var text = document.createElement("p"); + text.className = "mb-0"; + text.textContent = message; + alert.append(text); + + if (details.length) { + var list = document.createElement("ul"); + list.className = "mb-0 mt-1"; + details.forEach(function (detail) { + var item = document.createElement("li"); + item.textContent = detail; + list.append(item); + }); + alert.append(list); + } + form.prepend(alert); + } + + /** Returns the first field marked invalid, if any. */ + function renderErrors(form, error) { + var unplaced = []; + var firstInvalid = null; + Object.keys(error.field_errors).forEach(function (name) { + var messages = [].concat(error.field_errors[name]).map(String); + var field = name === "__all__" ? null : fieldFor(form, name); + if (field && field.nodeType === Node.ELEMENT_NODE) { + showFieldError(field, messages); + firstInvalid = firstInvalid || field; + } else { + unplaced = unplaced.concat(messages); + } + }); + var summary = error.message || unplaced.shift() || (firstInvalid ? FIELDS_MESSAGE : FAILED_MESSAGE); + showSummary(form, summary, unplaced); + return firstInvalid; + } + + async function readError(response) { + var isJson = (response.headers.get("Content-Type") || "").indexOf("application/json") !== -1; + var payload = null; + if (isJson) { + try { + payload = await response.json(); + } catch (parseError) { + payload = null; + } + } + if (!payload || typeof payload !== "object") { + return { status: response.status, message: TIMEOUT_MESSAGE, field_errors: {} }; + } + return { + status: response.status, + message: typeof payload.message === "string" ? payload.message : "", + field_errors: + payload.field_errors && typeof payload.field_errors === "object" ? payload.field_errors : {}, + }; + } + + async function send(form, submitter) { + var body = new FormData(form); + if (submitter && submitter.name) { + body.append(submitter.name, submitter.value); + } + var response; + try { + response = await fetch(form.getAttribute("action") || window.location.href, { + method: "POST", + body: body, + headers: { "X-Requested-With": "XMLHttpRequest" }, + credentials: "same-origin", + }); + } catch (networkError) { + return { ok: false, error: { status: 0, message: TIMEOUT_MESSAGE, field_errors: {} } }; + } + if (response.ok) { + return { ok: true, html: await response.text() }; + } + return { ok: false, error: await readError(response) }; + } + + function hideToast(toastEl) { + var toast = window.bootstrap && window.bootstrap.Toast.getInstance(toastEl); + if (toast) { + toast.hide(); + } + } + + function removeStaleNotices(target) { + target.querySelectorAll("." + STALE_CLASS).forEach(function (notice) { + notice.remove(); + }); + } + + function showStaleNotice(target, message) { + if (!target.querySelector("[data-cs-results]")) { + return; + } + removeStaleNotices(target); + var notice = document.createElement("p"); + notice.className = STALE_CLASS; + notice.textContent = message; + target.prepend(notice); + } + + function swap(target, html) { + target.dispatchEvent(new CustomEvent("cs:beforeswap", { bubbles: true })); + target.innerHTML = html; + } + + CS.asyncForm = function (options) { + var form = options.form; + var button = options.submitButton; + var target = options.target; + var running = false; + var runToasts = []; + // Bumped whenever the model changes, so a run started before can tell it is outdated. + var modelVersion = 0; + + function toast(message, level) { + var toastEl = CS.toast(message, level); + if (toastEl) { + runToasts.push(toastEl); + } + } + + function clearPreviousRun() { + clearErrors(form); + runToasts.splice(0).forEach(hideToast); + removeStaleNotices(target); + } + + function setRunning(isRunning) { + running = isRunning; + button.disabled = isRunning; + [button, target].forEach(function (el) { + if (isRunning) { + el.setAttribute("aria-busy", "true"); + } else { + el.removeAttribute("aria-busy"); + } + }); + } + + async function run(submitter) { + clearPreviousRun(); + setRunning(true); + var runModelVersion = modelVersion; + var stopElapsed = startElapsed(options.loadingEl); + var result; + try { + result = await send(form, submitter); + } catch (readFailure) { + // The body stream broke mid-read: same outcome as a dropped connection. + result = { ok: false, error: { status: 0, message: TIMEOUT_MESSAGE, field_errors: {} } }; + } finally { + stopElapsed(); + setRunning(false); + } + + if (runModelVersion !== modelVersion) { + toast(DISCARDED_MESSAGE, "info"); + return; + } + if (result.ok) { + swap(target, result.html); + if (options.onSuccess) { + options.onSuccess(target); + } + focusResults(target); + if (options.successMessage) { + toast(options.successMessage, "success"); + } + return; + } + if (result.error.status === 401 && CS.redirectToLogin()) { + return; + } + // Disabling the button dropped its focus; return it to where the fix is needed. + (renderErrors(form, result.error) || button).focus(); + showStaleNotice(target, STALE_MESSAGE); + toast(ERROR_TOAST, "danger"); + if (options.onError) { + options.onError(result.error); + } + } + + form.addEventListener("submit", function (event) { + var submitter = event.submitter; + if (event.defaultPrevented || (submitter && submitter !== button)) { + return; + } + event.preventDefault(); + if (!running) { + run(submitter); + } + }); + + function onModelSwitch(event) { + if (event.detail && event.detail.restored) { + return; + } + modelVersion += 1; + showStaleNotice(target, MODEL_STALE_MESSAGE); + } + document.addEventListener("cs:model-changed", onModelSwitch); + document.addEventListener("cs:model-cleared", onModelSwitch); + + form.addEventListener("input", function (event) { + var field = event.target.closest("[" + INVALID_MARK + "]"); + if (field) { + clearFieldError(field); + } + }); + }; +})(window.CS); diff --git a/cs_app/main/static/main/js/core/auth-guard.js b/cs_app/main/static/main/js/core/auth-guard.js new file mode 100644 index 0000000..3151917 --- /dev/null +++ b/cs_app/main/static/main/js/core/auth-guard.js @@ -0,0 +1,29 @@ +/* + * CS.redirectToLogin() sends the user to body[data-login-url] with ?next= set to + * the current page. Buttons rendered with data-authenticated="False" use it + * instead of submitting (delegated, so buttons swapped in later are covered). + */ +(function (CS) { + "use strict"; + + CS.redirectToLogin = function () { + var loginUrl = document.body.dataset.loginUrl; + if (!loginUrl) { + return false; + } + var url = new URL(loginUrl, window.location.href); + url.searchParams.set("next", window.location.pathname + window.location.search); + window.location.assign(url.href); + return true; + }; + + document.addEventListener("click", function (event) { + var button = event.target.closest("button[data-authenticated]"); + if (!button || button.getAttribute("data-authenticated") === "True") { + return; + } + if (CS.redirectToLogin()) { + event.preventDefault(); + } + }); +})(window.CS); diff --git a/cs_app/main/static/main/js/core/clipboard.js b/cs_app/main/static/main/js/core/clipboard.js new file mode 100644 index 0000000..d60aa1b --- /dev/null +++ b/cs_app/main/static/main/js/core/clipboard.js @@ -0,0 +1,26 @@ +/* + * CS.clipboard.copy(text, successMessage) — copies text and confirms with a toast. + * Resolves to true on success, false otherwise (never rejects). + */ +(function (CS) { + "use strict"; + + CS.clipboard = { + copy: function (text, successMessage) { + if (!navigator.clipboard || !window.isSecureContext) { + CS.toast("Copying is not available here. Select the text and copy it manually.", "warning"); + return Promise.resolve(false); + } + return navigator.clipboard.writeText(text).then( + function () { + CS.toast(successMessage || "Copied to clipboard.", "success"); + return true; + }, + function () { + CS.toast("Could not copy. Select the text and copy it manually.", "danger"); + return false; + } + ); + }, + }; +})(window.CS); diff --git a/cs_app/main/static/main/js/core/format.js b/cs_app/main/static/main/js/core/format.js new file mode 100644 index 0000000..45d242b --- /dev/null +++ b/cs_app/main/static/main/js/core/format.js @@ -0,0 +1,70 @@ +/* + * CS.format.number(value, digits) — locale-stable display of numeric results. + * CS.format.sweepRange(start, stop, step, maxSteps) — the live run-count hint of a + * parameter sweep form (simulation and timeseries pages). + */ +(function (CS) { + "use strict"; + + var formatters = {}; + + function formatterFor(digits) { + if (!formatters[digits]) { + formatters[digits] = new Intl.NumberFormat("en-US", { + minimumFractionDigits: digits, + maximumFractionDigits: digits, + }); + } + return formatters[digits]; + } + + /** Same text as the server's swept-value labels: 0.30000000000000004 → "0.3". */ + function parameter(value) { + return String(Number(value.toPrecision(10))); + } + + CS.format = { + /** + * Format `value` with exactly `digits` decimals (default 2) and + * thousands separators. Non-numeric input yields an em dash. + */ + number: function (value, digits) { + var number = typeof value === "number" ? value : parseFloat(value); + if (!Number.isFinite(number)) { + return "—"; + } + return formatterFor(digits === undefined ? 2 : digits).format(number); + }, + + /** + * Describe a sweep range as "N runs · start → stop, step s", or say what is + * wrong with it. The server remains authoritative; this only warns early. + * + * @returns {{text: string, warning: boolean}} + */ + sweepRange: function (start, stop, step, maxSteps) { + if (![start, stop, step].every(Number.isFinite)) { + return { text: "Enter start, stop and step to see the number of runs.", warning: false }; + } + if (step <= 0) { + return { text: "Step must be greater than zero.", warning: true }; + } + if (start > stop) { + return { text: "Stop must be greater than or equal to start.", warning: true }; + } + // The same values as the package's np.arange(start, stop + step / 2, step). + var runs = Math.ceil((stop - start + step / 2) / step); + var summary = runs + (runs === 1 ? " run" : " runs") + " · " + parameter(start) + " → " + + parameter(stop) + ", step " + parameter(step); + // The forms' own limit: (stop - start) / step < max steps. + if ((stop - start) / step >= maxSteps) { + return { + text: summary + ". That is over the limit of " + maxSteps + + " runs: increase the step or narrow the range.", + warning: true, + }; + } + return { text: summary, warning: false }; + }, + }; +})(window.CS); diff --git a/cs_app/main/static/main/js/core/forms.js b/cs_app/main/static/main/js/core/forms.js new file mode 100644 index 0000000..8a2bf5c --- /dev/null +++ b/cs_app/main/static/main/js/core/forms.js @@ -0,0 +1,104 @@ +/* + * Server-rendered forms (log in, create account, contact, profile), marked + * with [data-cs-form]. Progressive enhancement only; the forms work without it. + * - Field errors rendered by crispy are tied to their inputs (aria-invalid, + * aria-describedby) and the first invalid field receives focus. + * - Password inputs get a show/hide toggle button (aria-pressed). + */ +(function () { + "use strict"; + + var EYE_ICON = + '' + + ''; + + function describeBy(input, id) { + var ids = (input.getAttribute("aria-describedby") || "").split(/\s+/).filter(Boolean); + if (ids.indexOf(id) === -1) { + ids.push(id); + input.setAttribute("aria-describedby", ids.join(" ")); + } + } + + function firstField(form) { + return form.querySelector("input:not([type=hidden]):not([disabled]), select:not([disabled]), textarea:not([disabled])"); + } + + /** Crispy places `.invalid-feedback#error_N_` and `#_helptext` next to each field. */ + function linkErrors(form) { + form.querySelectorAll(".is-invalid").forEach(function (input) { + input.setAttribute("aria-invalid", "true"); + form.querySelectorAll('.invalid-feedback[id$="_' + input.id + '"]').forEach(function (feedback) { + describeBy(input, feedback.id); + }); + }); + form.querySelectorAll("input[id], select[id], textarea[id]").forEach(function (input) { + if (document.getElementById(input.id + "_helptext")) { + describeBy(input, input.id + "_helptext"); + } + }); + + // Errors that belong to no field (e.g. wrong credentials) render as one alert. + var summary = form.querySelector(".alert-danger"); + var target = form.querySelector(".is-invalid") || (summary && firstField(form)); + if (summary && target) { + summary.id = summary.id || form.id + "-errors"; + describeBy(target, summary.id); + } + if (target) { + target.focus(); + } + } + + function labelText(input) { + var label = input.labels && input.labels[0]; + return label ? label.textContent.replace("*", "").trim().toLowerCase() : "password"; + } + + function addPasswordToggle(input) { + var group = document.createElement("div"); + group.className = "input-group has-validation"; + input.parentNode.insertBefore(group, input); + group.appendChild(input); + + var button = document.createElement("button"); + button.type = "button"; + button.className = "btn cs-password-toggle"; + button.setAttribute("aria-controls", input.id); + button.setAttribute("aria-pressed", "false"); + button.setAttribute("aria-label", "Show " + labelText(input)); + button.title = button.getAttribute("aria-label"); + button.innerHTML = EYE_ICON; + group.appendChild(button); + + // Bootstrap only shows feedback that follows the invalid input inside the group. + var sibling = group.nextElementSibling; + while (sibling && sibling.classList.contains("invalid-feedback")) { + var next = sibling.nextElementSibling; + group.appendChild(sibling); + sibling = next; + } + + button.addEventListener("click", function () { + var reveal = input.type === "password"; + input.type = reveal ? "text" : "password"; + button.setAttribute("aria-pressed", String(reveal)); + }); + } + + function concealPasswords(form) { + form.querySelectorAll(".cs-password-toggle[aria-pressed='true']").forEach(function (button) { + button.click(); + }); + } + + document.querySelectorAll("form[data-cs-form]").forEach(function (form, index) { + form.id = form.id || "cs-form-" + index; + form.querySelectorAll("input[type=password][id]").forEach(addPasswordToggle); + // Submit the password as a password field so browsers offer to save it. + form.addEventListener("submit", function () { + concealPasswords(form); + }); + linkErrors(form); + }); +})(); diff --git a/cs_app/main/static/main/js/core/table.js b/cs_app/main/static/main/js/core/table.js new file mode 100644 index 0000000..7c5f741 --- /dev/null +++ b/cs_app/main/static/main/js/core/table.js @@ -0,0 +1,114 @@ +/* + * CS.table.sortable(tableEl) — click/Enter/Space on a header sorts the first + * tbody by that column (numeric-aware, ascending then descending) and sets + * aria-sort on the sorted . Header text is wrapped in a .cs-sort button + * unless the header already contains one; opts out. + * A cell's data-sort-value overrides its text as the sort key. Calling it again + * on the same table is a no-op. + * CS.table.toCSV(tableEl) — RFC 4180 CSV of the header and body rows. + */ +(function (CS) { + "use strict"; + + var collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + + function cellKey(cell) { + return cell ? (cell.dataset.sortValue || cell.textContent).trim() : ""; + } + + function toNumber(text) { + var normalized = text.replace(/[,\s ]/g, ""); + return /^[-+]?(\d+\.?\d*|\.\d+)(e[-+]?\d+)?$/i.test(normalized) ? parseFloat(normalized) : NaN; + } + + // Numbers before text; text compared naturally ("S2" < "S10"). + function compareKeys(a, b) { + var numA = toNumber(a); + var numB = toNumber(b); + var aIsNum = !Number.isNaN(numA); + var bIsNum = !Number.isNaN(numB); + if (aIsNum && bIsNum) { + return numA - numB; + } + if (aIsNum !== bIsNum) { + return aIsNum ? -1 : 1; + } + return collator.compare(a, b); + } + + function ensureSortButton(th) { + var button = th.querySelector(".cs-sort"); + if (button) { + return button; + } + button = document.createElement("button"); + button.type = "button"; + button.className = "cs-sort"; + button.append.apply(button, Array.prototype.slice.call(th.childNodes)); + th.append(button); + return button; + } + + function sortBy(table, headers, th) { + var body = table.tBodies[0]; + if (!body) { + return; + } + var direction = th.getAttribute("aria-sort") === "ascending" ? "descending" : "ascending"; + var sign = direction === "ascending" ? 1 : -1; + var column = th.cellIndex; + + Array.prototype.slice + .call(body.rows) + .sort(function (rowA, rowB) { + return sign * compareKeys(cellKey(rowA.cells[column]), cellKey(rowB.cells[column])); + }) + .forEach(function (row) { + body.appendChild(row); + }); + + headers.forEach(function (header) { + header.removeAttribute("aria-sort"); + }); + th.setAttribute("aria-sort", direction); + } + + function csvField(text) { + var value = text.trim().replace(/\s+/g, " "); + // Neutralise spreadsheet formulas in text cells (numbers such as -1.5 stay intact). + if (/^[=+\-@]/.test(value) && Number.isNaN(toNumber(value))) { + value = "'" + value; + } + return /[",\r\n]/.test(value) ? '"' + value.replace(/"/g, '""') + '"' : value; + } + + CS.table = { + sortable: function (tableEl) { + var headerRow = tableEl.tHead && tableEl.tHead.rows[0]; + if (!headerRow || tableEl.hasAttribute("data-cs-sortable")) { + return; + } + tableEl.setAttribute("data-cs-sortable", ""); + var headers = Array.prototype.filter.call(headerRow.cells, function (th) { + return th.dataset.csSort !== "false"; + }); + headers.forEach(function (th) { + ensureSortButton(th).addEventListener("click", function () { + sortBy(tableEl, headers, th); + }); + }); + }, + + toCSV: function (tableEl) { + return Array.prototype.map + .call(tableEl.querySelectorAll("thead tr, tbody tr"), function (row) { + return Array.prototype.map + .call(row.cells, function (cell) { + return csvField(cell.textContent); + }) + .join(","); + }) + .join("\r\n"); + }, + }; +})(window.CS); diff --git a/cs_app/main/static/main/js/core/theme-init.js b/cs_app/main/static/main/js/core/theme-init.js new file mode 100644 index 0000000..7989604 --- /dev/null +++ b/cs_app/main/static/main/js/core/theme-init.js @@ -0,0 +1,50 @@ +/* + * Loaded synchronously in so the colour mode is set before first paint. + * Defines the minimal CS.theme primitives that core/theme.js builds on. + */ +(function () { + "use strict"; + + var STORAGE_KEY = "cs-theme"; + var MODES = ["light", "dark", "auto"]; + var darkQuery = window.matchMedia("(prefers-color-scheme: dark)"); + + function storedMode() { + var value = null; + try { + value = window.localStorage.getItem(STORAGE_KEY); + } catch (error) { + // Storage can be blocked (privacy mode); fall back to "auto". + } + return MODES.indexOf(value) !== -1 ? value : "auto"; + } + + function resolve(mode) { + if (mode === "auto") { + return darkQuery.matches ? "dark" : "light"; + } + return mode; + } + + function apply(mode) { + var resolved = resolve(mode); + document.documentElement.setAttribute("data-bs-theme", resolved); + return resolved; + } + + var CS = window.CS = window.CS || {}; + CS.theme = { + STORAGE_KEY: STORAGE_KEY, + MODES: MODES, + darkQuery: darkQuery, + storedMode: storedMode, + resolve: resolve, + apply: apply, + /** Resolved theme currently on : "light" | "dark". */ + current: function () { + return document.documentElement.getAttribute("data-bs-theme") === "dark" ? "dark" : "light"; + }, + }; + + apply(storedMode()); +})(); diff --git a/cs_app/main/static/main/js/core/theme.js b/cs_app/main/static/main/js/core/theme.js new file mode 100644 index 0000000..550c54f --- /dev/null +++ b/cs_app/main/static/main/js/core/theme.js @@ -0,0 +1,100 @@ +/* + * Theme toggle (light / dark / auto). Persists the chosen mode in localStorage + * and dispatches `cs:themechange` on document with {detail: {theme}} whenever + * the resolved theme changes, including system changes while in "auto". + * Controls: any button with data-cs-theme-value="light|dark|auto"; its + * aria-pressed reflects the active mode. [data-cs-theme-label] shows the mode. + */ +(function (CS) { + "use strict"; + + var theme = CS.theme; + var LABELS = { light: "Light", dark: "Dark", auto: "Auto" }; + var mode = theme.storedMode(); + + function dispatchChange(resolved) { + document.dispatchEvent(new CustomEvent("cs:themechange", { detail: { theme: resolved } })); + } + + // Switch colours in one step: without this, elements with colour transitions + // briefly show light-theme text on the dark background (and vice versa). + var restoreFrame = null; + + function applyWithoutTransitions() { + var root = document.documentElement; + window.cancelAnimationFrame(restoreFrame); + root.classList.add("cs-theme-switching"); + var resolved = theme.apply(mode); + window.getComputedStyle(root).getPropertyValue("color"); // flush styles before re-enabling + restoreFrame = window.requestAnimationFrame(function () { + root.classList.remove("cs-theme-switching"); + }); + return resolved; + } + + function applyMode() { + var previous = theme.current(); + if (theme.resolve(mode) === previous) { + return; + } + dispatchChange(applyWithoutTransitions()); + } + + function persist() { + try { + if (mode === "auto") { + window.localStorage.removeItem(theme.STORAGE_KEY); + } else { + window.localStorage.setItem(theme.STORAGE_KEY, mode); + } + } catch (error) { + // Not persisted when storage is blocked; the choice still applies to this page. + } + } + + function syncControls() { + document.querySelectorAll("[data-cs-theme-value]").forEach(function (control) { + control.setAttribute("aria-pressed", String(control.getAttribute("data-cs-theme-value") === mode)); + }); + document.querySelectorAll("[data-cs-theme-label]").forEach(function (label) { + label.textContent = LABELS[mode]; + }); + } + + /** Selected mode: "light" | "dark" | "auto". */ + theme.mode = function () { + return mode; + }; + + theme.set = function (nextMode) { + if (theme.MODES.indexOf(nextMode) === -1) { + return; + } + mode = nextMode; + persist(); + applyMode(); + syncControls(); + }; + + theme.darkQuery.addEventListener("change", function () { + if (mode === "auto") { + applyMode(); + } + }); + + document.addEventListener("click", function (event) { + var control = event.target.closest("[data-cs-theme-value]"); + if (!control) { + return; + } + theme.set(control.getAttribute("data-cs-theme-value")); + // The dropdown closes and hides the chosen item; keep focus on its toggle, not . + var menu = control.closest(".dropdown-menu[aria-labelledby]"); + var toggle = menu && document.getElementById(menu.getAttribute("aria-labelledby")); + if (toggle) { + toggle.focus(); + } + }); + + syncControls(); +})(window.CS); diff --git a/cs_app/main/static/main/js/core/toast.js b/cs_app/main/static/main/js/core/toast.js new file mode 100644 index 0000000..9958c62 --- /dev/null +++ b/cs_app/main/static/main/js/core/toast.js @@ -0,0 +1,47 @@ +/* + * CS.toast(message, level) — transient feedback for async flows. + * level: "success" | "info" | "warning" | "danger" (default "info"). + * Renders into the base template's .toast-container (aria-live="polite"). + * The message is set via textContent, so user data is never parsed as HTML. + */ +(function (CS) { + "use strict"; + + var LEVELS = ["success", "info", "warning", "danger"]; + var DELAY_MS = { success: 4000, info: 5000, warning: 7000, danger: 8000 }; + + CS.toast = function (message, level) { + var region = document.querySelector(".toast-container"); + if (!region || !window.bootstrap) { + return null; + } + var safeLevel = LEVELS.indexOf(level) !== -1 ? level : "info"; + + var toastEl = document.createElement("div"); + toastEl.className = "toast cs-toast cs-toast--" + safeLevel; + toastEl.setAttribute("aria-atomic", "true"); + + var row = document.createElement("div"); + row.className = "d-flex align-items-start"; + + var body = document.createElement("div"); + body.className = "toast-body"; + body.textContent = message; + + var close = document.createElement("button"); + close.type = "button"; + close.className = "btn-close me-2 mt-2"; + close.setAttribute("data-bs-dismiss", "toast"); + close.setAttribute("aria-label", "Close"); + + row.append(body, close); + toastEl.append(row); + region.append(toastEl); + + toastEl.addEventListener("hidden.bs.toast", function () { + toastEl.remove(); + }); + window.bootstrap.Toast.getOrCreateInstance(toastEl, { delay: DELAY_MS[safeLevel] }).show(); + return toastEl; + }; +})(window.CS); diff --git a/cs_app/main/static/main/js/core/workbench.js b/cs_app/main/static/main/js/core/workbench.js new file mode 100644 index 0000000..a34566f --- /dev/null +++ b/cs_app/main/static/main/js/core/workbench.js @@ -0,0 +1,36 @@ +/* + * Workbench control panel (.cs-panel): on wide screens it sticks below the + * header (.is-sticky, see app.css) only while it fits the viewport. A taller + * panel scrolls with the page instead of inside itself, so none of its + * controls can end up out of reach. + */ +(function () { + "use strict"; + + var WIDE_QUERY = window.matchMedia("(min-width: 992px)"); + // Room kept above and below a sticky panel (1rem each, as in app.css). + var GUTTER_PX = 32; + + var panels = document.querySelectorAll(".cs-panel"); + if (!panels.length) { + return; + } + var header = document.querySelector(".cs-header"); + + function update() { + var available = window.innerHeight - (header ? header.offsetHeight : 0) - GUTTER_PX; + panels.forEach(function (panel) { + panel.classList.toggle("is-sticky", WIDE_QUERY.matches && panel.offsetHeight <= available); + }); + } + + // The panel grows and shrinks with its content (sweep fields, error summaries, the model chip). + if (window.ResizeObserver) { + var observer = new ResizeObserver(update); + panels.forEach(function (panel) { + observer.observe(panel); + }); + } + window.addEventListener("resize", update); + update(); +})(); diff --git a/cs_app/main/static/main/js/pages/calculations.js b/cs_app/main/static/main/js/pages/calculations.js new file mode 100644 index 0000000..782bd05 --- /dev/null +++ b/cs_app/main/static/main/js/pages/calculations.js @@ -0,0 +1,68 @@ +/* + * SWMM vs ANN comparison page (/calculations). + * + * The run form is enhanced with CS.asyncForm: the server returns the results + * fragment (partials/_calculations_results.html), which replaces the contents of + * #calculations-output. initResults(root) wires up whatever that fragment holds and + * runs both on page load (results rendered by a no-JS POST) and after every swap. + * All numbers are computed server-side; the charts read the rows embedded as + * #calculations-chart-data. + */ +(function (CS) { + "use strict"; + + function readRows(root) { + var payload = root.querySelector("#calculations-chart-data"); + return payload ? JSON.parse(payload.textContent) : null; + } + + function drawCharts(root, rows) { + // The volume unit follows the model's flow units (m³ for SI, ft³ for US models). + var unit = " [" + root.querySelector("[data-calc-unit]").dataset.calcUnit + "]"; + CS.charts.parity(root.querySelector("#calculations-parity-chart"), rows, { + xLabel: "SWMM runoff" + unit, + yLabel: "ANN runoff" + unit, + ariaLabel: "Parity chart of ANN against SWMM runoff per subcatchment, with a 1:1 reference line", + fileName: "swmm-vs-ann-parity", + }); + CS.charts.bars(root.querySelector("#calculations-bars-chart"), rows, { + xLabel: "Subcatchment", + yLabel: "Runoff" + unit, + ariaLabel: "Grouped bar chart of SWMM and ANN runoff per subcatchment", + fileName: "swmm-vs-ann-runoff", + }); + } + + function initResults(root) { + var rows = readRows(root); + if (!rows) { + return; + } + // Unhide before drawing: Plotly sizes each chart from its container. + root.querySelectorAll("[data-calc-requires-js]").forEach(function (el) { + el.hidden = false; + }); + drawCharts(root, rows); + CS.table.sortable(root.querySelector("#calculations-table")); + } + + // Delegated, so the button in every swapped-in fragment works. + document.addEventListener("click", function (event) { + if (!event.target.closest("#copy-calculations-table-button")) { + return; + } + var table = document.getElementById("calculations-table"); + CS.clipboard.copy(CS.table.toCSV(table), "Comparison table copied as CSV."); + }); + + var output = document.getElementById("calculations-output"); + initResults(output); + CS.asyncForm({ + form: document.getElementById("calculations-form"), + submitButton: document.getElementById("run-calculations-button"), + target: output, + loadingEl: document.getElementById("calculations-loading-state"), + successMessage: "Comparison finished.", + onSuccess: initResults, + }); +})(window.CS); diff --git a/cs_app/main/static/main/js/pages/main_view.js b/cs_app/main/static/main/js/pages/main_view.js index b9a0b99..6afc40c 100644 --- a/cs_app/main/static/main/js/pages/main_view.js +++ b/cs_app/main/static/main/js/pages/main_view.js @@ -1,38 +1,70 @@ -"use strict"; +/* + * Home page: the example hyetograph/hydrograph, the example sweep charts and + * the Copy buttons of the code snippets. Chart data and axis labels come from + * the server (#chart-data); the table of contents is Bootstrap scrollspy + * (data attributes in the template). + */ +(function (CS) { + "use strict"; -document.addEventListener("DOMContentLoaded", function () { - var chartDataElement = document.getElementById("chart-data"); - if (!chartDataElement) { - return; - } + var SWEEP_CHART_HEIGHT = 320; + var HERO_CHART_HEIGHT = 360; - try { - var data = JSON.parse(chartDataElement.textContent); + function readChartData() { + var element = document.getElementById("chart-data"); + return element ? JSON.parse(element.textContent) : null; + } - renderLineChart("plot-slope", data.slope, "slope", "runoff", { - title: "Dependence of runoff on subcatchment slope.", - xLabel: "Percent Slope [-]", - yLabel: "Runoff [m3]", - xRange: [0, 100], - }); - renderLineChart("plot-area", data.area, "area", "runoff", { - title: "Dependence of runoff on subcatchment area.", - xLabel: "Area [ha]", - yLabel: "Runoff [m3]", + function renderHydrograph(hydrograph) { + var element = document.getElementById("plot-hydrograph"); + if (!element || !hydrograph) { + return; + } + // Runoff only: the example model has no runon, and the loss rates would + // crowd the hero chart. + CS.charts.hydrograph(element, hydrograph.records, { + flowFields: ["runoff"], + yLabels: hydrograph.yLabels, + xLabel: hydrograph.xLabel, + height: HERO_CHART_HEIGHT, + fileName: "example-storm-S1", }); - renderLineChart("plot-width", data.width, "width", "runoff", { - title: "Dependence of runoff on subcatchment width.", - xLabel: "Width [m]", - yLabel: "Runoff [m3]", - xRange: [0, 1000], - }); - } catch (error) { - console.error("Failed to render charts:", error); - ["plot-slope", "plot-area", "plot-width"].forEach(function (id) { - var element = document.getElementById(id); - if (element) { - element.innerHTML = '

Failed to load chart.

'; + } + + function renderSweeps(sweeps) { + document.querySelectorAll("[data-home-sweep]").forEach(function (element) { + var key = element.getAttribute("data-home-sweep"); + var sweep = sweeps && sweeps[key]; + if (!sweep) { + return; } + CS.charts.line(element, sweep.records, sweep.xField, "runoff", { + xLabel: sweep.xLabel, + yLabel: sweep.yLabel, + xRange: sweep.xRange, + height: SWEEP_CHART_HEIGHT, + fileName: "example-" + key + "-sweep", + }); }); } -}); + + function copySnippet(event) { + var button = event.target.closest(".cs-code__copy"); + if (!button) { + return; + } + var code = button.closest(".cs-code").querySelector("pre code"); + CS.clipboard.copy(code.textContent, "Code copied to the clipboard."); + } + + document.addEventListener("click", copySnippet); + + document.addEventListener("DOMContentLoaded", function () { + var data = readChartData(); + if (!data) { + return; + } + renderHydrograph(data.hydrograph); + renderSweeps(data.sweeps); + }); +})(window.CS); diff --git a/cs_app/main/static/main/js/pages/simulation.js b/cs_app/main/static/main/js/pages/simulation.js index fff1ce4..3956c9c 100644 --- a/cs_app/main/static/main/js/pages/simulation.js +++ b/cs_app/main/static/main/js/pages/simulation.js @@ -1,84 +1,256 @@ -"use strict"; +/* + * Simulation page (main/simulation.html). + * + * Panel: a hint in step 1 while no model is loaded; the range fields only for + * range-based methods, with a live "N runs" hint that warns before the server's + * step limit is hit. The run form is enhanced by CS.asyncForm; the results + * fragment is initialised by initResults() on load and after every swap + * (key figures, chart, sortable table). Result actions are delegated from the + * canvas, so they keep working across swaps. + */ +(function (CS) { + "use strict"; -document.addEventListener("DOMContentLoaded", function () { - var optionSelect = document.getElementById("id_option"); - var predefinedMethods = ["simulate_n_imperv", "simulate_n_perv", "simulate_s_imperv", "simulate_s_perv"]; + var RUNOFF = "runoff"; + var PEAK_RATE = "peak_runoff_rate"; - function toggleRangeFields() { - if (!optionSelect) { - return; + // ── Parameters panel ──────────────────────────────────────────────── + + function readNumber(input) { + return input.value.trim() === "" ? NaN : Number(input.value); + } + + function initRange(form) { + var option = form.elements.namedItem("option"); + var range = document.getElementById("simulation-range"); + var hint = document.getElementById("simulation-range-hint"); + var note = document.getElementById("simulation-literature-note"); + var inputs = ["start", "stop", "step"].map(function (name) { + return form.elements.namedItem(name); + }); + var predefined = form.dataset.predefinedMethods.split(" "); + var maxSteps = Number(form.dataset.maxSteps); + + function updateHint() { + var values = inputs.map(readNumber); + var result = CS.format.sweepRange(values[0], values[1], values[2], maxSteps); + hint.textContent = result.text; + hint.classList.toggle("is-warning", result.warning); + hint.hidden = false; } - var isPredefined = predefinedMethods.indexOf(optionSelect.value) !== -1; - ["id_start", "id_stop", "id_step"].forEach(function (fieldId) { - var field = document.getElementById(fieldId); - if (!field) { - return; - } - var wrapper = field.closest(".col-md-2"); - if (wrapper) { - wrapper.style.display = isPredefined ? "none" : ""; - } + + // Disabled as well as hidden: leftover values in the unused range are then neither + // sent nor checked by constraint validation (an invalid hidden input would + // silently block the submit). + function toggleRange() { + var isPredefined = predefined.indexOf(option.value) !== -1; + range.hidden = isPredefined; + range.disabled = isPredefined; + note.hidden = !isPredefined; + } + + option.addEventListener("change", toggleRange); + inputs.forEach(function (input) { + input.addEventListener("input", updateHint); }); + toggleRange(); + updateHint(); } - if (optionSelect) { - optionSelect.addEventListener("change", toggleRangeFields); - toggleRangeFields(); + function initModelHint() { + var hint = document.getElementById("simulation-model-hint"); + document.addEventListener("cs:model-changed", function () { + hint.hidden = true; + }); + document.addEventListener("cs:model-cleared", function () { + hint.hidden = false; + }); } - var runSimulationButton = document.getElementById("run-simulation-button"); - var simulationLoadingState = document.getElementById("simulation-loading-state"); - var simulationForm = runSimulationButton ? runSimulationButton.closest("form") : null; - var isSubmitting = false; + // ── Key figures ───────────────────────────────────────────────────── - function resetSimulationSubmitState() { - isSubmitting = false; - if (runSimulationButton) { - runSimulationButton.disabled = false; - runSimulationButton.removeAttribute("aria-disabled"); + /** "Total Runoff Volume [m³]" → {name: "Total Runoff Volume", unit: "m³"}; "[-]" has no unit. */ + function splitLabel(label) { + var match = /^(.*?)\s*\[([^\]]*)\]\s*$/.exec(label || ""); + if (!match) { + return { name: label || "", unit: "" }; } - if (simulationLoadingState) { - simulationLoadingState.classList.add("d-none"); + return { name: match[1], unit: match[2] === "-" ? "" : match[2] }; + } + + /** About four significant digits, so volumes and small rates both read well. */ + function formatValue(value) { + var magnitude = Math.abs(value); + return CS.format.number(value, magnitude >= 100 ? 1 : magnitude >= 1 ? 2 : 4); + } + + /** "Percent Slope = 4 %": the swept value a figure refers to, as the table prints it. */ + function describeSwept(value, xLabel) { + var label = splitLabel(xLabel); + // Non-breaking spaces keep "= 10 %" together when the tile wraps. + var text = label.name + "\u00a0=\u00a0" + Number(value.toFixed(3)); + return label.unit ? text + "\u00a0" + label.unit : text; + } + + function percentChange(from, to) { + if (!from) { + return "—"; } + var change = ((to - from) / Math.abs(from)) * 100; + return (change > 0 ? "+" : "") + CS.format.number(change, 1); } - if (simulationForm && runSimulationButton) { - simulationForm.addEventListener("submit", function (event) { - var submitter = event.submitter || document.activeElement; - if (submitter && submitter.id && submitter.id !== "run-simulation-button") { - return; - } - if (isSubmitting) { - event.preventDefault(); - return; - } - isSubmitting = true; - runSimulationButton.disabled = true; - runSimulationButton.setAttribute("aria-disabled", "true"); - if (simulationLoadingState) { - simulationLoadingState.classList.remove("d-none"); + function finiteRows(rows, field) { + return rows.filter(function (row) { + return Number.isFinite(row[field]); + }); + } + + /** Tiles for the outputs present in the data, rows ordered by the swept value. */ + function keyFigures(config) { + var x = config.x; + var yLabels = config.yLabels || {}; + var rows = finiteRows(config.data, x).sort(function (a, b) { + return a[x] - b[x]; + }); + var tiles = []; + + var runoffRows = finiteRows(rows, RUNOFF); + if (runoffRows.length > 1) { + var lowest = runoffRows[0]; + var highest = runoffRows[runoffRows.length - 1]; + var runoffUnit = splitLabel(yLabels[RUNOFF]).unit; + tiles.push( + { label: "Runoff at lowest value", value: formatValue(lowest[RUNOFF]), unit: runoffUnit, + note: "At " + describeSwept(lowest[x], config.xLabel) }, + { label: "Runoff at highest value", value: formatValue(highest[RUNOFF]), unit: runoffUnit, + note: "At " + describeSwept(highest[x], config.xLabel) }, + { label: "Runoff change", value: percentChange(lowest[RUNOFF], highest[RUNOFF]), unit: "%", + note: "From lowest to highest value" } + ); + } + + var peakRows = finiteRows(rows, PEAK_RATE); + if (peakRows.length) { + var peak = peakRows.reduce(function (best, row) { + return row[PEAK_RATE] > best[PEAK_RATE] ? row : best; + }); + tiles.push({ label: "Highest peak runoff rate", value: formatValue(peak[PEAK_RATE]), + unit: splitLabel(yLabels[PEAK_RATE]).unit, note: "At " + describeSwept(peak[x], config.xLabel) }); + } + return tiles; + } + + function element(tag, className, text) { + var el = document.createElement(tag); + el.className = className; + if (text) { + el.textContent = text; + } + return el; + } + + function renderKeyFigures(list, tiles) { + list.replaceChildren.apply(list, tiles.map(function (tile) { + var value = element("dd", "cs-metric__value", tile.value); + if (tile.unit && tile.value !== "—") { + value.append(element("span", "cs-metric__unit", tile.unit)); } + var group = element("div", "cs-metric"); + group.append(element("dt", "cs-metric__label", tile.label), value, + element("dd", "cs-metric__note", tile.note)); + return group; + })); + list.hidden = tiles.length === 0; + } + + // ── Results ───────────────────────────────────────────────────────── + + function readConfig(root) { + var script = root.querySelector("#chart-config"); + if (!script) { + return null; + } + try { + var config = JSON.parse(script.textContent); + return config && Array.isArray(config.data) ? config : null; + } catch (error) { + console.error("Invalid simulation chart config:", error); + return null; + } + } + + function chartLabel(config) { + var outputs = config.y.map(function (field) { + return splitLabel((config.yLabels || {})[field] || field).name; }); - window.addEventListener("pageshow", resetSimulationSubmitState); + return "Line charts of " + outputs.join(", ") + " against " + config.xLabel + + ". The results table lists the same values."; } - var configElement = document.getElementById("chart-config"); - if (!configElement) { - return; + function reveal(button, isAvailable) { + if (button) { + button.hidden = !isAvailable; + } } - try { - var config = JSON.parse(configElement.textContent); - renderLineChart("simulation-chart", config.data, config.x, config.y, { - title: config.title, + function initResults(root) { + var table = root.querySelector("#simulation-table"); + if (table) { + CS.table.sortable(table); + reveal(root.querySelector("[data-sim-action='copy-table']"), true); + } + var config = readConfig(root); + if (!config) { + return; + } + renderKeyFigures(root.querySelector("#simulation-metrics"), keyFigures(config)); + var pngButton = root.querySelector("[data-sim-action='download-png']"); + var drawn = CS.charts.line(root.querySelector("#simulation-chart"), config.data, config.x, config.y, { xLabel: config.xLabel, yLabels: config.yLabels, + ariaLabel: chartLabel(config), + fileName: pngButton.dataset.filename, + }); + reveal(pngButton, drawn !== null); + } + + var ACTIONS = { + "copy-table": function (canvas) { + CS.clipboard.copy(CS.table.toCSV(canvas.querySelector("#simulation-table")), "Table copied as CSV."); + }, + "download-png": function (canvas, button) { + CS.charts.downloadPng(canvas.querySelector("#simulation-chart"), button.dataset.filename); + }, + }; + + function initActions(canvas) { + canvas.addEventListener("click", function (event) { + var button = event.target.closest("[data-sim-action]"); + if (button && ACTIONS[button.dataset.simAction]) { + ACTIONS[button.dataset.simAction](canvas, button); + } }); - } catch (error) { - console.error("Failed to render chart:", error); - var chartElement = document.getElementById("simulation-chart"); - if (chartElement) { - chartElement.innerHTML = '

Failed to load chart.

'; - } } -}); + + document.addEventListener("DOMContentLoaded", function () { + var form = document.getElementById("simulation-form"); + var canvas = document.getElementById("simulation-canvas"); + if (!form || !canvas) { + return; + } + initModelHint(); + initRange(form); + initActions(canvas); + initResults(canvas); + + CS.asyncForm({ + form: form, + submitButton: document.getElementById("run-simulation-button"), + target: canvas, + loadingEl: document.getElementById("simulation-loading-state"), + successMessage: "Simulation finished", + onSuccess: initResults, + }); + }); +})(window.CS); diff --git a/cs_app/main/static/main/js/pages/timeseries.js b/cs_app/main/static/main/js/pages/timeseries.js index c59cb61..27829f4 100644 --- a/cs_app/main/static/main/js/pages/timeseries.js +++ b/cs_app/main/static/main/js/pages/timeseries.js @@ -1,109 +1,230 @@ -"use strict"; - -document.addEventListener("DOMContentLoaded", function () { - var modeSelect = document.getElementById("id_mode"); - var sweepWrappers = ["feature-wrapper", "start-wrapper", "stop-wrapper", "step-wrapper"]; - var runTimeseriesButton = document.getElementById("run-timeseries-button"); - var timeseriesLoadingState = document.getElementById("timeseries-loading-state"); - var timeseriesForm = runTimeseriesButton ? runTimeseriesButton.closest("form") : null; - var isTimeseriesSubmitting = false; - - function toggleSweepFields() { - var isSweep = modeSelect && modeSelect.value === "sweep"; - sweepWrappers.forEach(function (wrapperId) { - var wrapper = document.getElementById(wrapperId); - if (wrapper) { - wrapper.style.display = isSweep ? "" : "none"; +/* + * Timeseries page (/timeseries). + * + * Run form: CSS shows the sweep range only in sweep mode; here its fieldset is + * also disabled in single mode, so leftover values there are neither sent nor + * checked by the browser's constraint validation (an invalid hidden input would + * silently block the submit). A live "N runs" hint follows the range. Runs go + * through CS.asyncForm, which swaps the server-rendered results fragment into + * #timeseries-canvas. + * + * Results: the server renders the metrics and the sweep summary table; + * initResults(root) adds what needs the data — the hydrograph (single) or sweep + * chart, the timestep table and table sorting — on page load and after every + * swap. Controls inside the fragment are handled by delegation on the canvas. + */ +(function (CS) { + "use strict"; + + var DESKTOP_QUERY = "(min-width: 992px)"; + var PHONE_QUERY = "(max-width: 575.98px)"; + var EXPORT_UNAVAILABLE = "Chart export is unavailable right now. Reload the page and try again."; + var CHART_FAILED = "The chart could not be drawn. Download the results to inspect the data."; + + var currentConfig = null; + + // ── Run form ────────────────────────────────────────────────────── + + function initRunForm(form) { + var sweepFields = form.querySelector("#ts-sweep-fields"); + var hint = form.querySelector("#ts-runs-hint"); + var maxSteps = Number(form.dataset.maxSteps); + + function numberValue(name) { + var value = form.elements.namedItem(name).value; + return value.trim() === "" ? NaN : Number(value); + } + + function updateHint() { + var result = CS.format.sweepRange( + numberValue("start"), numberValue("stop"), numberValue("step"), maxSteps + ); + hint.textContent = result.text; + hint.classList.toggle("is-warning", result.warning); + } + + function syncMode() { + sweepFields.disabled = form.elements.namedItem("mode").value !== "sweep"; + } + + form.addEventListener("change", function (event) { + if (event.target.name === "mode") { + syncMode(); } }); + sweepFields.addEventListener("input", updateHint); + syncMode(); + updateHint(); } - if (modeSelect) { - modeSelect.addEventListener("change", toggleSweepFields); - toggleSweepFields(); + // ── Timestep table ──────────────────────────────────────────────── + + function headerCell(label, scope, className) { + var th = document.createElement("th"); + th.scope = scope; + th.textContent = label; + th.className = className; + return th; } - function resetTimeseriesSubmitState() { - isTimeseriesSubmitting = false; - if (runTimeseriesButton) { - runTimeseriesButton.disabled = false; - runTimeseriesButton.removeAttribute("aria-disabled"); - } - if (timeseriesLoadingState) { - timeseriesLoadingState.classList.add("d-none"); - } + /** null (NaN on the server) stays missing instead of becoming 0 through Number(null). */ + function toNumber(value) { + return value === null || value === undefined ? NaN : Number(value); } - if (timeseriesForm && runTimeseriesButton) { - timeseriesForm.addEventListener("submit", function (event) { - var submitter = event.submitter || document.activeElement; - if (submitter && submitter.id && submitter.id !== "run-timeseries-button") { - return; - } - if (isTimeseriesSubmitting) { - event.preventDefault(); - return; - } - isTimeseriesSubmitting = true; - runTimeseriesButton.disabled = true; - runTimeseriesButton.setAttribute("aria-disabled", "true"); - if (timeseriesLoadingState) { - timeseriesLoadingState.classList.remove("d-none"); + function fillTimestepTable(table, config) { + var yLabels = config.yLabels || {}; + var caption = table.createCaption(); + caption.className = "visually-hidden"; + caption.textContent = "Values at each reported timestep"; + + var headRow = table.createTHead().insertRow(); + headRow.appendChild(headerCell(config.xLabel || "Time", "col", "")); + config.columns.forEach(function (column) { + headRow.appendChild(headerCell(yLabels[column] || column, "col", "cs-num")); + }); + + var body = table.createTBody(); + config.data.forEach(function (record) { + var row = body.insertRow(); + row.appendChild(headerCell(String(record.datetime), "row", "cs-mono")); + config.columns.forEach(function (column) { + var value = toNumber(record[column]); + var cell = row.insertCell(); + cell.className = "cs-num"; + cell.textContent = CS.format.number(value, 4); + cell.dataset.sortValue = Number.isFinite(value) ? String(value) : ""; + }); + }); + CS.table.sortable(table); + } + + function initTimestepTable(root, config) { + var details = root.querySelector("[data-ts-data]"); + var table = details.querySelector("table"); + details.addEventListener("toggle", function () { + if (details.open && !table.rows.length) { + fillTimestepTable(table, config); } }); - window.addEventListener("pageshow", resetTimeseriesSubmitState); + details.hidden = false; + } + + // ── Charts ──────────────────────────────────────────────────────── + + /** Phones stack the legend above the plot, so they get extra height for it (see .ts-chart). */ + function chartHeight() { + return window.matchMedia(PHONE_QUERY).matches ? 520 : 440; + } + + function pngFileName(root) { + return root.querySelector("#download-timeseries-png-button").dataset.filename; + } + + function showChartError(chartEl, error) { + console.error("Failed to render the timeseries chart:", error); + var message = document.createElement("div"); + message.className = "cs-empty-state"; + message.textContent = CHART_FAILED; + chartEl.replaceChildren(message); } - var configElement = document.getElementById("ts-chart-config"); - if (configElement) { + /** draw() returns the Plotly promise (or null); failures, sync or async, leave a message. */ + function drawChart(chartEl, draw) { try { - var config = JSON.parse(configElement.textContent); - if (config.mode === "single") { - renderLineChart("timeseries-chart", config.data, "datetime", config.columns, { - title: config.title, - xLabel: config.xLabel, - yLabels: config.yLabels, + var drawing = draw(); + if (drawing) { + drawing.catch(function (error) { + showChartError(chartEl, error); }); - } else if (config.mode === "sweep") { - renderSweepChart( - "timeseries-chart", - config.data, - config.columns, - config.feature, - config.catchment, - { - xLabel: config.xLabel, - yLabels: config.yLabels, - } - ); } } catch (error) { - console.error("Failed to render chart:", error); - var chartElement = document.getElementById("timeseries-chart"); - if (chartElement) { - chartElement.innerHTML = '

Failed to load chart.

'; - } + showChartError(chartEl, error); } } - var pngButton = document.getElementById("download-timeseries-png-button"); - var exportFeedback = document.getElementById("timeseries-export-feedback"); - if (!pngButton) { - return; + function renderHydrograph(root, config) { + var chartEl = root.querySelector("#timeseries-chart"); + drawChart(chartEl, function () { + return CS.charts.hydrograph(chartEl, config.data, { + yLabels: config.yLabels, + xLabel: config.xLabel, + height: chartHeight(), + rangeSlider: window.matchMedia(DESKTOP_QUERY).matches, + ariaLabel: "Rainfall and runoff chart. " + (config.title || ""), + fileName: pngFileName(root), + }); + }); + } + + function renderSweep(root, config, column) { + var chartEl = root.querySelector("#timeseries-chart"); + var label = (config.yLabels || {})[column] || column; + drawChart(chartEl, function () { + return CS.charts.sweep(chartEl, config.data, [column], config.parameterLabel, config.catchment, { + title: "", + xLabel: config.xLabel, + yLabels: config.yLabels, + height: chartHeight(), + ariaLabel: label + " over time for each " + config.parameterName + " value. " + (config.title || ""), + fileName: pngFileName(root), + }); + }); } - pngButton.addEventListener("click", function () { - var rawName = pngButton.getAttribute("data-filename") || "timeseries_chart"; - var pngName = rawName.replace(/\.xlsx$/i, ".png"); - if (exportFeedback) { - exportFeedback.classList.add("d-none"); - exportFeedback.textContent = ""; + // ── Results fragment ────────────────────────────────────────────── + + function initResults(root) { + var configEl = root.querySelector("#ts-chart-config"); + currentConfig = configEl ? JSON.parse(configEl.textContent) : null; + if (!currentConfig) { + return; } + root.querySelector("#download-timeseries-png-button").hidden = false; + if (currentConfig.mode === "sweep") { + renderSweep(root, currentConfig, root.querySelector("[data-ts-series]").value); + CS.table.sortable(root.querySelector('[data-ts-table="sweep"]')); + } else { + renderHydrograph(root, currentConfig); + initTimestepTable(root, currentConfig); + } + } + + function exportPng(canvas) { + var exported = CS.charts.downloadPng(canvas.querySelector("#timeseries-chart"), pngFileName(canvas)); + canvas.querySelector("#timeseries-export-feedback").textContent = exported ? "" : EXPORT_UNAVAILABLE; + } - var exported = downloadChartAsPng("timeseries-chart", pngName); - if (!exported && exportFeedback) { - exportFeedback.textContent = "Chart export is unavailable right now. Reload the page and try again."; - exportFeedback.classList.remove("d-none"); + /** Controls inside the fragment are replaced on every run, so listen on the canvas. */ + function bindCanvas(canvas) { + canvas.addEventListener("click", function (event) { + if (event.target.closest("#download-timeseries-png-button")) { + exportPng(canvas); + } + }); + canvas.addEventListener("change", function (event) { + if (event.target.matches("[data-ts-series]") && currentConfig) { + renderSweep(canvas, currentConfig, event.target.value); + } + }); + } + + document.addEventListener("DOMContentLoaded", function () { + var form = document.getElementById("timeseries-form"); + var canvas = document.getElementById("timeseries-canvas"); + if (!form || !canvas) { + return; } + initRunForm(form); + bindCanvas(canvas); + initResults(canvas); + CS.asyncForm({ + form: form, + submitButton: document.getElementById("run-timeseries-button"), + target: canvas, + loadingEl: document.getElementById("timeseries-loading-state"), + successMessage: "Timeseries analysis finished", + onSuccess: initResults, + }); }); -}); +})(window.CS); diff --git a/cs_app/main/static/main/js/upload_zone.js b/cs_app/main/static/main/js/upload_zone.js index e53180b..e8e69db 100644 --- a/cs_app/main/static/main/js/upload_zone.js +++ b/cs_app/main/static/main/js/upload_zone.js @@ -1,11 +1,24 @@ "use strict"; +/* + * Upload zone (main/_upload_zone.html). + * + * Events dispatched on `document`: + * "cs:model-changed" detail: {filename, size, subcatchments: [name, ...], restored} + * fired once the subcatchment list of a newly loaded model + * (upload, sample data or restored session) is known; + * `restored` is true when the session's model is unchanged: shown + * on page load, or the same content loaded again (the server + * answers `unchanged`), so results computed from it stay valid. + * "cs:model-cleared" fired after the user removes the loaded model. + */ document.addEventListener("DOMContentLoaded", function () { if (typeof Dropzone === "undefined") { return; } var dropzoneForm = document.getElementById("my-dropzone"); + // Also stops a second copy of this script when the partial is included twice. if (!dropzoneForm || dropzoneForm.dropzone) { return; } @@ -21,12 +34,21 @@ document.addEventListener("DOMContentLoaded", function () { var clearUrl = dropzoneForm.dataset.uploadClearUrl; var statusUrl = dropzoneForm.dataset.uploadStatusUrl; var subcatchmentsUrl = dropzoneForm.dataset.subcatchmentsUrl; - var loginUrl = document.body ? document.body.dataset.loginUrl || "" : ""; if (!uploadUrl) { return; } + var wrapper = dropzoneForm.closest(".upload-zone-wrapper"); + var statusElement = document.getElementById("upload-status"); + var statusText = document.getElementById("upload-status-text"); + var statusMeta = document.getElementById("upload-status-meta"); + var removeButton = wrapper ? wrapper.querySelector(".upload-chip-remove") : null; + var trigger = dropzoneForm.querySelector(".upload-trigger"); + + var currentModel = null; + // Incremented per subcatchment request so a stale response cannot overwrite a newer model. + var subcatchmentsRequestId = 0; var preferredCatchmentValue = ""; var catchmentSelect = document.getElementById("id_catchment_name"); @@ -37,58 +59,141 @@ document.addEventListener("DOMContentLoaded", function () { }); } - function fetchSubcatchments() { - var select = document.getElementById("id_catchment_name"); - if (!select || !subcatchmentsUrl) { + function notify(message, level) { + if (window.CS && typeof window.CS.toast === "function") { + window.CS.toast(message, level); + return true; + } + window.alert(message); + return false; + } + + function handleUnauthorized() { + var toastShown = notify("You must be logged in to upload files.", "warning"); + // Leave the toast on screen briefly; an alert has already blocked until dismissed. + window.setTimeout(window.CS.redirectToLogin, toastShown ? 1500 : 0); + } + + function formatFileSize(bytes) { + if (!bytes) { + return ""; + } + if (bytes < 1024) { + return bytes + " B"; + } + if (bytes < 1024 * 1024) { + return (bytes / 1024).toFixed(1) + " KB"; + } + return (bytes / (1024 * 1024)).toFixed(1) + " MB"; + } + + function describeModel(model, subcatchments) { + var parts = [formatFileSize(model.size)]; + if (subcatchments) { + var count = subcatchments.length; + parts.push(count + (count === 1 ? " subcatchment" : " subcatchments")); + } + return parts.filter(Boolean).join(" · "); + } + + function setChipVisible(visible) { + if (wrapper) { + wrapper.classList.toggle("has-model", visible); + } + if (statusElement) { + statusElement.style.display = visible ? "" : "none"; + } + } + + /** names: the model's subcatchments, or null when they could not be read. */ + function populateCatchmentSelect(select, names, previousValue) { + select.innerHTML = ""; + if (!names || names.length === 0) { + var emptyOption = document.createElement("option"); + emptyOption.value = ""; + emptyOption.textContent = names + ? "--- No subcatchments found in this model ---" + : "--- Could not read subcatchments ---"; + select.appendChild(emptyOption); + preferredCatchmentValue = ""; return; } - if (select.value) { - preferredCatchmentValue = select.value; + var placeholder = document.createElement("option"); + placeholder.value = ""; + placeholder.textContent = "--- Select subcatchment ---"; + select.appendChild(placeholder); + + var hasSelectedValue = false; + names.forEach(function (name) { + var option = document.createElement("option"); + option.value = name; + option.textContent = name; + if (name === previousValue) { + option.selected = true; + hasSelectedValue = true; + } + select.appendChild(option); + }); + if (!hasSelectedValue) { + preferredCatchmentValue = ""; } - var previousValue = preferredCatchmentValue; - select.disabled = true; - select.innerHTML = ''; - fetch(subcatchmentsUrl, { - headers: { "X-Requested-With": "XMLHttpRequest" }, - }) - .then(function (response) { - return response.ok ? response.json() : null; + } + + function announceModel(subcatchments) { + if (!currentModel) { + return; + } + if (statusMeta) { + statusMeta.textContent = describeModel(currentModel, subcatchments); + } + document.dispatchEvent(new CustomEvent("cs:model-changed", { + detail: { + filename: currentModel.filename, + size: currentModel.size, + subcatchments: (subcatchments || []).slice(), + restored: currentModel.restored, + }, + })); + } + + function fetchSubcatchments() { + var requestId = ++subcatchmentsRequestId; + var select = document.getElementById("id_catchment_name"); + var previousValue = ""; + if (select) { + if (select.value) { + preferredCatchmentValue = select.value; + } + previousValue = preferredCatchmentValue; + select.disabled = true; + select.innerHTML = ''; + } + + var request = subcatchmentsUrl + ? fetch(subcatchmentsUrl, { headers: { "X-Requested-With": "XMLHttpRequest" } }) + .then(function (response) { + return response.ok ? response.json() : null; + }) + : Promise.resolve(null); + + request + .catch(function (error) { + console.warn("subcatchments fetch failed:", error); + return null; }) .then(function (data) { - select.innerHTML = ""; - if (!data || !data.subcatchments || data.subcatchments.length === 0) { - var emptyOption = document.createElement("option"); - emptyOption.value = ""; - emptyOption.textContent = "--- Upload a file first ---"; - select.appendChild(emptyOption); - preferredCatchmentValue = ""; + if (requestId !== subcatchmentsRequestId) { return; } - var placeholder = document.createElement("option"); - placeholder.value = ""; - placeholder.textContent = "--- Select catchment ---"; - select.appendChild(placeholder); - - var hasSelectedValue = false; - data.subcatchments.forEach(function (name) { - var option = document.createElement("option"); - option.value = name; - option.textContent = name; - if (name === previousValue) { - option.selected = true; - hasSelectedValue = true; - } - select.appendChild(option); - }); - if (!hasSelectedValue) { - preferredCatchmentValue = ""; + // null means "unknown": the chip then shows the file size only. + var names = data && Array.isArray(data.subcatchments) ? data.subcatchments : null; + // Re-query in case the page swapped its form while the request was in flight. + select = document.getElementById("id_catchment_name"); + if (select) { + populateCatchmentSelect(select, names, previousValue); + select.disabled = false; } - }) - .catch(function (error) { - console.warn("subcatchments fetch failed:", error); - }) - .finally(function () { - select.disabled = false; + announceModel(names); }); } @@ -98,16 +203,20 @@ document.addEventListener("DOMContentLoaded", function () { return; } preferredCatchmentValue = ""; + select.disabled = false; select.innerHTML = ''; } - function showUploadStatus(filename) { - var statusElement = document.getElementById("upload-status"); - var statusText = document.getElementById("upload-status-text"); - if (statusElement && statusText) { - statusText.textContent = "Loaded: " + filename; - statusElement.style.display = ""; + function setModel(filename, size, restored) { + currentModel = { filename: filename, size: size || 0, restored: restored }; + if (statusText) { + statusText.textContent = filename; + } + if (statusMeta) { + statusMeta.textContent = describeModel(currentModel, null); } + setChipVisible(true); + fetchSubcatchments(); } function parseJsonSafely(response) { @@ -123,70 +232,49 @@ document.addEventListener("DOMContentLoaded", function () { }); } - var replacingUpload = false; + var previewTemplate = + '
' + + '
' + + '' + + '' + + "
" + + '' + + '' + + '' + + "
"; + var dzInstance = new Dropzone(dropzoneForm, { url: uploadUrl, acceptedFiles: ".inp", maxFilesize: 10, maxFiles: 1, - addRemoveLinks: true, - dictRemoveFile: "\u00d7", + previewTemplate: previewTemplate, + // Same units as the model chip (formatFileSize). + filesizeBase: 1024, + dictFileSizeUnits: { tb: "TB", gb: "GB", mb: "MB", kb: "KB", b: "B" }, headers: { "X-Requested-With": "XMLHttpRequest", "X-CSRFToken": csrfToken, }, init: function () { - this.on("removedfile", function () { - var messageElement = document.querySelector("#my-dropzone .dz-message"); - if (messageElement) { - messageElement.style.display = ""; - } - var statusElement = document.getElementById("upload-status"); - if (statusElement) { - statusElement.style.display = "none"; - } - if (!replacingUpload && clearUrl) { - fetch(clearUrl, { - method: "POST", - headers: { - "X-CSRFToken": csrfToken, - "X-Requested-With": "XMLHttpRequest", - }, - }).catch(function (error) { - console.warn("upload clear failed:", error); - }); - resetCatchmentDropdown(); - } + this.on("success", function (file, response) { + setModel(file.name, file.size, Boolean(file.restored || (response && response.unchanged))); }); - this.on("addedfile", function () { - var messageElement = document.querySelector("#my-dropzone .dz-message"); - if (messageElement) { - messageElement.style.display = "none"; - } - }); - this.on("success", function (file) { - var statusElement = document.getElementById("upload-status"); - var statusText = document.getElementById("upload-status-text"); - if (statusElement && statusText) { - statusText.textContent = "Loaded: " + file.name; - statusElement.style.display = ""; + this.on("removedfile", function () { + // Dismissing a preview removes the focused button; keep focus in the upload zone. + if (trigger && document.activeElement === document.body) { + trigger.focus(); } - fetchSubcatchments(); }); this.on("maxfilesexceeded", function (file) { - replacingUpload = true; this.removeAllFiles(); - replacingUpload = false; this.addFile(file); }); }, error: function (file, response, xhr) { if (xhr && xhr.status === 401) { - var redirectUrl = response && response.login_url ? response.login_url : loginUrl; - alert("You must be logged in to upload files."); - if (redirectUrl) { - window.location.href = redirectUrl + "?next=" + encodeURIComponent(window.location.pathname); - } + handleUnauthorized(); return; } @@ -209,16 +297,16 @@ document.addEventListener("DOMContentLoaded", function () { }, }); - function showMockFile(filename, size) { - replacingUpload = true; + /** Show a model the server already holds (sample data, or `restored` from the session). */ + function showMockFile(filename, size, restored) { dzInstance.removeAllFiles(); - replacingUpload = false; var mockFile = { name: filename, size: size || 0, status: Dropzone.SUCCESS, accepted: true, + restored: restored, }; dzInstance.files.push(mockFile); @@ -227,10 +315,40 @@ document.addEventListener("DOMContentLoaded", function () { dzInstance.emit("complete", mockFile); } + function clearModel() { + subcatchmentsRequestId++; + currentModel = null; + dzInstance.removeAllFiles(true); + setChipVisible(false); + resetCatchmentDropdown(); + if (clearUrl) { + fetch(clearUrl, { + method: "POST", + headers: { + "X-CSRFToken": csrfToken, + "X-Requested-With": "XMLHttpRequest", + }, + }).catch(function (error) { + console.warn("upload clear failed:", error); + }); + } + document.dispatchEvent(new CustomEvent("cs:model-cleared")); + // The remove button just disappeared; keep keyboard users inside the upload zone. + if (trigger) { + trigger.focus(); + } + } + + if (removeButton) { + removeButton.addEventListener("click", clearModel); + } + var sampleButton = document.getElementById("load-sample-data-button"); if (sampleButton && sampleUploadUrl) { + sampleButton.hidden = false; var defaultText = sampleButton.textContent; sampleButton.addEventListener("click", function () { + var hadFocus = document.activeElement === sampleButton; sampleButton.disabled = true; sampleButton.textContent = "Loading sample data..."; @@ -243,38 +361,31 @@ document.addEventListener("DOMContentLoaded", function () { }) .then(function (response) { if (response.status === 401) { - return parseJsonSafely(response).then(function (payload) { - var redirectUrl = payload && payload.login_url ? payload.login_url : loginUrl; - if (redirectUrl) { - window.location.href = - redirectUrl + "?next=" + encodeURIComponent(window.location.pathname); - } - return null; - }); + handleUnauthorized(); + return null; } if (!response.ok) { return parseJsonSafely(response).then(function (payload) { - throw new Error( - payload && payload.error ? payload.error : "Failed to load sample data." - ); + throw new Error(payload.error || "Failed to load sample data."); }); } return parseJsonSafely(response); }) .then(function (data) { - if (!data) { - return; + if (data) { + showMockFile(data.filename || "example.inp", data.size, data.unchanged === true); } - var filename = data.filename || "example.inp"; - showMockFile(filename, data.size); - showUploadStatus(filename); }) .catch(function (error) { - alert(error.message || "Failed to load sample data."); + notify(error.message || "Failed to load sample data.", "danger"); }) .finally(function () { sampleButton.disabled = false; sampleButton.textContent = defaultText; + // Disabling the button dropped its focus; give it back to keyboard users. + if (hadFocus && document.activeElement === document.body) { + sampleButton.focus(); + } }); }); } @@ -287,10 +398,9 @@ document.addEventListener("DOMContentLoaded", function () { return response.ok ? response.json() : null; }) .then(function (data) { - if (data && data.has_file) { - showMockFile(data.filename, data.size); - showUploadStatus(data.filename); - fetchSubcatchments(); + // Ignore the restored state if the user loaded a model while this was in flight. + if (data && data.has_file && !currentModel) { + showMockFile(data.filename, data.size, true); } }) .catch(function (error) { diff --git a/cs_app/main/templates/main/_upload_zone.html b/cs_app/main/templates/main/_upload_zone.html index 1e29fcc..d8d9739 100644 --- a/cs_app/main/templates/main/_upload_zone.html +++ b/cs_app/main/templates/main/_upload_zone.html @@ -1,8 +1,22 @@ {% load static %} -
-
- - Upload SWMM Model +{# Self-contained: pages link upload_zone.css themselves; Dropzone is styled entirely by upload_zone.css, so its vendor stylesheet is not loaded. #} + + + +
+

SWMM model

+
+ + +
{% csrf_token %}
- - + + +
-
- - - - - -
-
-

Drag & drop your file or browse

-

SWMM input file for catchment simulation

-
- .inp — max 10 MB + +

SWMM input file, up to 10 MB

- -
diff --git a/cs_app/main/templates/main/about.html b/cs_app/main/templates/main/about.html index 16fe1a1..8d6560d 100644 --- a/cs_app/main/templates/main/about.html +++ b/cs_app/main/templates/main/about.html @@ -1,10 +1,91 @@ {% extends 'base.html' %} +{% load static %} {% block title %}About — Catchment Simulation{% endblock %} +{% block style %} + +{% endblock %} + {% block content %} -

About

-

The catchment_simulation package is a Python library for simulating subcatchments with different feature values using the Storm Water Management Model. The package was developed with the aim of providing a simple and user-friendly interface for simulating subcatchments with different feature values. The library offers various methods for simulating the subcatchment features, such as percent impervious, percent slope, width, and area. The package also provides methods for simulating the Manning's n value and the depth of depression storage on area for both impervious and pervious areas.

-

The package is useful for urban and environmental planners, hydrologists, and civil engineers who are involved in stormwater management. It allows them to simulate and analyze different scenarios of subcatchment features, which can help them make informed decisions regarding the design and operation of stormwater management systems. The package is open-source and can be used freely by anyone who needs it.

-

If you have any questions or suggestions regarding the package, please feel free to contact us.

-{% endblock %} \ No newline at end of file +
+
+

About

+

About

+

Catchment Simulation shows how a stormwater subcatchment responds when you change one of its parameters, without writing a line of code.

+
+ +
+
+

What it is

+

An open-source Python package, catchment_simulation, and this web app built on it. Both run the EPA Storm Water Management Model (SWMM) for one subcatchment of your model while varying a parameter such as slope, area, width, imperviousness, Manning's n or depression storage.

+
+ +
+

Who it is for

+

Hydrologists, civil and environmental engineers, urban planners and students working on stormwater management. Use it to check how sensitive a design is to a parameter, compare scenarios before changing a model, or show a class how a catchment reacts to rain.

+
+
+ +
+

How it works

+
    +
  1. + +
    +

    Load a model

    +

    Upload a SWMM .inp file or use the bundled sample. Your original file is never changed.

    +
    +
  2. +
  3. + +
    +

    Change one parameter

    +

    swmmio writes each value of the range into a temporary copy of the model.

    +
    +
  4. +
  5. + +
    +

    Run SWMM

    +

    pyswmm runs the SWMM engine for every copy and collects runoff, infiltration and evaporation.

    +
    +
  6. +
  7. + +
    +

    Read the answer

    +

    Charts, tables and metrics show the response; export the numbers to Excel or CSV.

    +
    +
  8. +
+
+ +
+

Open source

+

The package and this app are free to use, study and extend.

+ +
+ + +
+{% endblock %} diff --git a/cs_app/main/templates/main/calculations.html b/cs_app/main/templates/main/calculations.html index ba4aa8b..833f242 100644 --- a/cs_app/main/templates/main/calculations.html +++ b/cs_app/main/templates/main/calculations.html @@ -1,120 +1,120 @@ {% extends 'base.html' %} {% load static %} -{% block title %}Catchment Calculations{% endblock %} +{% block title %}Calculations: SWMM vs ANN runoff{% endblock %} + +{% block head_scripts %} + + +{% endblock %} {% block style %} + {% endblock %} {% block content %} - -
-
-
- -
-

Catchment Area Neural Network Model

-

The Catchment Area Neural Network Model is a machine learning model designed to analyze and predict the behavior of water flow in catchment areas. The model takes into account various features of the catchment area, such as the land surface, slope, imperviousness, and infiltration properties. By understanding the relationships between these features, the model can provide valuable insights for water resource management, flood control, and environmental conservation efforts.

-

-

Features


-

The model utilizes the following features as inputs:

-
    -
  • Area (ha): The actual area of the catchment
  • -
  • Width (m): The characteristic width of the overland flow path for surface runoff
  • -
  • Slope (%): The average percentage slope of the catchment
  • -
  • Imperv (%): The percentage of land surface (excluding any LID) that is impervious
  • -
  • N-Imperv (-): Manning's n coefficient for overland flow through the impervious portion of the partial catchment
  • -
  • N-Perv (-): Manning's n coefficient for overland flow through the pervious portion of the partial catchment
  • -
  • Dstore-Imperv (m): Depth of depression storage on the impervious portion of the partial catchment (inches or millimeters)
  • -
  • Dstore-Perv (m): Depth of depression storage on the pervious portion of the partial catchment (inches or millimeters)
  • -
  • Zero-Imperv (%): Percentage of impervious area without depression storage
  • -
  • Infiltration Data (-): Infiltration model (Horton, Modified Horton, Green-Ampt, Modified Green-Ampt, Curve Number (SCS))
  • -
-
-

Model Architecture:

- The model is a fully connected neural network with the following architecture:
-
- Opis obrazu - -
    -
  • Input layer with 8 neurons and ReLU activation function
  • -
  • Hidden layer with 8 neurons and ReLU activation function
  • -
  • Output layer with 1 neuron and ReLU activation function
  • -
  • The model is compiled using the Adam optimizer and Mean Squared Error (MSE) as the loss function. Additional metrics used for evaluation are Mean Absolute Error (MAE) and Mean Squared Error (MSE).
  • -
-
-

Applications

- This model can be used for various applications, including:
-
-
    -
  • Water resource management: By predicting the behavior of water flow in catchment areas, the model can help inform decision-making for water allocation and conservation efforts.
  • -
  • Flood control: Understanding the factors that contribute to flooding can help identify areas at risk and inform the design of effective flood mitigation measures.
  • -
  • Environmental conservation: Predicting the impact of changes in land use, climate, or other factors on catchment areas can help inform conservation strategies and preserve valuable ecosystems.
  • -
-

- +
+

Model comparison

+

SWMM vs neural network runoff

+

Run your SWMM model and see how closely the neural network reproduces its runoff for every subcatchment.

+
+ +
+
+
+
+ +

Step 1: Model

+ {% include 'main/_upload_zone.html' with compact=True %}
-
- -
-

Comparison of SWMM and ANN in runoff calculation.

+
+
+ +

Step 2: Compare

+
+
+ {% csrf_token %} +

SWMM runs the model, then the network predicts runoff from the same subcatchment properties.

+ + +
+
+ +
+

Validity range

+

Trained on a small data set, the network is reliable only within a limited precipitation range. Treat results outside it as indicative.

+
+
+
+
- {% include 'main/_upload_zone.html' %} +
+ {# CS.asyncForm replaces this wrapper's contents with the results fragment. #} +
+ {% include 'main/partials/_calculations_results.html' %} +
- -
- {% csrf_token %} -
-
- -
+
+

About the model

+

Catchment Area Neural Network Model

+

A small fully connected network that estimates subcatchment runoff from the same properties SWMM uses, so it can be checked against a full SWMM run.

+ +
+
+ Features +
+

The network reads eight properties of each subcatchment from the SWMM model:

+
+
Imperv (%)
Share of the land surface, excluding LID, that is impervious.
+
Width
Characteristic width of the overland flow path.
+
Slope (%)
Average surface slope.
+
N-Imperv
Manning's n for overland flow over the impervious area.
+
N-Perv
Manning's n for overland flow over the pervious area.
+
Dstore-Imperv
Depression storage depth on the impervious area.
+
Dstore-Perv
Depression storage depth on the pervious area.
+
Zero-Imperv (%)
Share of the impervious area without depression storage.
+
- - - -
-
Warning!
-

Sensitivity analysis indicates that the model works correctly only within a certain range of precipitation. This is due to the small training set. -

-

Results Comparison

- - - - - - - - - - - - - {% if not df_is_empty %} - {% for index, row in df.iterrows %} - - - - - - {% endfor %} - {% else %} - - - - - - {% endif %} - -
NameSWMM Runoff [m3]ANN Runoff [m3]
{{ row.Name }}{{ row.SWMM_Runoff_m3|floatformat:2 }}{{ row.ANN_Runoff_m3|floatformat:2 }}
---
- - +
+
+ Architecture +
+
+ Diagram of the neural network: 8 inputs, 8 hidden neurons, 1 output +
+
    +
  • Input layer: 8 neurons, ReLU activation.
  • +
  • Hidden layer: 8 neurons, ReLU activation.
  • +
  • Output layer: 1 neuron, ReLU activation.
  • +
  • Trained with the Adam optimiser and mean squared error (MSE) loss; evaluated with mean absolute error (MAE) and MSE.
  • +
+
+
+
+ Applications +
+
    +
  • Water resource management: predicting catchment runoff informs water allocation and conservation decisions.
  • +
  • Flood control: understanding what drives runoff helps locate areas at risk and design mitigation measures.
  • +
  • Environmental conservation: estimating the effect of land-use or climate changes supports conservation strategies.
  • +
+
+
-
- +
+{% endblock %} +{% block additional_scripts %} + {% endblock %} diff --git a/cs_app/main/templates/main/contact.html b/cs_app/main/templates/main/contact.html index fe1a91b..b58e9da 100644 --- a/cs_app/main/templates/main/contact.html +++ b/cs_app/main/templates/main/contact.html @@ -1,8 +1,24 @@ {% extends 'base.html' %} -{% load crispy_forms_tags %} +{% load crispy_forms_tags static %} -{% block title %}Kontakt{% endblock %} +{% block title %}Contact — Catchment Simulation{% endblock %} + +{% block style %} + +{% endblock %} {% block content %} -{% crispy form form.helper %} -{% endblock %} \ No newline at end of file +
+
+

Contact

+

Contact

+

Ask about the package, report a problem with a model or suggest a tool. You will get a reply by email.

+
+ +
+ {% crispy form form.helper %} +
+ +

Found a bug in the package? You can also open an issue on GitHub.

+
+{% endblock %} diff --git a/cs_app/main/templates/main/main_view.html b/cs_app/main/templates/main/main_view.html index 0dcb2a9..fb24b3b 100644 --- a/cs_app/main/templates/main/main_view.html +++ b/cs_app/main/templates/main/main_view.html @@ -1,116 +1,291 @@ {% extends 'base.html' %} {% load static %} -{% block title %}Catchment Simulation — Documentation{% endblock %} +{% block title %}Catchment Simulation — SWMM subcatchment toolkit{% endblock %} {% block head_scripts %} + + + +{% endblock %} + +{% block style %} + {% endblock %} {% block content %} -

Catchment Simulation

- -

The catchment_simulation package includes methods for simulating subcatchments with different feature values using the Storm Water Management Model. The FeaturesSimulation class contains all the methods.

-

The class constructor takes two arguments — subcatchment_id and raw_file. The subcatchment_id is the unique identifier of the subcatchment that you want to simulate. The raw_file argument is the path to the SWMM input file. The constructor raises FileNotFoundError if the file does not exist and ValueError if the subcatchment ID is not found in the model. The class supports the context manager protocol (with statement) for automatic cleanup of temporary files created during simulation.

- -

Install the package with:

-
pip install catchment_simulation
- -

Simulation Methods

-

The package has the following methods:

-
    -
  • get_section(section="subcatchments") — Returns a pandas DataFrame of the specified section from the SWMM input file loaded during construction.
  • -
  • copy_file(copy=None, suffix="copy") — Creates a copy of the SWMM input file with a suffix added to the file name. Defaults to copying the raw file.
  • -
  • calculate() — Runs a SWMM simulation and returns summary statistics for the configured subcatchment as a dict with keys: runoff, peak_runoff_rate, infiltration, evaporation.
  • -
  • calculate_timeseries() — Runs a simulation and collects per-timestep data for the subcatchment. Returns a pd.DataFrame with a DatetimeIndex and columns: rainfall, runoff, infiltration_loss, evaporation_loss, runon.
  • -
  • simulate_subcatchment(feature, start, stop, step) — Varies a subcatchment feature over a range and returns a pd.DataFrame with summary statistics for each value.
  • -
  • simulate_subcatchment_timeseries(feature, start, stop, step) — Like simulate_subcatchment but returns per-timestep timeseries data instead of summary statistics. Returns a dict[float, pd.DataFrame] mapping parameter values to DataFrames.
  • -
  • simulate_area(start=1, stop=10, step=1) — Simulates the subcatchment area in a selected range.
  • -
  • simulate_percent_impervious(start=0, stop=100, step=10) — Simulates the percent impervious of a subcatchment.
  • -
  • simulate_percent_slope(start=0, stop=100, step=10) — Simulates the subcatchment's percent slope.
  • -
  • simulate_width(start=0, stop=100, step=10) — Simulates the width of the subcatchment.
  • -
  • simulate_curb_length(start=0, stop=100, step=10) — Simulates the curb length of a subcatchment.
  • -
  • simulate_percent_zero_imperv(start=0, stop=100, step=10) — Simulates varying percent impervious area with no depression storage.
  • -
  • simulate_manning_n(param) — Simulates a subcatchment using predefined Manning's n literature values (McCuen et al., 1996). Does not accept start/stop/step.
  • -
  • simulate_n_imperv() — Simulates Manning's n for impervious area using predefined literature values.
  • -
  • simulate_n_perv() — Simulates Manning's n for pervious area using predefined literature values.
  • -
  • simulate_destore(param) — Simulates varying depth of depression storage using predefined values (ASCE, 1992). Does not accept start/stop/step.
  • -
  • simulate_s_imperv() — Simulates the impervious depth of depression storage using predefined literature values.
  • -
  • simulate_s_perv() — Simulates the pervious depth of depression storage using predefined literature values.
  • -
+{% with units=home.units summary=home.hydrograph_summary sweeps=home.chart_data.sweeps %} +
+
+

Catchment Simulation · SWMM toolkit

+

See how a subcatchment turns rain into runoff.

+

+ Change the slope, area, width or imperviousness of any subcatchment in your SWMM model, + rerun it and compare the runoff — here in the browser or from Python. +

+ +
-

Analysis Functions

-

The catchment_simulation.analysis module provides analytical helpers for hydrograph data returned by calculate_timeseries:

-
    -
  • time_to_peak(timeseries, column="runoff") — Calculates the elapsed time from simulation start to the peak value. Returns a pd.Timedelta.
  • -
  • runoff_volume(timeseries, column="runoff", start=None, end=None) — Computes total runoff volume by integrating flow rate over time using the trapezoidal rule. Supports optional start/end time window. Returns volume in flow-unit × seconds.
  • +
    +
    +

    Example storm on subcatchment S1

    + Bundled example model +
    + {% if home %} +
    + + +
    +
    +
    +
    +
    Peak rainfall
    +
    {{ summary.peak_rainfall|floatformat:1 }}{{ units.depth_rate }}
    +
    +
    +
    Peak runoff
    +
    {{ summary.peak|floatformat:3 }}{{ units.flow_rate }}
    +
    +
    +
    Time to peak
    +
    {{ summary.time_to_peak|default:"—" }}
    +
    +
    +
    Runoff volume
    +
    {{ summary.volume|floatformat:"0g" }}{{ units.volume }}
    +
    +
    +
    + {% else %} +
    +

    The example data is not available

    +

    Load the sample model in any tool to see the same storm.

    +
    + {% endif %} +
    +
+ +
+
+

Tools

+

One model, three ways to read it

+

Upload a SWMM .inp file once, or start from the sample data, and every tool works on the same subcatchments.

+
+
    +
  • + +

    Simulation

    +

    Parameter sweep

    +

    Vary one subcatchment parameter over a range and see how runoff volume, peak rate and losses respond.

    +
  • +
  • + +

    Timeseries

    +

    Hydrograph

    +

    Follow rainfall and runoff minute by minute, read the time to peak and runoff volume, and export the series.

    +
  • +
  • + +

    Calculations

    +

    SWMM vs. neural network

    +

    Compare the runoff SWMM computes for every subcatchment with the prediction of a trained neural network.

    +
+
+ +
+ + +
+
+

Documentation

+

The Python package

+

Everything this site does comes from the open-source catchment_simulation package, which drives the Storm Water Management Model (SWMM) through pyswmm and swmmio. Its FeaturesSimulation class holds all the methods.

+

The constructor takes two arguments: subcatchment_id, the identifier of the subcatchment you want to simulate, and raw_file, the path to the SWMM input file. It raises FileNotFoundError when the file does not exist and ValueError when the subcatchment is not in the model. Use it as a context manager (with statement) so the temporary files it creates are removed afterwards.

+

Install it with pip:

+
+
pip install catchment-simulation
+ +
+
-

To use the package, install it and import it into your Python code. It is recommended to use the context manager (with statement) to ensure temporary files are cleaned up automatically. You can then call any of the methods on the instance, passing in any necessary arguments. Methods return a pandas DataFrame with the results of the simulation.

+
+

Simulation methods

+

Every method runs SWMM on a temporary copy of your model, so the original file is never changed. Sweeps return one row per simulated value.

-

Examples of How To Use

+

Model and single runs

+
+
get_section(section="subcatchments")
+
Returns the given section of the input file as a pandas DataFrame.
+
copy_file(copy=None, suffix="copy")
+
Copies the input file, adding a suffix to its name. Copies the raw file by default.
+
calculate()
+
Runs one simulation and returns summary statistics for the subcatchment as a dict with the keys runoff, peak_runoff_rate, infiltration and evaporation.
+
calculate_timeseries()
+
Runs one simulation and collects every time step. Returns a DataFrame with a DatetimeIndex and the columns rainfall, runoff, infiltration_loss, evaporation_loss and runon. Rainfall and infiltration loss are in mm/h (in/h in US-unit models), evaporation loss in mm/day (in/day), runoff and runon in the model's FLOW_UNITS.
+
-

Simulate catchment slope in selected range.

-

-from catchment_simulation import FeaturesSimulation
+            

Parameter sweeps

+
+
simulate_subcatchment(feature, start, stop, step)
+
Varies any subcatchment feature over a range and returns summary statistics for each value.
+
simulate_subcatchment_timeseries(feature, start, stop, step)
+
Like simulate_subcatchment, but keeps the full timeseries of every run: returns a dict[float, pd.DataFrame] keyed by parameter value.
+
simulate_area(start=1, stop=10, step=1)
+
Sweeps the subcatchment area.
+
simulate_percent_impervious(start=0, stop=100, step=10)
+
Sweeps the percentage of impervious area.
+
simulate_percent_slope(start=0, stop=100, step=10)
+
Sweeps the percent slope.
+
simulate_width(start=0, stop=100, step=10)
+
Sweeps the characteristic width of the overland flow path.
+
simulate_curb_length(start=0, stop=100, step=10)
+
Sweeps the total curb length.
+
simulate_percent_zero_imperv(start=0, stop=100, step=10)
+
Sweeps the percentage of impervious area with no depression storage.
+
+ +

Literature values

+
+
simulate_manning_n(param)
+
Runs the subcatchment with Manning's n values from the literature (McCuen et al., 1996). Takes no range.
+
simulate_n_imperv() · simulate_n_perv()
+
Manning's n for the impervious or the pervious area.
+
simulate_destore(param)
+
Runs the subcatchment with depression storage depths from the literature (ASCE, 1992). Takes no range.
+
simulate_s_imperv() · simulate_s_perv()
+
Depression storage depth for the impervious or the pervious area.
+
+
+ +
+

Analysis functions

+

The catchment_simulation.analysis module reads the DataFrame returned by calculate_timeseries().

+
+
time_to_peak(timeseries, column="runoff")
+
Time from the first time step to the peak value, as a pd.Timedelta.
+
runoff_volume(timeseries, column="runoff", start=None, end=None)
+
Total volume from integrating the flow rate over time (trapezoidal rule), optionally between start and end. The result is in flow units × seconds, so cubic metres for a model in CMS.
+
+
+ +
+

Examples

+

Examples on the bundled model

+

Each chart below comes from running the snippet next to it on the example model that ships with this site (subcatchment S1). Load the same model in any tool with Try sample data.

+ +
+

Sweep the slope

+
+
from catchment_simulation import FeaturesSimulation
 
 with FeaturesSimulation(subcatchment_id="S1", raw_file="example.inp") as model:
-    df = model.simulate_percent_slope(start=1, stop=10, step=1)
-
- - + df = model.simulate_percent_slope(start=1, stop=100, step=1)
+ +
+ {% include 'main/partials/_home_sweep_chart.html' with key='slope' sweep=sweeps.slope title='Runoff volume against slope' %} + -

Simulate catchment area in selected range.

-

-from catchment_simulation import FeaturesSimulation
+            
+

Sweep the area

+
+
from catchment_simulation import FeaturesSimulation
 
 with FeaturesSimulation(subcatchment_id="S1", raw_file="example.inp") as model:
-    df = model.simulate_area(start=1, stop=100, step=1)
-
- + df = model.simulate_area(start=1, stop=100, step=1)
+ +
+ {% include 'main/partials/_home_sweep_chart.html' with key='area' sweep=sweeps.area title='Runoff volume against area' %} + -

Simulate catchment width in selected range.

-

-from catchment_simulation import FeaturesSimulation
+            
+

Sweep the width

+
+
from catchment_simulation import FeaturesSimulation
 
 with FeaturesSimulation(subcatchment_id="S1", raw_file="example.inp") as model:
-    df = model.simulate_width(start=1, stop=1000, step=1)
-
- + df = model.simulate_width(start=10, stop=1000, step=10)
+ +
+ {% include 'main/partials/_home_sweep_chart.html' with key='width' sweep=sweeps.width title='Runoff volume against width' %} + -

Simulate Manning's n using predefined literature values.

-

-from catchment_simulation import FeaturesSimulation
+            
+

Use literature values for Manning's n

+
+
from catchment_simulation import FeaturesSimulation
 
 with FeaturesSimulation(subcatchment_id="S1", raw_file="example.inp") as model:
-    df = model.simulate_n_imperv()
-
+ df = model.simulate_n_imperv()
+ + + -

Collect timeseries data and compute analytical metrics.

-

-from catchment_simulation import FeaturesSimulation, time_to_peak, runoff_volume
+            
+

Collect a timeseries and its metrics

+

This is how the storm at the top of the page was computed.

+
+
from catchment_simulation import FeaturesSimulation, runoff_volume, time_to_peak
 
 with FeaturesSimulation(subcatchment_id="S1", raw_file="example.inp") as model:
     ts = model.calculate_timeseries()
-    ttp = time_to_peak(ts, column="runoff")
-    vol = runoff_volume(ts, column="runoff")
-
-

Sweep a parameter and collect timeseries for each value.

-

-from catchment_simulation import FeaturesSimulation
+ttp = time_to_peak(ts, column="runoff")
+vol = runoff_volume(ts, column="runoff")
+ +
+
+ +
+

Sweep a parameter and keep every timeseries

+
+
from catchment_simulation import FeaturesSimulation
 
 with FeaturesSimulation(subcatchment_id="S1", raw_file="example.inp") as model:
     results = model.simulate_subcatchment_timeseries(
         feature="PercImperv", start=0, stop=100, step=10
     )
-    # results is dict[float, pd.DataFrame]
-
+# results: dict[float, pd.DataFrame], one timeseries per value
+ + + + + + +{% endwith %} -{{ chart_data|json_script:"chart-data" }} +{{ home.chart_data|json_script:"chart-data" }} {% endblock %} {% block additional_scripts %} diff --git a/cs_app/main/templates/main/partials/_calculations_results.html b/cs_app/main/templates/main/partials/_calculations_results.html new file mode 100644 index 0000000..df0b866 --- /dev/null +++ b/cs_app/main/templates/main/partials/_calculations_results.html @@ -0,0 +1,99 @@ +{% load l10n %} +{# Numbers come from views._summarise_comparison; the charts read the same rows from #calculations-chart-data. #} +
+ {% if comparison %} +
+

Results comparison

+ + {% with metrics=comparison.metrics %} +
+
+
Mean absolute error
+
{{ metrics.mae|floatformat:2|default:"—" }}{{ comparison.unit }}
+
+
+
Root mean square error
+
{{ metrics.rmse|floatformat:2|default:"—" }}{{ comparison.unit }}
+
+
+
Mean absolute % error
+
{{ metrics.mape|floatformat:1|default:"—" }}{% if metrics.mape is not None %}%{% endif %}
+ {% if metrics.mape_excluded %} +
Excludes {{ metrics.mape_excluded }} subcatchment{{ metrics.mape_excluded|pluralize }} with zero SWMM runoff
+ {% endif %} +
+
+
Subcatchments
+
{{ metrics.count }}
+
+
+ {% endwith %} + + {# Shown by calculations.js once the charts can be drawn; without JS the table carries the data. #} + + +
+
+

Per-subcatchment comparison

+ +
+
+ + + + + + + + + + + + {% for row in comparison.rows %} + + + + + + + + {% endfor %} + +
NameSWMM Runoff [{{ comparison.unit }}]ANN Runoff [{{ comparison.unit }}]Difference [{{ comparison.unit }}]Difference [%]
{{ row.Name }}{{ row.SWMM_Runoff|floatformat:2|default:"—" }}{{ row.ANN_Runoff|floatformat:2|default:"—" }}{{ row.Difference|floatformat:2|default:"—" }}{{ row.Difference_pct|floatformat:1|default:"—" }}
+
+ {# Outside the scroll wrapper, so it wraps to the column width instead of the table's. #} +

Difference = ANN − SWMM: a positive value means the network predicts more runoff than SWMM.

+
+ {{ comparison.rows|json_script:"calculations-chart-data" }} +
+ {% else %} +
+ +

No comparison yet

+

Load a model and run the calculations to see error metrics, charts and a per-subcatchment table.

+
+ {% endif %} +
diff --git a/cs_app/main/templates/main/partials/_home_sweep_chart.html b/cs_app/main/templates/main/partials/_home_sweep_chart.html new file mode 100644 index 0000000..d30104f --- /dev/null +++ b/cs_app/main/templates/main/partials/_home_sweep_chart.html @@ -0,0 +1,25 @@ +{# Home page example chart. Context: key (plot- id), sweep (chart_data.sweeps entry), title. #} +{% if sweep %} +
+
+

{{ title }}

+
+
+ + +
+
+
+
+
{{ sweep.xLabel }}
+
{{ sweep.xRange.0|floatformat:"-2g" }} → {{ sweep.xRange.1|floatformat:"-2g" }}
+
+
+
{{ sweep.yLabel }}
+
{{ sweep.runoffRange.0|floatformat:"0g" }} → {{ sweep.runoffRange.1|floatformat:"0g" }}
+
+
+
+
+{% endif %} diff --git a/cs_app/main/templates/main/partials/_simulation_results.html b/cs_app/main/templates/main/partials/_simulation_results.html new file mode 100644 index 0000000..f3c9b13 --- /dev/null +++ b/cs_app/main/templates/main/partials/_simulation_results.html @@ -0,0 +1,89 @@ +{% comment %} +Results fragment of the simulation page. Rendered inside #simulation-canvas on a full +page load and returned alone to async runs (the canvas receives it as innerHTML), so +nothing may precede #simulation-results-heading: the client focuses the first heading. +{% endcomment %} +{% load l10n %} +
+ {% if show_download_button %} +
+
+

Simulation results

+

+ {{ results_data|length }} run{{ results_data|length|pluralize }} · {{ chart_config.xLabel }} +

+
+ + {# Key figures, filled by simulation.js from #chart-config. #} + + +
+
+

Response to {{ chart_config.xLabel }}

+ {# Script-only actions stay hidden until simulation.js can perform them. #} + +
+
+
+

The chart needs JavaScript. The table below lists the same values.

+
+
+
+ {{ chart_config|json_script:"chart-config" }} + + {# Not a
: the scrollable wrapper below is the labelled region. #} +
+

Results table

+
+ + + + + {% for label in chart_config.yLabels.values %} + + {% endfor %} + + + + {% for row in results_data %} + + {% for value in row %} + {% if forloop.first %} + + {% else %} + + {% endif %} + {% endfor %} + + {% endfor %} + +
{{ chart_config.xLabel }}{{ label }}
{{ value|floatformat:"-3" }}{{ value|floatformat:4 }}
+
+
+ +
+ {% if output_file_name %} + {# Submits #simulation-download-form (on the page), never the async run form. #} + + + {% endif %} + +
+
+ {% else %} +
+ +

No results yet

+

+ Load a model, pick a subcatchment and a parameter, then select Run Simulation. +

+
    +
  • Key figures: runoff at both ends of the range and how much it changes
  • +
  • A chart for every output, from total runoff to evaporation
  • +
  • A sortable table you can copy or download as Excel
  • +
+
+ {% endif %} +
diff --git a/cs_app/main/templates/main/partials/_timeseries_results.html b/cs_app/main/templates/main/partials/_timeseries_results.html new file mode 100644 index 0000000..0f73769 --- /dev/null +++ b/cs_app/main/templates/main/partials/_timeseries_results.html @@ -0,0 +1,122 @@ +{# Results root, rendered by the full page and returned alone to async runs; its export buttons submit the page's #timeseries-export-form. timeseries.js draws the chart and the timestep table from #ts-chart-config. #} +
+ {% if ts_show_results %} + {% with config=ts_chart_config metrics=ts_metrics units=ts_units %} +
+
+

{% if config.mode == "sweep" %}Parameter sweep · {{ config.parameterName }}{% else %}Single run{% endif %}

+

{{ config.title|default:"Timeseries results" }}

+
+ + {% if config.mode != "sweep" %} +
+
+

Peak runoff

+ {% if metrics.peak is not None %} +

{{ metrics.peak|floatformat:"3g" }}{{ units.flow }}

+ {% else %} +

—

+ {% endif %} + {% if metrics.peak_at %}

At {{ metrics.peak_at }}

{% endif %} +
+
+

Time to peak

+

{{ metrics.time_to_peak|default:"—" }}

+

{% if metrics.time_to_peak %}From simulation start{% else %}No runoff in this run{% endif %}

+
+
+

Runoff volume

+ {% if metrics.volume is not None %} +

{{ metrics.volume|floatformat:"2g" }}{{ units.volume }}

+ {% else %} +

—

+ {% endif %} +

Integrated over the whole run

+
+
+ {% endif %} + +
+
+

{% if config.mode == "sweep" %}Hydrographs for each {{ config.parameterName }} value{% else %}Rainfall and runoff{% endif %}

+
+ {% if config.mode == "sweep" %} +
+ + +
+ {% endif %} + {# Shown by timeseries.js: the chart, and so its export, needs JavaScript. #} + +
+
+
+
+
+

+ {% if config.mode != "sweep" %} +

Rainfall hangs from the top axis and infiltration loss shares its rate scale. Evaporation loss is reported per day, so it opens a panel of its own below. Select a legend entry to show them or runon.

+ {% endif %} +
+ + {% if config.mode == "sweep" %} +
+

Runoff summary per {{ config.parameterName }} value

+
+ + + + + + + + + + + + {% for row in ts_sweep_summary %} + + + + + + + {% endfor %} + +
Peak runoff, time to peak and runoff volume for each {{ config.parameterName }} value
{{ config.parameterLabel }}Peak runoff [{{ units.flow }}]Time to peakRunoff volume [{{ units.volume }}]
{{ row.value }}{{ row.peak|floatformat:"3g"|default:"—" }}{{ row.time_to_peak|default:"—" }}{{ row.volume|floatformat:"2g"|default:"—" }}
+
+
+ {% else %} + {# Filled by timeseries.js when first opened: long runs have thousands of timesteps. #} + + {% endif %} + + {{ config|json_script:"ts-chart-config" }} + + {% if output_file_name %} + {# The buttons belong to the page's #timeseries-export-form: fragments carry no
of their own. #} +
+ + + +
+ {% endif %} +
+ {% endwith %} + {% else %} +
+ +

No results yet

+

Load a model, choose a subcatchment and run the analysis. The rainfall and runoff chart appears here.

+
+ {% endif %} +
diff --git a/cs_app/main/templates/main/simulation.html b/cs_app/main/templates/main/simulation.html index 70ef1dd..521369d 100644 --- a/cs_app/main/templates/main/simulation.html +++ b/cs_app/main/templates/main/simulation.html @@ -1,7 +1,7 @@ {% extends 'base.html' %} -{% load static %} +{% load static crispy_forms_tags %} -{% block title %}Catchment Simulations{% endblock %} +{% block title %}Parameter sweep · Catchment Simulation{% endblock %} {% block head_scripts %} ', html + ) + assert json.loads(chart_rows.group(1))[0]["Difference_pct"] is None + + +def _upload(request): + """Call the upload view directly, skipping the CSRF check as django.test.Client does.""" + request._dont_enforce_csrf_checks = True + return upload(request) @pytest.mark.django_db @@ -665,7 +770,7 @@ def test_upload_unauthenticated_ajax_returns_401(): request.user = AnonymousUser() - response = upload(request) + response = _upload(request) assert response.status_code == 401 data = json.loads(response.content) @@ -688,7 +793,7 @@ def test_upload_unauthenticated_regular_request_redirects(): request.user = AnonymousUser() - response = upload(request) + response = _upload(request) assert response.status_code == 302 assert settings.LOGIN_URL in response.url @@ -733,7 +838,7 @@ def test_upload_returns_413_when_content_length_exceeds_body_limit(user): request.session.save() request.user = user - response = upload(request) + response = _upload(request) assert response.status_code == 413 data = json.loads(response.content) @@ -757,7 +862,7 @@ def test_upload_returns_413_when_uploaded_file_size_exceeds_limit(user): request.session.save() request.user = user - response = upload(request) + response = _upload(request) assert response.status_code == 413 data = json.loads(response.content) @@ -785,7 +890,7 @@ def test_upload_returns_413_when_content_length_is_spoofed_low(user, monkeypatch request.session.save() request.user = user - response = upload(request) + response = _upload(request) assert response.status_code == 413 data = json.loads(response.content) @@ -817,7 +922,7 @@ def test_upload_handles_invalid_or_negative_content_length(user, raw_content_len settings.MEDIA_ROOT, "uploaded_files", str(user.id), "content_length_variants.inp" ) try: - response = upload(request) + response = _upload(request) assert response.status_code == 400 finally: if os.path.exists(expected_path): @@ -846,13 +951,53 @@ def test_upload_handles_missing_content_length(user): settings.MEDIA_ROOT, "uploaded_files", str(user.id), "missing_length.inp" ) try: - response = upload(request) + response = _upload(request) assert response.status_code == 400 finally: if os.path.exists(expected_path): os.remove(expected_path) +@pytest.mark.django_db +@pytest.mark.parametrize( + ("filename", "referer", "expected_url", "expected_message"), + [ + ( + "fallback.inp", + "http://testserver/timeseries", + "http://testserver/timeseries", + "File was sent.", + ), + ("fallback.txt", "/calculations", "/calculations", "Invalid file type."), + ("fallback.inp", "https://evil.example/", "/simulation", "File was sent."), + ("fallback.inp", None, "/simulation", "File was sent."), + ], +) +def test_upload_without_js_redirects_back_with_a_message( + client, user, filename, referer, expected_url, expected_message +): + """The no-JS fallback form gets POST -> redirect -> GET, never a JSON page.""" + client.force_login(user) + inp_content = b"[TITLE]\nFallback\n\n[OPTIONS]\nFLOW_UNITS LPS\n" + headers = {"HTTP_REFERER": referer} if referer else {} + + try: + response = client.post( + reverse("main:upload"), + {"file": SimpleUploadedFile(filename, inp_content, content_type="text/plain")}, + **headers, + ) + + assert response.status_code == 302 + assert response.url == expected_url + [message] = get_messages(response.wsgi_request) + assert str(message).startswith(expected_message) + finally: + path = client.session.get("uploaded_file_path") + if path and os.path.exists(path): + os.remove(path) + + @pytest.mark.django_db def test_upload_authenticated_user_can_upload(user): """ @@ -879,7 +1024,7 @@ def test_upload_authenticated_user_can_upload(user): settings.MEDIA_ROOT, "uploaded_files", str(user.id), "test_upload.inp" ) try: - response = upload(request) + response = _upload(request) assert response.status_code == 200 data = json.loads(response.content) @@ -931,7 +1076,7 @@ def fail_bytes_validator(_file_content): settings.MEDIA_ROOT, "uploaded_files", str(user.id), "streamed_validation.inp" ) try: - response = upload(request) + response = _upload(request) assert response.status_code == 200 assert called_stream_validator["called"] is True @@ -961,7 +1106,7 @@ def test_upload_valid_small_inp_still_succeeds(user): settings.MEDIA_ROOT, "uploaded_files", str(user.id), "valid_small.inp" ) try: - response = upload(request) + response = _upload(request) assert response.status_code == 200 data = json.loads(response.content) @@ -991,7 +1136,7 @@ def test_upload_invalid_content_returns_400(user): request.session.save() request.user = user - response = upload(request) + response = _upload(request) assert response.status_code == 400 data = json.loads(response.content) @@ -1017,7 +1162,7 @@ def test_upload_binary_blob_with_single_marker_returns_400(user): request.session.save() request.user = user - response = upload(request) + response = _upload(request) assert response.status_code == 400 data = json.loads(response.content) @@ -1046,7 +1191,7 @@ def test_upload_valid_utf16_bom_file_succeeds(user): settings.MEDIA_ROOT, "uploaded_files", str(user.id), "utf16_valid.inp" ) try: - response = upload(request) + response = _upload(request) assert response.status_code == 200 assert request.session.get("uploaded_file_path") == expected_path @@ -1105,7 +1250,7 @@ def test_upload_size_equal_limit_is_allowed(user, monkeypatch): settings.MEDIA_ROOT, "uploaded_files", str(user.id), "equal_limit.inp" ) try: - response = upload(request) + response = _upload(request) assert response.status_code == 200 finally: if os.path.exists(expected_path): @@ -1137,7 +1282,7 @@ def test_upload_body_length_equal_limit_is_allowed(user, monkeypatch): settings.MEDIA_ROOT, "uploaded_files", str(user.id), "equal_body_limit.inp" ) try: - response = upload(request) + response = _upload(request) assert response.status_code == 200 finally: if os.path.exists(expected_path): @@ -1245,7 +1390,7 @@ def test_upload_clears_timeseries_form_state(user): settings.MEDIA_ROOT, "uploaded_files", str(user.id), "test_upload.inp" ) try: - response = upload(request) + response = _upload(request) assert response.status_code == 200 assert "ts_form_state" not in request.session @@ -1277,7 +1422,7 @@ def test_upload_clears_simulation_form_state(user): settings.MEDIA_ROOT, "uploaded_files", str(user.id), "test_upload.inp" ) try: - response = upload(request) + response = _upload(request) assert response.status_code == 200 assert "sim_form_state" not in request.session @@ -1311,7 +1456,7 @@ def test_upload_failure_preserves_existing_form_state_and_subcatchment_cache(use request.session.save() request.user = user - response = upload(request) + response = _upload(request) assert response.status_code == 400 assert request.session["uploaded_file_path"] == "uploaded_files/original.inp" @@ -1565,6 +1710,10 @@ def simulate_subcatchment_timeseries(self, feature, start, stop, step): assert chart_config["xLabel"] == "Time" assert chart_config["yLabels"]["runoff"] == "Runoff Rate [CMS]" assert chart_config["yLabels"]["rainfall"] == "Rainfall Intensity [mm/h]" + assert chart_config["yLabels"]["evaporation_loss"] == "Evaporation Loss [mm/day]" + assert {"field": "evaporation_loss", "label": "Evaporation Loss [mm/day]"} in chart_config[ + "series" + ] get_response = client.get(reverse("main:timeseries")) assert get_response.status_code == 200 @@ -1631,6 +1780,7 @@ def calculate_timeseries(self): assert chart_config["xLabel"] == "Time" assert chart_config["yLabels"]["runoff"] == "Runoff Rate [CMS]" assert chart_config["yLabels"]["infiltration_loss"] == "Infiltration Loss [mm/h]" + assert chart_config["yLabels"]["evaporation_loss"] == "Evaporation Loss [mm/day]" @pytest.mark.django_db @@ -1809,12 +1959,17 @@ def test_safe_download_filename_handles_empty_extension(): @pytest.mark.django_db def test_simulation_template_contains_loading_state(client, user): - """Simulation page should include loading state container for submit feedback.""" + """Simulation page includes a hidden loading state with an elapsed-time counter.""" client.force_login(user) response = client.get(reverse("main:simulation")) assert response.status_code == 200 - assert b'id="simulation-loading-state"' in response.content + content = response.content.decode() + loading = re.search(r']*id="simulation-loading-state"[^>]*>(.*?)', content, re.S) + assert loading is not None + assert " hidden" in loading.group(0).split(">", 1)[0] + assert "data-cs-elapsed" in loading.group(1) + assert "neural network" not in content.lower() @pytest.mark.django_db @@ -1858,6 +2013,27 @@ def test_timeseries_view_uses_refactored_assets_and_has_no_chart_json_without_re assert b"/static/js/charts.js" not in response.content +@pytest.mark.django_db +@pytest.mark.parametrize( + ("saved_state", "checked_mode"), [({}, "single"), ({"mode": "sweep"}, "sweep")] +) +def test_timeseries_mode_renders_as_radio_group(client, user, saved_state, checked_mode): + """Mode is a segmented radio group; exactly one option is checked (single by default).""" + client.force_login(user) + session = client.session + session["ts_form_state"] = saved_state + session.save() + + content = client.get(reverse("main:timeseries")).content.decode() + + radios = re.findall(r']*>', content) + assert [re.search(r'value="(\w+)"', radio).group(1) for radio in radios] == ["single", "sweep"] + assert [radio for radio in radios if " checked" in radio] == [ + next(radio for radio in radios if f'value="{checked_mode}"' in radio) + ] + assert all('class="btn-check"' in radio for radio in radios) + + @pytest.mark.django_db def test_calculations_view_uses_upload_zone_component_contract(client, user): """Calculations page should include the shared upload-zone contract.""" @@ -1955,9 +2131,9 @@ def test_timeseries_template_shows_csv_and_png_buttons_when_results_exist(client response = client.get(reverse("main:timeseries")) assert response.status_code == 200 - assert b"Export timeseries to CSV" in response.content + assert b"Download Results (.csv)" in response.content assert b'id="download-timeseries-png-button"' in response.content - assert b'data-filename="timeseries_single.xlsx"' in response.content + assert b'data-filename="timeseries_single"' in response.content @pytest.mark.django_db @@ -1988,6 +2164,10 @@ def test_refactored_pages_do_not_reference_legacy_chart_assets( ("url_name", "requires_login"), [ ("main:main_view", False), + ("main:about", False), + ("main:contact", False), + ("login", False), + ("register:register", False), ("main:simulation", True), ("main:timeseries", True), ("main:calculations", True), @@ -2111,6 +2291,24 @@ def test_simulation_form_predefined_method_valid(): assert form.is_valid(), f"Form errors: {form.errors}" +def test_simulation_form_predefined_method_ignores_invalid_range_values(): + """Values left in the (unused) range fields must not block a literature-value run.""" + from main.forms import SimulationForm + + form = SimulationForm( + data={ + "option": "simulate_n_imperv", + "start": "-5", + "stop": "1.5", + "step": "0", + "catchment_name": "S1", + }, + catchment_choices=[("S1", "S1")], + ) + + assert form.is_valid(), f"Form errors: {form.errors}" + + @pytest.mark.django_db def test_simulation_form_range_method_requires_params(): """ @@ -2174,6 +2372,143 @@ def test_timeseries_form_sweep_too_many_steps(): assert "step" in form.errors +INVALID_SWEEP_RANGE = {"feature": "Nope", "start": "-5", "stop": "abc", "step": "0"} + + +def test_timeseries_form_single_mode_ignores_invalid_sweep_fields(): + """The sweep range is irrelevant to a single run, so its leftover values cannot block it.""" + from main.forms import TimeseriesForm + + form = TimeseriesForm( + data={"mode": "single", "catchment_name": "S1", **INVALID_SWEEP_RANGE}, + catchment_choices=[("S1", "S1")], + ) + + assert form.is_valid(), f"Form errors: {form.errors}" + + +def test_timeseries_form_sweep_mode_reports_each_invalid_field_once(): + """In sweep mode field-level errors stand; 'Required' is not added on top of them.""" + from main.forms import TimeseriesForm + + form = TimeseriesForm( + data={"mode": "sweep", "catchment_name": "S1", **INVALID_SWEEP_RANGE}, + catchment_choices=[("S1", "S1")], + ) + + assert not form.is_valid() + assert set(form.errors) == {"feature", "start", "stop", "step"} + assert all(len(errors) == 1 for errors in form.errors.values()) + assert "Required for parameter sweep mode." not in form.errors["start"] + + +@pytest.mark.parametrize( + ("flow_units", "factor", "unit"), + [ + ("CMS", 1.0, "m³"), + ("lps", 0.001, "m³"), + ("MLD", 1000 / 86400, "m³"), + ("CFS", 1.0, "ft³"), + ("GPM", 0.133680556 / 60, "ft³"), + (None, 1.0, "model flow units × s"), + ], +) +def test_runoff_volume_unit_converts_flow_seconds_to_a_volume(flow_units, factor, unit): + from main.views import _runoff_volume_unit + + actual_factor, actual_unit = _runoff_volume_unit(flow_units) + + assert actual_factor == pytest.approx(factor) + assert actual_unit == unit + + +@pytest.mark.parametrize( + ("flow_units", "flow", "depth_rate", "evaporation_rate"), + [ + ("CMS", "CMS", "mm/h", "mm/day"), + ("lps", "LPS", "mm/h", "mm/day"), + ("MLD", "MLD", "mm/h", "mm/day"), + ("CFS", "CFS", "in/h", "in/day"), + ("gpm", "GPM", "in/h", "in/day"), + ("MGD", "MGD", "in/h", "in/day"), + (None, "model flow units", "model depth/time units", "model evaporation rate units"), + ("XYZ", "XYZ", "model depth/time units", "model evaporation rate units"), + ], +) +def test_timeseries_axis_labels_give_each_series_its_swmm_unit( + flow_units, flow, depth_rate, evaporation_rate +): + """SWMM reports rainfall and infiltration per hour, evaporation per day, flows in FLOW_UNITS.""" + from main.views import _build_timeseries_axis_labels + + x_label, y_labels = _build_timeseries_axis_labels( + ["rainfall", "runoff", "infiltration_loss", "evaporation_loss", "runon"], flow_units + ) + + assert x_label == "Time" + assert y_labels == { + "rainfall": f"Rainfall Intensity [{depth_rate}]", + "runoff": f"Runoff Rate [{flow}]", + "infiltration_loss": f"Infiltration Loss [{depth_rate}]", + "evaporation_loss": f"Evaporation Loss [{evaporation_rate}]", + "runon": f"Runon Rate [{flow}]", + } + # The hydrograph picks axes by the unit in the label: a shared unit would share an axis. + assert evaporation_rate != depth_rate + + +@pytest.mark.parametrize( + ("flow_units", "factor", "unit"), + [ + ("CMS", 1e3, "m³"), + ("lps", 1e3, "m³"), + ("CFS", 1e6 * 0.133680556, "ft³"), + ("MGD", 1e6 * 0.133680556, "ft³"), + (None, 1.0, "10⁶ L or gal"), + ], +) +def test_report_runoff_unit_converts_report_volumes(flow_units, factor, unit): + """The report's total runoff is 10^6 L (SI) or 10^6 US gal (US).""" + from main.views import _report_runoff_unit + + actual_factor, actual_unit = _report_runoff_unit(flow_units) + + assert actual_factor == pytest.approx(factor) + assert actual_unit == unit + + +@pytest.mark.parametrize( + ("seconds", "text"), + [ + (0, "0 min"), + (45, "45 s"), + (3600, "1 h"), + (32100, "8 h 55 min"), + (93784, "1 d 2 h 3 min 4 s"), + ], +) +def test_format_duration_drops_zero_parts(seconds, text): + from main.views import _format_duration + + assert _format_duration(pd.Timedelta(seconds=seconds)) == text + + +def test_hydrograph_metrics_without_runoff_has_no_peak_time(): + """A run with no runoff has a peak of 0 but no time to peak (the package raises).""" + from main.views import _hydrograph_metrics + + index = pd.date_range("2025-01-01", periods=3, freq="h", name="datetime") + metrics = _hydrograph_metrics(pd.DataFrame({"runoff": [0.0, 0.0, 0.0]}, index=index), 1.0) + + assert metrics == { + "peak": 0.0, + "peak_at": None, + "time_to_peak": None, + "time_to_peak_seconds": None, + "volume": 0.0, + } + + @pytest.mark.django_db def test_upload_status_no_file(user): """Test that upload_status returns has_file=False when no file in session.""" @@ -2246,40 +2581,119 @@ def test_upload_status_stale_reference(user): @pytest.mark.django_db -def test_clear_session_preserves_uploaded_file(user): - """Test that clear_session_variables does not remove uploaded_file_path.""" - factory = RequestFactory() - request = factory.get("/") +@pytest.mark.parametrize("url_name", ["main:upload", "main:upload_sample", "main:upload_clear"]) +def test_changing_the_model_drops_every_tools_results(client, user, url_name): + """Results of the previous model must not stay current next to a new (or no) model.""" + client.force_login(user) + session = client.session + tokens = {} + for scope, session_key in RESULT_SESSION_KEYS: + tokens[scope] = uuid.uuid4().hex + session[session_key] = tokens[scope] + cache.set(_result_cache_key(scope, user.id, tokens[scope]), json.dumps({"value": 1})) + session.save() + data = {} + if url_name == "main:upload": + inp_content = b"[TITLE]\nNew model\n\n[OPTIONS]\nFLOW_UNITS LPS\n" + data["file"] = SimpleUploadedFile("new_model.inp", inp_content, content_type="text/plain") - session_middleware = SessionMiddleware(lambda req: None) - session_middleware.process_request(request) - request.session["uploaded_file_path"] = "uploaded_files/test.inp" - request.session["show_download_button"] = True - request.session["chart_config"] = {"data": []} - request.session[SIM_RESULT_TOKEN_SESSION_KEY] = "cccccccccccccccccccccccccccccccc" - request.session[TS_RESULT_TOKEN_SESSION_KEY] = "dddddddddddddddddddddddddddddddd" - request.session["sim_form_state"] = {"option": "simulate_percent_slope", "catchment_name": "S1"} - request.session["ts_form_state"] = {"mode": "sweep", "catchment_name": "S1"} - request.session.save() - request.user = user - cache.set( - _result_cache_key("sim", user.id, "cccccccccccccccccccccccccccccccc"), - json.dumps({"value": 1}), - ) - cache.set( - _result_cache_key("ts", user.id, "dddddddddddddddddddddddddddddddd"), - json.dumps({"value": 1}), - ) + try: + response = client.post(reverse(url_name), data, HTTP_X_REQUESTED_WITH="XMLHttpRequest") + + assert response.status_code == 200 + for scope, session_key in RESULT_SESSION_KEYS: + assert session_key not in client.session + assert cache.get(_result_cache_key(scope, user.id, tokens[scope])) is None + finally: + path = client.session.get("uploaded_file_path") + if path and os.path.exists(path): + os.remove(path) - clear_session_variables(request) - assert request.session.get("uploaded_file_path") == "uploaded_files/test.inp" - assert "show_download_button" not in request.session - assert "chart_config" not in request.session - assert "sim_form_state" not in request.session - assert "ts_form_state" not in request.session - assert cache.get(_result_cache_key("sim", user.id, "cccccccccccccccccccccccccccccccc")) is None - assert cache.get(_result_cache_key("ts", user.id, "dddddddddddddddddddddddddddddddd")) is None +def _seed_results(client, user) -> dict: + session = client.session + tokens = {} + for scope, session_key in RESULT_SESSION_KEYS: + tokens[scope] = uuid.uuid4().hex + session[session_key] = tokens[scope] + cache.set(_result_cache_key(scope, user.id, tokens[scope]), json.dumps({"value": 1})) + session["sim_form_state"] = {"option": "simulate_percent_slope", "catchment_name": "S1"} + session.save() + return tokens + + +def _remove_session_model(client) -> None: + path = client.session.get("uploaded_file_path") + if path and os.path.exists(path): + os.remove(path) + + +@pytest.mark.django_db +def test_loading_the_same_sample_again_keeps_every_tools_results(client, user): + """The sample loaded again from another tool's page is the same model: keep its results.""" + client.force_login(user) + try: + first = client.post(reverse("main:upload_sample"), HTTP_X_REQUESTED_WITH="XMLHttpRequest") + assert first.json()["unchanged"] is False + tokens = _seed_results(client, user) + + again = client.post(reverse("main:upload_sample"), HTTP_X_REQUESTED_WITH="XMLHttpRequest") + + assert again.status_code == 200 + assert again.json()["unchanged"] is True + for scope, session_key in RESULT_SESSION_KEYS: + assert client.session[session_key] == tokens[scope] + assert cache.get(_result_cache_key(scope, user.id, tokens[scope])) is not None + assert client.session["sim_form_state"]["catchment_name"] == "S1" + finally: + _remove_session_model(client) + + +@pytest.mark.django_db +def test_uploading_identical_content_keeps_results_and_different_content_drops_them(client, user): + client.force_login(user) + content = b"[TITLE]\nSame model\n\n[OPTIONS]\nFLOW_UNITS CMS\n" + + def upload(name: str, body: bytes): + return client.post( + reverse("main:upload"), + {"file": SimpleUploadedFile(name, body, content_type="text/plain")}, + HTTP_X_REQUESTED_WITH="XMLHttpRequest", + ) + + try: + assert upload("model.inp", content).json()["unchanged"] is False + tokens = _seed_results(client, user) + + # Same content under another name: the results were computed from this model. + response = upload("model_copy.inp", content) + assert response.json()["unchanged"] is True + assert client.session[RESULT_SESSION_KEYS[0][1]] == tokens["sim"] + + response = upload("model_copy.inp", content + b"\n[JUNCTIONS]\n") + assert response.json()["unchanged"] is False + for scope, session_key in RESULT_SESSION_KEYS: + assert session_key not in client.session + assert cache.get(_result_cache_key(scope, user.id, tokens[scope])) is None + finally: + _remove_session_model(client) + + +@pytest.mark.django_db +def test_clearing_the_model_forgets_its_digest(client, user): + """After removing the model, loading it again is a new model (its results are gone).""" + client.force_login(user) + try: + client.post(reverse("main:upload_sample"), HTTP_X_REQUESTED_WITH="XMLHttpRequest") + client.post(reverse("main:upload_clear"), HTTP_X_REQUESTED_WITH="XMLHttpRequest") + assert MODEL_DIGEST_SESSION_KEY not in client.session + + response = client.post( + reverse("main:upload_sample"), HTTP_X_REQUESTED_WITH="XMLHttpRequest" + ) + assert response.json()["unchanged"] is False + finally: + _remove_session_model(client) # ── upload_clear tests (#4) ────────────────────────────────────────────── @@ -2483,64 +2897,6 @@ def test_upload_status_rejects_post(user): assert response.status_code == 405 -# ── Core flow test (#8) ────────────────────────────────────────────────── - - -@pytest.mark.django_db -def test_upload_persists_after_clear_session_and_new_simulation(user): - """ - Core flow: upload file -> clear_session_variables (as simulation does) - -> file path remains in session -> upload_status still returns it. - """ - factory = RequestFactory() - - # 1) Upload a file - inp_content = b"[TITLE]\nPersistence Test\n\n[OPTIONS]\nFLOW_UNITS LPS\n" - uploaded_file = SimpleUploadedFile("persist_test.inp", inp_content, content_type="text/plain") - request = factory.post( - "/upload/", - {"file": uploaded_file}, - HTTP_X_REQUESTED_WITH="XMLHttpRequest", - ) - session_middleware = SessionMiddleware(lambda req: None) - session_middleware.process_request(request) - request.session.save() - request.user = user - - saved_path = None - try: - response = upload(request) - assert response.status_code == 200 - saved_path = request.session.get("uploaded_file_path") - assert saved_path is not None - - # 2) Simulate what happens after a simulation run – clear results - request.session["show_download_button"] = True - request.session["chart_config"] = {"data": []} - clear_session_variables(request) - - # 3) Verify file path survived - assert request.session.get("uploaded_file_path") == saved_path - assert os.path.exists(saved_path) - - # 4) Verify upload_status reports the file - status_request = factory.get( - "/upload/status/", - HTTP_X_REQUESTED_WITH="XMLHttpRequest", - ) - session_middleware.process_request(status_request) - status_request.session = request.session - status_request.user = user - - status_response = upload_status(status_request) - data = json.loads(status_response.content) - assert data["has_file"] is True - assert data["filename"] == "persist_test.inp" - finally: - if saved_path and os.path.exists(saved_path): - os.remove(saved_path) - - @pytest.mark.django_db def test_upload_replaces_old_file_on_disk(user): """Uploading a new file removes the old file from disk (#2, #9).""" @@ -2559,7 +2915,7 @@ def test_upload_replaces_old_file_on_disk(user): request.user = user path_b = None try: - upload(request) + _upload(request) path_a = request.session["uploaded_file_path"] assert os.path.exists(path_a) @@ -2573,7 +2929,7 @@ def test_upload_replaces_old_file_on_disk(user): session_middleware.process_request(request2) request2.session = request.session request2.user = user - upload(request2) + _upload(request2) path_b = request2.session["uploaded_file_path"] # Old file should be gone, new file present @@ -2662,6 +3018,19 @@ def test_get_catchment_choices_no_file(): assert "Upload" in choices[0][1] +def test_get_catchment_choices_model_without_subcatchments(tmp_path): + """A loaded model with no subcatchments is not reported as a missing upload.""" + model_path = tmp_path / "empty.inp" + model_path.write_text("[TITLE]\n[OPTIONS]\nFLOW_UNITS CMS\n", encoding="utf-8") + request = RequestFactory().get("/") + SessionMiddleware(lambda req: None).process_request(request) + request.session["uploaded_file_path"] = str(model_path) + + choices = _get_catchment_choices(request) + + assert choices == [("", "--- No subcatchments found in this model ---")] + + @pytest.mark.django_db def test_subcatchments_with_real_inp_file(user): """subcatchments returns correct IDs from a real INP file.""" @@ -2716,3 +3085,171 @@ def test_subcatchment_ids_cached_in_session(user): # Second call should use cache (same result) ids2 = _get_subcatchment_ids(request) assert ids1 == ids2 + + +# ---------------------------------------------------------------------- +# App shell (base.html) +# ---------------------------------------------------------------------- + +SHELL_PAGES = [ + ("main:main_view", False), + ("main:about", False), + ("main:contact", False), + ("main:simulation", True), + ("main:timeseries", True), + ("main:calculations", True), +] + + +def _get_page(client, user, url_name, requires_login): + if requires_login: + client.force_login(user) + response = client.get(reverse(url_name)) + assert response.status_code == 200 + return response.content.decode() + + +def _head(html): + return html.split("", 1)[0] + + +@pytest.mark.django_db +@pytest.mark.parametrize( + ("url_name", "requires_login"), + [(name, login) for name, login in SHELL_PAGES if name != "main:contact"], +) +def test_nav_marks_only_the_current_page(client, user, url_name, requires_login): + """Exactly one nav link carries aria-current="page" and it points at the page itself.""" + html = _get_page(client, user, url_name, requires_login) + + current = re.findall(r']*href="([^"]+)"[^>]*aria-current="page"', html) + assert current == [reverse(url_name)] + + +@pytest.mark.django_db +def test_nav_has_no_current_page_outside_primary_sections(client): + """Pages that are not in the primary nav (e.g. contact) highlight nothing.""" + html = client.get(reverse("main:contact")).content.decode() + + assert 'aria-current="page"' not in html + + +@pytest.mark.django_db +@pytest.mark.parametrize(("url_name", "requires_login"), SHELL_PAGES) +def test_theme_init_runs_synchronously_in_head_before_styles( + client, user, url_name, requires_login +): + """Colour mode is applied before paint: blocking script in ahead of stylesheets.""" + head = _head(_get_page(client, user, url_name, requires_login)) + + match = re.search(r'` in `` + (NOT inline — a unit test allows only `Dropzone.autoDiscover=false;` as inline JS). + On every change the theme module dispatches + `document.dispatchEvent(new CustomEvent("cs:themechange", {detail: {theme: "light"|"dark"}}))` + (resolved theme, never "auto"); charts listen to it and re-layout. +- Skip link "Skip to content" → `#main-content`. +- Django messages stay **inline dismissible alerts** at the top of the content + (`.alert.alert-danger|success|warning|info` — tests rely on `alert-danger` + text), + restyled with tokens. Errors must persist, so they are not toasts. +- Transient feedback of async flows uses Bootstrap **toasts** via + `CS.toast(message, level)` (`level`: success|info|warning|danger), stacked bottom-right + in a `.toast-container` with `aria-live="polite"`. + +### 3.2 Workbench pages (Simulation, Timeseries, ANN comparison) + +``` +┌─────────────── page header: title + one-line purpose ─────────────────┐ +├──────────── control panel (sticky, 360px) ─┬──── results canvas ───────┤ +│ ① Model upload zone / current file chip │ empty state → invitation │ +│ ② Parameters (fields, live hint: │ after run: │ +│ "11 runs · 0 → 100 step 10") │ metric tiles │ +│ [ Run simulation ] progress + elapsed time │ chart card (toolbar) │ +│ │ data table (sortable) │ +│ │ export actions │ +└─────────────────────────────────────────────┴───────────────────────────┘ +``` +On < lg the panel stacks above results. On ≥ lg it is sticky only while it fits +the viewport (set by `core/workbench.js`); a taller panel scrolls with the page, +never inside itself. The numbered ①② markers are legitimate: the workflow is a +real sequence. + +### 3.3 Home (documentation) page + +Hero = thesis sentence + live hyetograph/hydrograph of the bundled example model +(signature). Below: three tool cards (Simulation, Timeseries, ANN comparison), +then the package documentation with a sticky table of contents (scrollspy) on ≥ lg, +code blocks with a copy button. + +## 4. Interaction contract + +- **Progressive enhancement.** Every form still works without JS (POST → redirect → + GET). With JS, run forms submit via `fetch` and swap in a server-rendered results + fragment — no full page reload. Server remains the single source of markup. +- Async requests send header `X-Requested-With: XMLHttpRequest`; CSRF from the form's + `csrfmiddlewaretoken`. Server contract for run endpoints + (`simulation_view`, `timeseries_view`, `calculations` POST): + - **Success:** `200`, `Content-Type: text/html`, body = the results fragment + rendered from the SAME partial template the full page includes + (`main/partials/__results.html`). Session/cache/token state is updated + exactly as in the non-AJAX path. + - Each tool's latest result stays in the cache (file-based, survives restarts, + 12 h) under a token in the session, so its page renders it again when the user + comes back. Loading a model with identical content keeps the results. + - **Validation / input error:** `4xx` (`400` form invalid or bad input, `413` too + large, `401` not authenticated) with JSON + `{"message": str, "field_errors": {field_name: [str, ...]}}` + (`field_errors` may be `{}`; `__all__` for non-field errors). + - **Server error:** `500` JSON `{"message": str, "field_errors": {}}`. + - In the AJAX path the server must NOT call `messages.*` (they would leak into the + next full page load). + - Client helper `CS.asyncForm` must treat any non-2xx response whose body is not + JSON (e.g. a proxy 502/504 HTML page after a gunicorn timeout) and network + failures as: "The server did not finish the run. It may have timed out — try a + smaller range or reload the page to check for results." and re-enable the form. + - Without JS (no header) everything behaves exactly as before (POST → redirect → GET). +- While running: button disabled + `aria-busy`, inline progress with elapsed seconds, + results region `aria-busy="true"`. After success: results swapped, focus moved to + the results heading, toast "Simulation finished". On error: inline alert in the + control panel explaining what to fix + toast; button re-enabled. +- Download/export buttons never trigger the run spinner (fixes audit S2). +- Client-side hints never replace server validation. + +## 5. Code conventions + +- No build step, no framework. Plain ES2017+ scripts, `"use strict"`, one global + namespace `window.CS` (`CS.theme`, `CS.toast`, `CS.charts`, `CS.asyncForm`, …). +- Files: `static/main/css/tokens.css`, `static/main/css/app.css` (components), + page CSS only when needed; `static/main/js/core/*.js`, `static/main/js/pages/*.js`. +- Vendor libs from CDN with SRI (`integrity` + `crossorigin`): Bootstrap 5.3.x, + Plotly (per page only), Dropzone (upload pages only), Prism (home only). +- Templates: English UI copy everywhere (replace remaining Polish: "Kontakt", + "Logowanie", "Wyślij", "Adres email", "Tytuł", "Treść", "Prześlij", alt "Opis obrazu") + and update the tests/page objects asserting them in the same change. Sentence case, + active verbs ("Download results (.xlsx)"). Keep the existing texts of the primary + run buttons ("Run Simulation", "Run Timeseries Analysis", "Run Calculations") and + "Download Results" since many tests key on them. +- No inline `style=""` attributes, with one exemption: `#upload-status` keeps its + inline `display:none` toggling (tests read `style.display`). +- Use `{% static 'img/logo.png' %}` style paths (no leading slash) and + `{% url %}` instead of hard-coded hrefs. +- Accessibility: WCAG 2.1 AA contrast in both themes, visible focus, labelled + controls, charts have `role="img"` + `aria-label` and a data table alternative. +- Existing e2e page objects are the contract for ids; when markup changes, update + the page object in the same change and keep tests green. diff --git a/docs/ui-modernization/PLAN.md b/docs/ui-modernization/PLAN.md new file mode 100644 index 0000000..f5cce7f --- /dev/null +++ b/docs/ui-modernization/PLAN.md @@ -0,0 +1,134 @@ +# Plan modernizacji interfejsu — catchments_simulation + +Branch: `ui-modernization` · specyfikacja wizualna i kontrakty: [`DESIGN_SPEC.md`](DESIGN_SPEC.md) + +## Decyzja architektoniczna + +Zostajemy przy Django renderującym HTML po stronie serwera + **progressive enhancement** +(vanilla JS bez kroku budowania, zgodnie z kierunkiem „static-first” z #76). +Formularze uruchamiające obliczenia wysyłane są przez `fetch`, a serwer zwraca +**fragment HTML wyników z tego samego szablonu częściowego**, którego używa pełna +strona — jedno źródło markupu, brak przeładowania strony, a bez JS wszystko działa +jak dotąd (POST → redirect → GET). Rewrite na SPA (React/Vite) odrzucony: duży koszt, +złamałby 250 testów i architekturę bez proporcjonalnego zysku dla użytkownika. + +Legenda: **[nowe]** nowa funkcjonalność · **[poprawa]** naprawa błędu/niespójności · +**[wzmocnienie]** ulepszenie istniejącego elementu. + +## Zakres zmian + +### Fundament (shell aplikacji) +- [wzmocnienie] Bootstrap 5.1.3 → 5.3.x (SRI liczone z plików), natywne color modes. +- [poprawa] crispy `bootstrap4` pack przy CSS Bootstrap 5 → `crispy-bootstrap5` (+ `uv.lock`). +- [nowe] System design tokenów (`tokens.css`) + komponenty (`app.css`), typografia Archivo / Public Sans / IBM Plex Mono. +- [nowe] Tryb ciemny (light/dark/auto) bez migotania, zapamiętywany, wykresy przełączają motyw na żywo. +- [wzmocnienie] Nawigacja: sticky navbar z hamburgerem na mobile (dziś brak), aktywny link `aria-current`, menu konta (profil/wyloguj), skip-link. +- [nowe] Toasty dla informacji zwrotnej z operacji asynchronicznych (`CS.toast`). +- [poprawa] Zepsuta klasa `me-md-autolink-dark`, `href="/"` → `{% url %}`, `{% static '/img/…' %}` z wiodącym `/` (psuje się przy manifest storage). +- [wzmocnienie] Vendor JS ładowany tylko tam, gdzie potrzebny (Plotly/Dropzone/Prism nie na każdej stronie). + +### Backend pod interaktywność +- [nowe] Kontrakt AJAX dla `simulation_view`, `timeseries_view`, `calculations` (fragment HTML / JSON `{message, field_errors}`), bez wycieku `messages` do sesji. +- [nowe] Szablony częściowe wyników `main/partials/_*_results.html` współdzielone przez pełną stronę i odpowiedź AJAX. +- [poprawa] `contact`: `reverse("contact")` bez namespace → NoReverseMatch po poprawnym wysłaniu; wynik `send_message` ignorowany (audyt W6 — część UI: komunikat o niepowodzeniu). +- [poprawa] `userprofile`: zepsuty `form_action` i `reverse("userprofile")`. +- [wzmocnienie] `calculations` POST wymaga zalogowania po stronie serwera (GET pozostaje publiczny — test e2e tego oczekuje). + +### Komponenty współdzielone +- [wzmocnienie] `charts.js`: wykresy świadome motywu (kolory z tokenów), spójna paleta, sekwencyjna rampa dla sweepów zamiast tęczy, uporządkowany modebar, `role="img"` + etykiety. +- [nowe] Wykres **hietogram + hydrogram** (opad jako odwrócone słupki z górnej osi, odpływ poniżej) — sygnatura wizualna aplikacji. +- [wzmocnienie] Strefa uploadu: styl z tokenów (dark mode), „chip” z aktualnym plikiem i liczbą zlewni, zdarzenie `cs:model-changed` dla stron. + +### Symulacja (sweep parametru) +- [nowe] Układ „workbench”: panel sterowania (① Model → ② Parametry → Uruchom) + płótno wyników. +- [nowe] Uruchamianie bez przeładowania, postęp z licznikiem czasu, fokus na wynikach, toast. +- [nowe] Podpowiedź na żywo: „11 przebiegów · 0 → 100 co 10”, ostrzeżenie przy limicie 100. +- [nowe] Tabela wyników z sortowaniem, kopiowaniem, formatowaniem liczb; kafelki podsumowania (min/max/zmiana odpływu). +- [poprawa] Przycisk „Download Results” blokował przycisk Run do odświeżenia strony (audyt S2). +- [poprawa] Tekst spinnera „neural network” na stronie symulacji SWMM. + +### Timeseries (hydrogram) +- [nowe] Hietogram + hydrogram dla trybu single; sweep z rampą kolorów i legendą wartości. +- [wzmocnienie] Kafelki metryk (czas do szczytu, objętość odpływu, szczyt), eksport PNG/CSV/XLSX w jednym pasku akcji. +- [nowe] Uruchamianie bez przeładowania jak w symulacji. + +### Porównanie SWMM vs ANN (Calculations) +- [nowe] Wykres parzystości (SWMM vs ANN z linią 1:1) i słupkowy per zlewnia. +- [nowe] Metryki błędu (MAE, RMSE, średni błąd %) i różnica per wiersz. +- [wzmocnienie] Opis modelu w zwijanych sekcjach, czytelny układ dwukolumnowy, poprawny `alt` obrazka. + +### Strony treściowe +- [nowe] Strona główna: hero z hydrogramem przykładowego modelu, karty narzędzi, dokumentacja ze sticky spisem treści (scrollspy), przycisk „Copy” przy kodzie. +- [wzmocnienie] Logowanie/rejestracja/kontakt/profil: karty formularzy, przełącznik widoczności hasła, spójne komunikaty. +- [poprawa] Mieszanka PL/EN → spójny angielski UI; tytuł „Kontakt” na stronie profilu. + +### Jakość +- [wzmocnienie] Dostępność: axe bez naruszeń krytycznych w obu motywach, fokus, kontrast AA. +- [wzmocnienie] Testy: aktualizacja page objects, nowe testy kontraktu AJAX, e2e ścieżek async, dark mode, mobile. + +### Poza zakresem (świadomie) +Audyt K1/K2/K3/W1/W3/W4 (współbieżność, kolejka zadań, storage, logika pakietu) — to zmiany +backendowe/infrastrukturalne; UI jedynie obsługuje ich skutki (np. timeout → czytelny komunikat). + +## Wykonanie i własność plików + +Agenci pracują w jednym drzewie roboczym, więc każdy ma rozłączny zestaw plików. + +| Faza | Agent | Pliki (wyłączna własność) | +|---|---|---| +| 1 | Shell | `base.html`, `css/tokens.css`, `css/app.css`, `css/base.css`, `js/core/*`, `js/base_ui.js`, `settings.py` (crispy), `pyproject.toml`, `uv.lock`, `main/tests/test_views.py`, `e2e/pages/base_page.py`, `e2e/pages/nav_component.py`, `e2e/test_navigation.py`, `e2e/test_responsive.py` | +| 1 | Backend | `main/views.py`, `main/forms.py`, `main/templates/main/partials/*`, (minimalnie) sekcje wyników w `simulation.html`/`timeseries.html`/`calculations.html`, nowy `main/tests/test_async_views.py` | +| 1 | Komponenty | `js/charts.js`, `js/upload_zone.js`, `css/upload_zone.css`, `_upload_zone.html`, `e2e/pages/upload_component.py`, `e2e/test_upload.py` | +| 2 | Symulacja | `simulation.html`, `partials/_simulation_results.html`, `js/pages/simulation.js`, `css/simulation.css`, page object + e2e symulacji | +| 2 | Timeseries | `timeseries.html`, `partials/_timeseries_results.html`, `js/pages/timeseries.js`, `css/timeseries.css`, page object + e2e | +| 2 | ANN | `calculations.html`, `partials/_calculations_results.html`, `js/pages/calculations.js`, `css/calculations.css`, page object + e2e | +| 2 | Treści | `main_view.html`, `about.html`, `contact.html`, `userprofile.html`, `login.html`, `register.html`, `js/pages/main_view.js`, dane hero, `forms.py` (etykiety), page objects + e2e tych stron | +| 3 | Walidacja | Gemini (agy, read-only) per komponent, pełne testy (także `slow`), zrzuty ekranu light/dark/mobile, przegląd Codex (gpt-6-sol), poprawki | + +Kryterium wyjścia każdego agenta: `pytest cs_app/main/tests` + jego pliki e2e zielone, +potem walidacja Gemini i poprawki znalezionych problemów. + +## Wynik (2026-09-29) + +Zrealizowano cały zakres powyżej w trzech fazach (fundament → strony → integracja + przegląd +krzyżowy Codex gpt-6-sol / Gemini / QA wizualne → poprawki). Stan końcowy: 317 testów unit, +229 e2e (łącznie z wolnymi, realny SWMM), 145 testów pakietu, ruff czysty; test uploadu +prawdziwego pliku (wcześniej `xfail`) przechodzi. + +Dodatkowo naprawione po drodze (wykryte przez agentów i zweryfikowane): +- [poprawa] Upload przez Dropzone zwracał 500 w realnym stosie middleware (audyt K3). +- [poprawa] Porównanie SWMM vs ANN zestawiało megalitry z m³ (audyt S7): `TotalRunoffMG` + to 10⁶ L (SI) / 10⁶ gal (US) — przeliczane teraz do m³ / ft³ (przykład: 3.36 → 3360 m³ vs ANN 2466 m³). +- [poprawa] `evaporation_loss` jest raportowane przez SWMM w mm/dzień (in/dzień), nie mm/h — + poprawione etykiety, osobny panel na wykresie, dokumentacja pakietu. +- [poprawa] Dane przykładowe na stronie głównej pochodziły z innego modelu niż dołączony + `example.inp` — zregenerowane skryptem `cs_app/data/build_home_data.py` + test zgodności. +- [poprawa] Formularz profilu ujawniał listę wszystkich użytkowników i pozwalał podać cudze id. +- [poprawa] Brak modelu w sesji → 500 (nieistniejący fallback `example.inp`) → teraz 400 z komunikatem. +- [poprawa] Wyniki poprzedniego modelu przeżywały wgranie nowego (audyt W2). +- [wzmocnienie] Calculations: Post/Redirect/Get także bez JS. + +### Poprawki po odbiorze (2026-09-30) +- [poprawa] Wyniki analiz znikały po przejściu do innej zakładki i powrocie: cache wyników był + w pamięci procesu (`LocMemCache`), czyszczony przy każdym auto-reloadzie `runserver` i osobny + dla każdego workera gunicorna. Teraz `FileBasedCache` (`DJANGO_CACHE_DIR`, domyślnie + `cs_app/.cache/django`), TTL 30 min → 12 h; testy nadal na `LocMemCache`. +- [wzmocnienie] Ponowne wczytanie modelu o identycznej treści (np. „Try sample data” na innej + zakładce) nie kasuje już wyników ani ustawień formularzy (porównanie SHA-256). +- [poprawa] Nawigacja: linki wyśrodkowane względem strony (siatka brand | linki | narzędzia), + przyciski „Log in” / „Create account” nie łamią się przy 992–1199 px. + +### Decyzje do potwierdzenia przez właściciela +1. Przeliczenie jednostek w porównaniu SWMM vs ANN zakłada, że sieć przewiduje m³ (zgodne rzędem + wielkości na przykładzie). Warto potwierdzić jednostkę celu treningowego ANN. +2. Teksty przycisków auth zmienione na „Log in” / „Create account” (testy zaktualizowane). +3. Rejestracja od razu loguje nowego użytkownika (wcześniej tylko przekierowanie). +4. Sweep > 8 wartości pokazuje pasek kolorów zamiast legendy (także na desktopie). +5. Kolory serii „evaporation” vs „runoff” wciąż słabo rozróżnialne dla daltonistów + (łagodzone wzorami linii) — do decyzji przy ewentualnej zmianie palety. +6. Wyniki trzymane są 12 h w cache plikowym na hoście. Przy wdrożeniu na kilku hostach + potrzebny wspólny backend (Redis/baza) — wystarczy zmiana `CACHES`. + +### Poza zakresem, nadal otwarte (audyt) +K1/K2 (współbieżność, synchroniczne symulacje; W1 — cache per proces — rozwiązane dla wyników przez `FileBasedCache`), W3/W4 (logika pakietu), +wyścig sesji przy zmianie modelu w trakcie trwającego przebiegu. diff --git a/pyproject.toml b/pyproject.toml index 415d94d..010c87c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,7 +46,7 @@ dev = [ web = [ "Django>=4.2,<5.0", "Werkzeug==3.1.5", - "crispy-bootstrap4==2022.1", + "crispy-bootstrap5==2024.2", "python-dotenv==1.0.0", "django-crispy-forms==2.0", "django-import-export==3.2.0", diff --git a/src/catchment_simulation/catchment_features_simulation.py b/src/catchment_simulation/catchment_features_simulation.py index 702ee43..fb5f825 100644 --- a/src/catchment_simulation/catchment_features_simulation.py +++ b/src/catchment_simulation/catchment_features_simulation.py @@ -195,7 +195,10 @@ def calculate_timeseries(self) -> pd.DataFrame: ------- pd.DataFrame DataFrame with a ``DatetimeIndex`` (name ``"datetime"``) and columns - corresponding to ``TIMESERIES_KEYS``. + corresponding to ``TIMESERIES_KEYS``, in SWMM's reporting units: + ``rainfall`` and ``infiltration_loss`` in mm/h (in/h for US-unit + models), ``evaporation_loss`` in mm/day (in/day), ``runoff`` and + ``runon`` in the model's ``FLOW_UNITS``. """ records: list[dict[str, Any]] = [] with Simulation(self.file) as sim: @@ -277,7 +280,8 @@ def simulate_subcatchment_timeseries( Returns ------- dict[float, pd.DataFrame] - Mapping of parameter values to DataFrames with timeseries data. + Mapping of parameter values to DataFrames with timeseries data, shaped + and in the units described in ``calculate_timeseries``. """ self._validate_simulation_params(start, stop, step) self.file = self.copy_file(self.raw_file) diff --git a/uv.lock b/uv.lock index 8e151a5..c7dd06b 100644 --- a/uv.lock +++ b/uv.lock @@ -115,7 +115,7 @@ e2e = [ { name = "pytest-playwright" }, ] web = [ - { name = "crispy-bootstrap4" }, + { name = "crispy-bootstrap5" }, { name = "dj-database-url" }, { name = "django" }, { name = "django-crispy-forms" }, @@ -133,7 +133,7 @@ web = [ [package.metadata] requires-dist = [ - { name = "crispy-bootstrap4", marker = "extra == 'web'", specifier = "==2022.1" }, + { name = "crispy-bootstrap5", marker = "extra == 'web'", specifier = "==2024.2" }, { name = "dj-database-url", marker = "extra == 'web'", specifier = "==2.0.0" }, { name = "django", marker = "extra == 'web'", specifier = ">=4.2,<5.0" }, { name = "django-crispy-forms", marker = "extra == 'web'", specifier = "==2.0" }, @@ -401,16 +401,16 @@ toml = [ ] [[package]] -name = "crispy-bootstrap4" -version = "2022.1" +name = "crispy-bootstrap5" +version = "2024.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "django" }, { name = "django-crispy-forms" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5e/be/d8203e4dacbcee891875c741eb6f1d615da2f26a1367085adf278e2929d5/crispy-bootstrap4-2022.1.tar.gz", hash = "sha256:f18386c4d96180c1bb1212487d6e8f5a3dd1a9bb58d3032183973d4d130b55f9", size = 10941, upload-time = "2022-12-17T07:31:27.836Z" } +sdist = { url = "https://files.pythonhosted.org/packages/75/45/af5c7a13742650b1187a1a6db5197577b57f2b1ca3a042c3b386ddfbebfc/crispy-bootstrap5-2024.2.tar.gz", hash = "sha256:7d1fa40c6faf472e30e85c72551a3d2c9eedbf0abfff920683315e4e6f670f2b", size = 23411, upload-time = "2024-02-25T08:46:04.075Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/91/71/cbfa04246b137f69eefa58029e9247064284ea722ba140b07a4244cc8478/crispy_bootstrap4-2022.1-py3-none-any.whl", hash = "sha256:5241ab1dc2188c95560aa786439bcbedec7416e6b0f5a52dded82380810367ec", size = 22974, upload-time = "2022-12-17T07:31:26.408Z" }, + { url = "https://files.pythonhosted.org/packages/13/11/a1c22b4c803125901af7d2fa683f4c6eb14d511644da5fc0b45a0cb44cae/crispy_bootstrap5-2024.2-py3-none-any.whl", hash = "sha256:3867e320920a6ef156e94f9e0f06a80344c453e1b3bd96cd9dc0522ae9e9afb8", size = 24351, upload-time = "2024-02-25T08:46:02.171Z" }, ] [[package]]