Water Balance
An interactive flow diagram that accounts for water moving through a site — sources, tanks, treatment, consumption, and recycled loops — rendered with React Flow. It answers "where did the water go, and does it add up?"
At a glance
| Route | /water_balance/graph → WaterBalance (libs/waterBalance/src/WaterBalancePage.jsx) |
| Sidebar label | Water Mass Balance — a top-level item in the main nav section (not nested under Efficiency, despite the route grouping) |
| Page heading | "Water Balance Diagram" |
| Permission | WATER_BALANCE |
| Without the permission | The sidebar item points at /feature_locked/WATER_BALANCE/WATER_BALANCE — a marketing page served by libs/featureLocked |
| Available in | production demo — routed in these two apps only |
| Library | libs/waterBalance/ — standard dataSource → controller → store → components layering |
| Data endpoint | GET waterBalance/data (the only one) |
| Refresh | 5 min — only while viewing today |
| Diagram engine | reactflow v11 (legacy package name) |
| Export | PNG, client-side via html-to-image |
- It does not compute anything. Node types, values, positions, edge paths, and the balance percentage all arrive from the backend; the frontend only maps type strings to components.
- It does not produce the water-balance report. That is a separate server-rendered document — see Reports.
- It does not export data. The download is a PNG image of the diagram; there is no table or spreadsheet export.
- It is not available in the standalone apps. The
water_balancepaths in uwms, rwi, and lakepulse are the report sub-route (/reports/water_balance), not this diagram. - It does not poll continuously — auto-refresh only runs while you are viewing today.
The physical flow
The diagram wires typed nodes (tanks, pumps, valves, processes, meters) together with typed edges (raw vs recycled flows). A simplified site:
Summary: Solid lines are raw water, dashed lines are recycled water (per the in-app legend). Consumption flows carry animated arrows. Node types, values, and layout all come from the backend — the frontend only maps each type string to a component.
How a response becomes a diagram
Summary: One fetch fills the store; page and view are pure client-side selectors into that payload. The
keyonReactFlowmeans switching either one remounts the whole graph rather than diffing it.
Page chrome & controls
| Control | Where | What it does | On change |
|---|---|---|---|
| Granularity select | Header | Day (HOUR) or Month (DATE) | Refetches. Choosing Day also resets the date to today |
| Date picker | Header | AppDatePickerSelection — day or month mode follows the select | Refetches |
| Today | Header | TodayDate button — jumps to today + HOUR | Refetches |
| Search | Header | Shared SearchComponent | — |
| View toggle | Graph, top-left panel | Overview / Detailed | Client-side; remounts the graph |
| Page dropdown | Graph, top-left panel | Site/section picker, labelled by each page's displayName | Client-side; remounts the graph |
| Zoom / fit | Graph, bottom-right | Standard React Flow controls (the interactive toggle is hidden) | — |
| Info | Graph, bottom-right | Toggles the legend panel | — |
| Download | Graph, bottom-right | Opens the export dialog | — |
Before data arrives the header renders a disabled placeholder select plus a
disabled CustomSingleDatePicker; once balanceData.data exists it swaps to the
live granularity select and AppDatePickerSelection. See
Components for which date pickers are standard vs legacy.
Two views, two granularities
| Control | Options | Backend key | Refetch? |
|---|---|---|---|
View (WaterBalanceGraphType) | Overview (SUMMARY) · Detailed (DETAIL) | summary / diagram | No |
Granularity (WaterBalanceDateType) | Day (HOUR) · Month (DATE) | — (query param type) | Yes |
Page (selectedPageId) | Whatever keys the response carries; physical is always present | — | No |
Each view also selects its own legend via infoMenuKey (SUMMARY_INFO /
DETAIL_INFO).
Node types
The backend gives each node a type string; WaterBalanceGraphComponents.jsx
maps it to a component (nodeTypes).
Detailed view — the physical diagram
type | Component | Represents | What it shows | Alert state | On click |
|---|---|---|---|---|---|
CARD | ConsumptionNode | Consumption / meter reading | Value, label (tooltip on hover, clamped to 2 lines) | Blinks red + red label bar when meta.limitReached; adds a (subTitle unit) line. Greyscaled with lastUpdatedTime when offline | Opens that unit's legacy source page in a new tab |
TANK | WaterTankNode | Storage tank | Animated fill (shared WaterTank), value, label | Blinks red when meta.thresholdReached; greyscaled + lastUpdatedTime when offline | Opens the stock page for that unit in a new tab |
OVAL | OvalNode | Source / output label | Value + label in a pill | — | — |
PROCESS | ProcessNode | Treatment / process step | Value + label in a filled box, white text | — | — |
PUMP | PumpNode | Pump | Static icon only — no data at all | — | — |
VALVE | ValveNode | Valve / flow meter | Static icon only — no data at all | — | — |
Tank threshold lines, the empty line, and the threshold percentages only render at
zoom ≥ 0.75 (a useStore selector on the transform).
Overview — summary tiles
type | Component | Represents | What it shows | Alert state | On click |
|---|---|---|---|---|---|
SUMMARY_DATA | SummaryDataNode | Single metric tile | value siUnit + label; formula tooltip via a ? icon | — | — |
SUMMARY_DETAIL | SummaryDetailNode | Grouped metric | value siUnit + label (count); drawn as a stack of cards; formula tooltip | Blinks red if any member unit has meta.limitReached | Toggles a popup listing every member unit |
WATER_BALANCE | WaterBalanceNode | Headline balance figure | value % (large) + formula tooltip | — | — |
SUMMARY_DIFFERENCE | SummaryDifferenceNode | Reconciliation figure | label : value in a pill — no unit | — | — |
The ? formula icon is hidden whenever data.formula is empty.
Every node draws four source + four target
Handles (one per side),
all styled transparent and borderless so they are invisible, and the graph uses
connectionMode="loose" so edges can attach anywhere. All node components are
React.memo.
Edge types
type | Component | Path | Appearance | Meaning |
|---|---|---|---|---|
custom | FlowEdge | Poly-line built from data.controlPoints | Solid | Raw water on a hand-routed path |
step | CustomStepEdge | getSmoothStepPath | Solid | Raw water, grid-routed |
dashStep | CustomDashEdge | getSmoothStepPath | Dashed 12 12 | Recycled water |
- Default stroke is
#006183at width 2; a per-edgestylefrom the backend overrides it. - Edges with
data.isConsumptionoverlayAnimatedArrow— arrow glyphs that travel the path via SVGanimateMotion. Duration is derived from the path length (Euclidean forcustom, Manhattan for the step edges). - In the export dialog,
customandstepare both swapped forCustomStepEdgeWithoutAnimationso the still frame is clean;dashStepis kept as-is (CustomDownloadEdgeType).
Click-through navigation
All node navigation goes through the shared useNavigateSearchParams hook, whose
openInNewTab defaults to true — so every one of these opens a new tab.
| From | Target | Carries |
|---|---|---|
CARD node | /monitoring/source_category/SOURCE_CATEGORY/{subCategoryId}/{unitId} | navDate, navType |
TANK node | /monitoring/stock_category/STOCK_CATEGORY?unitId={unitId} | navDate, navType |
Summary popup row, with meta.maxCapacity | Stock page for that unit | navDate, navType |
Summary popup row, without meta.maxCapacity | Legacy source page for that unit | navDate, navType |
The hook also writes the date and granularity into LocalDB (commonDate,
commonDateType) and sets constantDate / constantDateType on the global
AppStore. That is the other half of the loop: when the app supplies a
constantDate, Water Balance mirrors it into params.date1 and refetches — so a
date chosen here follows you into the unit page, and a date set elsewhere follows
you back.
Data & APIs
The data endpoint
| Endpoint | GET waterBalance/data (Urls.waterBalanceData) |
| Params | date1 (DD/MM/YYYY), type (HOUR | DATE) |
| Fetched on | Mount · date change · granularity change · constantDate change · the 5-min interval |
| Not fetched on | View toggle · page dropdown — both read from the store |
constants.refreshDuration is 5 minutes, and the interval is only created when
DateFormatter.isSame(params.date1) says the selected date is today.
Full response shape
{
"data": {
"pages": {
"physical": {
"id": "physical",
"displayName": "Physical", // label in the page dropdown
"siUnit": "kl", // read from the physical page only
"summary": { // Overview view
"nodes": [ /* SUMMARY_* + WATER_BALANCE nodes */ ],
"edges": [ /* typed edges */ ]
},
"diagram": { // Detailed view
"nodes": [ /* CARD / TANK / OVAL / PROCESS / PUMP / VALVE */ ],
"edges": [ /* typed edges */ ]
}
}
// ...further pages, keyed by id
}
}
}
The store reads siUnit from pages.physical only — every page's values are
rendered with that one unit. kl is the component-level default.
Node data contract, field by field
Standard React Flow node fields (id, type, position) come straight from the
backend. Everything below lives under data:
| Field | Used by | Purpose |
|---|---|---|
label | all | Display name; tooltipped where it can overflow |
value | all but PUMP/VALVE | The reading |
online | CARD, TANK | false greyscales the node and reveals lastUpdatedTime |
lastUpdatedTime | CARD, TANK | Shown only while offline |
unitId | CARD, TANK, popup rows | Navigation target |
subCategoryId | CARD, popup rows | Navigation target |
formula | the four summary types | Tooltip text; the ? icon hides when empty |
graphNodes[] | SUMMARY_DETAIL | Member units — each with displayName, value, unitId, subCategoryId, meta.limitReached, meta.maxCapacity |
meta.limitReached | CARD, popup rows | Drives the red/blink state |
meta.subTitle | CARD | Extra line rendered only when the limit is reached |
meta.percentage | TANK | Fill level |
meta.thresholdReached | TANK | Drives the red/blink state |
meta.thresholdValue.fullPercentage | TANK | Upper threshold line + label |
meta.thresholdValue.emptyPercentage | TANK | Lower threshold line + label |
meta.maxCapacity | popup rows | Presence routes to the stock page instead of the source page |
meta.backgroundColor | SUMMARY_DETAIL | Popup header colour |
style.displayTextColor | CARD, TANK | Value colour while online |
style.backgroundColor | TANK | Wave + border colour |
style.background | OVAL, PROCESS | Fill colour |
Edge data:
| Field | Used by | Purpose |
|---|---|---|
controlPoints[] | custom | {x, y} waypoints for the poly-line |
isConsumption | all three | Overlays the animated arrows |
Analytics
| Event | When |
|---|---|
PAGE_VIEW | Page mount |
WATER_BALANCE_DATE_CHANGE | Date or granularity actually changes (payload = the new params) |
Both no-op when analytics is off — the backwards analyticsEnv flag, see
Configuration & Security.
State
WaterBalanceStore is a React Context (WaterBalanceDataProvider /
WaterBalanceDataContext) exposing balanceData ({ data, siUnit }), graphType,
params, isLoading, fullScreenLoader, selectedPageId, openInfoPopup,
openDownloadPopup, and downloadContainerRef. On mount the page also calls
setMenuIconEnabled(false) and setSelectedCategory(NavigationOptions.waterBalance)
on the global store. See State Management and
API Call Flow.
Render states
Summary: A failed request and a site with no diagram configured land in the same place — the "contact us" overlay. There is no distinct error state.
| State | What the user sees |
|---|---|
| Loading | Centred loader in place of the graph; the view toggle is disabled and the export dialog is not mounted |
| No diagram configured | ContactForDiagram overlay: a warning icon and "Please reach out to us through 'Issue Reporting form' to get your Water Balance Summary made." It makes no API or mail call |
| Fetch failed | Identical to the above — the error is only console.error'd |
| No data at all | The info/legend popup auto-opens once loading finishes |
| Offline unit | Node greyscaled, value greyed, lastUpdatedTime shown |
| Limit / threshold reached | Node blinks red (2 s loop) and recolours |
Export
The download control opens a dialog holding a static copy of the current graph:
selection, dragging, connecting, zoom, and panning are all disabled, maxZoom is
clamped to 1, and fitView frames it. Above it sits the title and a read-only
date picker (dimmed, pointerEvents: none) so the exported image carries its date.
| How | Download.PNG in libs/shared/src/utils/downloadUtil.js → html-to-image toPng |
| Target | The dialog's container ref (downloadContainerRef) |
| Options | pixelRatio: 8, skipFonts: true, cacheBust: true, <style> nodes filtered out |
| Delivery | Data URL on a synthetic <a download> that is clicked |
| Filename | Day: {date1}_{pageId}_{username} · Month: {MMM/YYYY}_{pageId}_{username} |
| While capturing | A full-screen backdrop + spinner (fullScreenLoader) |
The export is an image only — no data-table or spreadsheet export is wired up.
Info popup / legend
Titled "Balance Diagram Info", contents keyed off the active view.
Overview (SUMMARY_INFO) | Detailed (DETAIL_INFO) |
|---|---|
Combined · Difference · Zoom In/Out (Ctrl + scroll) | Raw Water · Recycled Water · Limit Reached · 90% Filled · Storage Tank · No Flow Meter · Pump · Valve |
React Flow version
Built on reactflow v11 — the legacy package name. Imports come from
'reactflow': ReactFlow, Handle, Position, BaseEdge, getSmoothStepPath,
useStore, Background, Controls, ControlButton, Panel.
React Flow v12 ships as the renamed @xyflow/react package. An upgrade would
swap the import path and adopt its minor API changes. Until then keep new
water-balance work on the v11 reactflow imports so the graph stays consistent.
Visibility & access
Which apps register the route
| App | Path | Renders |
|---|---|---|
production | /water_balance/graph | WaterBalance |
demo | /water_balance/graph | WaterBalance |
uwms · rwi · lakepulse | — | Not registered. Their water_balance path is the report sub-route |
Route-level gating: none
In both apps the route element is a bare <WaterBalance /> — no PermissionWrapper.
Hitting the URL directly renders the page regardless of permission. The intended gate
is the sidebar.
Sidebar gating
| Property | Value |
|---|---|
| Nav key | WATER_BALANCE |
| Display name | Water Mass Balance |
| Placement | Top level of the main nav section, beside Metrics and Efficiency |
permissionId | WATER_BALANCE |
lockedPath | /feature_locked/WATER_BALANCE/WATER_BALANCE |
isDemoOption | false — visible in every build |
Without the permission the item routes to the feature-locked marketing page rather than disappearing. See Permissions → Sidebar access modes.
Service and subscription gating
This feature performs no checkServiceExist check — unlike the
Dashboard cards, it is either permitted or locked. Subscription
expiry is handled globally by the app-wide overlay, not here.
Dependencies
Shared store & services
| Import | Used for |
|---|---|
AppStore | constantDate sync, setMenuIconEnabled, setSelectedCategory |
apiClient · Urls | The single waterBalance/data call |
AnalyticsService · AnalyticEvents | PAGE_VIEW, WATER_BALANCE_DATE_CHANGE |
LocalDBInstance · LocalDBKeys | Reading the username for the export filename |
Download | Download.PNG for the image export |
DateFormatter | isSame — the today-only refresh check |
Formatter | valueFormatter, defaultValue |
NavigationHelper · useNavigateSearchParams | Node click-through |
Component library
SubPageWrapper, FixedBar, If / IfNot, Expanded, CustomLoader,
WaterTank, AppDatePickerSelection, CustomSingleDatePicker (disabled
placeholder only), TodayDate, SearchComponent, AppTooltip, AppButton.
Lib-internal
WaterBalanceDataContext / WaterBalanceDataProvider, waterBalanceEnums,
markerStyle, and the BalanceNodeType / CustomEgdeType / CustomDownloadEdgeType
maps.
External npm
reactflow (v11), html-to-image, @mui/material, @emotion/react (blink
keyframes), @mui/icons-material, @iconify/react, moment, lodash. Exact
versions live in package.json.
Usage examples
Read the current graph from the store
import { useContext } from 'react';
import { WaterBalanceDataContext } from '@aquagen-mf-webapp/waterBalance/store/WaterBalanceStore';
import { WaterBalanceGraphType } from '@aquagen-mf-webapp/waterBalance/enums/waterBalanceEnums';
function NodeCount() {
const { balanceData, selectedPageId, graphType, isLoading } = useContext(
WaterBalanceDataContext
);
if (isLoading) return null;
const key = WaterBalanceGraphType[graphType].responseKey; // 'summary' | 'diagram'
const nodes = balanceData.data?.[selectedPageId]?.[key]?.nodes ?? [];
return <span>{nodes.length} nodes in {balanceData.siUnit}</span>;
}
Switch view or page programmatically
const { setGraphType, setSelectedPageId, setParams, params } = useContext(
WaterBalanceDataContext
);
setGraphType(WaterBalanceGraphType.DETAIL.id); // client-side, remounts the graph
setSelectedPageId('physical'); // client-side, remounts the graph
setParams({ ...params, type: 'DATE' }); // triggers a refetch
Fire the feature's analytics events
import { AnalyticsService } from '@aquagen-mf-webapp/shared/services';
import { AnalyticEvents } from '@aquagen-mf-webapp/shared/enums';
AnalyticsService.sendEvent(AnalyticEvents.PAGE_VIEW, {}, true); // third arg on mount
AnalyticsService.sendEvent(AnalyticEvents.WATER_BALANCE_DATE_CHANGE, newParams);
Troubleshooting
"Please reach out to us…" appears even though the site has a diagram
The overlay renders whenever the current page + view has zero nodes — check
pages[selectedPageId].summary and .diagram separately. A silently failed
request produces the same screen, so check the console for
Error Fetching Water Balance Data before assuming the site is unconfigured.
A meter reads -- but the unit is definitely reporting
A genuine 0 is falsy, and both the consumption card and the tank test the value
with a falsy check. -- therefore means either no data or exactly zero.
The graph stopped updating
The 5-minute interval is only created when the selected date is today. Viewing any past date is intentionally static.
The page dropdown lost a site after a refresh
Its option list is memoised with an empty dependency array, so the 5-minute refresh does not rebuild it. Switching view or page remounts React Flow, which does.
Clicking a node does nothing
Only CARD, TANK, and summary-popup rows navigate. OVAL, PROCESS, PUMP, and
VALVE have no handler — and edges show cursor: pointer without one either.
The exported PNG has a mangled filename
The name embeds DD/MM/YYYY, and browsers rewrite path separators in the download
attribute.
Known issues & gotchas
| Issue | Where | Impact |
|---|---|---|
| 1000 arrow nodes per animated edge | AnimatedArrow loops to 1000, one <path> + <animateMotion> each; the last begins after ~25 min | Heavy DOM/CPU cost on graphs with many consumption edges. Its minDuration/maxDuration props are dead |
A real 0 renders as -- | ConsumptionNode uses value || '--'; the tank uses Formatter.defaultValue, also a falsy check | A genuine zero reading is indistinguishable from no data |
| Fetch errors are invisible | The data source catches, logs, and returns undefined | Users see the "contact us" overlay instead of an error — indistinguishable from an unconfigured site |
| Slashes in the export filename | The name embeds DD/MM/YYYY or MMM/YYYY | Browsers mangle path separators in the download attribute |
| Export reads the session unguarded | JSON.parse(LocalDBInstance.getItem(loginResponse)).username with no null check | Throws if the session is missing |
setState during another component's render | SummaryDetailNode's popup calls setIsBlink in its render body | React logs "cannot update a component while rendering a different component" |
Empty-dependency useMemos | The legend and the page dropdown both memoise with [] | Correct only because the key on ReactFlow remounts them. The 5-min refresh does not refresh the page dropdown's option list |
| Edges look clickable but aren't | All edges set cursor: pointer and interactionWidth: 100 with no handler | A misleading 100 px-wide hit area |
DownloadPopupTable.jsx is dead | Never imported, and would throw — it uses useContext and the context without importing either | Do not revive as-is |
| Unreachable legend group | InfoPopupDetails.SUMMARY can never match, since infoMenuKey is only ever SUMMARY_INFO or DETAIL_INFO | Dead code |
| Redundant toggle | handleBasicInfoClick calls setOpenInfoPopup twice with the same computed value | Harmless — it toggles once — but confusing |
| Unused class | The page's .download-image class is never targeted; capture uses the dialog ref | Dead styling hook |
Code reference
| File | Role |
|---|---|
WaterBalancePage.jsx | Page shell, provider, empty-state wiring |
dataSource/waterBalance.js | The waterBalance/data call |
controller/waterBalanceContoller.js | Pass-through to the data source |
store/WaterBalanceStore.js | Context state, refetch + auto-refresh logic |
enums/waterBalanceEnums.js | View and granularity enums |
components/WaterBalanceHeading.jsx | Title, search, granularity select, date picker, Today |
components/BuildWaterBalanceGraph.jsx | React Flow host, view toggle, page dropdown, controls |
components/WaterBalanceGraphComponents.jsx | nodeTypes / edgeTypes / download-edge maps |
components/nodes/* | CARD, TANK, OVAL, PROCESS, PUMP, VALVE |
components/summaryNodes/* | The four summary tile types + the member-unit popup |
components/edges/* | The three live edge types + the still one used for export |
components/animated/AnimatedArrow.jsx | Flow-direction arrows |
components/WaterBalanceDownloadPopup.jsx | Export dialog + PNG capture |
components/WaterBalanceInfoPopup.jsx | Legend |
components/ContactForDiagram.jsx | No-diagram fallback |
style/markerStyle.js | The invisible-handle style |
Related
- Application Routes — the
/water_balance/graphroute - Permissions — the
WATER_BALANCEgate and the feature-locked path - Reports — the separate water-balance report page
- Water Usage Ratio · Water Monitoring — sibling water features
- Components — the date pickers and shared
WaterTank - API Call Flow · State Management