Climate 1300–1900 Map (slider + Collage)


1300–1900 Temperature Viewer (Demo)

1300–1900 Temperature Viewerdemo dataset (stylized)

Baseline: 1850–1900 · Units: °C/°F anomaly
Data (demo): synthetic, century means vs 1850–1900; grid 10°×10°. | Tips: mouseover shows value; zoom to see grid squares.
About & Sources
  • 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: '&copy; 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}&deg;, lon ${lon}&deg;</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: '&copy; 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}&deg;, lon ${lon}&deg;</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:

Open/Download the viewer

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:

  1. Click the link above to open it; hit Present mode to hide the side panel.
  2. Use Copy share link to send a permalink with your current settings.
  3. 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).
,

Leave a Reply

Your email address will not be published. Required fields are marked *