Spaces:
Running
Running
| """ | |
| Policy vs Deforestation Explorer — built with gr.Server | |
| Uses gr.Server for API endpoints (with MCP tool support) and a custom | |
| HTML frontend with Chart.js for interactive visualization. | |
| """ | |
| import json | |
| import urllib.request | |
| from gradio import Server | |
| from fastapi.responses import HTMLResponse, JSONResponse | |
| app = Server() | |
| COUNTRIES = { | |
| "Brazil": "BRA", "Indonesia": "IDN", "DR Congo": "COD", | |
| "Colombia": "COL", "Bolivia": "BOL", "Malaysia": "MYS", | |
| "India": "IND", "Mexico": "MEX", "Peru": "PER", | |
| "Australia": "AUS", "Canada": "CAN", "Russia": "RUS", | |
| "China": "CHN", "United States": "USA", "Nigeria": "NGA", | |
| "Myanmar": "MMR", "Tanzania": "TZA", "Paraguay": "PRY", | |
| "Madagascar": "MDG", "Cameroon": "CMR", | |
| } | |
| POLICY_EVENTS = { | |
| "Brazil": [ | |
| {"year": 2004, "text": "PPCDAm action plan launched", "url": "https://en.wikipedia.org/wiki/Deforestation_in_Brazil"}, | |
| {"year": 2006, "text": "Soy Moratorium signed", "url": "https://en.wikipedia.org/wiki/Amazon_Soy_Moratorium"}, | |
| {"year": 2008, "text": "Amazon Fund established", "url": "https://en.wikipedia.org/wiki/Amazon_Fund"}, | |
| {"year": 2012, "text": "New Forest Code enacted", "url": "https://en.wikipedia.org/wiki/Deforestation_in_Brazil"}, | |
| {"year": 2019, "text": "Enforcement weakened under Bolsonaro", "url": "https://en.wikipedia.org/wiki/Deforestation_in_Brazil"}, | |
| {"year": 2023, "text": "Zero deforestation pledge renewed", "url": "https://en.wikipedia.org/wiki/Deforestation_in_Brazil"}, | |
| ], | |
| "Indonesia": [ | |
| {"year": 2002, "text": "Illegal Logging Decree", "url": "https://en.wikipedia.org/wiki/Deforestation_in_Indonesia"}, | |
| {"year": 2011, "text": "Forest moratorium on concessions", "url": "https://en.wikipedia.org/wiki/Deforestation_in_Indonesia"}, | |
| {"year": 2014, "text": "One Map Policy", "url": "https://en.wikipedia.org/wiki/Joko_Widodo"}, | |
| {"year": 2018, "text": "Moratorium extended permanently", "url": "https://en.wikipedia.org/wiki/Deforestation_in_Indonesia"}, | |
| {"year": 2023, "text": "FOLU Net Sink 2030 strategy", "url": "https://en.wikipedia.org/wiki/Climate_change_in_Indonesia"}, | |
| ], | |
| "DR Congo": [ | |
| {"year": 2002, "text": "Forest Code enacted", "url": "https://en.wikipedia.org/wiki/Deforestation_in_the_Democratic_Republic_of_the_Congo"}, | |
| {"year": 2014, "text": "REDD+ national strategy", "url": "https://en.wikipedia.org/wiki/REDD_and_REDD%2B"}, | |
| {"year": 2022, "text": "Logging moratorium lifted", "url": "https://en.wikipedia.org/wiki/Deforestation_in_the_Democratic_Republic_of_the_Congo"}, | |
| ], | |
| "Colombia": [ | |
| {"year": 2010, "text": "REDD+ strategy launched", "url": "https://en.wikipedia.org/wiki/REDD_and_REDD%2B"}, | |
| {"year": 2016, "text": "FARC peace deal", "url": "https://en.wikipedia.org/wiki/Colombian_peace_process"}, | |
| {"year": 2018, "text": "Deforestation Control Council", "url": "https://en.wikipedia.org/wiki/Deforestation_in_Colombia"}, | |
| {"year": 2023, "text": "Amazon pact signed", "url": "https://en.wikipedia.org/wiki/Amazon_Cooperation_Treaty_Organization"}, | |
| ], | |
| "India": [ | |
| {"year": 2006, "text": "Forest Rights Act", "url": "https://en.wikipedia.org/wiki/Forest_Rights_Act,_2006"}, | |
| {"year": 2014, "text": "Green India Mission", "url": "https://en.wikipedia.org/wiki/Government_of_India"}, | |
| {"year": 2019, "text": "Compensatory Afforestation Fund", "url": "https://en.wikipedia.org/wiki/Compensatory_Afforestation_Fund_Act,_2016"}, | |
| ], | |
| "Malaysia": [ | |
| {"year": 2010, "text": "50% forest cover pledge", "url": "https://en.wikipedia.org/wiki/Deforestation_in_Malaysia"}, | |
| {"year": 2017, "text": "MSPO mandatory certification", "url": "https://en.wikipedia.org/wiki/Malaysian_Sustainable_Palm_Oil"}, | |
| ], | |
| "China": [ | |
| {"year": 1998, "text": "Natural Forest Protection Program", "url": "https://en.wikipedia.org/wiki/Reforestation_in_China"}, | |
| {"year": 2003, "text": "Grain-to-Green expanded", "url": "https://en.wikipedia.org/wiki/Reforestation_in_China"}, | |
| {"year": 2016, "text": "13th Five-Year Plan forestry targets", "url": "https://en.wikipedia.org/wiki/13th_Five-Year_Plan_(China)"}, | |
| ], | |
| "Mexico": [ | |
| {"year": 2001, "text": "ProÁrbol reforestation", "url": "https://en.wikipedia.org/wiki/CONAFOR"}, | |
| {"year": 2012, "text": "Climate Change Law", "url": "https://en.wikipedia.org/wiki/Climate_change_in_Mexico"}, | |
| {"year": 2020, "text": "Sembrando Vida program", "url": "https://en.wikipedia.org/wiki/Andr%C3%A9s_Manuel_L%C3%B3pez_Obrador"}, | |
| ], | |
| "Canada": [ | |
| {"year": 2010, "text": "Boreal Forest Agreement", "url": "https://en.wikipedia.org/wiki/Boreal_forest_of_Canada"}, | |
| {"year": 2021, "text": "2 Billion Trees program", "url": "https://www.canada.ca/en/campaign/2-billion-trees.html"}, | |
| ], | |
| "Australia": [ | |
| {"year": 2000, "text": "Regional Forest Agreements", "url": "https://en.wikipedia.org/wiki/Regional_Forest_Agreement"}, | |
| {"year": 2012, "text": "Carbon farming legislation", "url": "https://en.wikipedia.org/wiki/Climate_change_in_Australia"}, | |
| {"year": 2022, "text": "Nature Repair Market Act", "url": "https://en.wikipedia.org/wiki/Climate_change_in_Australia"}, | |
| ], | |
| "United States": [ | |
| {"year": 2001, "text": "Healthy Forests Initiative", "url": "https://en.wikipedia.org/wiki/Healthy_Forests_Initiative"}, | |
| {"year": 2008, "text": "Lacey Act amended to ban illegal timber", "url": "https://en.wikipedia.org/wiki/Lacey_Act_of_1900"}, | |
| {"year": 2022, "text": "Inflation Reduction Act — $5B for forests", "url": "https://en.wikipedia.org/wiki/Inflation_Reduction_Act"}, | |
| ], | |
| "Russia": [ | |
| {"year": 1997, "text": "First Russian Forest Code", "url": "https://en.wikipedia.org/wiki/Forestry_in_Russia"}, | |
| {"year": 2006, "text": "New Forest Code — privatisation of management", "url": "https://en.wikipedia.org/wiki/Forestry_in_Russia"}, | |
| {"year": 2020, "text": "Forest protection reform after record fires", "url": "https://en.wikipedia.org/wiki/Forestry_in_Russia"}, | |
| ], | |
| "Bolivia": [ | |
| {"year": 1996, "text": "Forestry Law 1700 enacted", "url": "https://en.wikipedia.org/wiki/Deforestation"}, | |
| {"year": 2012, "text": "Mother Earth Law", "url": "https://en.wikipedia.org/wiki/Law_of_the_Rights_of_Mother_Earth"}, | |
| ], | |
| "Peru": [ | |
| {"year": 2011, "text": "Forest and Wildlife Law 29763", "url": "https://en.wikipedia.org/wiki/Deforestation_in_Peru"}, | |
| {"year": 2014, "text": "Joint Declaration with Norway & Germany", "url": "https://en.wikipedia.org/wiki/Deforestation_in_Peru"}, | |
| ], | |
| "Paraguay": [ | |
| {"year": 2004, "text": "Zero Deforestation Law (East region)", "url": "https://en.wikipedia.org/wiki/Deforestation"}, | |
| ], | |
| "Madagascar": [ | |
| {"year": 2003, "text": "Durban Vision — triple protected areas", "url": "https://en.wikipedia.org/wiki/Environment_of_Madagascar"}, | |
| {"year": 2015, "text": "National REDD+ strategy", "url": "https://en.wikipedia.org/wiki/REDD_and_REDD%2B"}, | |
| ], | |
| "Cameroon": [ | |
| {"year": 1994, "text": "Forest Law 94/01 enacted", "url": "https://en.wikipedia.org/wiki/Forestry_in_Cameroon"}, | |
| {"year": 2010, "text": "CAFI partnership initiated", "url": "https://en.wikipedia.org/wiki/Forestry_in_Cameroon"}, | |
| ], | |
| "Nigeria": [ | |
| {"year": 1999, "text": "Federal Forestry Policy", "url": "https://en.wikipedia.org/wiki/Forestry_in_Nigeria"}, | |
| {"year": 2017, "text": "Great Green Wall commitment", "url": "https://en.wikipedia.org/wiki/Great_Green_Wall_(Africa)"}, | |
| ], | |
| "Myanmar": [ | |
| {"year": 1992, "text": "Forest Law passed", "url": "https://en.wikipedia.org/wiki/Deforestation"}, | |
| {"year": 2014, "text": "Raw log export ban", "url": "https://en.wikipedia.org/wiki/Deforestation"}, | |
| ], | |
| "Tanzania": [ | |
| {"year": 2002, "text": "Forest Act enacted", "url": "https://en.wikipedia.org/wiki/Environmental_issues_in_Tanzania"}, | |
| {"year": 2015, "text": "National REDD+ strategy", "url": "https://en.wikipedia.org/wiki/REDD_and_REDD%2B"}, | |
| ], | |
| } | |
| CLIMATE_EVENTS = { | |
| "Australia": [ | |
| {"year": 2002, "text": "Millennium Drought intensifies", "url": "https://en.wikipedia.org/wiki/2000s_Australian_drought"}, | |
| {"year": 2009, "text": "Black Saturday bushfires (430k ha)", "url": "https://en.wikipedia.org/wiki/Black_Saturday_bushfires"}, | |
| {"year": 2020, "text": "Black Summer fires (18.6M ha)", "url": "https://en.wikipedia.org/wiki/2019%E2%80%9320_Australian_bushfire_season"}, | |
| ], | |
| "Indonesia": [ | |
| {"year": 1997, "text": "Southeast Asian haze from peat fires", "url": "https://en.wikipedia.org/wiki/1997_Southeast_Asian_haze"}, | |
| {"year": 2015, "text": "Peat fires burn 2.6M ha", "url": "https://en.wikipedia.org/wiki/2015_Southeast_Asian_haze"}, | |
| {"year": 2019, "text": "Major fire season, 1.6M ha burned", "url": "https://en.wikipedia.org/wiki/2019_Southeast_Asian_haze"}, | |
| ], | |
| "Brazil": [ | |
| {"year": 2019, "text": "Amazon fires spike 84%", "url": "https://en.wikipedia.org/wiki/2019_Amazon_rainforest_wildfires"}, | |
| {"year": 2020, "text": "Pantanal wetland fires (4M ha)", "url": "https://en.wikipedia.org/wiki/2020_Pantanal_wildfires"}, | |
| ], | |
| "Canada": [ | |
| {"year": 2005, "text": "Mountain pine beetle peak (18M ha)", "url": "https://en.wikipedia.org/wiki/Mountain_pine_beetle"}, | |
| {"year": 2016, "text": "Fort McMurray fire", "url": "https://en.wikipedia.org/wiki/2016_Fort_McMurray_wildfire"}, | |
| {"year": 2023, "text": "Record fire season (18.5M ha)", "url": "https://en.wikipedia.org/wiki/2023_Canadian_wildfires"}, | |
| ], | |
| "Russia": [ | |
| {"year": 2010, "text": "Moscow smog — wildfires kill 56k", "url": "https://en.wikipedia.org/wiki/2010_Russian_wildfires"}, | |
| {"year": 2019, "text": "Siberian fires (4M ha)", "url": "https://en.wikipedia.org/wiki/2019_Siberia_wildfires"}, | |
| {"year": 2021, "text": "Record Siberian fires (18.8M ha)", "url": "https://en.wikipedia.org/wiki/2021_Russia_wildfires"}, | |
| ], | |
| "China": [ | |
| {"year": 1998, "text": "Yangtze floods kill 4,000+", "url": "https://en.wikipedia.org/wiki/1998_China_floods"}, | |
| ], | |
| "United States": [ | |
| {"year": 2018, "text": "Camp Fire destroys Paradise", "url": "https://en.wikipedia.org/wiki/Camp_Fire_(2018)"}, | |
| {"year": 2020, "text": "Record fire season (4.2M acres)", "url": "https://en.wikipedia.org/wiki/2020_California_wildfires"}, | |
| {"year": 2023, "text": "Maui fires & Canadian smoke crisis", "url": "https://en.wikipedia.org/wiki/2023_Hawaii_wildfires"}, | |
| ], | |
| "Colombia": [ | |
| {"year": 2017, "text": "Deforestation spikes in post-FARC areas", "url": "https://en.wikipedia.org/wiki/Deforestation_in_Colombia"}, | |
| ], | |
| "Mexico": [ | |
| {"year": 2011, "text": "Worst drought in 70 years", "url": "https://en.wikipedia.org/wiki/Climate_change_in_Mexico"}, | |
| ], | |
| "Bolivia": [ | |
| {"year": 2019, "text": "Chiquitano fires burn 2.3M ha", "url": "https://en.wikipedia.org/wiki/2019_Amazon_rainforest_wildfires"}, | |
| ], | |
| "Madagascar": [ | |
| {"year": 2020, "text": "Severe drought in southern regions", "url": "https://en.wikipedia.org/wiki/Environment_of_Madagascar"}, | |
| ], | |
| "DR Congo": [ | |
| {"year": 2003, "text": "Second Congo War ends", "url": "https://en.wikipedia.org/wiki/Second_Congo_War"}, | |
| ], | |
| } | |
| _cache = {} | |
| HISTORICAL_CONTEXT = { | |
| "Brazil": { | |
| range(1990, 1996): "Rapid expansion of cattle ranching and soy farming in the Amazon after economic stabilisation.", | |
| range(1996, 2005): "Peak deforestation era — illegal logging, land speculation, and weak enforcement. Arc of deforestation expanded.", | |
| range(2005, 2013): "PPCDAm enforcement + satellite monitoring (DETER) + soy/beef moratoriums drove sharp decline in clearing rates.", | |
| range(2013, 2019): "Deforestation crept back up as budget cuts weakened IBAMA enforcement and new Forest Code allowed amnesty for past clearing.", | |
| range(2019, 2023): "Environmental enforcement dismantled, IBAMA fines dropped 70%. Amazon tipping point warnings from scientists.", | |
| }, | |
| "Indonesia": { | |
| range(1990, 2000): "Suharto-era logging concessions and transmigration programs accelerated forest loss, especially in Sumatra and Kalimantan.", | |
| range(2000, 2005): "Post-Suharto decentralisation gave district heads power to issue logging/plantation permits, often corruptly.", | |
| range(2005, 2012): "Palm oil boom — Indonesia became world's largest producer. Peatland drainage caused massive fires (2006, 2009).", | |
| range(2012, 2018): "2015 fires burned 2.6M hectares, caused $16B damage. Led to peat restoration agency and stronger moratorium.", | |
| range(2018, 2023): "Deforestation rates declined significantly. Palm oil export ban (2022) temporarily reduced pressure.", | |
| }, | |
| "Australia": { | |
| range(1990, 2000): "Broadscale land clearing for agriculture, especially in Queensland. Woody vegetation loss peaked mid-1990s.", | |
| range(2000, 2007): "Millennium drought (2001-2009) devastated forests. Queensland banned broadscale clearing in 2006.", | |
| range(2007, 2013): "Black Saturday bushfires (2009) destroyed 430,000 hectares. Drought continued to stress forests.", | |
| range(2013, 2020): "Forest recovery after drought broke. But 2019-20 Black Summer fires burned 18.6M hectares — worst fire season on record.", | |
| range(2020, 2023): "La Niña rains aided recovery. New environmental laws and carbon farming incentives.", | |
| }, | |
| "DR Congo": { | |
| range(1990, 2002): "Civil wars (1996-2003) disrupted industrial logging but subsistence clearing continued.", | |
| range(2002, 2015): "Population growth drove smallholder agriculture expansion — the primary deforestation driver. Charcoal demand surged.", | |
| range(2015, 2023): "Artisanal mining and cocoa expansion increased. DRC has lowest governance capacity of major forest nations.", | |
| }, | |
| "Colombia": { | |
| range(1990, 2016): "FARC conflict paradoxically protected forests — armed groups controlled access to remote areas.", | |
| range(2016, 2020): "Post-peace deal: deforestation spiked 44% as land grabbers moved into former FARC territory.", | |
| range(2020, 2023): "Government crackdown on deforestation. Amazon pact signed with Brazil and other nations.", | |
| }, | |
| "India": { | |
| range(1990, 2005): "Forest cover data contested — government counts plantations as forest. Native forest loss continued.", | |
| range(2005, 2015): "Massive afforestation programs (Green India Mission) increased total tree cover, though primary forest still declined.", | |
| range(2015, 2023): "Forest Rights Act empowered tribal communities. Compensatory afforestation fund reached $6B.", | |
| }, | |
| "China": { | |
| range(1990, 2000): "1998 Yangtze floods killed 4,000+ — blamed on upstream deforestation. Triggered logging ban.", | |
| range(2000, 2010): "Grain-to-Green: world's largest reforestation program. Paid 120M farmers to convert cropland to forest.", | |
| range(2010, 2023): "China became net reforester. But imports shifted deforestation to SE Asia and Africa.", | |
| }, | |
| "Malaysia": { | |
| range(1990, 2005): "Rapid palm oil expansion, especially in Sabah and Sarawak. Malaysia became 2nd largest producer.", | |
| range(2005, 2015): "International pressure over orangutan habitat. RSPO certification introduced but adoption slow.", | |
| range(2015, 2023): "MSPO mandatory certification. Pledged 50% forest cover but definition includes oil palm.", | |
| }, | |
| "Mexico": { | |
| range(1990, 2005): "NAFTA (1994) shifted agriculture — some marginal farmland abandoned and reforested, but Lacandón jungle clearing continued.", | |
| range(2005, 2015): "Drug cartel activity in forests (avocado, poppy cultivation) drove illegal clearing in Michoacán and Guerrero.", | |
| range(2015, 2023): "Sembrando Vida program controversially paid farmers to plant trees — but some cut existing forest to qualify.", | |
| }, | |
| "Canada": { | |
| range(1990, 2005): "Forestry industry dominated — clearcut logging in British Columbia and boreal regions.", | |
| range(2005, 2015): "Mountain pine beetle epidemic killed 18M hectares of BC forest — largest insect blight in North American history.", | |
| range(2015, 2023): "Wildfires intensified with climate change. 2023 was worst fire season ever — 18.5M hectares burned.", | |
| }, | |
| "Russia": { | |
| range(1990, 2000): "Post-Soviet collapse reduced industrial logging but also enforcement. Illegal logging surged.", | |
| range(2000, 2010): "Siberian wildfires increased dramatically. 2010 fires caused Moscow smog crisis.", | |
| range(2010, 2023): "Permafrost thaw and wildfires became primary forest loss drivers. 2021: record 18.8M hectares burned.", | |
| }, | |
| "United States": { | |
| range(1990, 2005): "Net forest area roughly stable. Urban sprawl consumed some forest, offset by farmland reversion in East.", | |
| range(2005, 2015): "Western wildfires intensified — bark beetle outbreaks weakened millions of hectares. 2012 was record fire year.", | |
| range(2015, 2023): "Paradise fire (2018), record 2020 season (4.2M acres in CA/OR/WA). Climate-driven megafires now the norm.", | |
| }, | |
| } | |
| def _fetch_wb(country_code: str, indicator: str) -> list[dict]: | |
| cache_key = f"{country_code}:{indicator}" | |
| if cache_key in _cache: | |
| return _cache[cache_key] | |
| url = ( | |
| f"https://api.worldbank.org/v2/country/{country_code}" | |
| f"/indicator/{indicator}?format=json&per_page=50&date=1990:2022" | |
| ) | |
| for attempt in range(3): | |
| try: | |
| resp = urllib.request.urlopen(url, timeout=30) | |
| data = json.loads(resp.read()) | |
| if len(data) < 2 or not data[1]: | |
| _cache[cache_key] = [] | |
| return [] | |
| results = [ | |
| {"year": int(d["date"]), "value": round(d["value"], 3)} | |
| for d in data[1] | |
| if d["value"] is not None | |
| ] | |
| results.sort(key=lambda x: x["year"]) | |
| _cache[cache_key] = results | |
| return results | |
| except Exception: | |
| if attempt == 2: | |
| return [] | |
| import time | |
| time.sleep(1) | |
| def get_forest_data(country: str) -> dict: | |
| """Get forest area (% of land) time series for a country. Returns yearly data from World Bank.""" | |
| code = COUNTRIES.get(country) | |
| if not code: | |
| return {"error": f"Unknown country. Available: {', '.join(COUNTRIES.keys())}"} | |
| forest = _fetch_wb(code, "AG.LND.FRST.ZS") | |
| governance = _fetch_wb(code, "RL.EST") | |
| policies = POLICY_EVENTS.get(country, []) | |
| climate = CLIMATE_EVENTS.get(country, []) | |
| summary = {} | |
| if forest: | |
| first, last = forest[0], forest[-1] | |
| change = last["value"] - first["value"] | |
| years = last["year"] - first["year"] | |
| summary = { | |
| "start_year": first["year"], | |
| "end_year": last["year"], | |
| "start_pct": first["value"], | |
| "end_pct": last["value"], | |
| "change_pct": round(change, 3), | |
| "annual_rate": round(change / years, 4) if years else 0, | |
| } | |
| return { | |
| "country": country, | |
| "forest": forest, | |
| "governance": governance, | |
| "policies": policies, | |
| "climate": climate, | |
| "summary": summary, | |
| } | |
| def compare_countries(country_a: str, country_b: str) -> dict: | |
| """Compare forest cover trends between two countries.""" | |
| a = get_forest_data(country_a) | |
| b = get_forest_data(country_b) | |
| return {"country_a": a, "country_b": b} | |
| def explain_spike(country: str, year: int) -> dict: | |
| """Explain what happened to forest cover around a specific year. Identifies rate changes and nearby policy events.""" | |
| code = COUNTRIES.get(country) | |
| if not code: | |
| return {"error": f"Unknown country."} | |
| forest = _fetch_wb(code, "AG.LND.FRST.ZS") | |
| if not forest: | |
| return {"error": "No data available."} | |
| policies = POLICY_EVENTS.get(country, []) | |
| nearby_policies = [p for p in policies if abs(p["year"] - year) <= 3] | |
| climate = CLIMATE_EVENTS.get(country, []) | |
| nearby_climate = [c for c in climate if abs(c["year"] - year) <= 3] | |
| window = [f for f in forest if abs(f["year"] - year) <= 5] | |
| window.sort(key=lambda x: x["year"]) | |
| point = next((f for f in forest if f["year"] == year), None) | |
| prev = next((f for f in forest if f["year"] == year - 1), None) | |
| nxt = next((f for f in forest if f["year"] == year + 1), None) | |
| before = [f for f in forest if year - 5 <= f["year"] < year] | |
| after = [f for f in forest if year < f["year"] <= year + 5] | |
| rate_before = None | |
| rate_after = None | |
| if len(before) >= 2: | |
| rate_before = round((before[-1]["value"] - before[0]["value"]) / (before[-1]["year"] - before[0]["year"]), 4) | |
| if len(after) >= 2: | |
| rate_after = round((after[-1]["value"] - after[0]["value"]) / (after[-1]["year"] - after[0]["year"]), 4) | |
| yoy_change = None | |
| if point and prev: | |
| yoy_change = round(point["value"] - prev["value"], 3) | |
| trend = "stable" | |
| if rate_before is not None and rate_after is not None: | |
| if rate_after > rate_before + 0.01: | |
| trend = "recovery" | |
| elif rate_after < rate_before - 0.01: | |
| trend = "acceleration" | |
| if yoy_change is not None: | |
| if yoy_change > 0.05: | |
| trend = "sharp increase" | |
| elif yoy_change < -0.05: | |
| trend = "sharp decline" | |
| context = None | |
| country_ctx = HISTORICAL_CONTEXT.get(country, {}) | |
| for year_range, text in country_ctx.items(): | |
| if year in year_range: | |
| context = text | |
| break | |
| return { | |
| "country": country, | |
| "year": year, | |
| "forest_pct": point["value"] if point else None, | |
| "yoy_change": yoy_change, | |
| "trend": trend, | |
| "rate_5yr_before": rate_before, | |
| "rate_5yr_after": rate_after, | |
| "nearby_policies": nearby_policies, | |
| "nearby_climate": nearby_climate, | |
| "context": context, | |
| "window": window, | |
| } | |
| def list_countries() -> list[str]: | |
| """List all available countries.""" | |
| return list(COUNTRIES.keys()) | |
| async def homepage(): | |
| countries_json = json.dumps(list(COUNTRIES.keys())) | |
| return f"""<!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Forest Dispatch — A Record of What We Have Lost</title> | |
| <link rel="preconnect" href="https://fonts.googleapis.com"> | |
| <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> | |
| <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"> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-annotation@3"></script> | |
| <style> | |
| * {{ margin: 0; padding: 0; box-sizing: border-box; }} | |
| :root {{ | |
| --paper: #ede5d3; | |
| --paper-tint: #e4dbc5; | |
| --paper-deep: #d8cdb3; | |
| --ink: #1a1f18; | |
| --ink-soft: #3a3f35; | |
| --ink-mute: #6a6d60; | |
| --ink-fade: #9c9e8f; | |
| --rule: #3a3f35; | |
| --rule-soft: #c5bda5; | |
| --moss: #4a6b3e; | |
| --moss-deep: #2f4827; | |
| --oxblood: #8b2a1f; | |
| --oxblood-deep: #5c1e16; | |
| --amber: #a8732a; | |
| --amber-deep: #7d5418; | |
| --highlight: #d4c98a; | |
| }} | |
| html {{ background: var(--paper); }} | |
| body {{ | |
| font-family: 'Fraunces', Georgia, serif; | |
| font-optical-sizing: auto; | |
| background: var(--paper); | |
| color: var(--ink); | |
| min-height: 100vh; | |
| font-size: 16px; | |
| line-height: 1.55; | |
| position: relative; | |
| overflow-x: hidden; | |
| }} | |
| /* Grain texture overlay — gives that risograph/newsprint feel */ | |
| body::before {{ | |
| content: ''; | |
| position: fixed; | |
| inset: 0; | |
| pointer-events: none; | |
| z-index: 1000; | |
| opacity: 0.35; | |
| mix-blend-mode: multiply; | |
| 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"); | |
| }} | |
| /* Paper vignette */ | |
| body::after {{ | |
| content: ''; | |
| position: fixed; | |
| inset: 0; | |
| pointer-events: none; | |
| z-index: 999; | |
| background: radial-gradient(ellipse at center, transparent 40%, rgba(60, 50, 30, 0.18) 100%); | |
| }} | |
| a {{ color: var(--oxblood); text-decoration: none; border-bottom: 1px solid currentColor; padding-bottom: 1px; transition: opacity 0.15s; }} | |
| a:hover {{ opacity: 0.7; }} | |
| .mono {{ font-family: 'JetBrains Mono', ui-monospace, monospace; font-feature-settings: "tnum", "zero"; }} | |
| .serif-display {{ font-family: 'Instrument Serif', Georgia, serif; font-weight: 400; }} | |
| .tabular {{ font-variant-numeric: tabular-nums; }} | |
| .smallcaps {{ | |
| text-transform: uppercase; | |
| letter-spacing: 0.18em; | |
| font-size: 0.72em; | |
| font-weight: 600; | |
| font-family: 'JetBrains Mono', ui-monospace, monospace; | |
| }} | |
| /* ====== MASTHEAD ====== */ | |
| .masthead {{ | |
| padding: 16px 48px 0; | |
| border-bottom: 2px solid var(--ink); | |
| position: relative; | |
| z-index: 2; | |
| }} | |
| .masthead-top {{ | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: baseline; | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 10px; | |
| letter-spacing: 0.15em; | |
| text-transform: uppercase; | |
| color: var(--ink-soft); | |
| padding-bottom: 6px; | |
| border-bottom: 1px solid var(--rule-soft); | |
| }} | |
| .masthead-top .vol {{ display: flex; gap: 22px; }} | |
| .masthead-top .vol span:not(:last-child)::after {{ | |
| content: '·'; | |
| margin-left: 22px; | |
| color: var(--ink-fade); | |
| }} | |
| .masthead-row {{ | |
| display: flex; | |
| align-items: center; | |
| justify-content: space-between; | |
| gap: 32px; | |
| padding: 12px 0; | |
| }} | |
| .wordmark {{ | |
| font-family: 'Instrument Serif', serif; | |
| font-size: clamp(40px, 5.5vw, 72px); | |
| line-height: 0.95; | |
| letter-spacing: -0.02em; | |
| font-weight: 400; | |
| font-style: italic; | |
| flex-shrink: 0; | |
| }} | |
| .wordmark .dispatch {{ font-style: normal; }} | |
| .dek-text {{ | |
| flex: 1; | |
| max-width: 520px; | |
| font-size: 13px; | |
| line-height: 1.5; | |
| color: var(--ink-soft); | |
| font-style: italic; | |
| }} | |
| .dek-text::first-letter {{ | |
| font-family: 'Instrument Serif', serif; | |
| font-size: 3.4em; | |
| line-height: 0.8; | |
| float: left; | |
| padding: 4px 8px 0 0; | |
| color: var(--oxblood); | |
| font-style: normal; | |
| }} | |
| .dek-meta {{ | |
| text-align: right; | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 9px; | |
| letter-spacing: 0.15em; | |
| text-transform: uppercase; | |
| color: var(--ink-mute); | |
| line-height: 1.7; | |
| white-space: nowrap; | |
| flex-shrink: 0; | |
| }} | |
| .dek-meta .badge {{ | |
| display: inline-block; | |
| background: var(--ink); | |
| color: var(--paper); | |
| padding: 2px 7px; | |
| margin-top: 3px; | |
| font-weight: 500; | |
| }} | |
| /* ====== MAIN GRID ====== */ | |
| .sheet {{ | |
| max-width: 1400px; | |
| margin: 0 auto; | |
| padding: 0 48px 48px; | |
| position: relative; | |
| z-index: 2; | |
| }} | |
| /* ====== CONTROL STRIP ====== */ | |
| .control-strip {{ | |
| display: grid; | |
| grid-template-columns: 1fr auto auto auto; | |
| gap: 24px; | |
| align-items: end; | |
| padding: 14px 0 16px; | |
| border-bottom: 1px solid var(--rule); | |
| margin-bottom: 18px; | |
| }} | |
| .section-title {{ | |
| font-family: 'Instrument Serif', serif; | |
| font-size: 18px; | |
| letter-spacing: -0.01em; | |
| line-height: 1.2; | |
| font-style: italic; | |
| color: var(--ink-soft); | |
| padding-bottom: 4px; | |
| }} | |
| .section-title .caps {{ | |
| display: block; | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 9px; | |
| letter-spacing: 0.2em; | |
| color: var(--ink-mute); | |
| text-transform: uppercase; | |
| margin-bottom: 4px; | |
| font-weight: 500; | |
| font-style: normal; | |
| }} | |
| .control-group {{ | |
| display: flex; | |
| flex-direction: column; | |
| gap: 4px; | |
| }} | |
| .control-group label {{ | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 9px; | |
| letter-spacing: 0.2em; | |
| text-transform: uppercase; | |
| color: var(--ink-mute); | |
| font-weight: 500; | |
| }} | |
| select {{ | |
| background: transparent; | |
| border: none; | |
| border-bottom: 1px solid var(--ink); | |
| color: var(--ink); | |
| padding: 4px 24px 4px 0; | |
| font-family: 'Fraunces', serif; | |
| font-size: 18px; | |
| font-weight: 500; | |
| min-width: 180px; | |
| cursor: pointer; | |
| outline: none; | |
| appearance: none; | |
| 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"); | |
| background-repeat: no-repeat; | |
| background-position: right 4px center; | |
| transition: border-color 0.15s; | |
| }} | |
| select:focus {{ border-bottom-color: var(--oxblood); }} | |
| button.action {{ | |
| background: var(--ink); | |
| color: var(--paper); | |
| border: none; | |
| padding: 12px 28px; | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 11px; | |
| letter-spacing: 0.2em; | |
| text-transform: uppercase; | |
| font-weight: 500; | |
| cursor: pointer; | |
| transition: all 0.15s; | |
| position: relative; | |
| box-shadow: 3px 3px 0 var(--rule-soft); | |
| }} | |
| button.action:hover {{ | |
| transform: translate(-1px, -1px); | |
| box-shadow: 4px 4px 0 var(--oxblood); | |
| }} | |
| button.action:active {{ | |
| transform: translate(2px, 2px); | |
| box-shadow: 1px 1px 0 var(--rule-soft); | |
| }} | |
| /* ====== FINDINGS ROW ====== */ | |
| .findings {{ | |
| display: grid; | |
| grid-template-columns: repeat(4, 1fr); | |
| border-top: 2px solid var(--ink); | |
| border-bottom: 2px solid var(--ink); | |
| margin-bottom: 32px; | |
| min-height: 110px; | |
| }} | |
| .finding {{ | |
| padding: 14px 22px 16px; | |
| border-right: 1px solid var(--rule); | |
| position: relative; | |
| display: flex; | |
| flex-direction: column; | |
| justify-content: space-between; | |
| }} | |
| .finding:last-child {{ border-right: none; }} | |
| .finding::before {{ | |
| content: attr(data-num); | |
| position: absolute; | |
| top: 8px; | |
| right: 10px; | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 9px; | |
| color: var(--ink-fade); | |
| letter-spacing: 0.15em; | |
| }} | |
| .finding .label {{ | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 9px; | |
| letter-spacing: 0.22em; | |
| text-transform: uppercase; | |
| color: var(--ink-mute); | |
| margin-bottom: 6px; | |
| font-weight: 500; | |
| }} | |
| .finding .value {{ | |
| font-family: 'Instrument Serif', serif; | |
| font-size: 40px; | |
| line-height: 1; | |
| letter-spacing: -0.02em; | |
| color: var(--ink); | |
| font-variant-numeric: tabular-nums; | |
| font-weight: 400; | |
| }} | |
| .finding .value.down {{ color: var(--oxblood); font-style: italic; }} | |
| .finding .value.up {{ color: var(--moss-deep); }} | |
| .finding .sub {{ | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 10px; | |
| color: var(--ink-mute); | |
| margin-top: 8px; | |
| letter-spacing: 0.05em; | |
| }} | |
| .finding .unit {{ | |
| font-family: 'Fraunces', serif; | |
| font-size: 14px; | |
| font-style: italic; | |
| color: var(--ink-soft); | |
| margin-left: 2px; | |
| font-weight: 400; | |
| }} | |
| /* ====== CHART FIGURE ====== */ | |
| .figure {{ | |
| margin-bottom: 28px; | |
| }} | |
| .figure-head {{ | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: baseline; | |
| padding-bottom: 14px; | |
| border-bottom: 1px solid var(--rule); | |
| margin-bottom: 2px; | |
| }} | |
| .figure-title {{ | |
| font-family: 'Instrument Serif', serif; | |
| font-size: 26px; | |
| letter-spacing: -0.01em; | |
| font-style: italic; | |
| }} | |
| .figure-title::before {{ | |
| content: 'Fig. 1 '; | |
| font-family: 'JetBrains Mono', monospace; | |
| font-style: normal; | |
| font-size: 12px; | |
| letter-spacing: 0.15em; | |
| color: var(--ink-mute); | |
| vertical-align: middle; | |
| margin-right: 10px; | |
| padding-right: 10px; | |
| border-right: 1px solid var(--rule); | |
| }} | |
| .figure-note {{ | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 10px; | |
| letter-spacing: 0.1em; | |
| color: var(--ink-mute); | |
| text-transform: uppercase; | |
| }} | |
| .figure-frame {{ | |
| background: var(--paper-tint); | |
| border: 1px solid var(--rule); | |
| border-top: none; | |
| padding: 20px; | |
| height: clamp(420px, 56vh, 580px); | |
| position: relative; | |
| }} | |
| .figure-loading {{ | |
| position: absolute; | |
| inset: 0; | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| font-family: 'Instrument Serif', serif; | |
| font-style: italic; | |
| font-size: 22px; | |
| color: var(--ink-mute); | |
| background: var(--paper-tint); | |
| z-index: 5; | |
| }} | |
| .figure-loading::after {{ | |
| content: ' ·'; | |
| animation: ellipsis 1.4s infinite; | |
| }} | |
| .figure-caption {{ | |
| padding-top: 10px; | |
| font-size: 13px; | |
| font-style: italic; | |
| color: var(--ink-soft); | |
| line-height: 1.5; | |
| max-width: 700px; | |
| }} | |
| /* ====== INSIGHT / DOSSIER ====== */ | |
| .dossier {{ | |
| display: none; | |
| background: var(--paper-tint); | |
| border-top: 1px solid var(--ink); | |
| border-bottom: 1px solid var(--ink); | |
| padding: 28px 32px; | |
| margin-bottom: 40px; | |
| position: relative; | |
| }} | |
| .dossier::before {{ | |
| content: ''; | |
| position: absolute; | |
| top: 12px; | |
| left: 12px; | |
| right: 12px; | |
| bottom: 12px; | |
| border: 1px dashed var(--rule); | |
| pointer-events: none; | |
| }} | |
| .dossier-label {{ | |
| position: absolute; | |
| top: -9px; | |
| left: 32px; | |
| background: var(--paper); | |
| padding: 0 12px; | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 10px; | |
| letter-spacing: 0.2em; | |
| text-transform: uppercase; | |
| color: var(--oxblood); | |
| font-weight: 700; | |
| }} | |
| .dossier-head {{ | |
| font-family: 'Instrument Serif', serif; | |
| font-size: 22px; | |
| font-style: italic; | |
| margin-bottom: 16px; | |
| color: var(--ink); | |
| }} | |
| .dossier-head .hint {{ | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 10px; | |
| font-style: normal; | |
| letter-spacing: 0.15em; | |
| text-transform: uppercase; | |
| color: var(--ink-mute); | |
| margin-left: 10px; | |
| font-weight: 500; | |
| }} | |
| /* ====== PANELS ====== */ | |
| .split {{ | |
| display: grid; | |
| grid-template-columns: 1fr 1fr; | |
| gap: 0; | |
| border-top: 1px solid var(--rule); | |
| border-bottom: 1px solid var(--rule); | |
| display: none; | |
| }} | |
| .split.active {{ display: grid; }} | |
| .panel {{ | |
| padding: 28px 28px 24px; | |
| border-right: 1px solid var(--rule); | |
| }} | |
| .panel:last-child {{ border-right: none; }} | |
| .panel-head {{ | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: baseline; | |
| margin-bottom: 18px; | |
| padding-bottom: 12px; | |
| border-bottom: 1px solid var(--rule-soft); | |
| }} | |
| .panel-title {{ | |
| font-family: 'Instrument Serif', serif; | |
| font-size: 22px; | |
| font-style: italic; | |
| letter-spacing: -0.01em; | |
| }} | |
| .panel-count {{ | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 10px; | |
| color: var(--ink-mute); | |
| letter-spacing: 0.15em; | |
| text-transform: uppercase; | |
| }} | |
| /* ====== POLICY ITEMS ====== */ | |
| .policy {{ | |
| display: grid; | |
| grid-template-columns: 72px 1fr auto; | |
| gap: 20px; | |
| padding: 14px 0; | |
| border-bottom: 1px solid var(--rule-soft); | |
| align-items: baseline; | |
| }} | |
| .policy:last-child {{ border-bottom: none; }} | |
| .policy-year {{ | |
| font-family: 'Instrument Serif', serif; | |
| font-size: 28px; | |
| font-style: italic; | |
| line-height: 1; | |
| color: var(--oxblood); | |
| font-variant-numeric: tabular-nums; | |
| }} | |
| .policy-text {{ | |
| font-size: 15px; | |
| line-height: 1.4; | |
| color: var(--ink); | |
| }} | |
| .policy-text a {{ | |
| color: var(--ink-mute); | |
| margin-left: 6px; | |
| font-size: 11px; | |
| border-bottom: none; | |
| }} | |
| .policy-text a:hover {{ color: var(--oxblood); }} | |
| .policy-metric {{ | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 11px; | |
| color: var(--ink-mute); | |
| white-space: nowrap; | |
| letter-spacing: 0.05em; | |
| }} | |
| /* ====== RATE VERDICT ====== */ | |
| .rate-row {{ | |
| display: grid; | |
| grid-template-columns: 60px 1fr auto; | |
| gap: 20px; | |
| padding: 14px 0; | |
| border-bottom: 1px solid var(--rule-soft); | |
| align-items: baseline; | |
| }} | |
| .rate-row .stage {{ | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 10px; | |
| letter-spacing: 0.2em; | |
| text-transform: uppercase; | |
| color: var(--oxblood); | |
| font-weight: 700; | |
| }} | |
| .rate-row .desc {{ font-size: 14px; color: var(--ink); font-style: italic; }} | |
| .rate-row .rate {{ | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 14px; | |
| font-weight: 500; | |
| font-variant-numeric: tabular-nums; | |
| white-space: nowrap; | |
| }} | |
| .rate-row .rate.neg {{ color: var(--oxblood); }} | |
| .rate-row .rate.pos {{ color: var(--moss-deep); }} | |
| .verdict {{ | |
| margin-top: 24px; | |
| padding: 26px 20px; | |
| background: var(--paper); | |
| border: 1px solid var(--rule); | |
| text-align: center; | |
| position: relative; | |
| }} | |
| .verdict::before, .verdict::after {{ | |
| content: '§'; | |
| font-family: 'Instrument Serif', serif; | |
| font-style: italic; | |
| font-size: 18px; | |
| color: var(--rule-soft); | |
| position: absolute; | |
| top: 50%; | |
| transform: translateY(-50%); | |
| }} | |
| .verdict::before {{ left: 14px; }} | |
| .verdict::after {{ right: 14px; }} | |
| .verdict .headline {{ | |
| font-family: 'Instrument Serif', serif; | |
| font-size: 30px; | |
| font-style: italic; | |
| line-height: 1.1; | |
| letter-spacing: -0.01em; | |
| }} | |
| .verdict .headline.good {{ color: var(--moss-deep); }} | |
| .verdict .headline.bad {{ color: var(--oxblood); }} | |
| .verdict .sub {{ | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 10px; | |
| letter-spacing: 0.18em; | |
| text-transform: uppercase; | |
| color: var(--ink-mute); | |
| margin-top: 8px; | |
| font-weight: 500; | |
| }} | |
| /* ====== DOSSIER CONTENT ====== */ | |
| .dossier-stats {{ | |
| display: grid; | |
| grid-template-columns: repeat(4, 1fr); | |
| gap: 32px; | |
| margin-top: 16px; | |
| padding-bottom: 20px; | |
| border-bottom: 1px solid var(--rule-soft); | |
| }} | |
| .dossier-stat .k {{ | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 9px; | |
| letter-spacing: 0.2em; | |
| text-transform: uppercase; | |
| color: var(--ink-mute); | |
| margin-bottom: 4px; | |
| font-weight: 500; | |
| }} | |
| .dossier-stat .v {{ | |
| font-family: 'Instrument Serif', serif; | |
| font-size: 36px; | |
| line-height: 1; | |
| font-variant-numeric: tabular-nums; | |
| letter-spacing: -0.01em; | |
| font-style: italic; | |
| }} | |
| .dossier-stat .v.neg {{ color: var(--oxblood); }} | |
| .dossier-stat .v.pos {{ color: var(--moss-deep); }} | |
| .dossier-stat .v.alert {{ color: var(--amber-deep); }} | |
| .rate-strip {{ | |
| display: flex; | |
| gap: 48px; | |
| padding: 18px 0; | |
| border-bottom: 1px solid var(--rule-soft); | |
| font-size: 14px; | |
| }} | |
| .rate-strip .rate-item {{ | |
| display: flex; | |
| align-items: baseline; | |
| gap: 10px; | |
| }} | |
| .rate-strip .k {{ | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 10px; | |
| letter-spacing: 0.15em; | |
| text-transform: uppercase; | |
| color: var(--ink-mute); | |
| }} | |
| .rate-strip .v {{ | |
| font-family: 'JetBrains Mono', monospace; | |
| font-weight: 700; | |
| font-variant-numeric: tabular-nums; | |
| }} | |
| .rate-strip .v.neg {{ color: var(--oxblood); }} | |
| .rate-strip .v.pos {{ color: var(--moss-deep); }} | |
| .context-block {{ | |
| margin-top: 18px; | |
| padding: 20px 22px; | |
| background: var(--paper); | |
| border-left: 3px solid var(--oxblood); | |
| position: relative; | |
| }} | |
| .context-block .k {{ | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 9px; | |
| letter-spacing: 0.25em; | |
| text-transform: uppercase; | |
| color: var(--oxblood); | |
| font-weight: 700; | |
| margin-bottom: 8px; | |
| }} | |
| .context-block .v {{ | |
| font-family: 'Fraunces', serif; | |
| font-size: 16px; | |
| line-height: 1.55; | |
| color: var(--ink); | |
| font-style: italic; | |
| }} | |
| .nearby-block {{ | |
| margin-top: 18px; | |
| padding-top: 16px; | |
| border-top: 1px solid var(--rule-soft); | |
| }} | |
| .nearby-block .k {{ | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 9px; | |
| letter-spacing: 0.22em; | |
| text-transform: uppercase; | |
| color: var(--ink-mute); | |
| margin-bottom: 10px; | |
| font-weight: 600; | |
| }} | |
| .nearby-item {{ | |
| display: flex; | |
| gap: 14px; | |
| padding: 6px 0; | |
| align-items: baseline; | |
| font-size: 14px; | |
| }} | |
| .nearby-item .yr {{ | |
| font-family: 'Instrument Serif', serif; | |
| font-style: italic; | |
| color: var(--oxblood); | |
| font-weight: 400; | |
| min-width: 56px; | |
| }} | |
| /* ====== LOADING ====== */ | |
| .loading {{ | |
| grid-column: 1 / -1; | |
| padding: 60px 20px; | |
| text-align: center; | |
| font-family: 'Instrument Serif', serif; | |
| font-style: italic; | |
| font-size: 20px; | |
| color: var(--ink-mute); | |
| }} | |
| .loading::after {{ | |
| content: ' ·'; | |
| animation: ellipsis 1.4s infinite; | |
| }} | |
| @keyframes ellipsis {{ | |
| 0% {{ content: ' ·'; }} | |
| 33% {{ content: ' · ·'; }} | |
| 66% {{ content: ' · · ·'; }} | |
| }} | |
| /* ====== COLOPHON ====== */ | |
| .colophon {{ | |
| max-width: 1400px; | |
| margin: 0 auto; | |
| padding: 40px 48px 60px; | |
| border-top: 2px solid var(--ink); | |
| position: relative; | |
| z-index: 2; | |
| }} | |
| .colophon-top {{ | |
| display: grid; | |
| grid-template-columns: 2fr 1fr 1fr; | |
| gap: 48px; | |
| padding-bottom: 24px; | |
| border-bottom: 1px solid var(--rule); | |
| margin-bottom: 18px; | |
| }} | |
| .colophon-section .title {{ | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 10px; | |
| letter-spacing: 0.22em; | |
| text-transform: uppercase; | |
| color: var(--ink-mute); | |
| margin-bottom: 10px; | |
| font-weight: 600; | |
| }} | |
| .colophon-section .body {{ | |
| font-size: 14px; | |
| line-height: 1.55; | |
| color: var(--ink-soft); | |
| font-style: italic; | |
| }} | |
| .colophon-imprint {{ | |
| font-family: 'Instrument Serif', serif; | |
| font-size: 64px; | |
| line-height: 0.9; | |
| font-style: italic; | |
| color: var(--ink); | |
| letter-spacing: -0.02em; | |
| margin-bottom: 8px; | |
| }} | |
| .colophon-mark {{ | |
| display: flex; | |
| justify-content: space-between; | |
| align-items: center; | |
| font-family: 'JetBrains Mono', monospace; | |
| font-size: 10px; | |
| letter-spacing: 0.18em; | |
| text-transform: uppercase; | |
| color: var(--ink-mute); | |
| }} | |
| @media (max-width: 900px) {{ | |
| .masthead, .sheet, .colophon {{ padding-left: 18px; padding-right: 18px; }} | |
| .masthead {{ padding-top: 10px; }} | |
| .masthead-row {{ | |
| display: grid; | |
| grid-template-columns: 1fr auto; | |
| gap: 10px 14px; | |
| padding: 8px 0 10px; | |
| align-items: center; | |
| }} | |
| .wordmark {{ font-size: 28px; line-height: 1; grid-column: 1; grid-row: 1; margin: 0; }} | |
| .dek-text {{ | |
| display: none; | |
| }} | |
| .dek-meta {{ | |
| grid-column: 2; grid-row: 1; | |
| text-align: right; | |
| font-size: 8px; | |
| line-height: 1.4; | |
| white-space: nowrap; | |
| }} | |
| .dek-meta .badge {{ padding: 1px 5px; margin-top: 2px; font-size: 8px; }} | |
| .masthead-top {{ flex-wrap: wrap; gap: 6px; font-size: 8px; padding-bottom: 4px; letter-spacing: 0.12em; }} | |
| .masthead-top .vol {{ gap: 10px; }} | |
| .masthead-top .vol span:not(:last-child)::after {{ margin-left: 10px; }} | |
| .control-strip {{ | |
| grid-template-columns: 1fr 1fr; | |
| gap: 10px 12px; | |
| padding: 10px 0 12px; | |
| margin-bottom: 12px; | |
| }} | |
| .section-title {{ display: none; }} | |
| .control-group {{ min-width: 0; }} | |
| .control-group label {{ font-size: 8px; }} | |
| select {{ | |
| width: 100%; | |
| min-width: 0; | |
| font-size: 14px; | |
| padding: 4px 20px 4px 0; | |
| }} | |
| button.action {{ | |
| grid-column: 1 / -1; | |
| width: 100%; | |
| padding: 11px; | |
| font-size: 10px; | |
| }} | |
| .figure {{ margin-bottom: 20px; }} | |
| .figure-head {{ flex-direction: column; align-items: flex-start; gap: 4px; padding-bottom: 8px; margin-bottom: 0; }} | |
| .figure-title {{ font-size: 15px; }} | |
| .figure-title::before {{ display: none; }} | |
| .figure-note {{ font-size: 8px; }} | |
| .figure-frame {{ height: clamp(280px, 48vh, 380px); padding: 10px; }} | |
| .figure-caption {{ font-size: 11px; padding-top: 8px; }} | |
| .findings {{ grid-template-columns: 1fr 1fr; margin-bottom: 22px; }} | |
| .finding {{ border-right: 1px solid var(--rule); border-bottom: 1px solid var(--rule); padding: 10px 12px; min-height: auto; }} | |
| .finding:nth-child(even) {{ border-right: none; }} | |
| .finding:nth-last-child(-n+2) {{ border-bottom: none; }} | |
| .finding .label {{ font-size: 8px; }} | |
| .finding .value {{ font-size: 22px; }} | |
| .finding .unit {{ font-size: 10px; }} | |
| .finding .sub {{ font-size: 8px; margin-top: 4px; }} | |
| .dossier {{ padding: 20px 14px; }} | |
| .dossier-label {{ left: 14px; }} | |
| .dossier-head {{ font-size: 17px; }} | |
| .dossier-stats {{ grid-template-columns: 1fr 1fr; gap: 14px; }} | |
| .dossier-stat .v {{ font-size: 22px; }} | |
| .rate-strip {{ flex-direction: column; gap: 8px; }} | |
| .split.active {{ grid-template-columns: 1fr; }} | |
| .panel {{ border-right: none; border-bottom: 1px solid var(--rule); padding: 18px 14px; }} | |
| .panel:last-child {{ border-bottom: none; }} | |
| .panel-title {{ font-size: 17px; }} | |
| .policy {{ grid-template-columns: 48px 1fr; gap: 10px; padding: 10px 0; }} | |
| .policy-year {{ font-size: 22px; }} | |
| .policy-text {{ font-size: 14px; }} | |
| .policy-metric {{ grid-column: 2; color: var(--ink-fade); font-size: 10px; }} | |
| .colophon {{ padding: 26px 18px 40px; }} | |
| .colophon-top {{ grid-template-columns: 1fr; gap: 20px; padding-bottom: 18px; }} | |
| .colophon-imprint {{ font-size: 36px; }} | |
| .colophon-mark {{ flex-direction: column; gap: 6px; align-items: flex-start; font-size: 8px; }} | |
| }} | |
| @media (max-width: 420px) {{ | |
| .wordmark {{ font-size: 24px; }} | |
| .dek-meta {{ font-size: 7px; }} | |
| .findings {{ grid-template-columns: 1fr; }} | |
| .finding {{ border-right: none !important; }} | |
| .finding:last-child {{ border-bottom: none; }} | |
| .dossier-stats {{ grid-template-columns: 1fr 1fr; gap: 10px; }} | |
| }} | |
| </style> | |
| </head> | |
| <body> | |
| <!-- ====== MASTHEAD ====== --> | |
| <header class="masthead"> | |
| <div class="masthead-top"> | |
| <div class="vol"> | |
| <span>Vol. I</span> | |
| <span>No. 001</span> | |
| <span>Standing at 58.1% Global Tree Cover</span> | |
| </div> | |
| <div>Filed via World Bank Open Data</div> | |
| </div> | |
| <div class="masthead-row"> | |
| <h1 class="wordmark"><em>The</em> <span class="dispatch">Forest Dispatch</span></h1> | |
| <div class="dek-text"> | |
| A record of what has been lost, and what — through the stroke of a pen or the | |
| press of a moratorium — may yet be saved. | |
| </div> | |
| <div class="dek-meta"> | |
| An investigation<br> | |
| powered by Gradio<br> | |
| <span class="badge">MCP-enabled</span> | |
| </div> | |
| </div> | |
| </header> | |
| <main class="sheet"> | |
| <!-- ====== CONTROL STRIP ====== --> | |
| <section class="control-strip"> | |
| <div class="section-title"> | |
| <span class="caps">§ Selection</span> | |
| Choose your <em>subject</em> & <em>comparator</em> | |
| </div> | |
| <div class="control-group"> | |
| <label>Subject</label> | |
| <select id="country"></select> | |
| </div> | |
| <div class="control-group"> | |
| <label>Comparator</label> | |
| <select id="compare"><option value="">— none —</option></select> | |
| </div> | |
| <button class="action" onclick="loadData()">Investigate →</button> | |
| </section> | |
| <!-- ====== FIGURE (lead) ====== --> | |
| <section class="figure"> | |
| <div class="figure-head"> | |
| <div class="figure-title">Forest cover over time, <em>with policy annotations</em></div> | |
| <div class="figure-note">click · any · year · for · dossier</div> | |
| </div> | |
| <div class="figure-frame"> | |
| <div id="chart-loading" class="figure-loading" style="display:none">Gathering field notes</div> | |
| <canvas id="chart"></canvas> | |
| </div> | |
| <div class="figure-caption"> | |
| <span class="smallcaps">Legend —</span> | |
| <span style="display:inline-block;width:9px;height:9px;border-radius:50%;background:var(--oxblood);vertical-align:middle;margin-right:4px"></span> policy event | |
| · | |
| <span style="display:inline-block;width:0;height:0;border-left:6px solid transparent;border-right:6px solid transparent;border-bottom:10px solid var(--amber);vertical-align:middle;margin-right:4px"></span> climate / disaster event | |
| · | |
| <span style="display:inline-block;width:12px;height:2px;background:var(--amber);vertical-align:middle;margin-right:4px"></span> rule of law | |
| <br> | |
| Hover the line to read an event. Click any year for a full dossier. | |
| </div> | |
| </section> | |
| <!-- ====== FINDINGS (after the chart) ====== --> | |
| <div id="findings-wrap" style="position:relative"> | |
| <div id="findings" class="findings"></div> | |
| </div> | |
| <!-- ====== DOSSIER ====== --> | |
| <section id="dossier" class="dossier"> | |
| <div class="dossier-label">Dossier</div> | |
| <div class="dossier-head" id="dossier-head"></div> | |
| <div id="dossier-content"></div> | |
| </section> | |
| <!-- ====== POLICY & VERDICT ====== --> | |
| <div id="split" class="split"> | |
| <div class="panel" id="policies"></div> | |
| <div class="panel" id="verdict-panel"></div> | |
| </div> | |
| </main> | |
| <!-- ====== COLOPHON ====== --> | |
| <footer class="colophon"> | |
| <div class="colophon-top"> | |
| <div class="colophon-section"> | |
| <div class="colophon-imprint">Notes.</div> | |
| <div class="body"> | |
| <span class="smallcaps">On method —</span> Every figure herein is drawn from the public record. Where the policy | |
| register falls silent, we supply historical context in earnest italics — | |
| droughts, fires, wars, booms & busts — to explain the line. | |
| </div> | |
| </div> | |
| <div class="colophon-section"> | |
| <div class="title">Sources</div> | |
| <div class="body"> | |
| <a href="https://data.worldbank.org/">World Bank Open Data</a> · | |
| Forest area <span class="mono">(AG.LND.FRST.ZS)</span> · | |
| Rule of Law <span class="mono">(RL.EST)</span> | |
| </div> | |
| </div> | |
| <div class="colophon-section"> | |
| <div class="title">Built upon</div> | |
| <div class="body"> | |
| <a href="https://gradio.app">Gradio Server</a> — API endpoints | |
| available at <span class="mono">/gradio_api/</span>, | |
| also exposed as MCP tools for agents. | |
| </div> | |
| </div> | |
| </div> | |
| <div class="colophon-mark"> | |
| <span>© The Forest Dispatch · A record of loss & recovery</span> | |
| <span>Set in Instrument Serif · Fraunces · JetBrains Mono</span> | |
| </div> | |
| </footer> | |
| <script> | |
| const countries = {countries_json}; | |
| const countrySelect = document.getElementById('country'); | |
| const compareSelect = document.getElementById('compare'); | |
| let chart = null; | |
| let currentData = null; | |
| countries.forEach(c => {{ | |
| countrySelect.add(new Option(c, c)); | |
| compareSelect.add(new Option(c, c)); | |
| }}); | |
| countrySelect.value = 'Brazil'; | |
| async function callApi(name, params) {{ | |
| const callResp = await fetch('/gradio_api/call/' + name, {{ | |
| method: 'POST', | |
| headers: {{'Content-Type': 'application/json'}}, | |
| body: JSON.stringify({{data: Object.values(params)}}) | |
| }}); | |
| const {{event_id}} = await callResp.json(); | |
| if (!event_id) return {{error: 'No event id returned'}}; | |
| const stream = await fetch('/gradio_api/call/' + name + '/' + event_id); | |
| const reader = stream.body.getReader(); | |
| const decoder = new TextDecoder(); | |
| let buffer = ''; | |
| while (true) {{ | |
| const {{done, value}} = await reader.read(); | |
| if (done) break; | |
| buffer += decoder.decode(value, {{stream: true}}); | |
| const lines = buffer.split('\\n'); | |
| buffer = lines.pop(); | |
| for (const line of lines) {{ | |
| if (line.startsWith('event: complete')) continue; | |
| if (line.startsWith('data: ')) {{ | |
| try {{ | |
| const parsed = JSON.parse(line.slice(6)); | |
| return Array.isArray(parsed) ? parsed[0] : parsed; | |
| }} catch (e) {{}} | |
| }} | |
| }} | |
| }} | |
| return {{error: 'No data received'}}; | |
| }} | |
| async function loadData() {{ | |
| const country = countrySelect.value; | |
| const compare = compareSelect.value; | |
| document.getElementById('findings').innerHTML = '<div class="loading">Gathering field notes</div>'; | |
| document.getElementById('chart-loading').style.display = 'flex'; | |
| document.getElementById('split').classList.remove('active'); | |
| document.getElementById('dossier').style.display = 'none'; | |
| if (chart) {{ chart.destroy(); chart = null; }} | |
| let data, compareData = null; | |
| if (compare && compare !== country) {{ | |
| const result = await callApi('compare_countries', {{country_a: country, country_b: compare}}); | |
| data = result.country_a; | |
| compareData = result.country_b; | |
| }} else {{ | |
| data = await callApi('get_forest_data', {{country}}); | |
| }} | |
| if (data.error) {{ | |
| document.getElementById('findings').innerHTML = `<div class="loading">${{data.error}}</div>`; | |
| return; | |
| }} | |
| currentData = data; | |
| renderFindings(data); | |
| renderChart(data, compareData); | |
| renderPolicies(data); | |
| renderVerdict(data); | |
| document.getElementById('split').classList.add('active'); | |
| }} | |
| function renderFindings(data) {{ | |
| const s = data.summary; | |
| if (!s || !s.start_year) {{ | |
| document.getElementById('findings').innerHTML = '<div class="loading">No data available for this subject</div>'; | |
| return; | |
| }} | |
| const dirClass = s.change_pct < 0 ? 'down' : 'up'; | |
| const sign = s.change_pct < 0 ? '−' : '+'; | |
| const rateSign = s.annual_rate < 0 ? '−' : '+'; | |
| document.getElementById('findings').innerHTML = ` | |
| <div class="finding" data-num="i"> | |
| <div> | |
| <div class="label">${{data.country}} · cover in ${{s.start_year}}</div> | |
| <div class="value">${{s.start_pct.toFixed(1)}}<span class="unit">%</span></div> | |
| </div> | |
| <div class="sub">of national land area</div> | |
| </div> | |
| <div class="finding" data-num="ii"> | |
| <div> | |
| <div class="label">${{data.country}} · cover in ${{s.end_year}}</div> | |
| <div class="value">${{s.end_pct.toFixed(1)}}<span class="unit">%</span></div> | |
| </div> | |
| <div class="sub">of national land area</div> | |
| </div> | |
| <div class="finding" data-num="iii"> | |
| <div> | |
| <div class="label">${{data.country}} · net change</div> | |
| <div class="value ${{dirClass}}">${{sign}}${{Math.abs(s.change_pct).toFixed(2)}}<span class="unit">pp</span></div> | |
| </div> | |
| <div class="sub">across ${{s.end_year - s.start_year}} years observed</div> | |
| </div> | |
| <div class="finding" data-num="iv"> | |
| <div> | |
| <div class="label">${{data.country}} · annual rate</div> | |
| <div class="value ${{dirClass}}">${{rateSign}}${{Math.abs(s.annual_rate).toFixed(3)}}<span class="unit">%/yr</span></div> | |
| </div> | |
| <div class="sub">${{data.policies.length}} policies on record</div> | |
| </div> | |
| `; | |
| }} | |
| function renderChart(data, compareData) {{ | |
| if (chart) chart.destroy(); | |
| document.getElementById('chart-loading').style.display = 'none'; | |
| const ctx = document.getElementById('chart').getContext('2d'); | |
| const INK = '#1a1f18'; | |
| const OXBLOOD = '#8b2a1f'; | |
| const MOSS = '#4a6b3e'; | |
| const AMBER = '#a8732a'; | |
| const RULE = '#c5bda5'; | |
| const PAPER_TINT = '#e4dbc5'; | |
| const datasets = [{{ | |
| label: data.country, | |
| data: data.forest.map(f => ({{x: f.year, y: f.value}})), | |
| borderColor: MOSS, | |
| backgroundColor: 'rgba(74, 107, 62, 0.12)', | |
| fill: true, | |
| tension: 0.25, | |
| pointRadius: 2.5, | |
| pointBackgroundColor: MOSS, | |
| pointBorderColor: PAPER_TINT, | |
| pointBorderWidth: 1, | |
| pointHoverRadius: 6, | |
| borderWidth: 2.2, | |
| }}]; | |
| if (data.governance && data.governance.length) {{ | |
| datasets.push({{ | |
| label: 'Rule of Law', | |
| data: data.governance.map(g => ({{x: g.year, y: g.value}})), | |
| borderColor: AMBER, | |
| borderDash: [3, 3], | |
| borderWidth: 1.2, | |
| pointRadius: 0, | |
| tension: 0.3, | |
| yAxisID: 'y2', | |
| }}); | |
| }} | |
| if (compareData && compareData.forest) {{ | |
| datasets.push({{ | |
| label: compareData.country, | |
| data: compareData.forest.map(f => ({{x: f.year, y: f.value}})), | |
| borderColor: OXBLOOD, | |
| borderDash: [6, 3], | |
| borderWidth: 1.8, | |
| pointRadius: 1.5, | |
| pointBackgroundColor: OXBLOOD, | |
| tension: 0.25, | |
| }}); | |
| }} | |
| const annotations = {{}}; | |
| (data.policies || []).forEach((p, i) => {{ | |
| annotations['pline' + i] = {{ | |
| type: 'line', | |
| xMin: p.year, xMax: p.year, | |
| borderColor: 'rgba(139, 42, 31, 0.22)', | |
| borderWidth: 1, | |
| borderDash: [3, 4], | |
| }}; | |
| annotations['ppoint' + i] = {{ | |
| type: 'point', | |
| xValue: p.year, | |
| yValue: data.forest.find(f => f.year === p.year)?.value || data.forest[0].value, | |
| radius: 5, | |
| backgroundColor: OXBLOOD, | |
| borderColor: PAPER_TINT, | |
| borderWidth: 2, | |
| hoverRadius: 8, | |
| }}; | |
| }}); | |
| (data.climate || []).forEach((c, i) => {{ | |
| annotations['cline' + i] = {{ | |
| type: 'line', | |
| xMin: c.year, xMax: c.year, | |
| borderColor: 'rgba(168, 115, 42, 0.22)', | |
| borderWidth: 1, | |
| borderDash: [1, 3], | |
| }}; | |
| annotations['cpoint' + i] = {{ | |
| type: 'point', | |
| xValue: c.year, | |
| yValue: data.forest.find(f => f.year === c.year)?.value || data.forest[0].value, | |
| radius: 6, | |
| pointStyle: 'triangle', | |
| backgroundColor: AMBER, | |
| borderColor: PAPER_TINT, | |
| borderWidth: 2, | |
| hoverRadius: 9, | |
| }}; | |
| }}); | |
| chart = new Chart(ctx, {{ | |
| type: 'line', | |
| data: {{ datasets }}, | |
| options: {{ | |
| responsive: true, | |
| maintainAspectRatio: false, | |
| interaction: {{ mode: 'index', intersect: false }}, | |
| onClick: function(evt, elements) {{ | |
| if (!elements.length) return; | |
| const el = elements[0]; | |
| const ds = chart.data.datasets[el.datasetIndex]; | |
| if (!ds.data[el.index]) return; | |
| const year = Math.round(ds.data[el.index].x); | |
| handlePointClick(year); | |
| }}, | |
| plugins: {{ | |
| legend: {{ | |
| labels: {{ | |
| color: INK, | |
| font: {{ family: "'JetBrains Mono', monospace", size: 10, weight: 500 }}, | |
| usePointStyle: true, | |
| pointStyle: 'rectRot', | |
| boxWidth: 8, | |
| padding: 16, | |
| }} | |
| }}, | |
| annotation: {{ annotations }}, | |
| tooltip: {{ | |
| backgroundColor: INK, | |
| titleColor: '#ede5d3', | |
| bodyColor: '#ede5d3', | |
| borderWidth: 0, | |
| titleFont: {{ family: "'Instrument Serif', serif", style: 'italic', size: 16 }}, | |
| bodyFont: {{ family: "'JetBrains Mono', monospace", size: 11 }}, | |
| padding: 12, | |
| cornerRadius: 0, | |
| displayColors: false, | |
| callbacks: {{ | |
| title: items => items.length ? Math.round(items[0].parsed.x).toString() : '', | |
| label: function(ctx) {{ | |
| const v = ctx.parsed.y; | |
| return ctx.dataset.label + ' · ' + (v != null ? v.toFixed(2) : '—'); | |
| }}, | |
| afterBody: function(items) {{ | |
| if (!items.length) return; | |
| const year = Math.round(items[0].parsed.x); | |
| const lines = []; | |
| const policy = (data.policies || []).find(p => p.year === year); | |
| const climateEv = (data.climate || []).find(c => c.year === year); | |
| if (policy) lines.push('', '§ Policy — ' + policy.text); | |
| if (climateEv) lines.push('', '△ Climate — ' + climateEv.text); | |
| return lines; | |
| }} | |
| }} | |
| }} | |
| }}, | |
| scales: {{ | |
| x: {{ | |
| type: 'linear', | |
| ticks: {{ | |
| color: '#6a6d60', | |
| font: {{ family: "'JetBrains Mono', monospace", size: 10 }}, | |
| stepSize: 5, | |
| callback: v => v.toString(), | |
| }}, | |
| grid: {{ color: 'rgba(58, 63, 53, 0.1)', drawTicks: false }}, | |
| border: {{ color: INK, width: 1 }}, | |
| }}, | |
| y: {{ | |
| ticks: {{ | |
| color: MOSS, | |
| font: {{ family: "'JetBrains Mono', monospace", size: 10 }}, | |
| callback: v => v.toFixed(1) + '%', | |
| }}, | |
| grid: {{ color: 'rgba(58, 63, 53, 0.08)', drawTicks: false }}, | |
| border: {{ color: INK, width: 1 }}, | |
| }}, | |
| y2: {{ | |
| position: 'right', | |
| ticks: {{ | |
| color: AMBER, | |
| font: {{ family: "'JetBrains Mono', monospace", size: 9 }}, | |
| }}, | |
| grid: {{ display: false }}, | |
| border: {{ color: 'rgba(168, 115, 42, 0.4)' }}, | |
| }} | |
| }} | |
| }} | |
| }}); | |
| }} | |
| function renderPolicies(data) {{ | |
| const el = document.getElementById('policies'); | |
| if (!data.policies || !data.policies.length) {{ | |
| el.innerHTML = ` | |
| <div class="panel-head"> | |
| <div class="panel-title">The Register · <em>${{data.country}}</em></div> | |
| <div class="panel-count">— empty —</div> | |
| </div> | |
| <div style="font-style:italic;color:var(--ink-mute);padding:16px 0"> | |
| No policy events catalogued for ${{data.country}}. | |
| </div>`; | |
| return; | |
| }} | |
| const items = data.policies.map(p => {{ | |
| const forestVal = data.forest.find(f => f.year === p.year); | |
| const forestText = forestVal ? forestVal.value.toFixed(1) + '%' : '—'; | |
| const link = p.url ? `<a href="${{p.url}}" target="_blank" title="Source">↗</a>` : ''; | |
| return `<div class="policy"> | |
| <div class="policy-year">${{p.year}}</div> | |
| <div class="policy-text">${{p.text}}${{link}}</div> | |
| <div class="policy-metric">${{forestText}}</div> | |
| </div>`; | |
| }}).join(''); | |
| el.innerHTML = ` | |
| <div class="panel-head"> | |
| <div class="panel-title">The Register · <em>${{data.country}}</em></div> | |
| <div class="panel-count">${{data.policies.length.toString().padStart(2, '0')}} entries</div> | |
| </div> | |
| ${{items}}`; | |
| }} | |
| function renderVerdict(data) {{ | |
| const el = document.getElementById('verdict-panel'); | |
| if (!data.policies || !data.policies.length || !data.forest.length) {{ | |
| el.innerHTML = ` | |
| <div class="panel-head"> | |
| <div class="panel-title">The Verdict · <em>${{data.country}}</em></div> | |
| <div class="panel-count">— pending —</div> | |
| </div> | |
| <div style="font-style:italic;color:var(--ink-mute);padding:16px 0"> | |
| Insufficient data for ${{data.country}} to render judgment. | |
| </div>`; | |
| return; | |
| }} | |
| const firstPolicy = data.policies[0].year; | |
| const pre = data.forest.filter(f => f.year < firstPolicy); | |
| const post = data.forest.filter(f => f.year >= firstPolicy); | |
| if (pre.length < 2 || post.length < 2) {{ | |
| el.innerHTML = ` | |
| <div class="panel-head"> | |
| <div class="panel-title">The Verdict · <em>${{data.country}}</em></div> | |
| <div class="panel-count">— pending —</div> | |
| </div> | |
| <div style="font-style:italic;color:var(--ink-mute);padding:16px 0"> | |
| Too few observations either side of intervention. | |
| </div>`; | |
| return; | |
| }} | |
| const preRate = (pre[pre.length-1].value - pre[0].value) / (pre[pre.length-1].year - pre[0].year); | |
| const postRate = (post[post.length-1].value - post[0].value) / (post[post.length-1].year - post[0].year); | |
| const improved = Math.abs(postRate) < Math.abs(preRate); | |
| const pct = preRate !== 0 ? ((1 - Math.abs(postRate) / Math.abs(preRate)) * 100) : 0; | |
| el.innerHTML = ` | |
| <div class="panel-head"> | |
| <div class="panel-title">The Verdict · <em>${{data.country}}</em></div> | |
| <div class="panel-count">Pre / Post · 1st intervention</div> | |
| </div> | |
| <div class="rate-row"> | |
| <div class="stage">Pre</div> | |
| <div class="desc">${{pre[0].year}}–${{pre[pre.length-1].year}} · before first policy</div> | |
| <div class="rate ${{preRate < 0 ? 'neg' : 'pos'}}">${{preRate > 0 ? '+' : ''}}${{preRate.toFixed(3)}}%/yr</div> | |
| </div> | |
| <div class="rate-row"> | |
| <div class="stage">Post</div> | |
| <div class="desc">${{post[0].year}}–${{post[post.length-1].year}} · since first policy</div> | |
| <div class="rate ${{postRate < 0 ? 'neg' : 'pos'}}">${{postRate > 0 ? '+' : ''}}${{postRate.toFixed(3)}}%/yr</div> | |
| </div> | |
| <div class="verdict"> | |
| <div class="headline ${{improved ? 'good' : 'bad'}}"> | |
| ${{improved ? data.country + ' slowed by ' + Math.abs(pct).toFixed(0) + '%' : data.country + ': no improvement'}} | |
| </div> | |
| <div class="sub">${{improved ? 'after policy intervention' : 'rate unchanged or worsened'}}</div> | |
| </div> | |
| `; | |
| }} | |
| async function handlePointClick(year) {{ | |
| const country = countrySelect.value; | |
| const dossierEl = document.getElementById('dossier'); | |
| const headEl = document.getElementById('dossier-head'); | |
| const contentEl = document.getElementById('dossier-content'); | |
| dossierEl.style.display = 'block'; | |
| dossierEl.scrollIntoView({{ behavior: 'smooth', block: 'nearest' }}); | |
| headEl.innerHTML = `Filing dossier for <em>${{year}}</em>`; | |
| contentEl.innerHTML = '<div style="font-style:italic;color:var(--ink-mute);padding:14px 0">Compiling field notes…</div>'; | |
| const result = await callApi('explain_spike', {{country, year}}); | |
| if (result.error) {{ | |
| contentEl.innerHTML = '<div style="color:var(--oxblood);font-style:italic">' + result.error + '</div>'; | |
| return; | |
| }} | |
| const trendClass = (() => {{ | |
| if (['sharp decline', 'acceleration'].includes(result.trend)) return 'neg'; | |
| if (['recovery', 'sharp increase'].includes(result.trend)) return 'pos'; | |
| return 'alert'; | |
| }})(); | |
| headEl.innerHTML = `Dossier · <em>${{country}}</em> · ${{result.year}}`; | |
| let html = ` | |
| <div class="dossier-stats"> | |
| <div class="dossier-stat"> | |
| <div class="k">Year</div> | |
| <div class="v">${{result.year}}</div> | |
| </div> | |
| <div class="dossier-stat"> | |
| <div class="k">Forest cover</div> | |
| <div class="v">${{result.forest_pct !== null ? result.forest_pct.toFixed(2) + '%' : '—'}}</div> | |
| </div> | |
| <div class="dossier-stat"> | |
| <div class="k">Year-on-year</div> | |
| <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> | |
| </div> | |
| <div class="dossier-stat"> | |
| <div class="k">Trend</div> | |
| <div class="v ${{trendClass}}">${{result.trend}}</div> | |
| </div> | |
| </div> | |
| `; | |
| if (result.rate_5yr_before !== null || result.rate_5yr_after !== null) {{ | |
| html += `<div class="rate-strip">`; | |
| if (result.rate_5yr_before !== null) {{ | |
| 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>`; | |
| }} | |
| if (result.rate_5yr_after !== null) {{ | |
| 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>`; | |
| }} | |
| html += `</div>`; | |
| }} | |
| if (result.context) {{ | |
| html += `<div class="context-block"> | |
| <div class="k">Field note · historical context</div> | |
| <div class="v">${{result.context}}</div> | |
| </div>`; | |
| }} | |
| if (result.nearby_policies && result.nearby_policies.length) {{ | |
| html += `<div class="nearby-block"> | |
| <div class="k">§ Nearby policy events · ±3 years</div>`; | |
| result.nearby_policies.forEach(p => {{ | |
| const pLink = p.url ? ` <a href="${{p.url}}" target="_blank" style="color:var(--ink-mute);font-size:11px">↗</a>` : ''; | |
| html += `<div class="nearby-item"><span class="yr">${{p.year}}</span><span>${{p.text}}${{pLink}}</span></div>`; | |
| }}); | |
| html += `</div>`; | |
| }} | |
| if (result.nearby_climate && result.nearby_climate.length) {{ | |
| html += `<div class="nearby-block"> | |
| <div class="k" style="color:var(--amber-deep)">△ Nearby climate events · ±3 years</div>`; | |
| result.nearby_climate.forEach(c => {{ | |
| const cLink = c.url ? ` <a href="${{c.url}}" target="_blank" style="color:var(--ink-mute);font-size:11px">↗</a>` : ''; | |
| html += `<div class="nearby-item"><span class="yr" style="color:var(--amber-deep)">${{c.year}}</span><span>${{c.text}}${{cLink}}</span></div>`; | |
| }}); | |
| html += `</div>`; | |
| }} | |
| contentEl.innerHTML = html; | |
| }} | |
| loadData(); | |
| </script> | |
| </body> | |
| </html>""" | |
| if __name__ == "__main__": | |
| app.launch(mcp_server=True) | |