hmb HF Staff commited on
Commit
c29c721
·
verified ·
1 Parent(s): 3c6e4b6

Upload folder using huggingface_hub

Browse files
Files changed (3) hide show
  1. README.md +9 -5
  2. app.py +1607 -0
  3. requirements.txt +1 -0
README.md CHANGED
@@ -1,12 +1,16 @@
1
  ---
2
  title: Forest Dispatch
3
- emoji: 🔥
4
- colorFrom: pink
5
- colorTo: blue
6
  sdk: gradio
7
- sdk_version: 6.12.0
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
1
  ---
2
  title: Forest Dispatch
3
+ emoji: 🌳
4
+ colorFrom: green
5
+ colorTo: yellow
6
  sdk: gradio
7
+ sdk_version: 6.0.0
8
  app_file: app.py
9
  pinned: false
10
  ---
11
 
12
+ # The Forest Dispatch
13
+
14
+ A record of what we have lost — explore national forest cover trajectories alongside the policy events said to govern them.
15
+
16
+ Built with `gr.Server` (Gradio's API engine), powered by World Bank Open Data, and exposes all data endpoints as MCP tools for AI agents.
app.py ADDED
@@ -0,0 +1,1607 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Policy vs Deforestation Explorer — built with gr.Server
3
+
4
+ Uses gr.Server for API endpoints (with MCP tool support) and a custom
5
+ HTML frontend with Chart.js for interactive visualization.
6
+ """
7
+
8
+ import json
9
+ import urllib.request
10
+ from gradio import Server
11
+ from fastapi.responses import HTMLResponse, JSONResponse
12
+
13
+ app = Server()
14
+
15
+ COUNTRIES = {
16
+ "Brazil": "BRA", "Indonesia": "IDN", "DR Congo": "COD",
17
+ "Colombia": "COL", "Bolivia": "BOL", "Malaysia": "MYS",
18
+ "India": "IND", "Mexico": "MEX", "Peru": "PER",
19
+ "Australia": "AUS", "Canada": "CAN", "Russia": "RUS",
20
+ "China": "CHN", "United States": "USA", "Nigeria": "NGA",
21
+ "Myanmar": "MMR", "Tanzania": "TZA", "Paraguay": "PRY",
22
+ "Madagascar": "MDG", "Cameroon": "CMR",
23
+ }
24
+
25
+ POLICY_EVENTS = {
26
+ "Brazil": [
27
+ {"year": 2004, "text": "PPCDAm action plan launched", "url": "https://en.wikipedia.org/wiki/Deforestation_in_Brazil"},
28
+ {"year": 2006, "text": "Soy Moratorium signed", "url": "https://en.wikipedia.org/wiki/Amazon_Soy_Moratorium"},
29
+ {"year": 2008, "text": "Amazon Fund established", "url": "https://en.wikipedia.org/wiki/Amazon_Fund"},
30
+ {"year": 2012, "text": "New Forest Code enacted", "url": "https://en.wikipedia.org/wiki/Deforestation_in_Brazil"},
31
+ {"year": 2019, "text": "Enforcement weakened under Bolsonaro", "url": "https://en.wikipedia.org/wiki/Deforestation_in_Brazil"},
32
+ {"year": 2023, "text": "Zero deforestation pledge renewed", "url": "https://en.wikipedia.org/wiki/Deforestation_in_Brazil"},
33
+ ],
34
+ "Indonesia": [
35
+ {"year": 2002, "text": "Illegal Logging Decree", "url": "https://en.wikipedia.org/wiki/Deforestation_in_Indonesia"},
36
+ {"year": 2011, "text": "Forest moratorium on concessions", "url": "https://en.wikipedia.org/wiki/Deforestation_in_Indonesia"},
37
+ {"year": 2014, "text": "One Map Policy", "url": "https://en.wikipedia.org/wiki/Joko_Widodo"},
38
+ {"year": 2018, "text": "Moratorium extended permanently", "url": "https://en.wikipedia.org/wiki/Deforestation_in_Indonesia"},
39
+ {"year": 2023, "text": "FOLU Net Sink 2030 strategy", "url": "https://en.wikipedia.org/wiki/Climate_change_in_Indonesia"},
40
+ ],
41
+ "DR Congo": [
42
+ {"year": 2002, "text": "Forest Code enacted", "url": "https://en.wikipedia.org/wiki/Deforestation_in_the_Democratic_Republic_of_the_Congo"},
43
+ {"year": 2014, "text": "REDD+ national strategy", "url": "https://en.wikipedia.org/wiki/REDD_and_REDD%2B"},
44
+ {"year": 2022, "text": "Logging moratorium lifted", "url": "https://en.wikipedia.org/wiki/Deforestation_in_the_Democratic_Republic_of_the_Congo"},
45
+ ],
46
+ "Colombia": [
47
+ {"year": 2010, "text": "REDD+ strategy launched", "url": "https://en.wikipedia.org/wiki/REDD_and_REDD%2B"},
48
+ {"year": 2016, "text": "FARC peace deal", "url": "https://en.wikipedia.org/wiki/Colombian_peace_process"},
49
+ {"year": 2018, "text": "Deforestation Control Council", "url": "https://en.wikipedia.org/wiki/Deforestation_in_Colombia"},
50
+ {"year": 2023, "text": "Amazon pact signed", "url": "https://en.wikipedia.org/wiki/Amazon_Cooperation_Treaty_Organization"},
51
+ ],
52
+ "India": [
53
+ {"year": 2006, "text": "Forest Rights Act", "url": "https://en.wikipedia.org/wiki/Forest_Rights_Act,_2006"},
54
+ {"year": 2014, "text": "Green India Mission", "url": "https://en.wikipedia.org/wiki/Government_of_India"},
55
+ {"year": 2019, "text": "Compensatory Afforestation Fund", "url": "https://en.wikipedia.org/wiki/Compensatory_Afforestation_Fund_Act,_2016"},
56
+ ],
57
+ "Malaysia": [
58
+ {"year": 2010, "text": "50% forest cover pledge", "url": "https://en.wikipedia.org/wiki/Deforestation_in_Malaysia"},
59
+ {"year": 2017, "text": "MSPO mandatory certification", "url": "https://en.wikipedia.org/wiki/Malaysian_Sustainable_Palm_Oil"},
60
+ ],
61
+ "China": [
62
+ {"year": 1998, "text": "Natural Forest Protection Program", "url": "https://en.wikipedia.org/wiki/Reforestation_in_China"},
63
+ {"year": 2003, "text": "Grain-to-Green expanded", "url": "https://en.wikipedia.org/wiki/Reforestation_in_China"},
64
+ {"year": 2016, "text": "13th Five-Year Plan forestry targets", "url": "https://en.wikipedia.org/wiki/13th_Five-Year_Plan_(China)"},
65
+ ],
66
+ "Mexico": [
67
+ {"year": 2001, "text": "ProÁrbol reforestation", "url": "https://en.wikipedia.org/wiki/CONAFOR"},
68
+ {"year": 2012, "text": "Climate Change Law", "url": "https://en.wikipedia.org/wiki/Climate_change_in_Mexico"},
69
+ {"year": 2020, "text": "Sembrando Vida program", "url": "https://en.wikipedia.org/wiki/Andr%C3%A9s_Manuel_L%C3%B3pez_Obrador"},
70
+ ],
71
+ "Canada": [
72
+ {"year": 2010, "text": "Boreal Forest Agreement", "url": "https://en.wikipedia.org/wiki/Boreal_forest_of_Canada"},
73
+ {"year": 2021, "text": "2 Billion Trees program", "url": "https://www.canada.ca/en/campaign/2-billion-trees.html"},
74
+ ],
75
+ "Australia": [
76
+ {"year": 2000, "text": "Regional Forest Agreements", "url": "https://en.wikipedia.org/wiki/Regional_Forest_Agreement"},
77
+ {"year": 2012, "text": "Carbon farming legislation", "url": "https://en.wikipedia.org/wiki/Climate_change_in_Australia"},
78
+ {"year": 2022, "text": "Nature Repair Market Act", "url": "https://en.wikipedia.org/wiki/Climate_change_in_Australia"},
79
+ ],
80
+ }
81
+
82
+
83
+ _cache = {}
84
+
85
+ HISTORICAL_CONTEXT = {
86
+ "Brazil": {
87
+ range(1990, 1996): "Rapid expansion of cattle ranching and soy farming in the Amazon after economic stabilisation.",
88
+ range(1996, 2005): "Peak deforestation era — illegal logging, land speculation, and weak enforcement. Arc of deforestation expanded.",
89
+ range(2005, 2013): "PPCDAm enforcement + satellite monitoring (DETER) + soy/beef moratoriums drove sharp decline in clearing rates.",
90
+ range(2013, 2019): "Deforestation crept back up as budget cuts weakened IBAMA enforcement and new Forest Code allowed amnesty for past clearing.",
91
+ range(2019, 2023): "Environmental enforcement dismantled, IBAMA fines dropped 70%. Amazon tipping point warnings from scientists.",
92
+ },
93
+ "Indonesia": {
94
+ range(1990, 2000): "Suharto-era logging concessions and transmigration programs accelerated forest loss, especially in Sumatra and Kalimantan.",
95
+ range(2000, 2005): "Post-Suharto decentralisation gave district heads power to issue logging/plantation permits, often corruptly.",
96
+ range(2005, 2012): "Palm oil boom — Indonesia became world's largest producer. Peatland drainage caused massive fires (2006, 2009).",
97
+ range(2012, 2018): "2015 fires burned 2.6M hectares, caused $16B damage. Led to peat restoration agency and stronger moratorium.",
98
+ range(2018, 2023): "Deforestation rates declined significantly. Palm oil export ban (2022) temporarily reduced pressure.",
99
+ },
100
+ "Australia": {
101
+ range(1990, 2000): "Broadscale land clearing for agriculture, especially in Queensland. Woody vegetation loss peaked mid-1990s.",
102
+ range(2000, 2007): "Millennium drought (2001-2009) devastated forests. Queensland banned broadscale clearing in 2006.",
103
+ range(2007, 2013): "Black Saturday bushfires (2009) destroyed 430,000 hectares. Drought continued to stress forests.",
104
+ range(2013, 2020): "Forest recovery after drought broke. But 2019-20 Black Summer fires burned 18.6M hectares — worst fire season on record.",
105
+ range(2020, 2023): "La Niña rains aided recovery. New environmental laws and carbon farming incentives.",
106
+ },
107
+ "DR Congo": {
108
+ range(1990, 2002): "Civil wars (1996-2003) disrupted industrial logging but subsistence clearing continued.",
109
+ range(2002, 2015): "Population growth drove smallholder agriculture expansion — the primary deforestation driver. Charcoal demand surged.",
110
+ range(2015, 2023): "Artisanal mining and cocoa expansion increased. DRC has lowest governance capacity of major forest nations.",
111
+ },
112
+ "Colombia": {
113
+ range(1990, 2016): "FARC conflict paradoxically protected forests — armed groups controlled access to remote areas.",
114
+ range(2016, 2020): "Post-peace deal: deforestation spiked 44% as land grabbers moved into former FARC territory.",
115
+ range(2020, 2023): "Government crackdown on deforestation. Amazon pact signed with Brazil and other nations.",
116
+ },
117
+ "India": {
118
+ range(1990, 2005): "Forest cover data contested — government counts plantations as forest. Native forest loss continued.",
119
+ range(2005, 2015): "Massive afforestation programs (Green India Mission) increased total tree cover, though primary forest still declined.",
120
+ range(2015, 2023): "Forest Rights Act empowered tribal communities. Compensatory afforestation fund reached $6B.",
121
+ },
122
+ "China": {
123
+ range(1990, 2000): "1998 Yangtze floods killed 4,000+ — blamed on upstream deforestation. Triggered logging ban.",
124
+ range(2000, 2010): "Grain-to-Green: world's largest reforestation program. Paid 120M farmers to convert cropland to forest.",
125
+ range(2010, 2023): "China became net reforester. But imports shifted deforestation to SE Asia and Africa.",
126
+ },
127
+ "Malaysia": {
128
+ range(1990, 2005): "Rapid palm oil expansion, especially in Sabah and Sarawak. Malaysia became 2nd largest producer.",
129
+ range(2005, 2015): "International pressure over orangutan habitat. RSPO certification introduced but adoption slow.",
130
+ range(2015, 2023): "MSPO mandatory certification. Pledged 50% forest cover but definition includes oil palm.",
131
+ },
132
+ "Mexico": {
133
+ range(1990, 2005): "NAFTA (1994) shifted agriculture — some marginal farmland abandoned and reforested, but Lacandón jungle clearing continued.",
134
+ range(2005, 2015): "Drug cartel activity in forests (avocado, poppy cultivation) drove illegal clearing in Michoacán and Guerrero.",
135
+ range(2015, 2023): "Sembrando Vida program controversially paid farmers to plant trees — but some cut existing forest to qualify.",
136
+ },
137
+ "Canada": {
138
+ range(1990, 2005): "Forestry industry dominated — clearcut logging in British Columbia and boreal regions.",
139
+ range(2005, 2015): "Mountain pine beetle epidemic killed 18M hectares of BC forest — largest insect blight in North American history.",
140
+ range(2015, 2023): "Wildfires intensified with climate change. 2023 was worst fire season ever — 18.5M hectares burned.",
141
+ },
142
+ "Russia": {
143
+ range(1990, 2000): "Post-Soviet collapse reduced industrial logging but also enforcement. Illegal logging surged.",
144
+ range(2000, 2010): "Siberian wildfires increased dramatically. 2010 fires caused Moscow smog crisis.",
145
+ range(2010, 2023): "Permafrost thaw and wildfires became primary forest loss drivers. 2021: record 18.8M hectares burned.",
146
+ },
147
+ "United States": {
148
+ range(1990, 2005): "Net forest area roughly stable. Urban sprawl consumed some forest, offset by farmland reversion in East.",
149
+ range(2005, 2015): "Western wildfires intensified — bark beetle outbreaks weakened millions of hectares. 2012 was record fire year.",
150
+ range(2015, 2023): "Paradise fire (2018), record 2020 season (4.2M acres in CA/OR/WA). Climate-driven megafires now the norm.",
151
+ },
152
+ }
153
+
154
+
155
+ def _fetch_wb(country_code: str, indicator: str) -> list[dict]:
156
+ cache_key = f"{country_code}:{indicator}"
157
+ if cache_key in _cache:
158
+ return _cache[cache_key]
159
+ url = (
160
+ f"https://api.worldbank.org/v2/country/{country_code}"
161
+ f"/indicator/{indicator}?format=json&per_page=50&date=1990:2022"
162
+ )
163
+ for attempt in range(3):
164
+ try:
165
+ resp = urllib.request.urlopen(url, timeout=30)
166
+ data = json.loads(resp.read())
167
+ if len(data) < 2 or not data[1]:
168
+ _cache[cache_key] = []
169
+ return []
170
+ results = [
171
+ {"year": int(d["date"]), "value": round(d["value"], 3)}
172
+ for d in data[1]
173
+ if d["value"] is not None
174
+ ]
175
+ results.sort(key=lambda x: x["year"])
176
+ _cache[cache_key] = results
177
+ return results
178
+ except Exception:
179
+ if attempt == 2:
180
+ return []
181
+ import time
182
+ time.sleep(1)
183
+
184
+
185
+ @app.mcp.tool(name="get_forest_data")
186
+ @app.api(name="get_forest_data")
187
+ def get_forest_data(country: str) -> dict:
188
+ """Get forest area (% of land) time series for a country. Returns yearly data from World Bank."""
189
+ code = COUNTRIES.get(country)
190
+ if not code:
191
+ return {"error": f"Unknown country. Available: {', '.join(COUNTRIES.keys())}"}
192
+ forest = _fetch_wb(code, "AG.LND.FRST.ZS")
193
+ governance = _fetch_wb(code, "RL.EST")
194
+ policies = POLICY_EVENTS.get(country, [])
195
+
196
+ summary = {}
197
+ if forest:
198
+ first, last = forest[0], forest[-1]
199
+ change = last["value"] - first["value"]
200
+ years = last["year"] - first["year"]
201
+ summary = {
202
+ "start_year": first["year"],
203
+ "end_year": last["year"],
204
+ "start_pct": first["value"],
205
+ "end_pct": last["value"],
206
+ "change_pct": round(change, 3),
207
+ "annual_rate": round(change / years, 4) if years else 0,
208
+ }
209
+
210
+ return {
211
+ "country": country,
212
+ "forest": forest,
213
+ "governance": governance,
214
+ "policies": policies,
215
+ "summary": summary,
216
+ }
217
+
218
+
219
+ @app.mcp.tool(name="compare_countries")
220
+ @app.api(name="compare_countries")
221
+ def compare_countries(country_a: str, country_b: str) -> dict:
222
+ """Compare forest cover trends between two countries."""
223
+ a = get_forest_data(country_a)
224
+ b = get_forest_data(country_b)
225
+ return {"country_a": a, "country_b": b}
226
+
227
+
228
+ @app.mcp.tool(name="explain_spike")
229
+ @app.api(name="explain_spike")
230
+ def explain_spike(country: str, year: int) -> dict:
231
+ """Explain what happened to forest cover around a specific year. Identifies rate changes and nearby policy events."""
232
+ code = COUNTRIES.get(country)
233
+ if not code:
234
+ return {"error": f"Unknown country."}
235
+ forest = _fetch_wb(code, "AG.LND.FRST.ZS")
236
+ if not forest:
237
+ return {"error": "No data available."}
238
+
239
+ policies = POLICY_EVENTS.get(country, [])
240
+ nearby_policies = [p for p in policies if abs(p["year"] - year) <= 3]
241
+
242
+ window = [f for f in forest if abs(f["year"] - year) <= 5]
243
+ window.sort(key=lambda x: x["year"])
244
+
245
+ point = next((f for f in forest if f["year"] == year), None)
246
+ prev = next((f for f in forest if f["year"] == year - 1), None)
247
+ nxt = next((f for f in forest if f["year"] == year + 1), None)
248
+
249
+ before = [f for f in forest if year - 5 <= f["year"] < year]
250
+ after = [f for f in forest if year < f["year"] <= year + 5]
251
+
252
+ rate_before = None
253
+ rate_after = None
254
+ if len(before) >= 2:
255
+ rate_before = round((before[-1]["value"] - before[0]["value"]) / (before[-1]["year"] - before[0]["year"]), 4)
256
+ if len(after) >= 2:
257
+ rate_after = round((after[-1]["value"] - after[0]["value"]) / (after[-1]["year"] - after[0]["year"]), 4)
258
+
259
+ yoy_change = None
260
+ if point and prev:
261
+ yoy_change = round(point["value"] - prev["value"], 3)
262
+
263
+ trend = "stable"
264
+ if rate_before is not None and rate_after is not None:
265
+ if rate_after > rate_before + 0.01:
266
+ trend = "recovery"
267
+ elif rate_after < rate_before - 0.01:
268
+ trend = "acceleration"
269
+ if yoy_change is not None:
270
+ if yoy_change > 0.05:
271
+ trend = "sharp increase"
272
+ elif yoy_change < -0.05:
273
+ trend = "sharp decline"
274
+
275
+ context = None
276
+ country_ctx = HISTORICAL_CONTEXT.get(country, {})
277
+ for year_range, text in country_ctx.items():
278
+ if year in year_range:
279
+ context = text
280
+ break
281
+
282
+ return {
283
+ "country": country,
284
+ "year": year,
285
+ "forest_pct": point["value"] if point else None,
286
+ "yoy_change": yoy_change,
287
+ "trend": trend,
288
+ "rate_5yr_before": rate_before,
289
+ "rate_5yr_after": rate_after,
290
+ "nearby_policies": nearby_policies,
291
+ "context": context,
292
+ "window": window,
293
+ }
294
+
295
+
296
+ @app.mcp.tool(name="list_countries")
297
+ @app.api(name="list_countries")
298
+ def list_countries() -> list[str]:
299
+ """List all available countries."""
300
+ return list(COUNTRIES.keys())
301
+
302
+
303
+ @app.get("/", response_class=HTMLResponse)
304
+ async def homepage():
305
+ countries_json = json.dumps(list(COUNTRIES.keys()))
306
+ return f"""<!DOCTYPE html>
307
+ <html lang="en">
308
+ <head>
309
+ <meta charset="utf-8">
310
+ <meta name="viewport" content="width=device-width, initial-scale=1">
311
+ <title>Forest Dispatch — A Record of What We Have Lost</title>
312
+ <link rel="preconnect" href="https://fonts.googleapis.com">
313
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
314
+ <link href="https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&family=JetBrains+Mono:wght@400;500;700&family=Fraunces:ital,opsz,wght@0,9..144,400;0,9..144,600;0,9..144,800;1,9..144,400&display=swap" rel="stylesheet">
315
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
316
+ <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3"></script>
317
+ <style>
318
+ * {{ margin: 0; padding: 0; box-sizing: border-box; }}
319
+
320
+ :root {{
321
+ --paper: #ede5d3;
322
+ --paper-tint: #e4dbc5;
323
+ --paper-deep: #d8cdb3;
324
+ --ink: #1a1f18;
325
+ --ink-soft: #3a3f35;
326
+ --ink-mute: #6a6d60;
327
+ --ink-fade: #9c9e8f;
328
+ --rule: #3a3f35;
329
+ --rule-soft: #c5bda5;
330
+ --moss: #4a6b3e;
331
+ --moss-deep: #2f4827;
332
+ --oxblood: #8b2a1f;
333
+ --oxblood-deep: #5c1e16;
334
+ --amber: #a8732a;
335
+ --amber-deep: #7d5418;
336
+ --highlight: #d4c98a;
337
+ }}
338
+
339
+ html {{ background: var(--paper); }}
340
+
341
+ body {{
342
+ font-family: 'Fraunces', Georgia, serif;
343
+ font-optical-sizing: auto;
344
+ background: var(--paper);
345
+ color: var(--ink);
346
+ min-height: 100vh;
347
+ font-size: 16px;
348
+ line-height: 1.55;
349
+ position: relative;
350
+ overflow-x: hidden;
351
+ }}
352
+
353
+ /* Grain texture overlay — gives that risograph/newsprint feel */
354
+ body::before {{
355
+ content: '';
356
+ position: fixed;
357
+ inset: 0;
358
+ pointer-events: none;
359
+ z-index: 1000;
360
+ opacity: 0.35;
361
+ mix-blend-mode: multiply;
362
+ background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 200 200' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0.1 0 0 0 0 0.12 0 0 0 0 0.09 0 0 0 0.6 0'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
363
+ }}
364
+
365
+ /* Paper vignette */
366
+ body::after {{
367
+ content: '';
368
+ position: fixed;
369
+ inset: 0;
370
+ pointer-events: none;
371
+ z-index: 999;
372
+ background: radial-gradient(ellipse at center, transparent 40%, rgba(60, 50, 30, 0.18) 100%);
373
+ }}
374
+
375
+ a {{ color: var(--oxblood); text-decoration: none; border-bottom: 1px solid currentColor; padding-bottom: 1px; transition: opacity 0.15s; }}
376
+ a:hover {{ opacity: 0.7; }}
377
+
378
+ .mono {{ font-family: 'JetBrains Mono', ui-monospace, monospace; font-feature-settings: "tnum", "zero"; }}
379
+ .serif-display {{ font-family: 'Instrument Serif', Georgia, serif; font-weight: 400; }}
380
+ .tabular {{ font-variant-numeric: tabular-nums; }}
381
+ .smallcaps {{
382
+ text-transform: uppercase;
383
+ letter-spacing: 0.18em;
384
+ font-size: 0.72em;
385
+ font-weight: 600;
386
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
387
+ }}
388
+
389
+ /* ====== MASTHEAD ====== */
390
+ .masthead {{
391
+ padding: 16px 48px 0;
392
+ border-bottom: 2px solid var(--ink);
393
+ position: relative;
394
+ z-index: 2;
395
+ }}
396
+ .masthead-top {{
397
+ display: flex;
398
+ justify-content: space-between;
399
+ align-items: baseline;
400
+ font-family: 'JetBrains Mono', monospace;
401
+ font-size: 10px;
402
+ letter-spacing: 0.15em;
403
+ text-transform: uppercase;
404
+ color: var(--ink-soft);
405
+ padding-bottom: 6px;
406
+ border-bottom: 1px solid var(--rule-soft);
407
+ }}
408
+ .masthead-top .vol {{ display: flex; gap: 22px; }}
409
+ .masthead-top .vol span:not(:last-child)::after {{
410
+ content: '·';
411
+ margin-left: 22px;
412
+ color: var(--ink-fade);
413
+ }}
414
+ .masthead-row {{
415
+ display: flex;
416
+ align-items: center;
417
+ justify-content: space-between;
418
+ gap: 32px;
419
+ padding: 12px 0;
420
+ }}
421
+ .wordmark {{
422
+ font-family: 'Instrument Serif', serif;
423
+ font-size: clamp(40px, 5.5vw, 72px);
424
+ line-height: 0.95;
425
+ letter-spacing: -0.02em;
426
+ font-weight: 400;
427
+ font-style: italic;
428
+ flex-shrink: 0;
429
+ }}
430
+ .wordmark .dispatch {{ font-style: normal; }}
431
+ .dek-text {{
432
+ flex: 1;
433
+ max-width: 520px;
434
+ font-size: 13px;
435
+ line-height: 1.5;
436
+ color: var(--ink-soft);
437
+ font-style: italic;
438
+ }}
439
+ .dek-meta {{
440
+ text-align: right;
441
+ font-family: 'JetBrains Mono', monospace;
442
+ font-size: 9px;
443
+ letter-spacing: 0.15em;
444
+ text-transform: uppercase;
445
+ color: var(--ink-mute);
446
+ line-height: 1.7;
447
+ white-space: nowrap;
448
+ flex-shrink: 0;
449
+ }}
450
+ .dek-meta .badge {{
451
+ display: inline-block;
452
+ background: var(--ink);
453
+ color: var(--paper);
454
+ padding: 2px 7px;
455
+ margin-top: 3px;
456
+ font-weight: 500;
457
+ }}
458
+
459
+ /* ====== MAIN GRID ====== */
460
+ .sheet {{
461
+ max-width: 1400px;
462
+ margin: 0 auto;
463
+ padding: 0 48px 48px;
464
+ position: relative;
465
+ z-index: 2;
466
+ }}
467
+
468
+ /* ====== CONTROL STRIP ====== */
469
+ .control-strip {{
470
+ display: grid;
471
+ grid-template-columns: 1fr auto auto auto;
472
+ gap: 24px;
473
+ align-items: end;
474
+ padding: 14px 0 16px;
475
+ border-bottom: 1px solid var(--rule);
476
+ margin-bottom: 18px;
477
+ }}
478
+ .section-title {{
479
+ font-family: 'Instrument Serif', serif;
480
+ font-size: 18px;
481
+ letter-spacing: -0.01em;
482
+ line-height: 1.2;
483
+ font-style: italic;
484
+ color: var(--ink-soft);
485
+ padding-bottom: 4px;
486
+ }}
487
+ .section-title .caps {{
488
+ display: block;
489
+ font-family: 'JetBrains Mono', monospace;
490
+ font-size: 9px;
491
+ letter-spacing: 0.2em;
492
+ color: var(--ink-mute);
493
+ text-transform: uppercase;
494
+ margin-bottom: 4px;
495
+ font-weight: 500;
496
+ font-style: normal;
497
+ }}
498
+ .control-group {{
499
+ display: flex;
500
+ flex-direction: column;
501
+ gap: 4px;
502
+ }}
503
+ .control-group label {{
504
+ font-family: 'JetBrains Mono', monospace;
505
+ font-size: 9px;
506
+ letter-spacing: 0.2em;
507
+ text-transform: uppercase;
508
+ color: var(--ink-mute);
509
+ font-weight: 500;
510
+ }}
511
+ select {{
512
+ background: transparent;
513
+ border: none;
514
+ border-bottom: 1px solid var(--ink);
515
+ color: var(--ink);
516
+ padding: 4px 24px 4px 0;
517
+ font-family: 'Fraunces', serif;
518
+ font-size: 18px;
519
+ font-weight: 500;
520
+ min-width: 180px;
521
+ cursor: pointer;
522
+ outline: none;
523
+ appearance: none;
524
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 10 10'%3E%3Cpath d='M1 3 L5 7 L9 3' stroke='%231a1f18' stroke-width='1.5' fill='none'/%3E%3C/svg%3E");
525
+ background-repeat: no-repeat;
526
+ background-position: right 4px center;
527
+ transition: border-color 0.15s;
528
+ }}
529
+ select:focus {{ border-bottom-color: var(--oxblood); }}
530
+ button.action {{
531
+ background: var(--ink);
532
+ color: var(--paper);
533
+ border: none;
534
+ padding: 12px 28px;
535
+ font-family: 'JetBrains Mono', monospace;
536
+ font-size: 11px;
537
+ letter-spacing: 0.2em;
538
+ text-transform: uppercase;
539
+ font-weight: 500;
540
+ cursor: pointer;
541
+ transition: all 0.15s;
542
+ position: relative;
543
+ box-shadow: 3px 3px 0 var(--rule-soft);
544
+ }}
545
+ button.action:hover {{
546
+ transform: translate(-1px, -1px);
547
+ box-shadow: 4px 4px 0 var(--oxblood);
548
+ }}
549
+ button.action:active {{
550
+ transform: translate(2px, 2px);
551
+ box-shadow: 1px 1px 0 var(--rule-soft);
552
+ }}
553
+
554
+ /* ====== FINDINGS ROW ====== */
555
+ .findings {{
556
+ display: grid;
557
+ grid-template-columns: repeat(4, 1fr);
558
+ border-top: 2px solid var(--ink);
559
+ border-bottom: 2px solid var(--ink);
560
+ margin-bottom: 32px;
561
+ min-height: 110px;
562
+ }}
563
+ .finding {{
564
+ padding: 14px 22px 16px;
565
+ border-right: 1px solid var(--rule);
566
+ position: relative;
567
+ display: flex;
568
+ flex-direction: column;
569
+ justify-content: space-between;
570
+ }}
571
+ .finding:last-child {{ border-right: none; }}
572
+ .finding::before {{
573
+ content: attr(data-num);
574
+ position: absolute;
575
+ top: 8px;
576
+ right: 10px;
577
+ font-family: 'JetBrains Mono', monospace;
578
+ font-size: 9px;
579
+ color: var(--ink-fade);
580
+ letter-spacing: 0.15em;
581
+ }}
582
+ .finding .label {{
583
+ font-family: 'JetBrains Mono', monospace;
584
+ font-size: 9px;
585
+ letter-spacing: 0.22em;
586
+ text-transform: uppercase;
587
+ color: var(--ink-mute);
588
+ margin-bottom: 6px;
589
+ font-weight: 500;
590
+ }}
591
+ .finding .value {{
592
+ font-family: 'Instrument Serif', serif;
593
+ font-size: 40px;
594
+ line-height: 1;
595
+ letter-spacing: -0.02em;
596
+ color: var(--ink);
597
+ font-variant-numeric: tabular-nums;
598
+ font-weight: 400;
599
+ }}
600
+ .finding .value.down {{ color: var(--oxblood); font-style: italic; }}
601
+ .finding .value.up {{ color: var(--moss-deep); }}
602
+ .finding .sub {{
603
+ font-family: 'JetBrains Mono', monospace;
604
+ font-size: 10px;
605
+ color: var(--ink-mute);
606
+ margin-top: 8px;
607
+ letter-spacing: 0.05em;
608
+ }}
609
+ .finding .unit {{
610
+ font-family: 'Fraunces', serif;
611
+ font-size: 14px;
612
+ font-style: italic;
613
+ color: var(--ink-soft);
614
+ margin-left: 2px;
615
+ font-weight: 400;
616
+ }}
617
+
618
+ /* ====== CHART FIGURE ====== */
619
+ .figure {{
620
+ margin-bottom: 28px;
621
+ }}
622
+ .figure-head {{
623
+ display: flex;
624
+ justify-content: space-between;
625
+ align-items: baseline;
626
+ padding-bottom: 14px;
627
+ border-bottom: 1px solid var(--rule);
628
+ margin-bottom: 2px;
629
+ }}
630
+ .figure-title {{
631
+ font-family: 'Instrument Serif', serif;
632
+ font-size: 26px;
633
+ letter-spacing: -0.01em;
634
+ font-style: italic;
635
+ }}
636
+ .figure-title::before {{
637
+ content: 'Fig. 1 ';
638
+ font-family: 'JetBrains Mono', monospace;
639
+ font-style: normal;
640
+ font-size: 12px;
641
+ letter-spacing: 0.15em;
642
+ color: var(--ink-mute);
643
+ vertical-align: middle;
644
+ margin-right: 10px;
645
+ padding-right: 10px;
646
+ border-right: 1px solid var(--rule);
647
+ }}
648
+ .figure-note {{
649
+ font-family: 'JetBrains Mono', monospace;
650
+ font-size: 10px;
651
+ letter-spacing: 0.1em;
652
+ color: var(--ink-mute);
653
+ text-transform: uppercase;
654
+ }}
655
+ .figure-frame {{
656
+ background: var(--paper-tint);
657
+ border: 1px solid var(--rule);
658
+ border-top: none;
659
+ padding: 20px;
660
+ height: clamp(420px, 56vh, 580px);
661
+ position: relative;
662
+ }}
663
+ .figure-caption {{
664
+ padding-top: 10px;
665
+ font-size: 13px;
666
+ font-style: italic;
667
+ color: var(--ink-soft);
668
+ line-height: 1.5;
669
+ max-width: 700px;
670
+ }}
671
+
672
+ /* ====== INSIGHT / DOSSIER ====== */
673
+ .dossier {{
674
+ display: none;
675
+ background: var(--paper-tint);
676
+ border-top: 1px solid var(--ink);
677
+ border-bottom: 1px solid var(--ink);
678
+ padding: 28px 32px;
679
+ margin-bottom: 40px;
680
+ position: relative;
681
+ }}
682
+ .dossier::before {{
683
+ content: '';
684
+ position: absolute;
685
+ top: 12px;
686
+ left: 12px;
687
+ right: 12px;
688
+ bottom: 12px;
689
+ border: 1px dashed var(--rule);
690
+ pointer-events: none;
691
+ }}
692
+ .dossier-label {{
693
+ position: absolute;
694
+ top: -9px;
695
+ left: 32px;
696
+ background: var(--paper);
697
+ padding: 0 12px;
698
+ font-family: 'JetBrains Mono', monospace;
699
+ font-size: 10px;
700
+ letter-spacing: 0.2em;
701
+ text-transform: uppercase;
702
+ color: var(--oxblood);
703
+ font-weight: 700;
704
+ }}
705
+ .dossier-head {{
706
+ font-family: 'Instrument Serif', serif;
707
+ font-size: 22px;
708
+ font-style: italic;
709
+ margin-bottom: 16px;
710
+ color: var(--ink);
711
+ }}
712
+ .dossier-head .hint {{
713
+ font-family: 'JetBrains Mono', monospace;
714
+ font-size: 10px;
715
+ font-style: normal;
716
+ letter-spacing: 0.15em;
717
+ text-transform: uppercase;
718
+ color: var(--ink-mute);
719
+ margin-left: 10px;
720
+ font-weight: 500;
721
+ }}
722
+
723
+ /* ====== PANELS ====== */
724
+ .split {{
725
+ display: grid;
726
+ grid-template-columns: 1fr 1fr;
727
+ gap: 0;
728
+ border-top: 1px solid var(--rule);
729
+ border-bottom: 1px solid var(--rule);
730
+ display: none;
731
+ }}
732
+ .split.active {{ display: grid; }}
733
+ .panel {{
734
+ padding: 28px 28px 24px;
735
+ border-right: 1px solid var(--rule);
736
+ }}
737
+ .panel:last-child {{ border-right: none; }}
738
+ .panel-head {{
739
+ display: flex;
740
+ justify-content: space-between;
741
+ align-items: baseline;
742
+ margin-bottom: 18px;
743
+ padding-bottom: 12px;
744
+ border-bottom: 1px solid var(--rule-soft);
745
+ }}
746
+ .panel-title {{
747
+ font-family: 'Instrument Serif', serif;
748
+ font-size: 22px;
749
+ font-style: italic;
750
+ letter-spacing: -0.01em;
751
+ }}
752
+ .panel-count {{
753
+ font-family: 'JetBrains Mono', monospace;
754
+ font-size: 10px;
755
+ color: var(--ink-mute);
756
+ letter-spacing: 0.15em;
757
+ text-transform: uppercase;
758
+ }}
759
+
760
+ /* ====== POLICY ITEMS ====== */
761
+ .policy {{
762
+ display: grid;
763
+ grid-template-columns: 72px 1fr auto;
764
+ gap: 20px;
765
+ padding: 14px 0;
766
+ border-bottom: 1px solid var(--rule-soft);
767
+ align-items: baseline;
768
+ }}
769
+ .policy:last-child {{ border-bottom: none; }}
770
+ .policy-year {{
771
+ font-family: 'Instrument Serif', serif;
772
+ font-size: 28px;
773
+ font-style: italic;
774
+ line-height: 1;
775
+ color: var(--oxblood);
776
+ font-variant-numeric: tabular-nums;
777
+ }}
778
+ .policy-text {{
779
+ font-size: 15px;
780
+ line-height: 1.4;
781
+ color: var(--ink);
782
+ }}
783
+ .policy-text a {{
784
+ color: var(--ink-mute);
785
+ margin-left: 6px;
786
+ font-size: 11px;
787
+ border-bottom: none;
788
+ }}
789
+ .policy-text a:hover {{ color: var(--oxblood); }}
790
+ .policy-metric {{
791
+ font-family: 'JetBrains Mono', monospace;
792
+ font-size: 11px;
793
+ color: var(--ink-mute);
794
+ white-space: nowrap;
795
+ letter-spacing: 0.05em;
796
+ }}
797
+
798
+ /* ====== RATE VERDICT ====== */
799
+ .rate-row {{
800
+ display: grid;
801
+ grid-template-columns: 60px 1fr auto;
802
+ gap: 20px;
803
+ padding: 14px 0;
804
+ border-bottom: 1px solid var(--rule-soft);
805
+ align-items: baseline;
806
+ }}
807
+ .rate-row .stage {{
808
+ font-family: 'JetBrains Mono', monospace;
809
+ font-size: 10px;
810
+ letter-spacing: 0.2em;
811
+ text-transform: uppercase;
812
+ color: var(--oxblood);
813
+ font-weight: 700;
814
+ }}
815
+ .rate-row .desc {{ font-size: 14px; color: var(--ink); font-style: italic; }}
816
+ .rate-row .rate {{
817
+ font-family: 'JetBrains Mono', monospace;
818
+ font-size: 14px;
819
+ font-weight: 500;
820
+ font-variant-numeric: tabular-nums;
821
+ white-space: nowrap;
822
+ }}
823
+ .rate-row .rate.neg {{ color: var(--oxblood); }}
824
+ .rate-row .rate.pos {{ color: var(--moss-deep); }}
825
+
826
+ .verdict {{
827
+ margin-top: 24px;
828
+ padding: 26px 20px;
829
+ background: var(--paper);
830
+ border: 1px solid var(--rule);
831
+ text-align: center;
832
+ position: relative;
833
+ }}
834
+ .verdict::before, .verdict::after {{
835
+ content: '§';
836
+ font-family: 'Instrument Serif', serif;
837
+ font-style: italic;
838
+ font-size: 18px;
839
+ color: var(--rule-soft);
840
+ position: absolute;
841
+ top: 50%;
842
+ transform: translateY(-50%);
843
+ }}
844
+ .verdict::before {{ left: 14px; }}
845
+ .verdict::after {{ right: 14px; }}
846
+ .verdict .headline {{
847
+ font-family: 'Instrument Serif', serif;
848
+ font-size: 30px;
849
+ font-style: italic;
850
+ line-height: 1.1;
851
+ letter-spacing: -0.01em;
852
+ }}
853
+ .verdict .headline.good {{ color: var(--moss-deep); }}
854
+ .verdict .headline.bad {{ color: var(--oxblood); }}
855
+ .verdict .sub {{
856
+ font-family: 'JetBrains Mono', monospace;
857
+ font-size: 10px;
858
+ letter-spacing: 0.18em;
859
+ text-transform: uppercase;
860
+ color: var(--ink-mute);
861
+ margin-top: 8px;
862
+ font-weight: 500;
863
+ }}
864
+
865
+ /* ====== DOSSIER CONTENT ====== */
866
+ .dossier-stats {{
867
+ display: grid;
868
+ grid-template-columns: repeat(4, 1fr);
869
+ gap: 32px;
870
+ margin-top: 16px;
871
+ padding-bottom: 20px;
872
+ border-bottom: 1px solid var(--rule-soft);
873
+ }}
874
+ .dossier-stat .k {{
875
+ font-family: 'JetBrains Mono', monospace;
876
+ font-size: 9px;
877
+ letter-spacing: 0.2em;
878
+ text-transform: uppercase;
879
+ color: var(--ink-mute);
880
+ margin-bottom: 4px;
881
+ font-weight: 500;
882
+ }}
883
+ .dossier-stat .v {{
884
+ font-family: 'Instrument Serif', serif;
885
+ font-size: 36px;
886
+ line-height: 1;
887
+ font-variant-numeric: tabular-nums;
888
+ letter-spacing: -0.01em;
889
+ font-style: italic;
890
+ }}
891
+ .dossier-stat .v.neg {{ color: var(--oxblood); }}
892
+ .dossier-stat .v.pos {{ color: var(--moss-deep); }}
893
+ .dossier-stat .v.alert {{ color: var(--amber-deep); }}
894
+
895
+ .rate-strip {{
896
+ display: flex;
897
+ gap: 48px;
898
+ padding: 18px 0;
899
+ border-bottom: 1px solid var(--rule-soft);
900
+ font-size: 14px;
901
+ }}
902
+ .rate-strip .rate-item {{
903
+ display: flex;
904
+ align-items: baseline;
905
+ gap: 10px;
906
+ }}
907
+ .rate-strip .k {{
908
+ font-family: 'JetBrains Mono', monospace;
909
+ font-size: 10px;
910
+ letter-spacing: 0.15em;
911
+ text-transform: uppercase;
912
+ color: var(--ink-mute);
913
+ }}
914
+ .rate-strip .v {{
915
+ font-family: 'JetBrains Mono', monospace;
916
+ font-weight: 700;
917
+ font-variant-numeric: tabular-nums;
918
+ }}
919
+ .rate-strip .v.neg {{ color: var(--oxblood); }}
920
+ .rate-strip .v.pos {{ color: var(--moss-deep); }}
921
+
922
+ .context-block {{
923
+ margin-top: 18px;
924
+ padding: 20px 22px;
925
+ background: var(--paper);
926
+ border-left: 3px solid var(--oxblood);
927
+ position: relative;
928
+ }}
929
+ .context-block .k {{
930
+ font-family: 'JetBrains Mono', monospace;
931
+ font-size: 9px;
932
+ letter-spacing: 0.25em;
933
+ text-transform: uppercase;
934
+ color: var(--oxblood);
935
+ font-weight: 700;
936
+ margin-bottom: 8px;
937
+ }}
938
+ .context-block .v {{
939
+ font-family: 'Fraunces', serif;
940
+ font-size: 16px;
941
+ line-height: 1.55;
942
+ color: var(--ink);
943
+ font-style: italic;
944
+ }}
945
+
946
+ .nearby-block {{
947
+ margin-top: 18px;
948
+ padding-top: 16px;
949
+ border-top: 1px solid var(--rule-soft);
950
+ }}
951
+ .nearby-block .k {{
952
+ font-family: 'JetBrains Mono', monospace;
953
+ font-size: 9px;
954
+ letter-spacing: 0.22em;
955
+ text-transform: uppercase;
956
+ color: var(--ink-mute);
957
+ margin-bottom: 10px;
958
+ font-weight: 600;
959
+ }}
960
+ .nearby-item {{
961
+ display: flex;
962
+ gap: 14px;
963
+ padding: 6px 0;
964
+ align-items: baseline;
965
+ font-size: 14px;
966
+ }}
967
+ .nearby-item .yr {{
968
+ font-family: 'Instrument Serif', serif;
969
+ font-style: italic;
970
+ color: var(--oxblood);
971
+ font-weight: 400;
972
+ min-width: 56px;
973
+ }}
974
+
975
+ /* ====== LOADING ====== */
976
+ .loading {{
977
+ grid-column: 1 / -1;
978
+ padding: 60px 20px;
979
+ text-align: center;
980
+ font-family: 'Instrument Serif', serif;
981
+ font-style: italic;
982
+ font-size: 20px;
983
+ color: var(--ink-mute);
984
+ }}
985
+ .loading::after {{
986
+ content: ' ·';
987
+ animation: ellipsis 1.4s infinite;
988
+ }}
989
+ @keyframes ellipsis {{
990
+ 0% {{ content: ' ·'; }}
991
+ 33% {{ content: ' · ·'; }}
992
+ 66% {{ content: ' · · ·'; }}
993
+ }}
994
+
995
+ /* ====== COLOPHON ====== */
996
+ .colophon {{
997
+ max-width: 1400px;
998
+ margin: 0 auto;
999
+ padding: 40px 48px 60px;
1000
+ border-top: 2px solid var(--ink);
1001
+ position: relative;
1002
+ z-index: 2;
1003
+ }}
1004
+ .colophon-top {{
1005
+ display: grid;
1006
+ grid-template-columns: 2fr 1fr 1fr;
1007
+ gap: 48px;
1008
+ padding-bottom: 24px;
1009
+ border-bottom: 1px solid var(--rule);
1010
+ margin-bottom: 18px;
1011
+ }}
1012
+ .colophon-section .title {{
1013
+ font-family: 'JetBrains Mono', monospace;
1014
+ font-size: 10px;
1015
+ letter-spacing: 0.22em;
1016
+ text-transform: uppercase;
1017
+ color: var(--ink-mute);
1018
+ margin-bottom: 10px;
1019
+ font-weight: 600;
1020
+ }}
1021
+ .colophon-section .body {{
1022
+ font-size: 14px;
1023
+ line-height: 1.55;
1024
+ color: var(--ink-soft);
1025
+ font-style: italic;
1026
+ }}
1027
+ .colophon-imprint {{
1028
+ font-family: 'Instrument Serif', serif;
1029
+ font-size: 64px;
1030
+ line-height: 0.9;
1031
+ font-style: italic;
1032
+ color: var(--ink);
1033
+ letter-spacing: -0.02em;
1034
+ margin-bottom: 8px;
1035
+ }}
1036
+ .colophon-mark {{
1037
+ display: flex;
1038
+ justify-content: space-between;
1039
+ align-items: center;
1040
+ font-family: 'JetBrains Mono', monospace;
1041
+ font-size: 10px;
1042
+ letter-spacing: 0.18em;
1043
+ text-transform: uppercase;
1044
+ color: var(--ink-mute);
1045
+ }}
1046
+
1047
+ @media (max-width: 900px) {{
1048
+ .masthead, .sheet, .colophon {{ padding-left: 24px; padding-right: 24px; }}
1049
+ .findings {{ grid-template-columns: 1fr 1fr; }}
1050
+ .finding {{ border-right: none; border-bottom: 1px solid var(--rule); }}
1051
+ .finding:nth-child(2n) {{ border-right: none; }}
1052
+ .finding:nth-child(odd) {{ border-right: 1px solid var(--rule); }}
1053
+ .dossier-stats {{ grid-template-columns: 1fr 1fr; gap: 20px; }}
1054
+ .split.active {{ grid-template-columns: 1fr; }}
1055
+ .panel {{ border-right: none; border-bottom: 1px solid var(--rule); }}
1056
+ .control-strip {{ grid-template-columns: 1fr; gap: 16px; }}
1057
+ .section-num {{ display: none; }}
1058
+ .dek {{ flex-direction: column; align-items: flex-start; }}
1059
+ .wordmark {{ font-size: 72px; }}
1060
+ .colophon-top {{ grid-template-columns: 1fr; }}
1061
+ }}
1062
+ </style>
1063
+ </head>
1064
+ <body>
1065
+
1066
+ <!-- ====== MASTHEAD ====== -->
1067
+ <header class="masthead">
1068
+ <div class="masthead-top">
1069
+ <div class="vol">
1070
+ <span>Vol. I</span>
1071
+ <span>No. 001</span>
1072
+ <span>Standing at 58.1% Global Tree Cover</span>
1073
+ </div>
1074
+ <div>Filed via World Bank Open Data</div>
1075
+ </div>
1076
+ <div class="masthead-row">
1077
+ <h1 class="wordmark"><em>The</em> <span class="dispatch">Forest Dispatch</span></h1>
1078
+ <div class="dek-text">
1079
+ A record of what has been lost, and what — through the stroke of a pen or the
1080
+ press of a moratorium — may yet be saved.
1081
+ </div>
1082
+ <div class="dek-meta">
1083
+ An investigation<br>
1084
+ powered by Gradio<br>
1085
+ <span class="badge">MCP-enabled</span>
1086
+ </div>
1087
+ </div>
1088
+ </header>
1089
+
1090
+ <main class="sheet">
1091
+
1092
+ <!-- ====== CONTROL STRIP ====== -->
1093
+ <section class="control-strip">
1094
+ <div class="section-title">
1095
+ <span class="caps">§ Selection</span>
1096
+ Choose your <em>subject</em> &amp; <em>comparator</em>
1097
+ </div>
1098
+ <div class="control-group">
1099
+ <label>Subject</label>
1100
+ <select id="country"></select>
1101
+ </div>
1102
+ <div class="control-group">
1103
+ <label>Comparator</label>
1104
+ <select id="compare"><option value="">— none —</option></select>
1105
+ </div>
1106
+ <button class="action" onclick="loadData()">Investigate →</button>
1107
+ </section>
1108
+
1109
+ <!-- ====== FIGURE (lead) ====== -->
1110
+ <section class="figure">
1111
+ <div class="figure-head">
1112
+ <div class="figure-title">Forest cover over time, <em>with policy annotations</em></div>
1113
+ <div class="figure-note">click · any · year · for · dossier</div>
1114
+ </div>
1115
+ <div class="figure-frame">
1116
+ <canvas id="chart"></canvas>
1117
+ </div>
1118
+ <div class="figure-caption">
1119
+ Forest area as percentage of total land. Oxblood markers denote known policy events
1120
+ (hover the line to read). Rule of Law index plotted on secondary axis where available.
1121
+ Click any data point for a detailed dossier.
1122
+ </div>
1123
+ </section>
1124
+
1125
+ <!-- ====== FINDINGS (after the chart) ====== -->
1126
+ <div id="findings-wrap" style="position:relative">
1127
+ <div id="findings" class="findings"></div>
1128
+ </div>
1129
+
1130
+ <!-- ====== DOSSIER ====== -->
1131
+ <section id="dossier" class="dossier">
1132
+ <div class="dossier-label">Dossier</div>
1133
+ <div class="dossier-head" id="dossier-head"></div>
1134
+ <div id="dossier-content"></div>
1135
+ </section>
1136
+
1137
+ <!-- ====== POLICY &amp; VERDICT ====== -->
1138
+ <div id="split" class="split">
1139
+ <div class="panel" id="policies"></div>
1140
+ <div class="panel" id="verdict-panel"></div>
1141
+ </div>
1142
+
1143
+ </main>
1144
+
1145
+ <!-- ====== COLOPHON ====== -->
1146
+ <footer class="colophon">
1147
+ <div class="colophon-top">
1148
+ <div class="colophon-section">
1149
+ <div class="colophon-imprint">Fin.</div>
1150
+ <div class="body">
1151
+ Every figure herein is drawn from the public record. Where the policy
1152
+ register falls silent, we supply historical context in earnest italics —
1153
+ droughts, fires, wars, booms &amp; busts — to explain the line.
1154
+ </div>
1155
+ </div>
1156
+ <div class="colophon-section">
1157
+ <div class="title">Sources</div>
1158
+ <div class="body">
1159
+ <a href="https://data.worldbank.org/">World Bank Open Data</a> ·
1160
+ Forest area <span class="mono">(AG.LND.FRST.ZS)</span> ·
1161
+ Rule of Law <span class="mono">(RL.EST)</span>
1162
+ </div>
1163
+ </div>
1164
+ <div class="colophon-section">
1165
+ <div class="title">Built upon</div>
1166
+ <div class="body">
1167
+ <a href="https://gradio.app">Gradio Server</a> — API endpoints
1168
+ available at <span class="mono">/gradio_api/</span>,
1169
+ also exposed as MCP tools for agents.
1170
+ </div>
1171
+ </div>
1172
+ </div>
1173
+ <div class="colophon-mark">
1174
+ <span>© The Forest Dispatch · A record of loss &amp; recovery</span>
1175
+ <span>Set in Instrument Serif · Fraunces · JetBrains Mono</span>
1176
+ </div>
1177
+ </footer>
1178
+
1179
+ <script>
1180
+ const countries = {countries_json};
1181
+ const countrySelect = document.getElementById('country');
1182
+ const compareSelect = document.getElementById('compare');
1183
+ let chart = null;
1184
+ let currentData = null;
1185
+
1186
+ countries.forEach(c => {{
1187
+ countrySelect.add(new Option(c, c));
1188
+ compareSelect.add(new Option(c, c));
1189
+ }});
1190
+ countrySelect.value = 'Brazil';
1191
+
1192
+ async function callApi(name, params) {{
1193
+ const resp = await fetch('/gradio_api/run/' + name, {{
1194
+ method: 'POST',
1195
+ headers: {{'Content-Type': 'application/json'}},
1196
+ body: JSON.stringify({{data: Object.values(params)}})
1197
+ }});
1198
+ const result = await resp.json();
1199
+ return result.data ? result.data[0] : result;
1200
+ }}
1201
+
1202
+ async function loadData() {{
1203
+ const country = countrySelect.value;
1204
+ const compare = compareSelect.value;
1205
+
1206
+ document.getElementById('findings').innerHTML = '<div class="loading">Gathering field notes</div>';
1207
+ document.getElementById('split').classList.remove('active');
1208
+ document.getElementById('dossier').style.display = 'none';
1209
+ if (chart) {{ chart.destroy(); chart = null; }}
1210
+
1211
+ let data, compareData = null;
1212
+ if (compare && compare !== country) {{
1213
+ const result = await callApi('compare_countries', {{country_a: country, country_b: compare}});
1214
+ data = result.country_a;
1215
+ compareData = result.country_b;
1216
+ }} else {{
1217
+ data = await callApi('get_forest_data', {{country}});
1218
+ }}
1219
+
1220
+ if (data.error) {{
1221
+ document.getElementById('findings').innerHTML = `<div class="loading">${{data.error}}</div>`;
1222
+ return;
1223
+ }}
1224
+
1225
+ currentData = data;
1226
+ renderFindings(data);
1227
+ renderChart(data, compareData);
1228
+ renderPolicies(data);
1229
+ renderVerdict(data);
1230
+ document.getElementById('split').classList.add('active');
1231
+ }}
1232
+
1233
+ function renderFindings(data) {{
1234
+ const s = data.summary;
1235
+ if (!s || !s.start_year) {{
1236
+ document.getElementById('findings').innerHTML = '<div class="loading">No data available for this subject</div>';
1237
+ return;
1238
+ }}
1239
+ const dirClass = s.change_pct < 0 ? 'down' : 'up';
1240
+ const sign = s.change_pct < 0 ? '−' : '+';
1241
+ const rateSign = s.annual_rate < 0 ? '−' : '+';
1242
+
1243
+ document.getElementById('findings').innerHTML = `
1244
+ <div class="finding" data-num="i">
1245
+ <div>
1246
+ <div class="label">Cover · in ${{s.start_year}}</div>
1247
+ <div class="value">${{s.start_pct.toFixed(1)}}<span class="unit">%</span></div>
1248
+ </div>
1249
+ <div class="sub">of national land area</div>
1250
+ </div>
1251
+ <div class="finding" data-num="ii">
1252
+ <div>
1253
+ <div class="label">Cover · in ${{s.end_year}}</div>
1254
+ <div class="value">${{s.end_pct.toFixed(1)}}<span class="unit">%</span></div>
1255
+ </div>
1256
+ <div class="sub">of national land area</div>
1257
+ </div>
1258
+ <div class="finding" data-num="iii">
1259
+ <div>
1260
+ <div class="label">Net change</div>
1261
+ <div class="value ${{dirClass}}">${{sign}}${{Math.abs(s.change_pct).toFixed(2)}}<span class="unit">pp</span></div>
1262
+ </div>
1263
+ <div class="sub">across ${{s.end_year - s.start_year}} years observed</div>
1264
+ </div>
1265
+ <div class="finding" data-num="iv">
1266
+ <div>
1267
+ <div class="label">Annual rate</div>
1268
+ <div class="value ${{dirClass}}">${{rateSign}}${{Math.abs(s.annual_rate).toFixed(3)}}<span class="unit">%/yr</span></div>
1269
+ </div>
1270
+ <div class="sub">${{data.policies.length}} policies on record</div>
1271
+ </div>
1272
+ `;
1273
+ }}
1274
+
1275
+ function renderChart(data, compareData) {{
1276
+ if (chart) chart.destroy();
1277
+
1278
+ const ctx = document.getElementById('chart').getContext('2d');
1279
+ const INK = '#1a1f18';
1280
+ const OXBLOOD = '#8b2a1f';
1281
+ const MOSS = '#4a6b3e';
1282
+ const AMBER = '#a8732a';
1283
+ const RULE = '#c5bda5';
1284
+ const PAPER_TINT = '#e4dbc5';
1285
+
1286
+ const datasets = [{{
1287
+ label: data.country,
1288
+ data: data.forest.map(f => ({{x: f.year, y: f.value}})),
1289
+ borderColor: MOSS,
1290
+ backgroundColor: 'rgba(74, 107, 62, 0.12)',
1291
+ fill: true,
1292
+ tension: 0.25,
1293
+ pointRadius: 2.5,
1294
+ pointBackgroundColor: MOSS,
1295
+ pointBorderColor: PAPER_TINT,
1296
+ pointBorderWidth: 1,
1297
+ pointHoverRadius: 6,
1298
+ borderWidth: 2.2,
1299
+ }}];
1300
+
1301
+ if (data.governance && data.governance.length) {{
1302
+ datasets.push({{
1303
+ label: 'Rule of Law',
1304
+ data: data.governance.map(g => ({{x: g.year, y: g.value}})),
1305
+ borderColor: AMBER,
1306
+ borderDash: [3, 3],
1307
+ borderWidth: 1.2,
1308
+ pointRadius: 0,
1309
+ tension: 0.3,
1310
+ yAxisID: 'y2',
1311
+ }});
1312
+ }}
1313
+
1314
+ if (compareData && compareData.forest) {{
1315
+ datasets.push({{
1316
+ label: compareData.country,
1317
+ data: compareData.forest.map(f => ({{x: f.year, y: f.value}})),
1318
+ borderColor: OXBLOOD,
1319
+ borderDash: [6, 3],
1320
+ borderWidth: 1.8,
1321
+ pointRadius: 1.5,
1322
+ pointBackgroundColor: OXBLOOD,
1323
+ tension: 0.25,
1324
+ }});
1325
+ }}
1326
+
1327
+ const annotations = {{}};
1328
+ (data.policies || []).forEach((p, i) => {{
1329
+ annotations['line' + i] = {{
1330
+ type: 'line',
1331
+ xMin: p.year, xMax: p.year,
1332
+ borderColor: 'rgba(139, 42, 31, 0.25)',
1333
+ borderWidth: 1,
1334
+ borderDash: [3, 4],
1335
+ }};
1336
+ annotations['label' + i] = {{
1337
+ type: 'point',
1338
+ xValue: p.year,
1339
+ yValue: data.forest.find(f => f.year === p.year)?.value || data.forest[0].value,
1340
+ radius: 5,
1341
+ backgroundColor: OXBLOOD,
1342
+ borderColor: PAPER_TINT,
1343
+ borderWidth: 2,
1344
+ hoverRadius: 8,
1345
+ }};
1346
+ }});
1347
+
1348
+ chart = new Chart(ctx, {{
1349
+ type: 'line',
1350
+ data: {{ datasets }},
1351
+ options: {{
1352
+ responsive: true,
1353
+ maintainAspectRatio: false,
1354
+ interaction: {{ mode: 'index', intersect: false }},
1355
+ onClick: function(evt, elements) {{
1356
+ if (!elements.length) return;
1357
+ const el = elements[0];
1358
+ const ds = chart.data.datasets[el.datasetIndex];
1359
+ if (!ds.data[el.index]) return;
1360
+ const year = Math.round(ds.data[el.index].x);
1361
+ handlePointClick(year);
1362
+ }},
1363
+ plugins: {{
1364
+ legend: {{
1365
+ labels: {{
1366
+ color: INK,
1367
+ font: {{ family: "'JetBrains Mono', monospace", size: 10, weight: 500 }},
1368
+ usePointStyle: true,
1369
+ pointStyle: 'rectRot',
1370
+ boxWidth: 8,
1371
+ padding: 16,
1372
+ }}
1373
+ }},
1374
+ annotation: {{ annotations }},
1375
+ tooltip: {{
1376
+ backgroundColor: INK,
1377
+ titleColor: '#ede5d3',
1378
+ bodyColor: '#ede5d3',
1379
+ borderWidth: 0,
1380
+ titleFont: {{ family: "'Instrument Serif', serif", style: 'italic', size: 16 }},
1381
+ bodyFont: {{ family: "'JetBrains Mono', monospace", size: 11 }},
1382
+ padding: 12,
1383
+ cornerRadius: 0,
1384
+ displayColors: false,
1385
+ callbacks: {{
1386
+ title: items => items.length ? Math.round(items[0].parsed.x).toString() : '',
1387
+ label: function(ctx) {{
1388
+ const v = ctx.parsed.y;
1389
+ return ctx.dataset.label + ' · ' + (v != null ? v.toFixed(2) : '—');
1390
+ }},
1391
+ afterBody: function(items) {{
1392
+ if (!items.length) return;
1393
+ const year = Math.round(items[0].parsed.x);
1394
+ const policy = (data.policies || []).find(p => p.year === year);
1395
+ return policy ? ['', '§ ' + policy.text] : '';
1396
+ }}
1397
+ }}
1398
+ }}
1399
+ }},
1400
+ scales: {{
1401
+ x: {{
1402
+ type: 'linear',
1403
+ ticks: {{
1404
+ color: '#6a6d60',
1405
+ font: {{ family: "'JetBrains Mono', monospace", size: 10 }},
1406
+ stepSize: 5,
1407
+ callback: v => v.toString(),
1408
+ }},
1409
+ grid: {{ color: 'rgba(58, 63, 53, 0.1)', drawTicks: false }},
1410
+ border: {{ color: INK, width: 1 }},
1411
+ }},
1412
+ y: {{
1413
+ ticks: {{
1414
+ color: MOSS,
1415
+ font: {{ family: "'JetBrains Mono', monospace", size: 10 }},
1416
+ callback: v => v.toFixed(1) + '%',
1417
+ }},
1418
+ grid: {{ color: 'rgba(58, 63, 53, 0.08)', drawTicks: false }},
1419
+ border: {{ color: INK, width: 1 }},
1420
+ }},
1421
+ y2: {{
1422
+ position: 'right',
1423
+ ticks: {{
1424
+ color: AMBER,
1425
+ font: {{ family: "'JetBrains Mono', monospace", size: 9 }},
1426
+ }},
1427
+ grid: {{ display: false }},
1428
+ border: {{ color: 'rgba(168, 115, 42, 0.4)' }},
1429
+ }}
1430
+ }}
1431
+ }}
1432
+ }});
1433
+ }}
1434
+
1435
+ function renderPolicies(data) {{
1436
+ const el = document.getElementById('policies');
1437
+ if (!data.policies || !data.policies.length) {{
1438
+ el.innerHTML = `
1439
+ <div class="panel-head">
1440
+ <div class="panel-title">The Register</div>
1441
+ <div class="panel-count">— empty —</div>
1442
+ </div>
1443
+ <div style="font-style:italic;color:var(--ink-mute);padding:16px 0">
1444
+ No policy events catalogued for this jurisdiction.
1445
+ </div>`;
1446
+ return;
1447
+ }}
1448
+ const items = data.policies.map(p => {{
1449
+ const forestVal = data.forest.find(f => f.year === p.year);
1450
+ const forestText = forestVal ? forestVal.value.toFixed(1) + '%' : '—';
1451
+ const link = p.url ? `<a href="${{p.url}}" target="_blank" title="Source">↗</a>` : '';
1452
+ return `<div class="policy">
1453
+ <div class="policy-year">${{p.year}}</div>
1454
+ <div class="policy-text">${{p.text}}${{link}}</div>
1455
+ <div class="policy-metric">${{forestText}}</div>
1456
+ </div>`;
1457
+ }}).join('');
1458
+ el.innerHTML = `
1459
+ <div class="panel-head">
1460
+ <div class="panel-title">The Register</div>
1461
+ <div class="panel-count">${{data.policies.length.toString().padStart(2, '0')}} entries</div>
1462
+ </div>
1463
+ ${{items}}`;
1464
+ }}
1465
+
1466
+ function renderVerdict(data) {{
1467
+ const el = document.getElementById('verdict-panel');
1468
+ if (!data.policies || !data.policies.length || !data.forest.length) {{
1469
+ el.innerHTML = `
1470
+ <div class="panel-head">
1471
+ <div class="panel-title">The Verdict</div>
1472
+ <div class="panel-count">— pending —</div>
1473
+ </div>
1474
+ <div style="font-style:italic;color:var(--ink-mute);padding:16px 0">
1475
+ Insufficient data to render judgment.
1476
+ </div>`;
1477
+ return;
1478
+ }}
1479
+ const firstPolicy = data.policies[0].year;
1480
+ const pre = data.forest.filter(f => f.year < firstPolicy);
1481
+ const post = data.forest.filter(f => f.year >= firstPolicy);
1482
+
1483
+ if (pre.length < 2 || post.length < 2) {{
1484
+ el.innerHTML = `
1485
+ <div class="panel-head">
1486
+ <div class="panel-title">The Verdict</div>
1487
+ <div class="panel-count">— pending —</div>
1488
+ </div>
1489
+ <div style="font-style:italic;color:var(--ink-mute);padding:16px 0">
1490
+ Too few observations either side of intervention.
1491
+ </div>`;
1492
+ return;
1493
+ }}
1494
+
1495
+ const preRate = (pre[pre.length-1].value - pre[0].value) / (pre[pre.length-1].year - pre[0].year);
1496
+ const postRate = (post[post.length-1].value - post[0].value) / (post[post.length-1].year - post[0].year);
1497
+ const improved = Math.abs(postRate) < Math.abs(preRate);
1498
+ const pct = preRate !== 0 ? ((1 - Math.abs(postRate) / Math.abs(preRate)) * 100) : 0;
1499
+
1500
+ el.innerHTML = `
1501
+ <div class="panel-head">
1502
+ <div class="panel-title">The Verdict</div>
1503
+ <div class="panel-count">Pre / Post · 1st intervention</div>
1504
+ </div>
1505
+ <div class="rate-row">
1506
+ <div class="stage">Pre</div>
1507
+ <div class="desc">${{pre[0].year}}–${{pre[pre.length-1].year}} · before first policy</div>
1508
+ <div class="rate ${{preRate < 0 ? 'neg' : 'pos'}}">${{preRate > 0 ? '+' : ''}}${{preRate.toFixed(3)}}%/yr</div>
1509
+ </div>
1510
+ <div class="rate-row">
1511
+ <div class="stage">Post</div>
1512
+ <div class="desc">${{post[0].year}}–${{post[post.length-1].year}} · since first policy</div>
1513
+ <div class="rate ${{postRate < 0 ? 'neg' : 'pos'}}">${{postRate > 0 ? '+' : ''}}${{postRate.toFixed(3)}}%/yr</div>
1514
+ </div>
1515
+ <div class="verdict">
1516
+ <div class="headline ${{improved ? 'good' : 'bad'}}">
1517
+ ${{improved ? 'Slowed by ' + Math.abs(pct).toFixed(0) + '%' : 'No improvement observed'}}
1518
+ </div>
1519
+ <div class="sub">${{improved ? 'after policy intervention' : 'rate unchanged or worsened'}}</div>
1520
+ </div>
1521
+ `;
1522
+ }}
1523
+
1524
+ async function handlePointClick(year) {{
1525
+ const country = countrySelect.value;
1526
+ const dossierEl = document.getElementById('dossier');
1527
+ const headEl = document.getElementById('dossier-head');
1528
+ const contentEl = document.getElementById('dossier-content');
1529
+ dossierEl.style.display = 'block';
1530
+ dossierEl.scrollIntoView({{ behavior: 'smooth', block: 'nearest' }});
1531
+ headEl.innerHTML = `Filing dossier for <em>${{year}}</em>`;
1532
+ contentEl.innerHTML = '<div style="font-style:italic;color:var(--ink-mute);padding:14px 0">Compiling field notes…</div>';
1533
+
1534
+ const result = await callApi('explain_spike', {{country, year}});
1535
+ if (result.error) {{
1536
+ contentEl.innerHTML = '<div style="color:var(--oxblood);font-style:italic">' + result.error + '</div>';
1537
+ return;
1538
+ }}
1539
+
1540
+ const trendClass = (() => {{
1541
+ if (['sharp decline', 'acceleration'].includes(result.trend)) return 'neg';
1542
+ if (['recovery', 'sharp increase'].includes(result.trend)) return 'pos';
1543
+ return 'alert';
1544
+ }})();
1545
+
1546
+ headEl.innerHTML = `Dossier · <em>${{country}}</em> · ${{result.year}}`;
1547
+
1548
+ let html = `
1549
+ <div class="dossier-stats">
1550
+ <div class="dossier-stat">
1551
+ <div class="k">Year</div>
1552
+ <div class="v">${{result.year}}</div>
1553
+ </div>
1554
+ <div class="dossier-stat">
1555
+ <div class="k">Forest cover</div>
1556
+ <div class="v">${{result.forest_pct !== null ? result.forest_pct.toFixed(2) + '%' : '—'}}</div>
1557
+ </div>
1558
+ <div class="dossier-stat">
1559
+ <div class="k">Year-on-year</div>
1560
+ <div class="v ${{result.yoy_change !== null && result.yoy_change < 0 ? 'neg' : (result.yoy_change > 0 ? 'pos' : '')}}">${{result.yoy_change !== null ? (result.yoy_change > 0 ? '+' : '') + result.yoy_change.toFixed(3) + 'pp' : '—'}}</div>
1561
+ </div>
1562
+ <div class="dossier-stat">
1563
+ <div class="k">Trend</div>
1564
+ <div class="v ${{trendClass}}">${{result.trend}}</div>
1565
+ </div>
1566
+ </div>
1567
+ `;
1568
+
1569
+ if (result.rate_5yr_before !== null || result.rate_5yr_after !== null) {{
1570
+ html += `<div class="rate-strip">`;
1571
+ if (result.rate_5yr_before !== null) {{
1572
+ html += `<div class="rate-item"><span class="k">5yr · before</span><span class="v ${{result.rate_5yr_before < 0 ? 'neg' : 'pos'}}">${{result.rate_5yr_before > 0 ? '+' : ''}}${{result.rate_5yr_before}}%/yr</span></div>`;
1573
+ }}
1574
+ if (result.rate_5yr_after !== null) {{
1575
+ html += `<div class="rate-item"><span class="k">5yr · after</span><span class="v ${{result.rate_5yr_after < 0 ? 'neg' : 'pos'}}">${{result.rate_5yr_after > 0 ? '+' : ''}}${{result.rate_5yr_after}}%/yr</span></div>`;
1576
+ }}
1577
+ html += `</div>`;
1578
+ }}
1579
+
1580
+ if (result.context) {{
1581
+ html += `<div class="context-block">
1582
+ <div class="k">Field note · historical context</div>
1583
+ <div class="v">${{result.context}}</div>
1584
+ </div>`;
1585
+ }}
1586
+
1587
+ if (result.nearby_policies && result.nearby_policies.length) {{
1588
+ html += `<div class="nearby-block">
1589
+ <div class="k">Nearby policy events · ±3 years</div>`;
1590
+ result.nearby_policies.forEach(p => {{
1591
+ const pLink = p.url ? ` <a href="${{p.url}}" target="_blank" style="color:var(--ink-mute);font-size:11px">↗</a>` : '';
1592
+ html += `<div class="nearby-item"><span class="yr">${{p.year}}</span><span>${{p.text}}${{pLink}}</span></div>`;
1593
+ }});
1594
+ html += `</div>`;
1595
+ }}
1596
+
1597
+ contentEl.innerHTML = html;
1598
+ }}
1599
+
1600
+ loadData();
1601
+ </script>
1602
+ </body>
1603
+ </html>"""
1604
+
1605
+
1606
+ if __name__ == "__main__":
1607
+ app.launch(mcp_server=True)
requirements.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ gradio