GL Dashboard Specification
WebGL-accelerated geospatial dashboard for WEPPcloud runs using deck.gl, providing interactive maps, timeseries graphs, and multi-scenario comparison
Version: 1.0
Last Updated: 2026-03-31
Status: Production
Table of Contents
- Overview
- Architecture
- Access Control
- Component Map
- UI Structure
- Data Flow
- Layer System
- Graph System
- State Management
- Interaction Patterns
- Performance Considerations
- Testing Strategy
- Known Issues and Workarounds
Overview
The GL Dashboard is a WebGL-powered visualization tool that provides real-time exploration of WEPPcloud modeling results. It replaces legacy Leaflet-based controls with a deck.gl renderer capable of handling large datasets efficiently. The dashboard supports multiple layer types (raster, vector, timeseries), scenario comparison, and interactive graph generation.
Primary Use Cases:
- Visual inspection of landuse, soils, and watershed delineation
- WEPP model output analysis (yearly, event-based, cumulative)
- RAP (Rangeland Analysis Platform) timeseries exploration
- WATAR (Wildfire Ash Transport) overlay visualization
- OpenET evapotranspiration overlays (monthly) and yearly ET trends
- Multi-scenario comparison with differential colormaps
Key Features:
- Basemap Selection: Google Terrain, Google Satellite, OpenStreetMap
- Layer Detection: Automatic discovery of available datasets (landuse/nlcd.tif, soils/ssurgo.tif, WEPP outputs, RAP parquet files, OpenET parquet)
- Channels Support: Pass 2 channel order overlay (color by order) with optional labels, plus WEPP channel discharge/soil loss overlays (all-years + yearly)
- Stacked Layout: Map viewport above a collapsible graph panel; graph focus hides the map to give the canvas full height
- Year Slider: Interactive timeline control for RAP, WEPP Yearly, Climate Yearly, and OpenET Yearly
- Month Slider: OpenET-only month index slider (play/pause) that hides the year slider
- Legends Panel: Floating, collapsible panel showing active layer colormaps
- Omni Graph Integration: Boxplot/bar chart visualizations for scenario + contrast analysis
- OpenET Yearly Graph: Full-size ET graph with dataset radios + water year controls
Access Control
- Route:
/runs/<runid>/<config>/gl-dashboarduses sharedauthorize()checks. - Public runs (
PUBLICmarker on disk;Ron.ispublic(wd) == True) are readable without ownership. - Non-public runs still require ownership or elevated role (
Admin|Root). - Omni child slugs inherit parent run access policy because authorization resolves against the parent run id.
Architecture
Technology Stack
- Rendering: deck.gl 9.x (WebGL2 tile layer, GeoJSON layer, bitmap layer)
- Module System: ES6 modules with dynamic imports;
gl-dashboard.jscarries its own cache-busting query string into every module import (Safari-safe), so shared state must tolerate multiple module instances. - State Management: Centralized mutable state with subscription notifications
- Query Engine: DuckDB-powered backend for parquet/GeoJSON queries
- Color Scales: colormap library (viridis, rdbu, winter, jet2) + custom NLCD/soil palettes
File Structure (modularized)
wepppy/weppcloud/static/js/gl-dashboard/
├── config.js # Constants, layer/graph defs, colormaps
├── state.js # Centralized state + subscriptions
├── colors.js # Colormap + normalization helpers
├── scenario/manager.js # Scenario/comparison switching + diff ranges
├── data/
│ ├── query-engine.js # Query Engine fetch helpers (sitePrefix-aware)
│ ├── wepp-data.js # WEPP stat/yearly/event fetch + base/comparison
│ └── openet-data.js # OpenET metadata + monthly summary refresh (base-only)
├── ui/
│ ├── graph-mode.js # Graph mode/layout controller + slider placement
│ ├── year-slider.js # Timeline control (show/hide/playback)
│ └── month-slider.js # Month index slider for OpenET overlays
├── graphs/
│ ├── timeseries-graph.js # Canvas-based graph renderer
│ └── graph-loaders.js # Data loaders for Omni/RAP/WEPP graphs
├── layers/
│ ├── detector.js # Overlay detection (raster, vector, overlays)
│ ├── orchestrator.js # Detection sequencing + state wiring
│ └── renderer.js # Sidebar/legend DOM rendering
├── map/
│ ├── controller.js # deck.gl wrapper
│ ├── layers.js # Layer stack builder, tooltips, legends
│ └── raster-utils.js # GeoTIFF/SBS loaders, gdalinfo fetch
└── gl-dashboard.js # Thin orchestrator (imports, DI, DOM binding)
wepppy/weppcloud/templates/
└── gl_dashboard.htm # Jinja template with CSS and HTML structure
Module Dependencies (high level)
gl-dashboard.js (main)
├── config.js
├── state.js
├── colors.js
├── scenario/manager.js
├── data/query-engine.js
├── data/wepp-data.js
├── data/openet-data.js
├── ui/graph-mode.js
├── ui/year-slider.js
├── ui/month-slider.js
├── graphs/timeseries-graph.js
├── graphs/graph-loaders.js
├── layers/detector.js
├── layers/orchestrator.js
├── layers/renderer.js
├── map/layers.js
├── map/controller.js
└── map/raster-utils.js
Initialization Flow:
- Load context from
window.GL_DASHBOARD_CONTEXT(injected by Flask template; includesomniScenariosandomniContrastswhen available) - Dynamic
import()of modules; instantiate controllers with dependency injection (state getters/setters, fetch helpers, render callbacks) - Initialize deck.gl controller with basemap tile layer
- Kick off detection (raster gdalinfo + parquet summaries) asynchronously; render placeholder layer controls immediately, then populate once detection resolves (non-blocking page load)
- Bind UI event listeners (basemap selector, scenario/comparison, layer toggles, graph mode buttons, year slider)
- Apply initial layer stack (subcatchments visible by default); graph layout sync via
syncGraphLayout()is idempotent and handles year/month slider placement
Component Map
Core Modules
state.js
Purpose: Single source of truth for all runtime state
Exports:
getState()→ Returns state objectgetValue(key)→ Get single valuesetState(updates)→ Batch update with change notificationsetValue(key, value)→ Single key update
Cache-Safety Notes:
- State lives on
window.__GL_DASHBOARD_STATE__with subscribers onwindow.__GL_DASHBOARD_STATE_SUBSCRIBERS__. state.jsmerges missing defaults into an existing global state object instead of reinitializing; this prevents missing keys when module versions are mixed (e.g., cache-bustedgl-dashboard.js+ cached submodules).
Key State Properties:
{
currentBasemapKey: 'googleTerrain',
currentViewState: { longitude, latitude, zoom, pitch, bearing },
subcatchmentsVisible: true,
subcatchmentLabelsVisible: false,
channelsVisible: true,
channelLabelsVisible: false,
// Graph state
graphMode: 'minimized' | 'split' | 'full',
graphFocus: false,
activeGraphKey: null,
// Scenario state
currentScenarioPath: '',
comparisonMode: false,
// Layer summaries (loaded from Query Engine)
landuseSummary: { [topaz_id]: { dom: value, ... } },
soilsSummary: { [topaz_id]: { mukey: value } },
weppSummary: { [topaz_id]: { runoff_volume, soil_loss, ... } },
rapSummary: { [topaz_id]: { AFG: value, PFG: value, ... } },
// RAP state
rapMetadata: { years: [2000, 2001, ...], bands: [...] },
rapSelectedYear: 2023,
rapCumulativeMode: false,
rapLayers: [{ key, label, band, visible, ... }],
// Channels state
channelsGeoJson: FeatureCollection,
channelLabelsData: [{ position, text }],
channelsLayers: [{ key, label, mode, visible, ... }], // Channel Order
weppChannelSummary: { [topaz_id]: { channel_discharge_volume, channel_soil_loss, ... } },
weppChannelRanges: { channel_discharge_volume: { min, max }, channel_soil_loss: { min, max } },
weppChannelLayers: [{ key, label, mode, visible, ... }],
// WEPP Yearly state
weppYearlyMetadata: { years: [...], minYear, maxYear },
weppYearlySelectedYear: 2023,
weppYearlySummary: { [topaz_id]: { runoff, sedyld, ... } },
weppYearlyLayers: [{ key, label, mode, visible, ... }],
weppYearlyChannelSummary: { [topaz_id]: { channel_discharge_volume, channel_soil_loss, ... } },
weppYearlyChannelRanges: { channel_discharge_volume: { min, max }, channel_soil_loss: { min, max } },
weppYearlyChannelLayers: [{ key, label, mode, visible, ... }],
weppYearlyChannelCache: { [year]: summary },
// WEPP Event state
weppEventMetadata: { startDate, endDate },
weppEventSelectedDate: 'YYYY-MM-DD',
weppEventSummary: { [topaz_id]: { event_P, event_Q, event_ET, event_Saturation, event_peakro, event_tdet } },
weppEventLayers: [{ key, label, mode, visible, ... }],
// Cached data
graphDataCache: {},
hillLossCache: {},
channelLossCache: {},
weppYearlyCache: {},
}
config.js
Purpose: Static configuration and constants
Exports:
COMPARISON_MEASURES: Array of measure keys for scenario diffWATER_MEASURES,SOIL_MEASURES: Categorization for colormapsBASE_LAYER_DEFS: Raster layer paths (landuse/nlcd.tif, soils/ssurgo.tif)GRAPH_DEFS: Graph definitions for sidebar (Omni scenarios + Omni contrasts)createBasemapDefs(): Basemap tile URL templatescreateColorScales(colormapFn): Viridis, rdbu, winter, jet2 scales
layers/detector.js
Purpose: Detect available datasets from run directory
Key Functions:
detectRasterLayers(): Fetch gdalinfo for nlcd.tif, ssurgo.tif, sbs mapdetectLanduseOverlays(): Query landuse summary via Query EnginedetectSoilsOverlays(): Query soils summarydetectHillslopesOverlays(): Query hillslope summary (area, aspect, slope)detectWeppOverlays(): Query WEPP summary (runoff, sediment)detectChannelsOverlays(): Load channels GeoJSON + label positions (pass 2)detectWeppChannelOverlays(): Query WEPP channel summary (all-years)detectWeppYearlyChannelOverlays(): Query WEPP yearly channel metadata + summariesdetectWeppYearlyOverlays(): Query WEPP yearly metadata and yearsdetectWeppEventOverlays(): Query WEPP event metadata and datesdetectRapOverlays(): Query RAP metadata and bandsdetectWatarOverlays(): Query WATAR summary (ash loading, sediment)
Detection Strategy:
- Fetch gdalinfo JSON for each raster path
- Compute WGS84 bounds from corner coordinates
- Load subcatchments GeoJSON if not cached
- Query parquet files via Query Engine for vector overlays
- Build layer descriptor objects with visibility flags
map/controller.js
Purpose: Thin wrapper around deck.gl instance
Exports: createMapController(options)
Methods:
applyLayers(nextLayers): Update deck.gl layer stacksetViewState(viewState): Programmatic camera control
map/layers.js
Purpose: Layer stack builder and utility functions
Exports: createLayerUtils(options)
Methods:
buildLayerStack(baseLayer): Construct ordered layer arrayformatTooltip(info): Generate hover tooltip HTMLbuildLanduseLayer(summary, colorMap): GeoJSON layer with NLCD fill colorsbuildSoilsLayer(summary, colorFn): GeoJSON layer with mukey colorsbuildWeppLayer(summary, mode, colorScale, ranges): WEPP overlay with viridis/rdbubuildRapLayer(summary, band, colorScale, ranges): RAP band overlaybuildOpenetLayers(state): OpenET overlay (monthly ET)buildWatarLayer(summary, mode, colorScale, ranges): WATAR overlaybuildChannelsLayer(state): Channel Order overlay (pass 2)buildWeppChannelLayers(state): WEPP channel overlays (all-years)buildWeppYearlyChannelLayers(state): WEPP yearly channel overlaysbuildRasterLayer(layerDef): Bitmap layer from GeoTIFF
Layer Ordering (bottom to top):
- Basemap tiles (Google Terrain / Satellite / ESRI / OSM / OTM)
- Vector overlays (landuse, soils, WEPP, RAP, OpenET, WATAR summaries)
- Raster overlays (landuse GeoTIFF, soils GeoTIFF, SBS map)
- Channels (Channel Order, WEPP channels, WEPP yearly channels)
- Labels (subcatchment labels, channel labels)
graphs/timeseries-graph.js
Purpose: Canvas-based timeseries renderer (line, boxplot, bars)
Exports: createTimeseriesGraph(options)
Methods:
init(): Bind canvas, attach mouse listeners, resize handlersetData(data): Accept graph dataset and trigger rendersetCurrentYear(year): Update year cursor for line graphshighlightSubcatchment(topazId): Emphasize line in graphrender(): Dispatch to_renderLine(),_renderBoxplot(), or_renderBars()
Graph Data Format:
{
type: 'line' | 'boxplot' | 'bars',
title: 'Graph Title',
source: 'rap' | 'wepp-yearly' | 'openet' | 'openet_yearly' | 'climate_yearly' | 'omni',
years: [2000, 2001, ...],
series: {
[topaz_id]: [value1, value2, ...], // for line graphs
// OR
[topaz_id]: { stats: { min, q1, median, q3, max } } // for boxplot
},
categories: ['Base', 'Scenario1', 'Contrast 1', ...], // for bars
currentYear: 2023,
tooltipFormatter: (data) => '<div>...',
}
graphs/graph-loaders.js
Purpose: Fetch and transform data for graphs
Exports: createGraphLoaders(options)
Methods:
-
loadGraphDataset(key, options): Dispatcher for graph types -
loadOmniSoilLossHillGraph(): Boxplot of hillslope soil loss across scenarios (and contrasts) -
loadOmniSoilLossChnGraph(): Boxplot of channel soil loss -
loadOmniRunoffHillGraph(): Boxplot of hillslope runoff -
loadOmniOutletSedimentGraph(): Bar chart of outlet sediment discharge -
loadOmniOutletStreamGraph(): Bar chart of outlet stream discharge -
buildOpenetTimeseriesData(): OpenET monthly hillslope timeseries (base-only) -
buildOpenetYearlyGraph(): OpenET yearly ET graph (area-weighted)Query Pattern (Omni Graphs):
- For each scenario or contrast, query the interchange parquet files (
wepp/output/interchange/loss_pw0.hill.parquet,loss_pw0.chn.parquet,loss_pw0.all_years.out.parquet). - Join with hillslope area (in m²) from
watershed/hillslopes.parquetfor unit conversions. - Apply unit conversions:
- Water measures (runoff, subrunoff, baseflow):
(m³ / area_m²) * 1000 = mm(depth) - Soil measures (soil_loss, sediment_deposition, sediment_yield):
(kg / area_m²) * 10 = t/ha(mass per area)
- Water measures (runoff, subrunoff, baseflow):
- Compute statistics (min, q1, median, q3, max) per scenario/contrast for boxplots; outlet bars come from
loss_pw0.all_years.out.parquet. - Return boxplot series keyed by scenario/contrast and outlet bar series keyed by year.
- Contrast runs are queried via composite runid (
{parent};;omni-contrast;;<id>) and do not use the bodyscenarioparameter.
- For each scenario or contrast, query the interchange parquet files (
data/query-engine.js
Purpose:
Note: Query Engine endpoints are served at the root (/query-engine/...) and are not prefixed by sitePrefix.
Abstracts Query Engine HTTP calls
Exports: createQueryEngine(ctx)
Methods:
postQueryEngine(payload): POST to/query-engine/runs/{runid}/{config}/query(current scenario in body)postBaseQueryEngine(payload): POST to base run (no scenario body)postQueryEngineForScenario(payload, scenarioPath): POST to specific Omni scenario (addsscenarioto body)- Omni contrasts: use runid
{parent};;omni-contrast;;<id>and the base POST path; do not setscenario.
Query Payload Format:
{
datasets: [
{ path: 'wepp/output/interchange/loss_pw0.hill.parquet', alias: 'hill' },
{ path: 'watershed/hillslopes.parquet', alias: 'meta' }
],
joins: [
{ left: 'hill', right: 'meta', on: 'wepp_id', type: 'inner' }
],
columns: ['meta.topaz_id', 'hill."Soil Loss" AS sedyld'],
aggregations: [
{ sql: 'SUM(hill."Soil Loss")', alias: 'total_sediment' }
],
filters: [
{ column: 'hill.year', op: '=', value: 2023 }
],
group_by: ['meta.topaz_id']
}
data/wepp-data.js
Purpose: WEPP summary/yearly/event fetchers for base + comparison; caches ranges
Notes: Uses Query Engine helpers and scenario-aware paths; drives WEPP overlays + graphs.
data/openet-data.js
Purpose: OpenET metadata + monthly summary refresh (base-only)
Notes: Reads openetSelectedDatasetKey + month index, updates openetSummary + openetRanges.
UI Components
Basemap Selector
Element: #gl-basemap-select (custom dropdown)
Options: googleTerrain, googleSatellite, osm
Handler: setBasemap(key) → rebuilds basemap tile layer and calls applyLayers()
Scenario Selector
Element: #gl-scenario-select (select dropdown)
Options: Base (empty path) + Omni scenarios from ctx.omniScenarios + Omni contrasts from ctx.omniContrasts (appended)
Handler: setScenario(scenarioPath) → updates state, refetches overlays, recalculates comparison diff ranges
Comparison Toggle
Element: #gl-comparison-toggle (checkbox)
Handler: setComparisonMode(enabled) → loads base scenario summaries, computes diff ranges for rdbu colormaps
Subcatchments Toggle
Element: #gl-subcatchments-toggle (checkbox)
Handler: toggleSubcatchments(visible) → shows/hides all subcatchment-based overlays
Subcatchment Labels Toggle
Element: #gl-subcatchment-labels-toggle (checkbox)
Handler: toggleSubcatchmentLabels(visible) → renders TextLayer with topaz_id centroids
Channels Toggle
Element: #gl-channels-toggle (checkbox)
Handler: toggleChannels(visible) → shows/hides channel order + WEPP channel overlays
Channel Labels Toggle
Element: #gl-channel-labels-toggle (checkbox)
Handler: toggleChannelLabels(visible) → renders TextLayer with channel order labels
Layer List
Element: #gl-layer-list
Structure:
<ul id="gl-layer-list">
<details class="gl-layer-details" open>
<summary>Landuse</summary>
<ul class="gl-layer-items">
<li class="gl-layer-item">
<input type="radio" name="subcatchment-overlay" id="layer-Landuse-dom" />
<label for="layer-Landuse-dom">Dominant Cover</label>
</li>
</ul>
</details>
<details class="gl-layer-details">
<summary>Channels</summary>
<ul class="gl-layer-items">
<li class="gl-layer-item">
<input type="radio" name="wepp-channel-overlay" id="layer-Channels-channel-order" />
<label for="layer-Channels-channel-order">Channel Order</label>
</li>
</ul>
</details>
<details class="gl-layer-details">
<summary>WEPP</summary>
<ul class="gl-layer-items">
<li class="gl-layer-item">
<input type="radio" name="subcatchment-overlay" id="layer-WEPP-runoff" />
<label for="layer-WEPP-runoff">Runoff (mm)</label>
</li>
</ul>
<div class="gl-layer-subheading">Channels</div>
<ul class="gl-layer-items">
<li class="gl-layer-item">
<input type="radio" name="wepp-channel-overlay" id="layer-WEPP-Channel-wepp-channel-discharge" />
<label for="layer-WEPP-Channel-wepp-channel-discharge">Discharge Volume (m^3)</label>
</li>
<li class="gl-layer-item">
<input type="radio" name="wepp-channel-overlay" id="layer-WEPP-Channel-wepp-channel-soil-loss" />
<label for="layer-WEPP-Channel-wepp-channel-soil-loss">Soil Loss (kg)</label>
</li>
</ul>
</details>
<details class="gl-layer-details">
<summary>WEPP Yearly</summary>
<div class="gl-wepp-stat">
<div class="gl-wepp-stat__label">Statistic</div>
<div class="gl-wepp-stat__options">
<label><input type="radio" name="wepp-stat" value="mean" checked> Mean (Annual Average)</label>
<label><input type="radio" name="wepp-stat" value="p90"> 90th Percentile (Risk)</label>
<label><input type="radio" name="wepp-stat" value="sd"> Std. Deviation (Variability)</label>
<label><input type="radio" name="wepp-stat" value="cv"> CV % (Instability)</label>
</div>
</div>
<ul class="gl-layer-items">
<li class="gl-layer-item">
<input type="radio" name="subcatchment-overlay" id="layer-WEPP-Yearly-runoff" />
<label for="layer-WEPP-Yearly-runoff">Runoff (mm)</label>
</li>
<li class="gl-layer-item">
<input type="radio" name="subcatchment-overlay" id="layer-WEPP-Yearly-subrunoff" />
<label for="layer-WEPP-Yearly-subrunoff">Lateral Flow (mm)</label>
</li>
<li class="gl-layer-item">
<input type="radio" name="subcatchment-overlay" id="layer-WEPP-Yearly-baseflow" />
<label for="layer-WEPP-Yearly-baseflow">Baseflow (mm)</label>
</li>
<li class="gl-layer-item">
<input type="radio" name="subcatchment-overlay" id="layer-WEPP-Yearly-soil_loss" />
<label for="layer-WEPP-Yearly-soil_loss">Soil Loss (t/ha)</label>
</li>
<li class="gl-layer-item">
<input type="radio" name="subcatchment-overlay" id="layer-WEPP-Yearly-sediment_deposition" />
<label for="layer-WEPP-Yearly-sediment_deposition">Sediment Deposition (t/ha)</label>
</li>
<li class="gl-layer-item">
<input type="radio" name="subcatchment-overlay" id="layer-WEPP-Yearly-sediment_yield" />
<label for="layer-WEPP-Yearly-sediment_yield">Sediment Yield (t/ha)</label>
</li>
</ul>
<div class="gl-layer-subheading">Channels</div>
<ul class="gl-layer-items">
<li class="gl-layer-item">
<input type="radio" name="wepp-channel-overlay" id="layer-WEPP-Yearly-Channel-wepp-yearly-channel-discharge" />
<label for="layer-WEPP-Yearly-Channel-wepp-yearly-channel-discharge">Discharge Volume (m^3)</label>
</li>
<li class="gl-layer-item">
<input type="radio" name="wepp-channel-overlay" id="layer-WEPP-Yearly-Channel-wepp-yearly-channel-soil-loss" />
<label for="layer-WEPP-Yearly-Channel-wepp-yearly-channel-soil-loss">Soil Loss (kg)</label>
</li>
</ul>
</details>
<details class="gl-layer-details">
<summary>RAP</summary>
<div class="gl-rap-mode">
<input type="radio" name="rap-mode" id="layer-RAP-cumulative" />
<label for="layer-RAP-cumulative">Cumulative Cover</label>
</div>
<ul class="gl-layer-items">
<li class="gl-layer-item">
<input type="radio" name="subcatchment-overlay" id="layer-RAP-AFG" />
<label for="layer-RAP-AFG">Annual Forbs/Grasses (%)</label>
</li>
</ul>
</details>
<details class="gl-layer-details">
<summary>OpenET</summary>
<ul class="gl-layer-items">
<li class="gl-layer-item">
<input type="radio" name="subcatchment-overlay" id="layer-OpenET-ensemble" />
<label for="layer-OpenET-ensemble">Ensemble (ET mm)</label>
</li>
</ul>
</details>
</ul>
Subcatchment overlays render as radios (single selection across categories); raster entries render as checkboxes.
Channel overlays share their own radio group (wepp-channel-overlay) across Channel Order, WEPP channels, and WEPP Yearly channels; they remain independent from the subcatchment overlay group.
Label + Source Path Display:
- Layer rows render only the human-readable layer name (
.gl-layer-name). - Source file paths are no longer rendered as visible secondary text rows.
- Each layer label carries the source path in the
titleattribute, so file paths are hover-only tooltips. - This applies across Landuse/Soils/Hillslopes/Channels/WEPP/WEPP Yearly/WEPP Event/RAP/OpenET/RUSLE entries.
<label for="layer-Landuse-lu-cancov" title="landuse/landuse.parquet">
<span class="gl-layer-name">Canopy cover (cancov)</span>
</label>
Handler Pattern:
- Toggle checkbox/radio → update
layer.visibleflag in state - Call
deselectAllSubcatchmentOverlays()if switching to new category (mutually exclusive) - Call
applyLayers()→ rebuilds deck.gl stack - Call
syncGraphLayout()→ determines if year/month slider should appear - OpenET radio sets
openetSelectedDatasetKeyand refreshes OpenET summary/ranges
Graph List
Element: #gl-graph-list
Structure:
<ul id="gl-graph-list">
<details class="gl-layer-details" open>
<summary>Omni Scenarios</summary>
<ul class="gl-layer-items">
<li class="gl-layer-item">
<input type="radio" name="graph-selection" id="graph-omni-soil-loss-hill" />
<label for="graph-omni-soil-loss-hill">Soil Loss (hillslopes, tonne/ha)</label>
</li>
</ul>
</details>
<details class="gl-layer-details">
<summary>Omni Contrasts</summary>
<ul class="gl-layer-items">
<li class="gl-layer-item">
<input type="radio" name="graph-selection" id="graph-omni-contrast-soil-loss-hill" />
<label for="graph-omni-contrast-soil-loss-hill">Soil Loss (hillslopes, tonne/ha)</label>
</li>
</ul>
</details>
<details class="gl-layer-details">
<summary>OpenET Yearly</summary>
<div class="gl-openet-year-controls">
<label><input type="radio" name="openet-yearly-dataset" value="ensemble"> Ensemble</label>
<label><input type="radio" name="openet-yearly-dataset" value="eemetric"> eemetric</label>
<label><input type="radio" name="openet-year-mode" value="calendar"> Calendar Year</label>
<label><input type="radio" name="openet-year-mode" value="water"> Water Year</label>
<select id="gl-openet-year-start-month">
<option value="10">Oct</option>
</select>
</div>
<ul class="gl-layer-items">
<li class="gl-layer-item">
<input type="radio" name="graph-selection" id="graph-openet-yearly" />
<label for="graph-openet-yearly">OpenET Yearly</label>
</li>
</ul>
</details>
</ul>
Handler: activateGraphItem(key) → fetches data, calls timeseriesGraph.setData(), ensures graph panel is expanded
Graph Panel
Element: #gl-graph
Classes: .is-collapsed (minimized mode)
Controls:
- Mode Buttons:
[data-graph-mode="minimized|split|full"]- Minimized: Panel height 48px, graph hidden
- Split: Panel height ~640px, graph sits below the map in the stacked layout
- Full: Graph focus hides the map viewport; graph stretches vertically (80vh canvas)
- Canvas:
#gl-graph-canvas(520px height, devicePixelRatio scaling) - Tooltip:
#gl-graph-tooltip(positioned absolute on hover)
State Transitions:
Map-only overlays (Landuse/Soils/WEPP/WEPP Event/WATAR)
↓ auto
Graph minimized (controls disabled; slider hidden)
↓ RAP cumulative, any visible RAP overlay, any visible WEPP Yearly overlay, or OpenET overlay
Graph split (controls enabled; slider visible; OpenET uses month slider only when the OpenET context is active)
↓ Omni graph activated, Climate Yearly, OpenET Yearly, OR user clicks full
Graph full (focus; map hidden; split button disabled while Omni focused)
↓ user minimizes
Graph minimized
↓ switch back to map-only overlays
Graph minimized (controls disabled; slider hidden)
Graph Panel Modes
Minimized
State: graphMode = 'minimized', graphFocus = false
UI: Panel height 48px, content hidden, only header visible
Trigger: User clicks minimize, or no graph-capable layers active (map-only overlays)
Split
State: graphMode = 'split', graphFocus = false
UI: Panel height ~640px, map stacked above graph
Trigger: User clicks split, or RAP cumulative/visible RAP overlay, or WEPP Yearly/OpenET overlay becomes active (OpenET hides year slider and shows month slider only when OpenET is the active context)
Full
State: graphMode = 'full', graphFocus = true
UI: Graph focus hides the map viewport (graph stretches vertically)
Trigger: User clicks full, or any of the following are active:
- Omni graphs (scenarios + contrasts) or Cumulative Contribution → always force full by default
- Climate Yearly graph → full by default
- OpenET Yearly graph → full by default
- Explicit user override via layout buttons
Year Slider
Element: #gl-year-slider (single instance reused across contexts)
Placement & Visibility (context-aware):
- Climate Yearly / OpenET Yearly / Outlet graphs → slider is moved inside
#gl-graph-container, pinned to the bottom; container gets.has-bottom-sliderto add padding. Graph defaults to full pane. - RAP / WEPP Yearly → slider stays in the dedicated slot above the graph pane (
#gl-graph-year-slider) at 100% width; never overlaps the graph header. Graph defaults to split view. - Cumulative / Omni graphs (scenarios + contrasts) → slider is hidden (no timeline dimension).
- Hidden when no RAP/WEPP/Climate/OpenET Yearly graph context is active or when the OpenET month slider is visible.
Controls:
- Input:
#gl-year-slider-input(range slider) - Min/Max:
#gl-year-slider-min/#gl-year-slider-max - Current:
#gl-year-slider-value - Play:
#gl-year-slider-play(▶ / ⏸)
Behavior:
- On input change → emit
change→ refresh active RAP/WEPP Yearly layers and update the graph year. - Play mode: advance 1 year every 3 seconds; loops to min when exceeding max.
- Range sourced from context metadata:
- Climate:
ctx.climate.startYear/endYear - RAP:
rapMetadata.years - WEPP Yearly:
weppYearlyMetadata.minYear/maxYear
- Climate:
- Graph mode re-syncs on context changes; in-flight graph loads are de-duped per key to prevent reload loops.
Integration:
- RAP: updates
rapSelectedYear, refreshes overlays, and updates graph year when source israp. - WEPP Yearly: updates
weppYearlySelectedYear, refreshes overlays, and updates graph year when source iswepp_yearly. - Climate Yearly: updates
climateYearlySelectedYear, refreshes the climate graph, keeps full-pane focus. - OpenET Yearly: updates
openetYearlySelectedYear, refreshes the OpenET yearly graph, keeps full-pane focus.
Month Slider
Element: #gl-month-slider (single instance used for OpenET overlays)
Placement & Visibility:
- Visible only when an OpenET overlay is selected and the graph context resolves to OpenET (not Omni/Cumulative/Climate/WEPP Yearly).
- Uses the top slot (
#gl-graph-year-slider) and hides the year slider.
Controls:
- Input:
#gl-month-slider-input(range slider) - Min/Max:
#gl-month-slider-min/#gl-month-slider-max - Current:
#gl-month-slider-value - Play:
#gl-month-slider-play(▶ / ⏸)
Behavior:
- Range is an index into
openetMetadata.months(sorted list of{ year, month, label }). - On input change → updates
openetSelectedMonthIndex→refreshOpenetData()→ re-renders OpenET overlay + legend. - Play mode: advance 1 month every 3 seconds; loops to min when exceeding max.
- Selecting Omni/Cumulative/Climate/WEPP Yearly graphs hides the month slider even if the OpenET overlay remains selected.
Legends Panel
Element: #gl-legends-panel
Position: Absolute top-right corner of map (0.75rem offset)
Classes: .is-collapsed (toggled via header button)
Content: #gl-legends-content (vertical list of .gl-legend-section blocks)
Legend Format (Continuous):
<div class="gl-legend-section">
<h5 class="gl-legend-section__title">RUNOFF (MM)</h5>
<div class="gl-legend-continuous">
<div class="gl-legend-continuous__bar-wrapper">
<div class="gl-legend-continuous__bar gl-legend-continuous__bar--winter"></div>
</div>
<div class="gl-legend-continuous__labels">
<span>0.0</span>
<input
type="number"
class="gl-legend-range-input"
data-range-kind="continuous"
aria-label="Runoff legend maximum" />
</div>
<div class="gl-legend-continuous__unit">mm</div>
</div>
</div>
Legend Format (Diverging Comparison):
<div class="gl-legend-continuous gl-legend-diverging">
<div class="gl-legend-continuous__labels">
<span>-30.00</span>
<span>0</span>
<input
type="number"
class="gl-legend-range-input gl-legend-range-input--diverging"
data-range-kind="diverging"
aria-label="Runoff absolute difference maximum" />
</div>
</div>
Unit Display:
- Water measures:
mm(runoff_volume, subrunoff_volume, baseflow_volume) - Soil measures:
t/ha(soil_loss, sediment_deposition, sediment_yield) - CV statistic:
%(coefficient of variation, dimensionless percentage)
Update Trigger: updateLegendsPanel() runs during standard applyLayers() calls; it is intentionally skipped during live legend typing (skipLegendUpdate) and refreshed on commit (change/Enter).
Display Logic:
- Show legend for each visible layer
- Discrete legends (NLCD, soils) → color swatches with labels
- Continuous legends (WEPP, RAP, WATAR) → horizontal gradient bar with min/max
Editable Range Controls:
- Continuous legends expose an editable max input for
OpenET,WATAR,WEPP,WEPP Channels,WEPP Yearly,WEPP Yearly Channels, andWEPP Event. - Diverging legends expose an editable absolute max in comparison mode; entering
Xstoresmin=-Xandmax=+X. - On
input: map colors update immediately viaapplyLayers({ skipLegendUpdate: true, skipGraphSync: true })so the map redraws while focus stays in the input. - On
changeorEnter: the value is committed and the legends panel is rebuilt. - On
Escape: the value reverts to the last committed range and the input blurs. - Invalid values are clamped to a valid range (
max > minfor continuous,absMax > 0for diverging).
Theme + WCAG Notes:
.gl-legend-range-inputuses theme tokens for text/background/border (--wc-color-text,--wc-color-surface,--wc-color-border) and focus (--wc-color-primary).- Input width is intentionally widened (
9ch, min4.8rem) to reduce truncation and improve editability. - Contrast validation for control surfaces/borders is covered by the Theme Lab + theme metrics workflow in
docs/ui-docs/theme-system.mdanddocs/ui-docs/theme-metrics.spec.md.
Data Flow
Layer Activation Flow
User clicks layer checkbox/radio
↓
Event handler toggles layer.visible
↓
deselectAllSubcatchmentOverlays() if switching category
↓
applyLayers(options) → layerUtils.buildLayerStack()
↓
For each visible layer:
- Fetch summary if not cached
- Apply colormap (viridis, rdbu, NLCD, soil)
- Build GeoJSON layer with getFillColor callback
↓
mapController.applyLayers(stack)
↓
deck.gl re-renders
↓
if !options.skipLegendUpdate:
updateLegendsPanel() → show/hide legends
↓
if !options.skipGraphSync:
syncGraphLayout() → year/month slider visibility
Graph Activation Flow
User clicks graph radio button
↓
activateGraphItem(key)
↓
ensureGraphExpanded() → remove .is-collapsed
↓
loadGraphDataset(key) → graphLoaders dispatch
↓
For Omni graphs:
- Query each scenario's parquet files
- Compute boxplot stats (min, q1, median, q3, max)
- Join with hillslope area for normalization
↓
timeseriesGraph.setData(data)
↓
setGraphFocus(data.source === 'omni')
↓
If focus=true:
- glMainEl.classList.add('graph-focus')
- Graph panel expands to 70% width
- Map shrinks to 30% width
↓
timeseriesGraph.render()
↓
syncGraphModeForContext() updates mode buttons
Notes:
- OpenET Yearly sets focus/full + bottom year slider and highlights the selected year line.
- OpenET overlays load a monthly hillslope timeseries only when the graph panel is visible.
Scenario Comparison Flow
User selects scenario from dropdown
↓
setScenario(scenarioPath)
↓
currentScenarioPath = scenarioPath
↓
If comparisonMode enabled:
- loadBaseSummaries() (landuse, soils, WEPP, RAP, WATAR)
- computeComparisonDiffRanges() (percentile-based scaling)
↓
Detect overlays with new scenario path
↓
applyLayers() with rdbu colormap if comparison active
↓
Legends show "Δ" prefix and -max/+max range
Year Slider Flow
User drags year slider or clicks play
↓
yearSlider._emit('change', year)
↓
Event listener updates state:
- rapSelectedYear = year (for RAP)
- weppYearlySelectedYear = year (for WEPP Yearly)
- openetYearlySelectedYear = year (for OpenET Yearly)
- climateYearlySelectedYear = year (for Climate Yearly)
↓
refreshRapData(), refreshWeppYearlyData(), or reload climate/OpenET yearly graph
↓
Query Engine with year filter:
{
filters: [
{ column: 'rap.year', op: '=', value: year }
]
}
↓
rapSummary or weppYearlySummary updated
↓
applyLayers() → rebuild GeoJSON with new data
↓
timeseriesGraph.setCurrentYear(year) → update cursor in graph
Month Slider Flow
User drags month slider or clicks play
↓
monthSlider._emit('change', index)
↓
Event listener updates state:
- openetSelectedMonthIndex = index
↓
refreshOpenetData()
↓
Query Engine with dataset + year + month filter
↓
openetSummary/openetRanges updated
↓
applyLayers() → rebuild OpenET GeoJSON overlay + legend
↓
timeseriesGraph.setCurrentYear(selectedMonthX) → update cursor in OpenET graph
Legend Range Edit Flow
User edits `.gl-legend-range-input`
↓
input handler parses candidate value
↓
Update range state (`weppRanges`, `watarRanges`, `openetRanges`, etc.)
↓
applyLayers({ skipLegendUpdate: true, skipGraphSync: true })
↓
deck.gl re-renders with updated `getFillColor` updateTriggers
↓
Input focus remains in place (legend DOM not rebuilt)
On change / Enter:
- Commit value
- Rebuild legend (`updateLegendsPanel()`)
On Escape:
- Revert to last committed value
Layer System
Layer Types
Raster Layers
Sources: GeoTIFF files loaded via geotiff.js
Rendering: deck.gl BitmapLayer with RGBA array from canvas
Examples: landuse/nlcd.tif, soils/ssurgo.tif, BAER SBS map
Load Process:
- Fetch gdalinfo JSON to get bounds and dimensions
- Load GeoTIFF via
GeoTIFF.fromArrayBuffer() - Read raster data → Uint8Array or Float32Array
- Apply colormap (NLCD discrete, viridis continuous)
- Render to canvas → extract RGBA ImageData
- Pass to BitmapLayer with bounds
Colormap Application:
function applyColormap(rasterData, colorMap) {
const rgba = new Uint8ClampedArray(rasterData.length * 4);
for (let i = 0; i < rasterData.length; i++) {
const value = rasterData[i];
const color = colorMap[value] || [0, 0, 0, 0];
rgba[i * 4] = color[0];
rgba[i * 4 + 1] = color[1];
rgba[i * 4 + 2] = color[2];
rgba[i * 4 + 3] = color[3];
}
return rgba;
}
Vector Overlays (Subcatchment-based)
Sources: Parquet datasets queried via Query Engine
Rendering: deck.gl GeoJsonLayer with subcatchments geometry + attribute join
Examples: Landuse dom, Soils mukey, WEPP runoff, RAP AFG, OpenET ET, WATAR ash_loading
WEPP Output Units:
- Raw data in parquet files: Water volumes in m³, soil mass in kg
- Display units after conversion: Water depth in mm, soil mass in t/ha
- Conversion formulas:
- Water:
(value_m³ / hillslope_area_m²) * 1000 = mm - Soil:
(value_kg / hillslope_area_m²) * 10 = t/ha
- Water:
- Hillslope areas are retrieved from
watershed/hillslopes.parquetand joined onwepp_id
Data Structure:
// Summary from Query Engine (after conversion)
{
"123": { runoff_volume: 45.2, soil_loss: 2.1 }, // mm, t/ha
"456": { runoff_volume: 32.8, soil_loss: 1.5 } // mm, t/ha
}
// GeoJSON features
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": { "TopazID": "123", ... },
"geometry": { "type": "Polygon", "coordinates": [...] }
}
]
}
Colormap Note: Water measures default to the winter colormap (cool gradient) unless explicitly overridden.
Rendering Function:
function buildWeppLayer(summary, mode, colorScale, ranges) {
const range = ranges[mode] || { min: 0, max: 100 };
return new deck.GeoJsonLayer({
id: `wepp-${mode}`,
data: subcatchmentsGeoJson,
filled: true,
getFillColor: (feature) => {
const topazId = feature.properties.TopazID;
const row = summary[topazId];
if (!row) return [128, 128, 128, 100]; // Gray for missing
const value = row[mode];
if (!Number.isFinite(value)) return [128, 128, 128, 100];
const normalized = (value - range.min) / (range.max - range.min);
const idx = Math.floor(normalized * 255);
const color = colorScale[idx] || colorScale[255];
return [color[0], color[1], color[2], 230];
},
pickable: true,
stroked: true,
lineWidthMinPixels: 1,
getLineColor: [180, 180, 180, 255],
updateTriggers: {
getFillColor: [summary, mode, colorScale, ranges]
}
});
}
Channels (Pass 2)
Source: resources/channels.json
Rendering: deck.gl GeoJsonLayer (lines + fills)
Colormap: Order-based categorical palette (Order 1..7)
Notes:
- Channel Order is its own radio option under the Channels section.
- Controlled by the Channels toggle (
#gl-channels-toggle). - Legend title shows the active channel layer label (Channel Order).
WEPP Channel Overlays
Source: wepp/output/interchange/loss_pw0.all_years.chn.parquet
Metrics: channel_discharge_volume, channel_soil_loss
Geometry: channelsGeoJson (pass 2 channels)
Notes:
- Channel overlays use the same channel radio group as Channel Order.
- Yearly channel overlays are driven by the Year slider (
weppYearlySelectedYear).
WEPP Event Overlays
Sources: wepp/output/interchange/H.wat.parquet, wepp/output/interchange/H.pass.parquet, wepp/output/interchange/H.soil.parquet
Date filter: year, month, day_of_month from weppEventSelectedDate
Metrics: P (mm), Q (mm), ET (mm), Saturation (%), Peak Runoff Rate (m³/s), Total Detachment (kg)
Aggregation: P/Q/ET/tdet = SUM, peakro = MAX, Saturation = AVG(Saturation) * 100 (fixed 0-100 scale)
OpenET Monthly Overlays
Source: openet/openet_ts.parquet (base-only)
Fields: dataset_key, year, month, topaz_id, value (mm)
Selection: User picks dataset radio + month slider index (openetSelectedMonthIndex)
Summary: openetSummary[topaz_id] = value for the selected dataset + month
Ranges: openetRanges.min/max used for legend scaling
Colormap: winter (continuous)
Comparison Layers (Differential Colormaps)
Mode: Activated via comparison toggle
Colormap: rdbu (red-blue diverging scale)
Calculation: Δ = Base - Scenario (positive = scenario reduces measure)
Range Computation:
function computeComparisonDiffRanges() {
const diffs = [];
for (const topazId of Object.keys(weppSummary)) {
const scenarioValue = weppSummary[topazId].soil_loss;
const baseValue = baseSummaryCache.wepp[topazId].soil_loss;
if (Number.isFinite(scenarioValue) && Number.isFinite(baseValue)) {
diffs.push(baseValue - scenarioValue);
}
}
diffs.sort((a, b) => a - b);
const p5 = diffs[Math.floor(diffs.length * 0.05)];
const p95 = diffs[Math.floor(diffs.length * 0.95)];
const maxAbs = Math.max(Math.abs(p5), Math.abs(p95));
comparisonDiffRanges.soil_loss = { min: -maxAbs, max: maxAbs };
}
Layer Lifecycle
Detection Phase
Trigger: Page load, scenario change
Functions: detectRasterLayers(), detectLanduseOverlays(), etc.
Output: Populate state arrays (landuseLayers, soilsLayers, rapLayers, etc.)
Rendering Phase
Trigger: User toggles layer checkbox, year slider change
Functions: applyLayers() → buildLayerStack() → mapController.applyLayers()
Steps:
- Collect all visible layers from state
- For each layer type, call corresponding builder (buildWeppLayer, buildRapLayer, etc.)
- Order layers (basemap → vector overlays → rasters → channels → D8 arrows → labels)
- Pass array to deck.gl controller
- Update legends panel
Cleanup Phase
Trigger: Layer deselected, scenario change
Actions:
- Remove layer from deck.gl stack
- Clear cached summary if scenario path changed
- Hide corresponding legend
Mutual Exclusivity Rules
Subcatchment overlays share a single radio group (one visible at a time):
- Landuse, Soils, Hillslopes, RAP, OpenET, WEPP, WEPP Yearly, WEPP Event, WATAR.
- RAP cumulative mode XOR individual bands (enforced via radio within RAP group).
- Raster layers (NLCD, soils GeoTIFF, SBS) remain independent checkboxes.
- D8 Direction (flow arrows from
dem/wbt/flovec.tif) is a Hillslopes checkbox and does not participate in the radio group.
Channel overlays share a dedicated radio group (one visible at a time):
- Channel Order, WEPP Channels (Discharge Volume/Soil Loss), WEPP Yearly Channels (Discharge Volume/Soil Loss).
- Channel overlays are gated by the Channels toggle.
Implementation:
function deselectAllSubcatchmentOverlays() {
landuseLayers.forEach((l) => (l.visible = false));
soilsLayers.forEach((l) => (l.visible = false));
openetLayers.forEach((l) => (l.visible = false));
// ... (repeat for all subcatchment categories)
rapCumulativeMode = false;
yearSlider.hide();
monthSlider.hide();
}
Graph System
Graph Types
Line Graph (Timeseries)
Use Case: RAP bands over time, WEPP Yearly measures over time
Data Format:
{
type: 'line',
title: 'Annual Forbs/Grasses (%)',
years: [2000, 2001, ..., 2023],
series: {
"123": [12.5, 13.2, 11.8, ...],
"456": [8.3, 9.1, 8.7, ...]
},
currentYear: 2023
}
Rendering:
- X-axis: Years (evenly spaced)
- Y-axis: Value range (auto-scaled from min/max)
- Lines: One per subcatchment (topaz_id), color from GRAPH_COLORS palette
- Cursor: Vertical line at currentYear
- Hover: Highlight line, show tooltip with topaz_id + value
- Legend: Color swatches with topaz_id labels (right side)
- OpenET monthly uses this shape with month-indexed X values and the month slider driving the cursor.
Boxplot
Use Case: Omni scenario + contrast comparison (soil loss, runoff across hillslopes)
Data Format:
{
type: 'boxplot',
title: 'Soil Loss (hillslopes, tonne/ha)',
source: 'omni',
categories: ['Base', 'Low Severity', 'Moderate Severity'],
series: {
"123": { stats: { min: 0.5, q1: 1.2, median: 1.8, q3: 2.5, max: 3.2 } },
"456": { stats: { min: 0.3, q1: 0.9, median: 1.3, q3: 1.9, max: 2.6 } }
}
}
Rendering:
- X-axis: Categories (scenarios + contrasts)
- Y-axis: Measure value (auto-scaled)
- Boxes: One per scenario/contrast, drawn with whiskers (min/max), box (q1/q3), median line
- Hover: Show stats for hovered scenario/contrast
- Color: Alternating shades from GRAPH_COLORS
Bar Chart
Use Case: Omni outlet discharge (sediment, stream) for scenarios + contrasts
Data Format:
{
type: 'bars',
title: 'Sediment discharge (tonne/ha)',
source: 'omni',
categories: ['Base', 'Low Severity', 'Moderate Severity'],
series: [
{ label: 'Base', value: 12.5 },
{ label: 'Low Severity', value: 18.3 },
{ label: 'Moderate Severity', value: 24.7 }
]
}
Rendering:
- X-axis: Categories
- Y-axis: Value (auto-scaled)
- Bars: Vertical bars with fill color, hover highlight
- Hover: Show value with 2 decimal precision
Cumulative Contribution (Omni)
Use Case: Rank hillslopes by per-area contribution for a selected measure and plot cumulative contribution by percent of total hillslope area.
Data Sources: wepp/output/interchange/loss_pw0.hill.parquet + watershed/hillslopes.parquet (per scenario or contrast).
Measures: Runoff (m³), Lateral Flow (m³), Baseflow (m³), Soil Loss (t), Sed Deposition (t), Sed Yield (t).
Controls (sidebar → Graphs → Cumulative Contribution detail):
- Measure dropdown (
CUMULATIVE_MEASURE_OPTIONS) to choose the variable. - Select Scenarios checkboxes (all Omni scenarios, then a small divider, then Omni contrasts, plus implicit Base). Base is always included even if unchecked/unchecked logic defaults to selected set. Computation:
- For each scenario/contrast independently, compute per-area derivative = measure / area_ha, sort descending, cumulative sum by area and value.
- Values converted to tonne for soil measures; water values stay in m³.
- Percent-of-area axis uses a fixed 0.5% step (0 → 100) and each scenario/contrast is linearly interpolated onto that axis (prevents jagged traces when selections have different hillslope counts). Data Format:
{
type: 'line',
title: 'Cumulative Contribution — Soil Loss (t)',
years: [0, 0.5, ..., 100], // percent of total hillslope area
series: {
base: { label: 'Base', values: [...], color: [r,g,b,a] },
'omni/path': { label: 'mulch_15', values: [...], color: [r,g,b,a] }
},
xLabel: 'Percent of Total Hillslope Area',
yLabel: 'Soil Loss (t)',
source: 'omni',
tooltipFormatter: (id, value, pct) => `${label}: ${value} t @ ${pct}% area`
}
Rendering:
- X-axis: Percent of total hillslope area.
- Y-axis: Cumulative measure.
- Lines: One per scenario/contrast; Base always shown. Legend uses scenario/contrast names.
- Hover: Shows scenario/contrast, cumulative value, and percent-of-area for the nearest point.
Climate Yearly (precip + temp)
Purpose: Visualize yearly climate by month with support for calendar year or water year start. Two stacked subplots share the month axis.
Data Source: climate/wepp_cli.parquet (queried via query-engine; supports base and scenario paths). Required columns: year, month (or mo), prcp, tmin, tmax.
Controls (Graphs → Climate Yearly detail):
- Year mode toggle:
Calendar YearorWater Year(default). Water Year start monthselect (default October, disabled when Calendar Year is selected).- Year slider fixed in the graph pane; highlights the selected year. Computation:
- Water Year: months are rotated to start at the selected month; months ≥ start month are assigned to
year+1to group a WY. Calendar Year forces start month to January. - Monthly precip: sum per month (no cumulative).
- Monthly temp: average Tmin/Tmax per month.
- Per-year series are color-coded (precip: magenta tones; Tmin: blue; Tmax: red). Highlighted year uses thicker strokes/opacity (shared with year slider). Data Format:
{
type: 'climate-yearly',
months: ['Oct', 'Nov', ..., 'Sep'], // rotated when WY
years: [2000, 2001, ...],
precipSeries: { 2000: { values: [mm...], color }, ... },
tempSeries: { 2000: { tmin: [...], tmax: [...], colors: { tmin, tmax } }, ... },
selectedYear: 2023,
currentYear: 2023,
waterYear: true,
startMonth: 10,
source: 'climate_yearly'
}
Rendering:
- Top subplot: monthly precip lines for all years; Y auto-scales to max monthly total.
- Bottom subplot: monthly Tmin/Tmax lines for all years; Y auto-scales to min/max across both Tmin/Tmax.
- Month labels use 3-char abbreviations; rotated for Water Year.
- Legend shows highlighted year + Tmin/Tmax keys; year slider hover updates the highlight.
- Hover tooltip returns nearest month/year with
P,Tmin,Tmaxvalues.
OpenET Yearly (ET by month)
Purpose: Aggregate OpenET ET by month and plot one line per year (base-only).
Data Sources: openet/openet_ts.parquet + watershed/hillslopes.parquet (area-weighted).
Controls (Graphs → OpenET Yearly detail):
- Dataset radio (
ensemble,eemetric, etc.) - Year mode toggle:
Calendar YearorWater Year(default). Water Year start monthselect (default October, disabled when Calendar Year is selected).- Year slider in the graph pane; highlights the selected year line. Computation:
- Area-weighted mean ET:
SUM(openet.value * hill.area) / total_area(values in mm). - Water Year rotates months; months ≥ start month assigned to
year + 1. - No scenario support (base-only). Data Format:
{
type: 'line',
title: 'OpenET Yearly (ensemble)',
years: [1, 2, ..., 12], // month slots
seriesYears: [2000, 2001, ...],
series: { "2000": { values: [mm...], color }, ... },
selectedYear: 2023,
highlightSeriesId: "2023",
xLabel: 'Month',
yLabel: 'ET (mm)',
source: 'openet_yearly'
}
Graph Panel Modes
Minimized
State: graphMode = 'minimized', graphFocus = false
UI: Panel height 48px, content hidden, only header visible
Trigger: User clicks minimize button, or no graph-capable layers active
Split
State: graphMode = 'split', graphFocus = false
UI: Panel height ~640px, map width 70%, graph width 30%
Trigger: User clicks split button, or RAP/WEPP Yearly/OpenET overlay activated
Full
State: graphMode = 'full', graphFocus = true
UI: Panel height ~640px, map width 30%, graph width 70%
Trigger: User clicks full button, or Omni/Climate Yearly/OpenET Yearly graph activated
Mode Transition Logic
syncGraphLayout() Function:
function syncGraphLayout() {
const context = resolveGraphContext(state);
const openetSliderActive = isOpenetActive(state) && context.key === 'openet' && state.activeGraphKey !== 'openet-yearly';
// Idempotent: bail if layout key unchanged.
// (Key includes context, mode, slider placement, and OpenET slider state.)
positionYearSlider(context.slider);
positionMonthSlider(openetSliderActive);
if (openetSliderActive) {
yearSlider.hide();
}
}
State Management
Global State Contract
- State lives on
window.__GL_DASHBOARD_STATE__; subscribers live onwindow.__GL_DASHBOARD_STATE_SUBSCRIBERS__. state.jsnever replaces the state object after first creation. It merges missing defaults into the existing object to keep late-loaded modules consistent.- This is required because
gl-dashboard.jsappends its own cache-busting query string to all module imports; mixed module instances must still converge on the same state.
Do/Do Not
- Do: use
getState(),setState(),setValue()to trigger subscribers and keep legends/layout in sync. - Do not: reassign the state object or mutate nested objects without
setState(); it breaks change notifications.
Testing Strategy
- Playwright smoke tests: Live in
wepppy/weppcloud/static-src/tests/smoke/gl-dashboard-*.spec.js; run withGL_DASHBOARD_URL="https://.../gl-dashboard" npm run smoke -- tests/smoke/gl-dashboard-*.spec.js(or combineSMOKE_BASE_URL+GL_DASHBOARD_PATH). - OpenET coverage:
gl-dashboard-layers.spec.js+gl-dashboard-graph-modes.spec.js(skip when OpenET data is missing). - Renderer unit coverage:
wepppy/weppcloud/static/js/gl-dashboard/__tests__/renderer-open-state.test.jsvalidates hover-only filepath tooltips, editable continuous/diverging legend ranges, and symmetric diverging min/max behavior. - Fixtures: No dedicated fixture directory; specs exercise live WEPPcloud runs with inline helpers/mocks.
- Exploration script:
tests/gl-dashboard-exploration.spec.mjsis available for manual walkthroughs and doc captures. - Sanity check:
node --check wepppy/weppcloud/static/js/gl-dashboard.js.
Appendix A: Key File Paths
| Component | Path |
|---|---|
| Main orchestrator | wepppy/weppcloud/static/js/gl-dashboard/gl-dashboard.js |
| State management | wepppy/weppcloud/static/js/gl-dashboard/state.js |
| Configuration | wepppy/weppcloud/static/js/gl-dashboard/config.js |
| Color utilities | wepppy/weppcloud/static/js/gl-dashboard/colors.js |
| Layer detection | wepppy/weppcloud/static/js/gl-dashboard/layers/detector.js |
| Layer orchestration | wepppy/weppcloud/static/js/gl-dashboard/layers/orchestrator.js |
| Layer rendering | wepppy/weppcloud/static/js/gl-dashboard/layers/renderer.js |
| Map controller | wepppy/weppcloud/static/js/gl-dashboard/map/controller.js |
| Layer utilities | wepppy/weppcloud/static/js/gl-dashboard/map/layers.js |
| Raster utilities | wepppy/weppcloud/static/js/gl-dashboard/map/raster-utils.js |
| Timeseries graph | wepppy/weppcloud/static/js/gl-dashboard/graphs/timeseries-graph.js |
| Graph loaders | wepppy/weppcloud/static/js/gl-dashboard/graphs/graph-loaders.js |
| Query Engine | wepppy/weppcloud/static/js/gl-dashboard/data/query-engine.js |
| WEPP data manager | wepppy/weppcloud/static/js/gl-dashboard/data/wepp-data.js |
| OpenET data manager | wepppy/weppcloud/static/js/gl-dashboard/data/openet-data.js |
| Graph mode controller | wepppy/weppcloud/static/js/gl-dashboard/ui/graph-mode.js |
| Year slider | wepppy/weppcloud/static/js/gl-dashboard/ui/year-slider.js |
| Month slider | wepppy/weppcloud/static/js/gl-dashboard/ui/month-slider.js |
| Scenario manager | wepppy/weppcloud/static/js/gl-dashboard/scenario/manager.js |
| Template | wepppy/weppcloud/templates/gl_dashboard.htm |
| Smoke tests | wepppy/weppcloud/static-src/tests/smoke/gl-dashboard-*.spec.js |
Appendix B: Selector Reference
| Element | Selector | Purpose |
|---|---|---|
| Map container | #gl-dashboard-map |
deck.gl render target |
| Layer list | #gl-layer-list |
Sidebar layer checkboxes |
| Graph list | #gl-graph-list |
Sidebar graph radios |
| Legends panel | #gl-legends-panel |
Floating legends overlay |
| Legends content | #gl-legends-content |
Legend items container |
| Legend max input | .gl-legend-range-input |
Editable legend upper bound (continuous/diverging) |
| Graph panel | #gl-graph |
Bottom graph panel |
| Graph canvas | #gl-graph-canvas |
Canvas element for rendering |
| Graph tooltip | #gl-graph-tooltip |
Hover tooltip |
| Year slider | #gl-year-slider |
Year slider container |
| Year slider input | #gl-year-slider-input |
Range input |
| Year slider value | #gl-year-slider-value |
Current year display |
| Month slider | #gl-month-slider |
Month slider container |
| Month slider input | #gl-month-slider-input |
Range input |
| Month slider min | #gl-month-slider-min |
Min label |
| Month slider max | #gl-month-slider-max |
Max label |
| Month slider value | #gl-month-slider-value |
Current month display |
| Month slider play | #gl-month-slider-play |
Play/pause button |
| Basemap selector | #gl-basemap-select |
Basemap dropdown |
| Scenario selector | #gl-scenario-select |
Scenario dropdown |
| Comparison toggle | #gl-comparison-toggle |
Comparison checkbox |
| Subcatchments toggle | #gl-subcatchments-toggle |
Subcatchments checkbox |
| Subcatchment labels toggle | #gl-subcatchment-labels-toggle |
Labels checkbox |
| Graph mode buttons | [data-graph-mode] |
Min/split/full buttons |
Appendix C: Query Engine Payload Examples
Landuse Summary
{
"datasets": [
{ "path": "landuse/out.parquet", "alias": "landuse" },
{ "path": "watershed/hillslopes.parquet", "alias": "hill" }
],
"joins": [
{ "left": "landuse", "right": "hill", "on": "wepp_id", "type": "inner" }
],
"columns": ["hill.topaz_id"],
"aggregations": [
{ "sql": "MODE(landuse.dom)", "alias": "dom" }
],
"group_by": ["hill.topaz_id"]
}
WEPP Yearly (Year Filter)
{
"datasets": [
{ "path": "wepp/output/loss/hill_loss.parquet", "alias": "loss" },
{ "path": "watershed/hillslopes.parquet", "alias": "hill" }
],
"joins": [
{ "left": "loss", "right": "hill", "on": "wepp_id", "type": "inner" }
],
"columns": ["hill.topaz_id"],
"aggregations": [
{ "sql": "SUM(loss.runoff)", "alias": "runoff" },
{ "sql": "SUM(loss.sedyld)", "alias": "sedyld" }
],
"filters": [
{ "column": "loss.year", "op": "=", "value": 2023 }
],
"group_by": ["hill.topaz_id"]
}
RAP Timeseries (All Years)
{
"datasets": [
{ "path": "rap/rap.parquet", "alias": "rap" },
{ "path": "watershed/hillslopes.parquet", "alias": "hill" }
],
"joins": [
{ "left": "rap", "right": "hill", "on": "wepp_id", "type": "inner" }
],
"columns": ["hill.topaz_id", "rap.year"],
"aggregations": [
{ "sql": "AVG(rap.AFG)", "alias": "AFG" }
],
"group_by": ["hill.topaz_id", "rap.year"],
"order_by": [
{ "column": "rap.year", "direction": "ASC" }
]
}
OpenET Monthly Summary (dataset + month)
{
"datasets": [
{ "path": "openet/openet_ts.parquet", "alias": "openet" }
],
"columns": ["openet.topaz_id AS topaz_id", "openet.value AS value"],
"filters": [
{ "column": "openet.dataset_key", "op": "=", "value": "ensemble" },
{ "column": "openet.year", "op": "=", "value": 2023 },
{ "column": "openet.month", "op": "=", "value": 7 }
]
}
OpenET Yearly (area-weighted monthly mean)
{
"datasets": [
{ "path": "openet/openet_ts.parquet", "alias": "openet" },
{ "path": "watershed/hillslopes.parquet", "alias": "hill" }
],
"joins": [
{ "left": "openet", "right": "hill", "on": "topaz_id", "type": "inner" }
],
"columns": ["openet.year AS year", "openet.month AS month"],
"aggregations": [
{ "expression": "SUM(openet.value * hill.area)", "alias": "area_weighted" }
],
"filters": [
{ "column": "openet.dataset_key", "op": "=", "value": "ensemble" }
],
"group_by": ["openet.year", "openet.month"],
"order_by": ["year", "month"]
}
Document Status: Complete
Review: Ready for developer onboarding and testing team handoff
Maintenance: Update when new layer types or graph modes added