How the app is put together, and the shortest path to adding a tool of your
own. Read this once; then copy tools/single-season/ and start.
A single page (index.html) with no build step, plain ES modules, one design
system, one shared control library, and one mode (tool) per folder under
tools/. Every mode feeds the same water-balance engine in core/ and shows
its results in the same shell. Nothing is bundled or transpiled — save a file,
reload the browser.
index.html the only page; loads styles + app/main.js
core/ the FAO-56 engine — tested, shared among all the tools
app/main.js top bar, hash router, mode lifecycle ← add your route here
app/brand.js the app's display name
app/docs.js the "? Docs" drawer
app/geeSession.js Earth Engine sign-in persistence (only the GEE tools use it)
styles/tokens.css every colour/size as a CSS variable
styles/base.css reset + app frame (top bar, workbench, docs drawer)
styles/components.css groups, control rows, buttons, cards, tables, etc.
ui/ shared widgets
ui/panels/ shared input panels: weather, soil, crop, management, options
tools/<tool>/ one folder per tool
kansas_north_west*.csv bundled 46-year example weather record (raw, and with ETo)
-
Make the folder.
tools/my-tool/index.jsandtools/my-tool/docs.js. Split further (logic.js,charts.js) only when the file gets long; keep any pure calculation in its own module so it can be run under Node. -
Export the mode contract from
index.js:import { DOCS } from './docs.js'; export default { title: 'My Tool', // top bar + browser tab docs: DOCS, // { title, html } for the Docs drawer create, // () => root element — called ONCE, kept alive onShow, // optional; called every time the tool is shown };
create()runs once; the element is hidden/shown as the user navigates, so loaded data and results survive. Keep state in the closure — there is no global store. UseonShowonly for things that need a visible layout (fitting a map, resizing a chart). -
Build on the workbench. Every analysis tool uses the same two-part frame from
ui/workbench.js:const wb = createWorkbench({ inputsLabel: 'Inputs', outputsLabel: 'Outputs' }); wb.sidebar.append(gWeather.el, gSoil.el, …); // collapsible group()s wb.foot.append(runBar); // Run button + status wb.inputs.append(inputCharts.el); // left tab wb.outputs.append(resultsEl); // right tab return el('div', { class: 'view' }, wb.el); // exactly one .view per tool
Sidebar = compact controls; canvas = the room for charts. After a run:
wb.setOutputsEnabled(true); wb.showTab('outputs');. -
Reuse the panels. For a tool that takes weather/soil/crop/management, instantiate the shared panels (
ui/panels/*.js) — each returns{ el, get…() }shaped exactly the waycore/engine.jswants its inputs. Don't invent a parallel input format. -
Register the route in
app/main.jsROUTES:{ path: 'my-tool', label: 'My Tool', loader: () => import('../tools/my-tool/index.js') },
and add a card in
tools/home.jsCARDS(href#/my-tool, an inline SVG glyph, one-sentence description). That is the entire wiring. -
Write the docs for the user, not for yourself.
docs.jsexportsDOCS = { title, html }(trusted static HTML). Say what the tool does, what it needs, what the outputs mean and their caveats. Implementation notes, TODOs and "verify this" belong in code comments or your notes.
| module | gives you |
|---|---|
dom.js |
el(tag, attrs, …children) — the element builder. Skips null/false children, so cond ? el(…) : null is fine inside el(). (Native node.append(null) renders the text "null" — use an if.) |
components.js |
group, ctrl (label→control row), readout, numInput/textInput/dateInput/selectInput, checkbox/toggle/segmented, btn, metricsBar, dataTable, callout, openModal |
workbench.js |
the sidebar + tabbed-canvas shell above |
plotly.js |
the ONLY module that talks to Plotly: chartCard, plot, VIZ palette, linkZoom. Never call Plotly.* directly. |
inputCharts.js / seasonCharts.js |
the standard weather-input charts and the standard chart set for one engine run |
curveEditor.js, multiSlider.js |
the draggable Kcb curve and the θ slider used by the crop/soil panels |
format.js, download.js, csv.js |
number/date formatting; CSV/JSON/binary download; CSV parsing |
weatherSchema.js |
the accepted weather column names + validation |
Plotly is the only library loaded globally. Leaflet, Leaflet.draw, Google
Identity Services and the Earth Engine API are loaded lazily, on first open,
by the tool that needs them (see ensureLibs() in tools/gee-collector/ and
tools/field-scale/). Follow that pattern: don't add <script> tags to
index.html, and don't add a library when a hundred lines of plain JavaScript
will do (tools/field-scale/geotiff_writer.js is a GeoTIFF writer for exactly
that reason).
If two tools are the same machinery with different data, write a factory:
tools/field-scale/index.js exports createSpatialTool(config), and both Field
Scale and Mesoscale are one-line modules calling it.
- Never fork
core/. If a tool needs something the engine computes internally, export it fromcore/so everyone gets it. - Engine-shaped names everywhere. UI state uses the exact field names
runModelconsumes (Kcb_ini,L_dev,residue_cover…). No mapping layers. - Colours from
styles/tokens.cssandVIZonly. The series order inVIZis a colour-blind-safety property — append, never reshuffle. - Charts through
ui/plotly.jsonly, so fonts, margins, hover and the modebar stay identical app-wide. - Plain JavaScript.
.map/.filter/.reduceare welcome; deep optional-chaining chains, clever destructuring and ternary ladders are not when anifreads better. Comment the why, not the what. - One
.viewper tool, wrappingwb.el— the router toggleshiddenon it. Charts and maps built while that view is hidden have no size: defer fitting untilonShow/tab-show (bothui/plotly.jsand the spatial results view already do this). - The app's name lives in
app/brand.jsonly.
ES modules need http (not file://). Any static server works:
npx http-server -p 8123 -c-1 .then open http://localhost:8123. There is no build step and no install.
Pure modules (core/, a tool's logic.js) can be exercised directly with
node for quick checks.