1300–1900 Temperature Viewerdemo dataset (stylized)
Baseline: 1850–1900 · Units: °C/°F anomaly- a world map with a century slider (1300s → 1800s),
- °C / °F toggle (shows temperature anomalies in both),
- a tunable “just right” band to highlight neutral conditions,
- collage mode (six big 100-yr snapshots), and
- an optional migration overlay you can switch on/off.
You only need to add the data files it looks for (one JSON per century). Details and the data prep script are below.
What’s in the canvas app
- Map mode: draws a colored grid (diverging blue→white→red) from your century JSON on top of an interactive world map. The slider switches centuries; the Units toggle shows °C or °F (it’s the same anomaly scaled).
- Comfort band: controls the “just right” color emphasis (defaults to ±0.5 °C anomaly).
- Collage mode: shows large images (one per century) if you drop PNGs in
/images/. - Optional migration overlay: if you provide
/data/migrations.geojson, it’ll draw routes/points with popups.
Files the page expects
/data/century_1300.json
/data/century_1400.json
/data/century_1500.json
/data/century_1600.json
/data/century_1700.json
/data/century_1800.json
# (optional)
/images/century_1300.png ... /images/century_1800.png
/data/migrations.geojson
Where to get the temperature data (1300–1900)
- Pre-1850 reconstructions (global, annually resolved, 2°×2° grid):
Last Millennium Reanalysis (LMR v2.1) — ensemble mean temperature fields for 1–2000 CE, anomalies relative to 1951–1980. Download “Two-meter air temperature, full grid ensemble mean” (NetCDF). Atmospheric and Climate Science - Cross-checks & regional context: PAGES 2k (global multiproxy temperature reconstructions over the Common Era). pastglobalchanges.org+1
- Instrument era (from 1850):
HadCRUT5 (5° grid) and Berkeley Earth (lat-lon grid). Both can help validate your 1800s slice and compute a clean 1850–1900 baseline if you prefer. Met OfficeBerkeley Earth
Notes you’ll see when you open LMR v2.1: the files follow NOAA 20CR naming; all fields are anomalies vs. 1951–1980 and are provided on a 2°×2° grid. We’ll re-baseline them to 1850–1900 so “too cold / too warm / just right” is intuitive for your 1300–1800 comparisons. Atmospheric and Climate Science
Make the per-century JSON files (copy–paste script)
Run this locally (Python 3.10+). It reads LMR v2.1 NetCDF, computes century means (1300s…1800s), re-baselines to 1850–1899, and writes the JSON files your map expects
Make the per-century JSON files (copy–paste script)
Run this locally (Python 3.10+). It reads LMR v2.1 NetCDF, computes century means (1300s…1800s), re-baselines to 1850–1899, and writes the JSON files your map expects.
# requires: pip install xarray netCDF4 numpy pandas
import xarray as xr, numpy as np, json, pathlib
# ---- configure these paths ----
NC_PATH = "air_MCruns_ensemble_mean_LMRv2.1.nc" # LMR v2.1 2m air temp (ensemble mean)
OUT_DIR = pathlib.Path("data") # same folder your index.html serves /data from
OUT_DIR.mkdir(exist_ok=True)
# ---- open the dataset ----
ds = xr.open_dataset(NC_PATH)
# LMR follows NOAA 20CR variable naming; 2m air temp is typically "air" (K).
# Some builds might use "tas". Pick whatever exists:
for var in ["air", "tas", "temp"]:
if var in ds.data_vars:
V = var; break
else:
raise SystemExit(f"No temperature variable found. Vars: {list(ds.data_vars)}")
da = ds[V] # dims usually (time, lat, lon) OR (time, MCrun, lat, lon)
# If MCrun dimension exists, average over it (grand mean per year):
if "MCrun" in da.dims:
da = da.mean("MCrun")
# Keep only 1300..1899 and 1850..1899 (for baseline)
da = da.sel(time=slice(1300, 1899))
# Compute the 1850–1899 baseline (Kelvin). (Kelvin deltas == Celsius deltas)
baseline = da.sel(time=slice(1850, 1899)).mean("time")
# Helper to dump one century file
def write_century(start):
end = start + 99
slab = da.sel(time=slice(start, end)).mean("time") # K
anom_c = (slab - baseline) # still in K but equal to °C anomaly
# Build compact grid list
out = {
"century": f"{start}s",
"baseline": "1850-1900",
"units": "C_anomaly",
"grid": []
}
# iterate with .values to keep it fast
lats = anom_c["lat"].values
lons = anom_c["lon"].values
vals = anom_c.values # shape (lat, lon)
# Optional thinning if you ever need: step = 1 for native 2°
step = 1
for i in range(0, len(lats), step):
for j in range(0, len(lons), step):
v = vals[i, j]
if np.isfinite(v):
out["grid"].append({
"lat": float(lats[i]),
"lon": float(lons[j]),
"anom_c": float(v)
})
with open(OUT_DIR / f"century_{start}.json", "w", encoding="utf-8") as f:
json.dump(out, f, ensure_ascii=False)
for c0 in (1300,1400,1500,1600,1700,1800):
write_century(c0)
print("Done: wrote /data/century_*.json")
If you’d rather use HadCRUT5 or Berkeley Earth for the 1800s, compute the 1850–1899 mean and anomalies on their grids and either (a) regrid to 2°×2° to match LMR, or (b) leave their native grid — the viewer will still render it; it just plots colored squares at each (lat,lon). Met OfficeBerkeley Earth
Optional: migration overlay file
Add /data/migrations.geojson (GeoJSON FeatureCollection). Example:
{
"type": "FeatureCollection",
"features": [
{
"type":"Feature",
"properties":{"name":"Norse retreat from Greenland", "years":"c. 1400–1500"},
"geometry":{"type":"LineString","coordinates":[[-45,61],[-28,64],[-20,64]]}
},
{
"type":"Feature",
"properties":{"name":"Scots-Irish to North America", "years":"1700s"},
"geometry":{"type":"LineString","coordinates":[[-6,55],[-30,53],[-75,40]]}
}
]
}
(These are illustrative routes; tune coordinates and labels based on your sources.)
Suggested folder layout for cPanel
/public_html/climate-map/
index.html # the canvas file I made
/data/
century_1300.json
century_1400.json
...
century_1800.json
migrations.geojson # optional
/images/
century_1300.png # optional collage images you render/export
...
century_1800.png
A few “migration x climate” pointers (useful when adding overlays)
Climate often nudged — not single-handedly “caused” — migrations. A few well-cited angles you can annotate:
- Little Ice Age background (≈1300–1850): multi-century cooling with regional variability; best seen in proxy-based reconstructions (LMR; PAGES 2k). Atmospheric and Climate Sciencepastglobalchanges.org
- Greenland Norse withdrawal (c. 1400s): recent work argues rising seas and/or drying compounded pressures along with cooling; treat it as multi-factor. State of the PlanetEos
- Circum-Caribbean/Yucatán: drought episodes around the onset of the LIA; environmental stress linked to conflict and mobility; (earlier Classic Maya collapse droughts are older than 1300, but later 15th-century aridity is relevant). Cambridge University Press & AssessmentNature
- Europe & the Atlantic world (1500–1800): large out-migration from Europe driven mainly by empire, economics, conflict — with climate shocks occasionally shaping harvests and push/pull factors. WikipediaCUNY Pressbooks
Caveats (so your map tells an honest story)
- These are anomalies vs a baseline (we used 1850–1900 here). They’re not literal absolute °C at each grid cell; anomaly differences do map cleanly to °F with the standard conversion. Atmospheric and Climate Science
- Uncertainty varies by region/century. LMR is strongest where proxies are dense (N. Hemisphere continents) and lower over oceans/parts of the tropics. Treat fine-scale patterns with care, especially early centuries. Copernicus Publications
- Instrument records (HadCRUT5, Berkeley Earth) begin in 1850 (or ~1880 for global completeness). Use them to sanity-check your 1800s panel. Met OfficeEarth Observatory
If you want, I can also add a tiny server-side script to pre-compute and cache the JSON on your cPanel so you just drop the NetCDF file once and click “build”.
Analysis errored
Always show details# Build a single-file, shareable HTML viewer with an embedded (stylized) demo dataset.
# The file will be saved to /mnt/data and returned as a downloadable link.
import json, math, random, textwrap, os, pathlib
out_path = pathlib.Path('/mnt/data/climate-1300-1900-viewer.html')
# Generate a stylized demo dataset (coarse 10° grid) that roughly mirrors Little Ice Age patterns:
# - Stronger NH high-latitude cooling in 1600s–1700s, milder in 1300s and recovery into 1800s.
# - Tropics have smaller anomalies.
# Values are temperature anomalies relative to 1850–1900 baseline (°C).
random.seed(42)
centuries = [1300, 1400, 1500, 1600, 1700, 1800]
lats = list(range(-80, 81, 10)) # -80..80 by 10
lons = list(range(-180, 181, 10)) # -180..180 by 10
def century_factor(c):
# approximate mean anomaly by century (°C): more negative around 1600s–1700s
return {
1300: -0.35,
1400: -0.50,
1500: -0.65,
1600: -0.90,
1700: -0.80,
1800: -0.45,
}[c]
def lat_modifier(lat):
# stronger anomalies toward poles; NH slightly stronger than SH
# scale from 0 at equator to 1 at poles; use sin^p for shape
base = abs(math.sin(math.radians(lat))) ** 0.9
hemi = 1.15 if lat > 0 else 1.0
return base * hemi
def region_noise(lat, lon, c):
# small spatial structure using simple harmonics (deterministic, repeatable)
rl = math.radians(lat)
ro = math.radians(lon)
cf = (c - 1250) / 700.0 # normalized century index
return 0.08 * (math.sin(2*rl) * math.cos(3*ro + cf) + 0.5*math.sin(ro*0.7))
DATA = {}
for c in centuries:
records = []
base = century_factor(c)
for lat in lats:
for lon in lons:
# smaller anomalies near tropics; add structure and slight random blur
anom = base * (0.35 + 0.65 * lat_modifier(lat)) + region_noise(lat, lon, c)
anom += random.uniform(-0.03, 0.03) # subtle noise
records.append({"lat": lat, "lon": lon, "anom_c": round(anom, 3)})
DATA[str(c)] = {
"century": f"{c}s",
"baseline": "1850-1900",
"units": "C_anomaly",
"grid": records
}
# Build the HTML with everything inline (Leaflet CDN, simple CSS, JS app, and embedded DATA constant).
html = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>1300–1900 Temperature Viewer (Demo)</title>
<link rel="preconnect" href="https://unpkg.com" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
:root{{--bg:#0b1020;--panel:#121a2f;--muted:#9fb0d3;--text:#e8efff;--accent:#4da3ff;}}
*{{box-sizing:border-box}} html,body{{height:100%;margin:0;background:var(--bg);color:var(--text);font:15px/1.45 system-ui,-apple-system,Segoe UI,Roboto,Ubuntu}}
header{{padding:12px 16px;background:linear-gradient(180deg,#0e1630,#0b1020);border-bottom:1px solid #1d2540;display:flex;align-items:center;gap:12px;flex-wrap:wrap}}
header h1{{margin:0;font-size:18px;letter-spacing:.2px}} .tag{{font-size:12px;padding:2px 8px;border-radius:999px;background:#1f2a48;color:#9fb0d3;border:1px solid #26345a}}
.wrap{{display:grid;grid-template-columns:340px 1fr;gap:12px;height:calc(100% - 56px);padding:12px}}
@media(max-width:1020px){{.wrap{{grid-template-columns:1fr;grid-template-rows:auto 1fr}}}}
aside{{background:var(--panel);border:1px solid #1d2540;border-radius:16px;padding:14px;display:flex;flex-direction:column;gap:14px;min-height:0}}
.row{{display:flex;align-items:center;gap:10px;flex-wrap:wrap}} h2{{font-size:14px;margin:0;color:#cfe1ff;letter-spacing:.3px}}
.box{{background:#0f1730;border:1px solid #1f2a48;border-radius:12px;padding:10px}}
.seg{{display:inline-flex;border:1px solid #223055;border-radius:999px;overflow:hidden}} .seg button{{background:#1a2442;border:0;color:#bcd;padding:6px 10px;font-size:13px}} .seg button.active{{background:#2a3a66;color:#fff}}
input[type=range]{{width:100%}}
.legend{{display:grid;grid-template-columns:repeat(11,1fr);gap:2px;align-items:end;margin-top:6px}} .legend .swatch{{height:14px;border-radius:2px;border:1px solid #223055}}
.hint{{font-size:12px;color:#9fb0d3}} .status{{font-size:13px;color:#cfe1ff}} .pill{{padding:3px 8px;border-radius:999px;border:1px solid #2a3a66;background:#152044;color:#cfe1ff}}
.map-wrap{{position:relative;background:#060a18;border:1px solid #1d2540;border-radius:16px;overflow:hidden}}
#map{{height:100%;min-height:520px}}
.overlay-msg{{position:absolute;inset:0;display:grid;place-items:center;pointer-events:none}}
.overlay-msg .card{{background:rgba(10,16,30,.92);border:1px solid #25345d;color:#cfe1ff;padding:14px 16px;border-radius:12px;max-width:640px;text-align:center}}
.toolbar{{display:flex;gap:8px;align-items:center;flex-wrap:wrap}}
.collage{{display:none;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px}}
.collage.show{{display:grid}} .collage figure{{margin:0;background:#0f1730;border:1px solid #1f2a48;border-radius:10px;overflow:hidden}} .collage figcaption{{padding:8px 10px;color:#cfe1ff;font-size:13px;border-top:1px solid #1f2a48}}
footer{{padding:10px 14px;color:#9fb0d3;font-size:12px;display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap}}
a.btn{{display:inline-flex;align-items:center;gap:8px;border:1px solid #2a3a66;background:#152044;color:#cfe1ff;border-radius:999px;padding:6px 10px;text-decoration:none}}
</style>
</head>
<body>
<header>
<h1>1300–1900 Temperature Viewer<span class="tag">demo dataset (stylized)</span></h1>
<span class="pill">Baseline: 1850–1900 · Units: °C/°F anomaly</span>
</header>
<div class="wrap">
<aside>
<div class="row"><h2>Century</h2></div>
<div class="box">
<input id="century" type="range" min="0" max="5" step="1" value="5" />
<div class="row" style="justify-content:space-between;margin-top:6px">
<span class="hint">1300s</span><span class="hint">1400s</span><span class="hint">1500s</span><span class="hint">1600s</span><span class="hint">1700s</span><span class="hint">1800s</span>
</div>
<div class="status" id="centuryLabel">1800–1899</div>
</div>
<div class="row"><h2>Units</h2></div>
<div class="seg" id="unitSeg">
<button data-unit="c" class="active">°C anomaly</button>
<button data-unit="f">°F anomaly</button>
</div>
<div class="row"><h2>Comfort band</h2></div>
<div class="box">
<label class="hint">Neutral band (±°C): <strong id="bandVal">0.5</strong></label>
<input id="band" type="range" min="0.2" max="1.5" step="0.1" value="0.5" />
<div class="hint">Cells between −band and +band highlight as “just right”.</div>
</div>
<div class="row"><h2>Mode</h2></div>
<div class="seg">
<button id="modeMap" class="active">Map</button>
<button id="modeCollage">Collage</button>
</div>
<div class="row"><h2>Share / Present</h2></div>
<div class="box toolbar">
<a id="permalink" class="btn" href="#">Copy share link</a>
<a id="present" class="btn" href="#">Present mode</a>
</div>
<div class="row"><h2>Legend</h2></div>
<div class="box">
<div class="legend" id="legend"></div>
<div class="row" style="justify-content:space-between;margin-top:6px">
<span class="hint" id="legendMin"></span>
<span class="hint">colder ↔ warmer</span>
<span class="hint" id="legendMax"></span>
</div>
</div>
<div class="box hint">
<strong>Important:</strong> This page embeds a <em>stylized demo dataset</em> to illustrate the UI and typical patterns of the Little Ice Age (cooler 1600–1700s, milder 1800s, polar amplification). Replace with real fields from LMR v2.1 / PAGES 2k / HadCRUT5 for research & debate use.
</div>
</aside>
<main class="map-wrap">
<div id="map"></div>
<section id="collage" class="collage" aria-hidden="true"></section>
</main>
</div>
<footer>
<div>Data (demo): synthetic, century means vs 1850–1900; grid 10°×10°. | Tips: mouseover shows value; zoom to see grid squares.</div>
<div><a class="btn" href="#" id="aboutBtn">About & Sources</a></div>
</footer>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const CENTURIES = [1300,1400,1500,1600,1700,1800];
const EMBED = {data: {json.dumps(DATA)}}; // embedded demo data
const $ = s => document.querySelector(s);
const $$ = s => Array.from(document.querySelectorAll(s));
// Map init
const map = L.map('map', {worldCopyJump:true, zoomControl:true});
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {attribution: '© OpenStreetMap'}).addTo(map);
map.setView([22, 0], 2);
// Canvas overlay
const overlayPane = map.getPanes().overlayPane;
const canvas = L.DomUtil.create('canvas', 'grid-canvas', overlayPane);
const ctx = canvas.getContext('2d');
function resizeCanvas(){{const sz = map.getSize(); canvas.width = sz.x; canvas.height = sz.y; canvas.style.width=sz.x+'px'; canvas.style.height=sz.y+'px'; draw();}}
map.on('resize zoom move', resizeCanvas); resizeCanvas();
let unit = 'c', neutralBand = 0.5, idx = 5; // defaults to 1800s
const palette = ['#0b4b9e','#1e66c1','#3e86d6','#6aa5e7','#a8c7f0','#e6eef7','#f7e0da','#f0b9a9','#e2876e','#cc5d3b','#b93a18'];
const minC = -2.0, maxC = 2.0;
const toF = c => c*9/5 + 32;
function colorFor(c){ const x = Math.max(minC, Math.min(maxC, c)); const t = (x-minC)/(maxC-minC); const i = Math.max(0, Math.min(palette.length-1, Math.floor(t*palette.length))); return palette[i]; }
function legendBuild(){
const legend = $('#legend'); legend.innerHTML = '';
for(const p of palette){ const sw = document.createElement('div'); sw.className='swatch'; sw.style.background=p; legend.appendChild(sw); }
$('#legendMin').textContent = unit==='c' ? `${minC.toFixed(1)}°C` : `${toF(minC).toFixed(1)}°F`;
$('#legendMax').textContent = unit==='c' ? `${maxC.toFixed(1)}°C` : `${toF(maxC).toFixed(1)}°F`;
}
legendBuild();
function setCenturyLabel(){ const c = CENTURIES[idx]; $('#centuryLabel').textContent = `${c}–${c+99}`; }
setCenturyLabel();
// Tooltip
const tip = L.tooltip({permanent:false, direction:'top', opacity:0.95});
function showTip(lat, lon, anomC, pt){
const val = unit==='c' ? `${anomC.toFixed(2)} °C` : `${toF(anomC).toFixed(2)} °F`;
tip.setLatLng([lat, lon]).setContent(`<strong>${val}</strong><div style='font-size:12px;opacity:.8'>lat ${lat}°, lon ${lon}°</div>`).addTo(map);
}
map.on('mouseout', ()=>{ map.removeLayer(tip); });
// Draw
function clear(){ ctx.clearRect(0,0,canvas.width,canvas.height); }
function draw(){
clear();
const c = CENTURIES[idx];
const obj = EMBED.data[String(c)];
if(!obj) return;
const band = neutralBand;
const r = Math.max(3, 1.2*(map.getZoom()+1)); // square half-size
for(const cell of obj.grid){
const an = Number(cell.anom_c);
const ll = L.latLng(cell.lat, cell.lon);
const pt = map.latLngToContainerPoint(ll);
ctx.globalAlpha = (Math.abs(an) <= band) ? 0.86 : 1.0;
ctx.fillStyle = colorFor(an);
ctx.fillRect(pt.x - r, pt.y - r, r*2, r*2);
}
ctx.globalAlpha = 1;
}
map.on('move zoom', draw);
// Hit testing (nearest cell) for tooltip
function onMouseMove(e){
const c = CENTURIES[idx]; const obj = EMBED.data[String(c)]; if(!obj) return;
const mouse = e.containerPoint;
let bestD = 9999, best=null;
for(const cell of obj.grid){
const pt = map.latLngToContainerPoint([cell.lat, cell.lon]);
const dx = pt.x - mouse.x, dy = pt.y - mouse.y;
const d = dx*dx + dy*dy;
if(d < bestD){ bestD = d; best = [cell, pt]; }
}
if(best && bestD < 900){ // within ~30 px
const [cell, pt] = best; showTip(cell.lat, cell.lon, Number(cell.anom_c), pt);
}
}
map.on('mousemove', onMouseMove);
// Collage (render static canvases per century to <img>)
function rebuildCollage(){
const pane = $('#collage'); pane.innerHTML='';
for(const c of CENTURIES){
const fig = document.createElement('figure');
const img = document.createElement('img');
img.alt = `${c}s temperature anomaly (demo)`;
const cap = document.createElement('figcaption'); cap.textContent = `${c}–${c+99}`;
// render offscreen canvas snapshot
const off = document.createElement('canvas'); const octx = off.getContext('2d');
off.width = 640; off.height = 320;
// simple equirectangular projection preview
const obj = EMBED.data[String(c)]; if(obj){
for(const cell of obj.grid){
const x = Math.round((cell.lon + 180)/360 * off.width);
const y = Math.round((90 - (cell.lat+90))/-180 * off.height); // quick & dirty row mapping
octx.fillStyle = colorFor(Number(cell.anom_c));
octx.fillRect(x-3, y-3, 6, 6);
}
}
img.src = off.toDataURL('image/png');
fig.appendChild(img); fig.appendChild(cap); pane.appendChild(fig);
}
}
rebuildCollage();
// UI
$('#century').addEventListener('input', (e)=>{ idx = +e.target.value; setCenturyLabel(); draw(); syncHash(); });
$('#band').addEventListener('input', (e)=>{ neutralBand = +e.target.value; $('#bandVal').textContent = neutralBand.toFixed(1); draw(); syncHash(); });
$$('#unitSeg button').forEach(btn => btn.addEventListener('click', (e)=>{
$$('#unitSeg button').forEach(b=>b.classList.remove('active')); e.currentTarget.classList.add('active');
unit = e.currentTarget.dataset.unit; legendBuild(); syncHash();
}));
const modeMap = $('#modeMap'), modeCollage = $('#modeCollage');
modeMap.addEventListener('click', ()=>{ modeMap.classList.add('active'); modeCollage.classList.remove('active'); $('#map').style.display=''; $('#collage').classList.remove('show'); });
modeCollage.addEventListener('click', ()=>{ modeCollage.classList.add('active'); modeMap.classList.remove('active'); $('#map').style.display='none'; $('#collage').classList.add('show'); });
// Permalinks via URL hash
function syncHash(){
const state = new URLSearchParams();
state.set('c', String(CENTURIES[idx])); state.set('u', unit); state.set('b', String(neutralBand));
state.set('m', $('#collage').classList.contains('show') ? '1' : '0');
location.hash = state.toString();
}
function readHash(){
if(!location.hash) return;
const q = new URLSearchParams(location.hash.slice(1));
const c = Number(q.get('c')); const u = q.get('u'); const b = Number(q.get('b')); const m = q.get('m');
const id = CENTURIES.indexOf(c); if(id>=0){ idx = id; $('#century').value = String(idx); setCenturyLabel(); }
if(u==='f' || u==='c'){ unit = u; $$('#unitSeg button').forEach(b=>b.classList.toggle('active', b.dataset.unit===u)); legendBuild(); }
if(!isNaN(b)){ neutralBand = b; $('#band').value = b; $('#bandVal').textContent = b.toFixed(1); }
if(m==='1'){ modeCollage.click(); } else { modeMap.click(); }
}
window.addEventListener('hashchange', ()=>{ readHash(); draw(); });
readHash();
// Present mode (minimal UI toggling)
const body = document.body;
$('#present').addEventListener('click', (e)=>{
e.preventDefault(); body.classList.toggle('present');
const on = body.classList.contains('present');
document.querySelector('header').style.display = on ? 'none' : '';
document.querySelector('aside').style.display = on ? 'none' : '';
});
// Copy permalink
$('#permalink').addEventListener('click', (e)=>{
e.preventDefault(); syncHash();
navigator.clipboard.writeText(location.href).then(()=>{
$('#permalink').textContent = 'Link copied!'; setTimeout(()=>$('#permalink').textContent='Copy share link',1500);
});
});
// About / sources
$('#aboutBtn').addEventListener('click', (e)=>{
e.preventDefault();
alert('About this demo:\\n\\n• Baseline 1850–1900; anomalies in °C/°F.\\n• Grid: 10°x10°, stylized to reflect typical Little Ice Age patterns (stronger NH high-latitude cooling in 1600–1700s).\\n• Replace with real fields (e.g., Last Millennium Reanalysis v2.1) for analysis.');
});
// Initial draw
draw();
</script>
</body>
</html>
"""
out_path.write_text(html, encoding='utf-8')
out_path.exists(), str(out_path)
Cell In[1], line 186
function colorFor(c){ const x = Math.max(minC, Math.min(maxC, c)); const t = (x-minC)/(maxC-minC); const i = Math.max(0, Math.min(palette.length-1, Math.floor(t*palette.length))); return palette[i]; }
^
SyntaxError: f-string: invalid syntax. Perhaps you forgot a comma?
Thought for 9s
Analyzed
Always show details# Rebuild the HTML with a placeholder for embedded data to avoid f-string brace issues.
import json, pathlib, math, random
out_path = pathlib.Path('/mnt/data/climate-1300-1900-viewer.html')
# --- generate stylized demo dataset ---
random.seed(42)
centuries = [1300, 1400, 1500, 1600, 1700, 1800]
lats = list(range(-80, 81, 10))
lons = list(range(-180, 181, 10))
def century_factor(c):
return {1300:-0.35, 1400:-0.50, 1500:-0.65, 1600:-0.90, 1700:-0.80, 1800:-0.45}[c]
def lat_modifier(lat):
base = abs(math.sin(math.radians(lat))) ** 0.9
hemi = 1.15 if lat > 0 else 1.0
return base * hemi
def region_noise(lat, lon, c):
rl = math.radians(lat); ro = math.radians(lon)
cf = (c - 1250) / 700.0
return 0.08 * (math.sin(2*rl) * math.cos(3*ro + cf) + 0.5*math.sin(ro*0.7))
DATA = {}
for c in centuries:
records = []
base = century_factor(c)
for lat in lats:
for lon in lons:
anom = base * (0.35 + 0.65 * lat_modifier(lat)) + region_noise(lat, lon, c)
anom += random.uniform(-0.03, 0.03)
records.append({"lat": lat, "lon": lon, "anom_c": round(anom, 3)})
DATA[str(c)] = {"century": f"{c}s", "baseline": "1850-1900", "units": "C_anomaly", "grid": records}
embedded_json = json.dumps(DATA, separators=(',', ':'))
html = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>1300–1900 Temperature Viewer (Demo)</title>
<link rel="preconnect" href="https://unpkg.com" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
:root{--bg:#0b1020;--panel:#121a2f;--muted:#9fb0d3;--text:#e8efff;--accent:#4da3ff;}
*{box-sizing:border-box} html,body{height:100%;margin:0;background:var(--bg);color:var(--text);font:15px/1.45 system-ui,-apple-system,Segoe UI,Roboto,Ubuntu}
header{padding:12px 16px;background:linear-gradient(180deg,#0e1630,#0b1020);border-bottom:1px solid #1d2540;display:flex;align-items:center;gap:12px;flex-wrap:wrap}
header h1{margin:0;font-size:18px;letter-spacing:.2px} .tag{font-size:12px;padding:2px 8px;border-radius:999px;background:#1f2a48;color:#9fb0d3;border:1px solid #26345a}
.wrap{display:grid;grid-template-columns:340px 1fr;gap:12px;height:calc(100% - 56px);padding:12px}
@media(max-width:1020px){.wrap{grid-template-columns:1fr;grid-template-rows:auto 1fr}}
aside{background:var(--panel);border:1px solid #1d2540;border-radius:16px;padding:14px;display:flex;flex-direction:column;gap:14px;min-height:0}
.row{display:flex;align-items:center;gap:10px;flex-wrap:wrap} h2{font-size:14px;margin:0;color:#cfe1ff;letter-spacing:.3px}
.box{background:#0f1730;border:1px solid #1f2a48;border-radius:12px;padding:10px}
.seg{display:inline-flex;border:1px solid #223055;border-radius:999px;overflow:hidden} .seg button{background:#1a2442;border:0;color:#bcd;padding:6px 10px;font-size:13px} .seg button.active{background:#2a3a66;color:#fff}
input[type=range]{width:100%}
.legend{display:grid;grid-template-columns:repeat(11,1fr);gap:2px;align-items:end;margin-top:6px} .legend .swatch{height:14px;border-radius:2px;border:1px solid #223055}
.hint{font-size:12px;color:#9fb0d3} .status{font-size:13px;color:#cfe1ff} .pill{padding:3px 8px;border-radius:999px;border:1px solid #2a3a66;background:#152044;color:#cfe1ff}
.map-wrap{position:relative;background:#060a18;border:1px solid #1d2540;border-radius:16px;overflow:hidden}
#map{height:100%;min-height:520px}
.overlay-msg{position:absolute;inset:0;display:grid;place-items:center;pointer-events:none}
.overlay-msg .card{background:rgba(10,16,30,.92);border:1px solid #25345d;color:#cfe1ff;padding:14px 16px;border-radius:12px;max-width:640px;text-align:center}
.toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
.collage{display:none;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px}
.collage.show{display:grid} .collage figure{margin:0;background:#0f1730;border:1px solid #1f2a48;border-radius:10px;overflow:hidden} .collage figcaption{padding:8px 10px;color:#cfe1ff;font-size:13px;border-top:1px solid #1f2a48}
footer{padding:10px 14px;color:#9fb0d3;font-size:12px;display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap}
a.btn{display:inline-flex;align-items:center;gap:8px;border:1px solid #2a3a66;background:#152044;color:#cfe1ff;border-radius:999px;padding:6px 10px;text-decoration:none}
</style>
</head>
<body>
<header>
<h1>1300–1900 Temperature Viewer<span class="tag">demo dataset (stylized)</span></h1>
<span class="pill">Baseline: 1850–1900 · Units: °C/°F anomaly</span>
</header>
<div class="wrap">
<aside>
<div class="row"><h2>Century</h2></div>
<div class="box">
<input id="century" type="range" min="0" max="5" step="1" value="5" />
<div class="row" style="justify-content:space-between;margin-top:6px">
<span class="hint">1300s</span><span class="hint">1400s</span><span class="hint">1500s</span><span class="hint">1600s</span><span class="hint">1700s</span><span class="hint">1800s</span>
</div>
<div class="status" id="centuryLabel">1800–1899</div>
</div>
<div class="row"><h2>Units</h2></div>
<div class="seg" id="unitSeg">
<button data-unit="c" class="active">°C anomaly</button>
<button data-unit="f">°F anomaly</button>
</div>
<div class="row"><h2>Comfort band</h2></div>
<div class="box">
<label class="hint">Neutral band (±°C): <strong id="bandVal">0.5</strong></label>
<input id="band" type="range" min="0.2" max="1.5" step="0.1" value="0.5" />
<div class="hint">Cells between −band and +band highlight as “just right”.</div>
</div>
<div class="row"><h2>Mode</h2></div>
<div class="seg">
<button id="modeMap" class="active">Map</button>
<button id="modeCollage">Collage</button>
</div>
<div class="row"><h2>Share / Present</h2></div>
<div class="box toolbar">
<a id="permalink" class="btn" href="#">Copy share link</a>
<a id="present" class="btn" href="#">Present mode</a>
</div>
<div class="row"><h2>Legend</h2></div>
<div class="box">
<div class="legend" id="legend"></div>
<div class="row" style="justify-content:space-between;margin-top:6px">
<span class="hint" id="legendMin"></span>
<span class="hint">colder ↔ warmer</span>
<span class="hint" id="legendMax"></span>
</div>
</div>
<div class="box hint">
<strong>Important:</strong> This page embeds a <em>stylized demo dataset</em> to illustrate the UI and typical patterns of the Little Ice Age (cooler 1600–1700s, milder 1800s, polar amplification). Replace with real fields from LMR v2.1 / PAGES 2k / HadCRUT5 for research & debate use.
</div>
</aside>
<main class="map-wrap">
<div id="map"></div>
<section id="collage" class="collage" aria-hidden="true"></section>
</main>
</div>
<footer>
<div>Data (demo): synthetic, century means vs 1850–1900; grid 10°×10°. | Tips: mouseover shows value; zoom to see grid squares.</div>
<div><a class="btn" href="#" id="aboutBtn">About & Sources</a></div>
</footer>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
const CENTURIES = [1300,1400,1500,1600,1700,1800];
const EMBED = {data: __DATA__}; // embedded demo data
const $ = s => document.querySelector(s);
const $$ = s => Array.from(document.querySelectorAll(s));
// Map init
const map = L.map('map', {worldCopyJump:true, zoomControl:true});
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {attribution: '© OpenStreetMap'}).addTo(map);
map.setView([22, 0], 2);
// Canvas overlay
const overlayPane = map.getPanes().overlayPane;
const canvas = L.DomUtil.create('canvas', 'grid-canvas', overlayPane);
const ctx = canvas.getContext('2d');
function resizeCanvas(){const sz = map.getSize(); canvas.width = sz.x; canvas.height = sz.y; canvas.style.width=sz.x+'px'; canvas.style.height=sz.y+'px'; draw();}
map.on('resize zoom move', resizeCanvas); resizeCanvas();
let unit = 'c', neutralBand = 0.5, idx = 5; // defaults to 1800s
const palette = ['#0b4b9e','#1e66c1','#3e86d6','#6aa5e7','#a8c7f0','#e6eef7','#f7e0da','#f0b9a9','#e2876e','#cc5d3b','#b93a18'];
const minC = -2.0, maxC = 2.0;
const toF = c => c*9/5 + 32;
function colorFor(c){ const x = Math.max(minC, Math.min(maxC, c)); const t = (x-minC)/(maxC-minC); const i = Math.max(0, Math.min(palette.length-1, Math.floor(t*palette.length))); return palette[i]; }
function legendBuild(){
const legend = $('#legend'); legend.innerHTML = '';
for(const p of palette){ const sw = document.createElement('div'); sw.className='swatch'; sw.style.background=p; legend.appendChild(sw); }
$('#legendMin').textContent = unit==='c' ? `${minC.toFixed(1)}°C` : `${toF(minC).toFixed(1)}°F`;
$('#legendMax').textContent = unit==='c' ? `${maxC.toFixed(1)}°C` : `${toF(maxC).toFixed(1)}°F`;
}
legendBuild();
function setCenturyLabel(){ const c = CENTURIES[idx]; $('#centuryLabel').textContent = `${c}–${c+99}`; }
setCenturyLabel();
// Tooltip
const tip = L.tooltip({permanent:false, direction:'top', opacity:0.95});
function showTip(lat, lon, anomC){
const val = unit==='c' ? `${anomC.toFixed(2)} °C` : `${toF(anomC).toFixed(2)} °F`;
tip.setLatLng([lat, lon]).setContent(`<strong>${val}</strong><div style='font-size:12px;opacity:.8'>lat ${lat}°, lon ${lon}°</div>`).addTo(map);
}
map.on('mouseout', ()=>{ map.removeLayer(tip); });
// Draw
function clear(){ ctx.clearRect(0,0,canvas.width,canvas.height); }
function draw(){
clear();
const c = CENTURIES[idx];
const obj = EMBED.data[String(c)];
if(!obj) return;
const band = neutralBand;
const r = Math.max(3, 1.2*(map.getZoom()+1)); // square half-size
for(const cell of obj.grid){
const an = Number(cell.anom_c);
const ll = L.latLng(cell.lat, cell.lon);
const pt = map.latLngToContainerPoint(ll);
ctx.globalAlpha = (Math.abs(an) <= band) ? 0.86 : 1.0;
ctx.fillStyle = colorFor(an);
ctx.fillRect(pt.x - r, pt.y - r, r*2, r*2);
}
ctx.globalAlpha = 1;
}
map.on('move zoom', draw);
// Hit testing (nearest cell) for tooltip
function onMouseMove(e){
const c = CENTURIES[idx]; const obj = EMBED.data[String(c)]; if(!obj) return;
const mouse = e.containerPoint;
let bestD = 9999, best=null;
for(const cell of obj.grid){
const pt = map.latLngToContainerPoint([cell.lat, cell.lon]);
const dx = pt.x - mouse.x, dy = pt.y - mouse.y;
const d = dx*dx + dy*dy;
if(d < bestD){ bestD = d; best = cell; }
}
if(best && bestD < 900){ // ~30 px
showTip(best.lat, best.lon, Number(best.anom_c));
}
}
map.on('mousemove', onMouseMove);
// Collage (simple snapshots per century)
function rebuildCollage(){
const pane = $('#collage'); pane.innerHTML='';
for(const c of CENTURIES){
const fig = document.createElement('figure');
const img = document.createElement('img');
img.alt = `${c}s temperature anomaly (demo)`;
const cap = document.createElement('figcaption'); cap.textContent = `${c}–${c+99}`;
const off = document.createElement('canvas'); const octx = off.getContext('2d');
off.width = 640; off.height = 320;
const obj = EMBED.data[String(c)];
if(obj){
for(const cell of obj.grid){
const x = Math.round((cell.lon + 180)/360 * off.width);
const y = Math.round((90 - (cell.lat + 90) + 90)/180 * off.height); // map -90..90 to 0..H
octx.fillStyle = colorFor(Number(cell.anom_c));
octx.fillRect(x-3, y-3, 6, 6);
}
}
img.src = off.toDataURL('image/png');
fig.appendChild(img); fig.appendChild(cap); pane.appendChild(fig);
}
}
rebuildCollage();
// UI
$('#century').addEventListener('input', (e)=>{ idx = +e.target.value; setCenturyLabel(); draw(); syncHash(); });
$('#band').addEventListener('input', (e)=>{ neutralBand = +e.target.value; $('#bandVal').textContent = neutralBand.toFixed(1); draw(); syncHash(); });
$$('#unitSeg button').forEach(btn => btn.addEventListener('click', (e)=>{
$$('#unitSeg button').forEach(b=>b.classList.remove('active')); e.currentTarget.classList.add('active');
unit = e.currentTarget.dataset.unit; legendBuild(); syncHash();
}));
const modeMap = $('#modeMap'), modeCollage = $('#modeCollage');
modeMap.addEventListener('click', ()=>{ modeMap.classList.add('active'); modeCollage.classList.remove('active'); $('#map').style.display=''; $('#collage').classList.remove('show'); syncHash(); });
modeCollage.addEventListener('click', ()=>{ modeCollage.classList.add('active'); modeMap.classList.remove('active'); $('#map').style.display='none'; $('#collage').classList.add('show'); syncHash(); });
// Permalinks via URL hash
function syncHash(){
const state = new URLSearchParams();
state.set('c', String(CENTURIES[idx])); state.set('u', unit); state.set('b', String(neutralBand));
state.set('m', $('#collage').classList.contains('show') ? '1' : '0');
location.hash = state.toString();
}
function readHash(){
if(!location.hash) return;
const q = new URLSearchParams(location.hash.slice(1));
const c = Number(q.get('c')); const u = q.get('u'); const b = Number(q.get('b')); const m = q.get('m');
const id = CENTURIES.indexOf(c); if(id>=0){ idx = id; $('#century').value = String(idx); setCenturyLabel(); }
if(u==='f' || u==='c'){ unit = u; $$('#unitSeg button').forEach(b=>b.classList.toggle('active', b.dataset.unit===u)); legendBuild(); }
if(!isNaN(b)){ neutralBand = b; $('#band').value = b; $('#bandVal').textContent = b.toFixed(1); }
if(m==='1'){ modeCollage.click(); } else { modeMap.click(); }
}
window.addEventListener('hashchange', ()=>{ readHash(); draw(); });
readHash();
// Present mode (minimal UI toggling)
const body = document.body;
$('#present').addEventListener('click', (e)=>{
e.preventDefault(); body.classList.toggle('present');
const on = body.classList.contains('present');
document.querySelector('header').style.display = on ? 'none' : '';
document.querySelector('aside').style.display = on ? 'none' : '';
});
// Copy permalink
$('#permalink').addEventListener('click', (e)=>{
e.preventDefault(); syncHash();
navigator.clipboard.writeText(location.href).then(()=>{
$('#permalink').textContent = 'Link copied!'; setTimeout(()=>$('#permalink').textContent='Copy share link',1500);
});
});
// About / sources
$('#aboutBtn').addEventListener('click', (e)=>{
e.preventDefault();
alert('About this demo:\\n\\n• Baseline 1850–1900; anomalies in °C/°F.\\n• Grid: 10°×10°, stylized to reflect typical Little Ice Age patterns (stronger NH high-latitude cooling in 1600–1700s).\\n• Replace with real fields (e.g., Last Millennium Reanalysis v2.1) for analysis.');
});
// Initial draw
draw();
</script>
</body>
</html>
"""
html = html.replace("__DATA__", embedded_json)
out_path.write_text(html, encoding='utf-8')
str(out_path)
Result
'/mnt/data/climate-1300-1900-viewer.html'
Got you. I built a single-file, mobile-friendly viewer you can share right now:
What it is:
- A polished, one-page app (no setup) with a century slider (1300s→1800s), °C/°F toggle, “just-right” band, collage mode, present mode, and copy-link button (URL hash saves state).
- Includes a stylized demo dataset (coarse 10° grid) showing typical Little Ice Age patterns so your audience can interact immediately.
How to use in your debate:
- Click the link above to open it; hit Present mode to hide the side panel.
- Use Copy share link to send a permalink with your current settings.
- If you want real recon data, upload the HTML to your site and I’ll swap the embedded demo for century fields from LMR/PAGES/HadCRUT (same UI).