The local problem

A blacktop schoolyard in Phoenix can run 20 °C hotter than the shaded park two blocks away. Children have recess on that blacktop, in July, in Arizona.

The fix is not complicated , plant trees. The hard part is the ask. A principal has to walk into a district facilities meeting and say how many trees, where exactly, what it costs, and how much cooler it actually gets. Without those four numbers it is a nice idea with no budget line, and it dies in the meeting.

Those numbers already exist, for free, in public satellite data. Landsat measures the temperature of the ground itself. Sentinel-2 measures vegetation at 10 m resolution. Both are open. But turning them into a document a school board will act on takes remote sensing, statistics and computational geometry that no school district has on staff.

That is the gap Canopy closes. It is a tool for one specific community institution , the local school , and the problem it solves is one a principal can describe in a sentence: our playground is too hot and I cannot get it funded.

What it does

Pick a school. Canopy reads the satellite scene over its recess yard and computes two measurements from raw pixel values:

  WHAT IT READS                 FROM              WHAT IT TELLS YOU
  ─────────────────────────────────────────────────────────────────────────
  NDVI  vegetation index        Sentinel-2        where the yard already has
                                red + NIR         tree canopy
  LST   land surface temp       Landsat 8/9       how hot each patch of
                                thermal Band 10   ground actually is

It masks cloud out of both, fits the relationship between them on that school's own pixels, and answers: if we add this much canopy, how much does the yard cool?

Then it proposes a planting layout, measures the crown overlap geometrically, prices the plan against a cited regional cost model, and emits a one-page costed document.

A real reading from the live app

John Jacobs Elementary School, Phoenix , exactly as the deployed tool renders it:

  CANOPY COVER NOW          AFTER THIS PLAN
  ████████░░░░░░░░░░░░      █████████████░░░░░░░
  24.2 %                    37.0 %
  Sentinel-2 B8/B4 at       crown union 1,524 m² after 6.5 %
  10 m · NDVI ≥ 0.62        MEASURED geometric overlap,
  hand-validated for        discounted for ground already
  this site · 2025-07-30    shaded · ~15-year maturity

  RECESS YARD SURFACE TEMPERATURE
  40.9 °C
  ├────────────────────────────────────────────────┤
  20    25    30    35    40 ▲   45    50    55  °C
                             this yard
  LANDSAT 9 B10 · 2025-07-29 · 10:42 local overpass
  mean of 2 thermal pixels at 100 m native
  peak afternoon is HOTTER than at overpass

  PREDICTED CHANGE AFTER PLANTING
  −1.1 °C        95% CI  [−1.2 … −1.0]
       ├──┼──┤
  −1.3 −1.1 −0.9
  OLS  LST ~ NDVI · R² = 0.618 · n = 400 px
  β₁ = −13.16 °C per NDVI unit · CI [−14.18, −12.14]

  Yard mean would move 40.9 °C → 39.8 °C. This is an
  ASSOCIATION from a fit on this scene, not a causal claim.

  COSTED PLAN ,  Portland, OR (cited)
  Large shade tree, 2" caliper       6    $4,272 – $5,664
  Medium shade tree, 2" caliper      6    $4,272 – $5,664
  ─────────────────────────────────────────────────────
  TOTAL                                   $8,544 – $11,328
  Every line resolves to the City of Portland Title 11
  Trees Fee Schedule, effective 2025-07-01.

Note what the tool volunteers without being asked: that the satellite passes over at 10:42 in the morning and the afternoon is hotter; that the canopy threshold was hand-validated for this specific yard; that the overlap was measured rather than assumed; and that the result is an association, not a cause.

The part that makes it different: it refuses

Any tool can produce a confident number. The hard engineering problem is building one that declines , reliably, every time.

                              a normal tool      Canopy
  ──────────────────────────────────────────────────────────────────
  clear scene, good fit          number           number
  40 % cloud over the yard       number ✗ WRONG   ⬤ REFUSED
  regression fit too weak        number ✗ WRONG   ⬤ SUPPRESSED
  cost line with no source       invented ✗       ⬤ UNSOURCED
  unknown pixel in an average    treated as 0     stays unknown

Three gates sit in the pipeline. Each can stop it.

   satellite imagery
          │
     ┌────┴────┐
    NDVI     LST
     └────┬────┘
     cloud mask
          │
   ╔══════▼═══════════════════╗
   ║ GATE 1  yard coverage    ║──── below 80 % ──▶ REFUSE (no temperature)
   ╚══════╤═══════════════════╝
          │  100.0 %  ✓ PASS
   ╔══════▼═══════════════════╗
   ║ GATE 2  regression fit   ║──── R² below 0.30 ──▶ SUPPRESS (ΔT withheld)
   ╚══════╤═══════════════════╝
          │  R² 0.618  ✓ PASS
   ╔══════▼═══════════════════╗
   ║ GATE 3  cost citations   ║──── any unsourced ──▶ TOTAL WITHHELD
   ╚══════╤═══════════════════╝
          │  2/2 lines  ✓ PASS
     one-page report

That gate panel is not a diagram we drew for this writeup , it is rendered live in the app, on every reading, with the real thresholds and the real verdicts.

Gate 1 , cloud. Canopy does not average whatever pixels survived the mask and hope. Coverage is computed and tested. Falling short produces a typed failure, not a number with an asterisk.

Gate 2 , weak fit. Prediction is a discriminated union with a suppressed variant, so the TypeScript compiler forces every renderer to handle it. A renderer physically cannot print a suppressed estimate. On a suppressed school the app still draws the scatter plot, with the line: "The scatter is shown even though the prediction is withheld , so you can see why."

Gate 3 , no citation, no number. A cost line without a source name, URL and retrieval date is excluded, and the headline total is blocked. Switch the region selector to Maricopa County and the app says: "Deliberately uncited , not broken. No published figure has been resolved for this region, so every line reads UNSOURCED and the total is withheld rather than guessed."

Flipping that one control is the entire product thesis in two seconds.

It declares its own data quality

A SYNTHETIC IMAGERY badge sits at the top of every reading: "Pixel values are generated, not observed. Yard geometry is real OpenStreetMap data." We put it in the most prominent position on the panel rather than a footnote.

How we built it

TypeScript monorepo, npm workspaces, Node 22, zero runtime dependencies in the computation core, MIT licensed. Ports-and-adapters so the science is testable with no browser and no network.

  packages/core/          pure computation ,  no UI, no I/O, no network
    raster/ndvi.ts        vegetation index
    raster/lst.ts         Landsat thermal chain, 4 steps
    raster/mask.ts        cloud masking + coverage        → GATE 1
    raster/resample.ts    100 m thermal → 10 m optical
    model/regression.ts   OLS + R² + real Student-t CI
    model/prediction.ts   ΔLST from ΔNDVI, gated          → GATE 2
    model/canopy.ts       crown union by 0.5 m quadrature
    model/suggestPlan.ts  placement: in-yard, hot, unshaded
    model/cost.ts         citation required per line      → GATE 3
  packages/render/        one SVG renderer: screen AND export
  packages/adapters/      imagery + cost, behind ports
  apps/web/               Vite + React 19, 5 typed UI states
  fixtures/schools/       4 schoolyards

The thermal chain

Four steps, each a pure function, each unit-tested against an independently known value:

  1  digital number  ──▶  radiance         L = M_L·Q_cal + A_L
  2  radiance        ──▶  brightness temp  BT = K₂ / ln(K₁/L + 1)   ← KELVIN
  3  NDVI            ──▶  emissivity       Pv = ((NDVI−0.2)/0.3)²
                                           ε  = 0.004·Pv + 0.986
  4  BT + ε          ──▶  LST              BT / (1 + (λ·BT/ρ)·ln ε)

Calibration constants are parsed per scene from the satellite's own metadata , never hardcoded, because they differ between scenes and between Landsat 8 and 9.

The statistics

Ordinary least squares with R² and a genuine 95 % confidence interval on the slope. The critical value is a Student-t quantile at n−2 degrees of freedom, computed from a regularised incomplete beta function (Lentz's continued fraction) over a Lanczos ln Γ, solved by bisection , not a hardcoded 1.96.

At n = 400 the difference is negligible. We did it anyway, because "where does that interval come from?" deserves a real answer.

Verification

  packages/core/src/raster    ████████████████████  100 %   6 modules
  packages/core/src/model     ████████████████████  100 %   5 modules
  ──────────────────────────────────────────────────────────────────
  237 tests · 9 files · 0 runtime deps · 0 network calls in core
  typecheck clean · production build green

One test asserts that both the ready state and the suppressed state are reached by real committed data. A refusal path no test reaches is a refusal path you do not have.

AI disclosure

CSC requires this, and we are glad to give it in full.

This project was built with heavy use of AI coding assistants. Specifically:

  • Claude (Anthropic) was used throughout as a pair-programming and code-generation assistant: scaffolding the monorepo, drafting module implementations, writing tests, generating documentation, reviewing diffs, and debugging.
  • AI wrote a large share of the code in this repository. We are not claiming otherwise.

What we can explain, and did not outsource:

  • Every formula in the thermal chain, the emissivity model, the Student-t derivation and the quadrature. We can derive each on request.
  • Every design decision, especially the three gates. The choice to make refusal a type-level property rather than a runtime check was ours, and we can explain why an optional warning field would have failed.
  • Every number on screen. Each is reproducible by running the pipeline.

Where AI got it wrong and we caught it:

  • A units bug fed Celsius into a step requiring Kelvin, producing ~0.4 °C temperatures , plausible enough to ship. Caught only because we tested each step against an independently known value rather than testing end to end.
  • Stale compiled JavaScript shadowed the TypeScript sources, so tests passed against week-old build output. 23 phantom failures pointed at a filename that appears nowhere in the source.

Outside resources credited: OpenStreetMap (ODbL) for parcel geometry; the City of Portland Title 11 Trees Fee Schedule for cost figures; the Sobrino proportion-of-vegetation method for emissivity; Landsat 8/9 and Sentinel-2 band specifications; Nemhauser, Wolsey & Fisher (1978) for the submodular result informing our roadmap. No external code libraries are used at runtime.

Data provenance , stated plainly

  REAL          school parcel polygons, OpenStreetMap (ODbL)
                retrieved 2026-08-05 · John Jacobs = OSM way 121870035
                43,473 m² parcel

  APPROXIMATED  the recess-yard sub-polygon is a centroid inset of that
                parcel (~9,000 m²). Building footprints are not
                available offline, so the yard subset is an estimate.

  SYNTHETIC     the pixel values in the shipped fixtures are generated
                by a seeded scene model calibrated to realistic ground
                truth. THEY ARE NOT OBSERVED SATELLITE MEASUREMENTS.

The imagery port exists so real scenes drop in without touching model code , the swap is a data change, not a rewrite. We could have shipped without saying this and it would have looked stronger at a glance. That would have been inconsistent with a tool whose central claim is that it refuses to assert what it cannot support.

Challenges we ran into

A unit trap that produces plausible wrong answers. Step 4 needs Kelvin. Feed it Celsius and you get ~0.4 °C , no crash, no error, just a number small enough to look like rounding. Caught only by testing each step against a known value.

The temptation to borrow a constant. The easy version of ΔT is to look up "trees reduce temperature by X degrees" and multiply. Published urban-cooling figures vary by an order of magnitude across climates, so any borrowed constant dies to one informed question. Fitting per-scene was far more work and is the only defensible version.

Making refusal structural rather than remembered. Our first cut returned a number plus an optional warning string. That is a bug waiting for a deadline. Converting to a discriminated union broke the build in several places , which was the point.

Unknown must not become zero. A cloud-masked pixel has no temperature. Coerce it to 0 and every mean drops, making yards look cooler than they are , the most dangerous possible direction for a tool about dangerous heat.

Vegetation thresholds are not universal. The standard NDVI cutoff of 0.60 counts well-watered desert turf as tree canopy, inflating existing shade and hiding the opportunity. John Jacobs carries a hand-validated 0.62 with the reasoning recorded in its metadata.

Accomplishments we're proud of

  • The full Landsat thermal chain implemented from raw radiometry, 100 % line coverage on every raster and model module.
  • A real Student-t confidence interval built from scratch instead of a magic number.
  • Three refusal paths enforced by the type system. We think this is the actual contribution.
  • Shipping a cost model deliberately empty, with the reason written into the data file, rather than filling it with guesses.
  • Deleting an unreachable code branch instead of writing a test that pretended to cover it.

What we learned

Remote sensing is unforgiving in a specific way: almost every mistake produces a number rather than an error. Wrong units, unknowns coerced to zero, a threshold tuned for the wrong biome, a stale build artefact , each yields output that looks completely fine.

The only defences that work: test every step against a value you know independently; make "we do not know" a first-class typed outcome; and treat a green test suite as a claim requiring verification rather than a conclusion.

And the credibility of a tool like this lives in what it refuses to say. "Plant 12 trees, save 4.2 °C, costs \$8,500" is trivial to generate and impossible to defend. The version with an interval, an R², a stated overpass time and an honest "we cannot price this" is the one a facilities director can carry into a budget meeting.

What's next

  • Live Landsat 9 and Sentinel-2 scenes through the existing imagery port , no model code changes.
  • Spatial block cross-validation. Adjacent satellite pixels are strongly autocorrelated, so ordinary k-fold leaks and inflates R² , which matters because R² drives Gate 2. This is a correctness fix, and it is next.
  • Conformal prediction intervals, for a distribution-free coverage guarantee.
  • Greedy submodular tree placement, which carries a provable (1 − 1/e) ≈ 63 % optimality bound.
  • District mode: run every school, rank by cooling per dollar, hand the facilities office a prioritised list. Schools with suppressed predictions appear as "insufficient evidence", never silently dropped.

Try it

Runs entirely offline , no API keys, no backend, no network request after load. Four schoolyards ship with the build. MIT licensed.


Extended technical section

The material below is the full engineering account. A companion technical report with the complete derivations, the numerical methods, and the reference list is attached to this submission as a file.

How the temperature is actually computed

Surface temperature does not arrive from a satellite as a temperature. It arrives as a quantized integer representing detector counts. Converting that to a physical quantity takes four stages, and each one is a place where a wrong answer looks exactly like a right answer.

Stage one. Digital number to spectral radiance. The sensor output is rescaled linearly using coefficients published per scene:

L = M_L * Q_cal + A_L

M_L and A_L differ between acquisitions and between Landsat 8 and Landsat 9. We parse them from scene metadata every time. Hardcoding them produces confident output for the wrong scene, which is worse than an error.

Stage two. Radiance to brightness temperature. Inverting the Planck relation under the band averaged approximation:

BT = K2 / ln( K1 / L + 1 )

The result is in Kelvin. Our implementation returns unknown, not zero, for non positive radiance, because the logarithm is undefined there and a saturated or masked detector reading must stay unknown.

Stage three. Emissivity. Two surfaces at the same true temperature radiate differently. Asphalt and grass are not interchangeable. We estimate emissivity from vegetation fraction using the proportion of vegetation method:

Pv = ( (NDVI - 0.2) / (0.5 - 0.2) ) ^ 2      clamped to [0, 1]
e  = 0.004 * Pv + 0.986

Stage four. Corrected surface temperature.

LST = BT / ( 1 + (lambda * BT / rho) * ln(e) )

with lambda the band center wavelength of 10.895 micrometers and rho equal to h*c/sigma, which is 1.438e-2 meter Kelvin, derived from the Planck constant, the speed of light and the Boltzmann constant.

Conversion to Celsius happens exactly once, after stage four. Section "What broke" explains why that sentence is written so emphatically.

How the prediction is fitted

The easy version of a cooling prediction is to find a published figure saying trees reduce temperature by some number of degrees, multiply by the canopy change, and print the result. We built that first. It does not survive one informed question, because published urban cooling coefficients vary by an order of magnitude across climates, and there is no honest answer to "why that number for Phoenix."

The version we shipped fits the relationship on the school's own pixels:

LST = b0 + b1 * NDVI + error

estimated by ordinary least squares over the cloud free pixels inside the yard polygon. The predicted change is then

delta_T = b1 * delta_NDVI

with the interval on delta_T obtained by scaling the interval on b1. A weak fit therefore widens the reported temperature range instead of hiding behind a single figure.

For the hero site the fitted slope is negative 13.16 degrees Celsius per NDVI unit, with R squared of 0.618 across 400 pixels, and a 95 percent interval on the slope of negative 14.18 to negative 12.14.

On the confidence interval

We did not use 1.96.

The correct critical value is a Student t quantile at n minus two degrees of freedom. Computing it properly required implementing the regularized incomplete beta function by continued fraction expansion, evaluated with the Lentz algorithm, over a logarithm of the gamma function from the Lanczos approximation, with the critical value recovered by bisection on the resulting cumulative distribution function.

At 400 pixels the correct value and 1.96 agree closely enough that the printed interval is unchanged. We implemented it correctly anyway. A system that is only correct where correctness is visible is not correct, and "where does that interval come from" deserves a derivation rather than an assertion.

How the geometry is measured

Twelve trees do not produce twelve times one crown of shade. Crowns overlap, and overlapping crowns do not add.

Union area of overlapping discs has no simple closed form beyond two discs, so we integrate numerically on a fixed grid at half meter cells, which gives roughly a tenth of a percent area error at typical crown radii. The union is clipped to the yard boundary, so crown area falling outside the yard does not count toward the plan.

We use deterministic quadrature rather than random sampling on purpose. A sampled estimate would make the printed figures drift between renders, which breaks reproducibility and means the generated document cannot be independently checked. We additionally test the numerical result against the exact closed form area of two overlapping circles, so the approximation is validated against something derivable by hand.

For the hero site, twelve trees give a crown union of 1,524 square meters after a measured overlap of 6.5 percent, discounted for ground already shaded.

The important word is measured. The overlap figure comes from where the trees actually are, and when a user drags a tree the geometry is recomputed. The number on the panel and the picture on the map cannot disagree.

What the three gates actually check

  GATE 1   yard cloud free coverage
           threshold  >= 80 percent
           hero site  100.0 percent           PASS
           on failure no temperature is emitted at all

  GATE 2   regression fit quality
           threshold  >= 0.50 full, >= 0.30 indicative
           hero site  R2 = 0.618              PASS
           on failure the temperature change is withheld

  GATE 3   cost citations resolved
           threshold  every printable line
           hero site  2 of 2 lines            PASS
           on failure the headline total is withheld

That panel is rendered live by the application on every reading. It is output, not documentation.

Why the gates are types and not checks

The first implementation returned a number plus an optional warning string, and trusted each consumer to look at the warning before displaying anything. That is not a safeguard. It is an agreement, and agreements are the first thing to go when a deadline is close.

The shipped implementation models the prediction as a discriminated union with separate variants for a full estimate, an indicative estimate, and a suppressed result. The variants carry different fields, so a consumer cannot reach a temperature value without first narrowing the type, and the compiler rejects any exhaustive match that omits the suppressed case.

Converting to that representation broke compilation in several places. Those places were exactly the code paths that would have printed an unsupported number.

The same pattern governs the interface. Application state is a five variant union covering empty, loading, ready, suppressed and error. A forgotten branch is a compile error rather than a blank screen in front of a judge.

Refusal is not silence

When the fit gate suppresses a prediction, the application still draws the scatter plot, with the line "The scatter is shown even though the prediction is withheld, so you can see why." A cloud of points with no trend explains the refusal better than any error message.

When the citation gate withholds a total, the application says "cost not shown, one or more line items lack a cited source. Canopy will not print a cost it cannot attribute to a real published figure." Selecting the uncited region adds "Deliberately uncited, not broken."

That last phrase exists because we expected a reviewer to read a correct refusal as an unfinished feature.

What broke, and what it taught us

A unit error that produced a believable number

Stage four needs Kelvin. An early version supplied Celsius. The output was surface temperatures around 0.4 degrees.

No crash. No error. No unknown value. Just a number small enough to look like a scaling detail somebody would tidy up later.

It was caught only because every stage is tested against an independently computed reference rather than against the stage before it. An end to end test would have asserted the wrong answer and passed.

A test suite that was lying

Late in the build, 23 tests failed pointing at a filename that appears nowhere in the source. The source read one name. The error reported another.

Compiled JavaScript left over from an earlier build was being loaded in preference to the TypeScript source, so the tests were running against week old output. Deleting the leftovers took the suite from 23 failures to fully passing without a single source change. The same problem had been quietly disabling an asset generation script, which had been reporting success while writing nothing at all.

The lesson stuck: a green test suite is a claim that needs checking, not a conclusion. A suite passing against the wrong bytes is worse than a failing one, because it reassures you.

Two people looking at two different repositories

At one point two contributors reached opposite conclusions about whether a core file existed. Both were reporting honestly. One working copy was four commits behind, with uncommitted files invisible to the other side. A fully cited cost model existed on one machine and, never having been committed, did not exist for anyone else.

Verify against the shared remote before asserting what the code contains.

A threshold that is right somewhere else

The standard NDVI cutoff for tree canopy is 0.60. In irrigated desert turf, well watered grass clears 0.60 easily and gets counted as tree shade.

The direction of that error matters. Counting grass as canopy inflates existing shade and shrinks the apparent opportunity, so the tool would tell a school it already has shade it does not have. The hero site now uses 0.62, validated by eye against visible imagery, with the reasoning stored in its metadata.

A per site threshold with a written justification is uglier than one global constant, and it is correct.

Verification in detail

  packages/core/src/raster    ####################  100 %   6 modules
  packages/core/src/model     ####################  100 %   5 modules
  ------------------------------------------------------------------
  237 tests  ·  9 files  ·  0 runtime dependencies
  0 network calls in the computation core
  typecheck clean  ·  production build green

What we test and why those things:

Known value tests on each thermal stage, against values computed independently. Not end to end, because end to end would have passed the Kelvin bug.

Closed form tests on the crown geometry, against the exact two circle overlap area.

Numerical tests on the Student t cumulative distribution and the bisection that recovers the critical value.

State reachability tests asserting that across the shipped sites every school lands in a valid terminal state, and that the ready state and the suppressed state are both reached by real committed data. A refusal path no test reaches is a refusal path you do not have.

An isolation test asserting the computation core opens no network connections.

Invariant tests: crown union never exceeds the sum of individual crowns, and unknown never becomes zero.

We also deleted an unreachable branch in the gamma function rather than writing a test that pretended to cover it. Coverage obtained by exercising dead code misrepresents what has been verified.

The four schoolyards

Four sites ship with the build, chosen to exercise different behavior rather than to sample a population.

  John Jacobs Elementary   moderate canopy, good fit, full estimate
  Cactus Wren Elementary   different baseline, shows it generalizes
  Sunridge Elementary      well shaded, correctly recommends less
  Dos Rios Elementary      carries cloud, exercises the coverage gate

Dos Rios is deliberate. Without a site that trips the cloud gate, that gate would never run against committed data, and we would be shipping a safety mechanism nobody had watched work.

Limitations, stated plainly

The estimate is associational, from a correlational fit on one scene. It is not a causal quantity and the interface says so.

The measurement is surface temperature, not air temperature. They are related and they are not the same, and the difference matters for any claim about how a child actually feels.

The reading is taken at satellite overpass, 10:42 in the morning for this scene. The afternoon is hotter. The interface says that too.

Crown radii are nominal planting class figures marked unverified. Costs are cited. Radii are not. Those are two separate claims and only one is resolved.

Thermal resolution is coarse. A 9,000 square meter yard is covered by very few native thermal pixels, and the panel reports the count rather than hiding it behind a smooth image.

The shipped pixel values are synthetic. The geometry is real.

One limitation we consider a defect rather than a caveat. Our cross validation splits pixels at random. Satellite pixels are spatially correlated, so neighboring and nearly identical pixels end up in both the training and the test half, which inflates the fit statistic. That statistic is the input to Gate 2. An inflated R squared means the suppression gate fails to fire when it should, so the shortcut weakens the exact mechanism the project is built around. Spatial block cross validation is the fix and it is the next thing we build. We are stating it here rather than filing it under future work because it is wrong now.

What we would build next

Spatial block cross validation, holding out contiguous regions rather than scattered pixels, and reporting both the naive and the blocked figure so the gap is visible.

Conformal prediction intervals, which give a coverage guarantee without assuming the residuals are normal or evenly spread. Where the conformal interval comes out wider than the parametric one, that difference is the amount of confidence the assumption was inventing.

Greedy placement with a proven bound. Shade coverage is submodular, meaning each additional tree adds less new shade than the one before, so greedy selection carries a guarantee of being within about 63 percent of the best possible arrangement. That replaces "we scored a lattice" with a stated bound.

District mode, running every school in a district and ranking by cooling per dollar, with schools whose predictions are suppressed listed as insufficient evidence rather than silently dropped or sorted last.

Why this belongs in a local impact hackathon

The community institution is a specific school. The problem is one a principal can state in a sentence. The output is a single page a facilities committee can read, with a cost that either resolves to a published fee schedule or refuses to appear.

The tool does not require the school to buy anything, install anything, or share any data. It runs in a browser with the network disconnected. Four schoolyards are bundled with the build. The code is public and MIT licensed, so a district technology teacher can read every line that produced the number they are being asked to fund.

And when the data cannot support the claim, it says so, in the place where the number would have been.

Built With

Share this project:

Updates

Submission history