{"id":969,"date":"2025-12-14T13:51:19","date_gmt":"2025-12-14T18:51:19","guid":{"rendered":"https:\/\/fixyourowncredit.studio1live.com\/downloads\/?p=969"},"modified":"2025-12-16T22:29:14","modified_gmt":"2025-12-17T03:29:14","slug":"climate-1300-1900-map-slider-collage","status":"publish","type":"post","link":"https:\/\/fixyourowncredit.studio1live.com\/downloads\/climate-1300-1900-map-slider-collage\/","title":{"rendered":"Climate 1300\u20131900 Map (slider + Collage)"},"content":{"rendered":"<body>\n\n\n\n  <meta charset=\"utf-8\">\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n  <title>1300\u20131900 Temperature Viewer (Demo)<\/title>\n  <link rel=\"preconnect\" href=\"https:\/\/unpkg.com\">\n  <link rel=\"stylesheet\" href=\"https:\/\/unpkg.com\/leaflet@1.9.4\/dist\/leaflet.css\">\n  <style>\n    :root{{--bg:#0b1020;--panel:#121a2f;--muted:#9fb0d3;--text:#e8efff;--accent:#4da3ff;}}\n    *{{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}}\n    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}}\n    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}}\n    .wrap{{display:grid;grid-template-columns:340px 1fr;gap:12px;height:calc(100% - 56px);padding:12px}}\n    @media(max-width:1020px){{.wrap{{grid-template-columns:1fr;grid-template-rows:auto 1fr}}}}\n    aside{{background:var(--panel);border:1px solid #1d2540;border-radius:16px;padding:14px;display:flex;flex-direction:column;gap:14px;min-height:0}}\n    .row{{display:flex;align-items:center;gap:10px;flex-wrap:wrap}} h2{{font-size:14px;margin:0;color:#cfe1ff;letter-spacing:.3px}}\n    .box{{background:#0f1730;border:1px solid #1f2a48;border-radius:12px;padding:10px}}\n    .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}}\n    input[type=range]{{width:100%}}\n    .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}}\n    .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}}\n    .map-wrap{{position:relative;background:#060a18;border:1px solid #1d2540;border-radius:16px;overflow:hidden}}\n    #map{{height:100%;min-height:520px}}\n    .overlay-msg{{position:absolute;inset:0;display:grid;place-items:center;pointer-events:none}}\n    .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}}\n    .toolbar{{display:flex;gap:8px;align-items:center;flex-wrap:wrap}}\n    .collage{{display:none;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px}}\n    .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}}\n    footer{{padding:10px 14px;color:#9fb0d3;font-size:12px;display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap}}\n    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}}\n  <\/style>\n\n\n  <header>\n    <h1>1300\u20131900 Temperature Viewer<span class=\"tag\">demo dataset (stylized)<\/span><\/h1>\n    <span class=\"pill\">Baseline: 1850\u20131900 \u00b7 Units: \u00b0C\/\u00b0F anomaly<\/span>\n  <\/header>\n  <div class=\"wrap\">\n    <aside>\n      <div class=\"row\"><h2>Century<\/h2><\/div>\n      <div class=\"box\">\n        <input id=\"century\" type=\"range\" min=\"0\" max=\"5\" step=\"1\" value=\"5\">\n        <div class=\"row\" style=\"justify-content:space-between;margin-top:6px\">\n          <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>\n        <\/div>\n        <div class=\"status\" id=\"centuryLabel\">1800\u20131899<\/div>\n      <\/div>\n\n      <div class=\"row\"><h2>Units<\/h2><\/div>\n      <div class=\"seg\" id=\"unitSeg\">\n        <button data-unit=\"c\" class=\"active\">\u00b0C anomaly<\/button>\n        <button data-unit=\"f\">\u00b0F anomaly<\/button>\n      <\/div>\n\n      <div class=\"row\"><h2>Comfort band<\/h2><\/div>\n      <div class=\"box\">\n        <label class=\"hint\">Neutral band (\u00b1\u00b0C): <strong id=\"bandVal\">0.5<\/strong><\/label>\n        <input id=\"band\" type=\"range\" min=\"0.2\" max=\"1.5\" step=\"0.1\" value=\"0.5\">\n        <div class=\"hint\">Cells between \u2212band and +band highlight as \u201cjust right\u201d.<\/div>\n      <\/div>\n\n      <div class=\"row\"><h2>Mode<\/h2><\/div>\n      <div class=\"seg\">\n        <button id=\"modeMap\" class=\"active\">Map<\/button>\n        <button id=\"modeCollage\">Collage<\/button>\n      <\/div>\n\n      <div class=\"row\"><h2>Share \/ Present<\/h2><\/div>\n      <div class=\"box toolbar\">\n        <a id=\"permalink\" class=\"btn\" href=\"#\">Copy share link<\/a>\n        <a id=\"present\" class=\"btn\" href=\"#\">Present mode<\/a>\n      <\/div>\n\n      <div class=\"row\"><h2>Legend<\/h2><\/div>\n      <div class=\"box\">\n        <div class=\"legend\" id=\"legend\"><\/div>\n        <div class=\"row\" style=\"justify-content:space-between;margin-top:6px\">\n          <span class=\"hint\" id=\"legendMin\"><\/span>\n          <span class=\"hint\">colder \u2194 warmer<\/span>\n          <span class=\"hint\" id=\"legendMax\"><\/span>\n        <\/div>\n      <\/div>\n\n      <div class=\"box hint\">\n        <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\u20131700s, milder 1800s, polar amplification). Replace with real fields from LMR v2.1 \/ PAGES 2k \/ HadCRUT5 for research &amp; debate use.\n      <\/div>\n    <\/aside>\n\n    <main class=\"map-wrap\">\n      <div id=\"map\"><\/div>\n      <section id=\"collage\" class=\"collage\" aria-hidden=\"true\"><\/section>\n    <\/main>\n  <\/div>\n\n  <footer>\n    <div>Data (demo): synthetic, century means vs 1850\u20131900; grid 10\u00b0\u00d710\u00b0. | Tips: mouseover shows value; zoom to see grid squares.<\/div>\n    <div><a class=\"btn\" href=\"#\" id=\"aboutBtn\">About &amp; Sources<\/a><\/div>\n  <\/footer>\n\n  <script src=\"https:\/\/unpkg.com\/leaflet@1.9.4\/dist\/leaflet.js\"><\/script>\n  <script>\n  const CENTURIES = [1300,1400,1500,1600,1700,1800];\n  const EMBED = {data: {json.dumps(DATA)}}; \/\/ embedded demo data\n\n  const $ = s => document.querySelector(s);\n  const $$ = s => Array.from(document.querySelectorAll(s));\n\n  \/\/ Map init\n  const map = L.map('map', {worldCopyJump:true, zoomControl:true});\n  L.tileLayer('https:\/\/{{s}}.tile.openstreetmap.org\/{{z}}\/{{x}}\/{{y}}.png', {attribution: '&copy; OpenStreetMap'}).addTo(map);\n  map.setView([22, 0], 2);\n\n  \/\/ Canvas overlay\n  const overlayPane = map.getPanes().overlayPane;\n  const canvas = L.DomUtil.create('canvas', 'grid-canvas', overlayPane);\n  const ctx = canvas.getContext('2d');\n  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();}}\n  map.on('resize zoom move', resizeCanvas); resizeCanvas();\n\n  let unit = 'c', neutralBand = 0.5, idx = 5; \/\/ defaults to 1800s\n\n  const palette = ['#0b4b9e','#1e66c1','#3e86d6','#6aa5e7','#a8c7f0','#e6eef7','#f7e0da','#f0b9a9','#e2876e','#cc5d3b','#b93a18'];\n  const minC = -2.0, maxC = 2.0;\n  const toF = c => c*9\/5 + 32;\n\n  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]; }\n\n  function legendBuild(){\n    const legend = $('#legend'); legend.innerHTML = '';\n    for(const p of palette){ const sw = document.createElement('div'); sw.className='swatch'; sw.style.background=p; legend.appendChild(sw); }\n    $('#legendMin').textContent = unit==='c' ? `${minC.toFixed(1)}\u00b0C` : `${toF(minC).toFixed(1)}\u00b0F`;\n    $('#legendMax').textContent = unit==='c' ? `${maxC.toFixed(1)}\u00b0C` : `${toF(maxC).toFixed(1)}\u00b0F`;\n  }\n  legendBuild();\n\n  function setCenturyLabel(){ const c = CENTURIES[idx]; $('#centuryLabel').textContent = `${c}\u2013${c+99}`; }\n  setCenturyLabel();\n\n  \/\/ Tooltip\n  const tip = L.tooltip({permanent:false, direction:'top', opacity:0.95});\n  function showTip(lat, lon, anomC, pt){\n    const val = unit==='c' ? `${anomC.toFixed(2)} \u00b0C` : `${toF(anomC).toFixed(2)} \u00b0F`;\n    tip.setLatLng([lat, lon]).setContent(`<strong>${val}<div style='font-size:12px;opacity:.8'>lat ${lat}&deg;, lon ${lon}&deg;`).addTo(map);\n  }\n  map.on('mouseout', ()=>{ map.removeLayer(tip); });\n\n  \/\/ Draw\n  function clear(){ ctx.clearRect(0,0,canvas.width,canvas.height); }\n  function draw(){\n    clear();\n    const c = CENTURIES[idx];\n    const obj = EMBED.data[String(c)];\n    if(!obj) return;\n    const band = neutralBand;\n    const r = Math.max(3, 1.2*(map.getZoom()+1)); \/\/ square half-size\n    for(const cell of obj.grid){\n      const an = Number(cell.anom_c);\n      const ll = L.latLng(cell.lat, cell.lon);\n      const pt = map.latLngToContainerPoint(ll);\n      ctx.globalAlpha = (Math.abs(an) <= band) ? 0.86 : 1.0;\n      ctx.fillStyle = colorFor(an);\n      ctx.fillRect(pt.x - r, pt.y - r, r*2, r*2);\n    }\n    ctx.globalAlpha = 1;\n  }\n  map.on('move zoom', draw);\n\n  \/\/ Hit testing (nearest cell) for tooltip\n  function onMouseMove(e){\n    const c = CENTURIES[idx]; const obj = EMBED.data[String(c)]; if(!obj) return;\n    const mouse = e.containerPoint;\n    let bestD = 9999, best=null;\n    for(const cell of obj.grid){\n      const pt = map.latLngToContainerPoint([cell.lat, cell.lon]);\n      const dx = pt.x - mouse.x, dy = pt.y - mouse.y;\n      const d = dx*dx + dy*dy;\n      if(d < bestD){ bestD = d; best = [cell, pt]; }\n    }\n    if(best &#038;&#038; bestD < 900){ \/\/ within ~30 px\n      const [cell, pt] = best; showTip(cell.lat, cell.lon, Number(cell.anom_c), pt);\n    }\n  }\n  map.on('mousemove', onMouseMove);\n\n  \/\/ Collage (render static canvases per century to <img>)\n  function rebuildCollage(){\n    const pane = $('#collage'); pane.innerHTML='';\n    for(const c of CENTURIES){\n      const fig = document.createElement('figure');\n      const img = document.createElement('img');\n      img.alt = `${c}s temperature anomaly (demo)`;\n      const cap = document.createElement('figcaption'); cap.textContent = `${c}\u2013${c+99}`;\n      \/\/ render offscreen canvas snapshot\n      const off = document.createElement('canvas'); const octx = off.getContext('2d');\n      off.width = 640; off.height = 320;\n      \/\/ simple equirectangular projection preview\n      const obj = EMBED.data[String(c)]; if(obj){\n        for(const cell of obj.grid){\n          const x = Math.round((cell.lon + 180)\/360 * off.width);\n          const y = Math.round((90 - (cell.lat+90))\/-180 * off.height); \/\/ quick & dirty row mapping\n          octx.fillStyle = colorFor(Number(cell.anom_c));\n          octx.fillRect(x-3, y-3, 6, 6);\n        }\n      }\n      img.src = off.toDataURL('image\/png');\n      fig.appendChild(img); fig.appendChild(cap); pane.appendChild(fig);\n    }\n  }\n  rebuildCollage();\n\n  \/\/ UI\n  $('#century').addEventListener('input', (e)=>{ idx = +e.target.value; setCenturyLabel(); draw(); syncHash(); });\n  $('#band').addEventListener('input', (e)=>{ neutralBand = +e.target.value; $('#bandVal').textContent = neutralBand.toFixed(1); draw(); syncHash(); });\n  $$('#unitSeg button').forEach(btn => btn.addEventListener('click', (e)=>{\n    $$('#unitSeg button').forEach(b=>b.classList.remove('active')); e.currentTarget.classList.add('active');\n    unit = e.currentTarget.dataset.unit; legendBuild(); syncHash();\n  }));\n  const modeMap = $('#modeMap'), modeCollage = $('#modeCollage');\n  modeMap.addEventListener('click', ()=>{ modeMap.classList.add('active'); modeCollage.classList.remove('active'); $('#map').style.display=''; $('#collage').classList.remove('show'); });\n  modeCollage.addEventListener('click', ()=>{ modeCollage.classList.add('active'); modeMap.classList.remove('active'); $('#map').style.display='none'; $('#collage').classList.add('show'); });\n\n  \/\/ Permalinks via URL hash\n  function syncHash(){\n    const state = new URLSearchParams();\n    state.set('c', String(CENTURIES[idx])); state.set('u', unit); state.set('b', String(neutralBand));\n    state.set('m', $('#collage').classList.contains('show') ? '1' : '0');\n    location.hash = state.toString();\n  }\n  function readHash(){\n    if(!location.hash) return;\n    const q = new URLSearchParams(location.hash.slice(1));\n    const c = Number(q.get('c')); const u = q.get('u'); const b = Number(q.get('b')); const m = q.get('m');\n    const id = CENTURIES.indexOf(c); if(id>=0){ idx = id; $('#century').value = String(idx); setCenturyLabel(); }\n    if(u==='f' || u==='c'){ unit = u; $$('#unitSeg button').forEach(b=>b.classList.toggle('active', b.dataset.unit===u)); legendBuild(); }\n    if(!isNaN(b)){ neutralBand = b; $('#band').value = b; $('#bandVal').textContent = b.toFixed(1); }\n    if(m==='1'){ modeCollage.click(); } else { modeMap.click(); }\n  }\n  window.addEventListener('hashchange', ()=>{ readHash(); draw(); });\n  readHash();\n\n  \/\/ Present mode (minimal UI toggling)\n  const body = document.body;\n  $('#present').addEventListener('click', (e)=>{\n    e.preventDefault(); body.classList.toggle('present');\n    const on = body.classList.contains('present');\n    document.querySelector('header').style.display = on ? 'none' : '';\n    document.querySelector('aside').style.display = on ? 'none' : '';\n  });\n\n  \/\/ Copy permalink\n  $('#permalink').addEventListener('click', (e)=>{\n    e.preventDefault(); syncHash();\n    navigator.clipboard.writeText(location.href).then(()=>{\n      $('#permalink').textContent = 'Link copied!'; setTimeout(()=>$('#permalink').textContent='Copy share link',1500);\n    });\n  });\n\n  \/\/ About \/ sources\n  $('#aboutBtn').addEventListener('click', (e)=>{\n    e.preventDefault();\n    alert('About this demo:\\\\n\\\\n\u2022 Baseline 1850\u20131900; anomalies in \u00b0C\/\u00b0F.\\\\n\u2022 Grid: 10\u00b0x10\u00b0, stylized to reflect typical Little Ice Age patterns (stronger NH high-latitude cooling in 1600\u20131700s).\\\\n\u2022 Replace with real fields (e.g., Last Millennium Reanalysis v2.1) for analysis.');\n  });\n\n  \/\/ Initial draw\n  draw();\n  <\/script>\n\n\n\n\n\n<ul class=\"wp-block-list\">\n<li>a <strong>world map with a century slider (1300s \u2192 1800s)<\/strong>,<\/li>\n\n\n\n<li><strong>\u00b0C \/ \u00b0F toggle<\/strong> (shows temperature <strong>anomalies<\/strong> in both),<\/li>\n\n\n\n<li>a tunable <strong>\u201cjust right\u201d band<\/strong> to highlight neutral conditions,<\/li>\n\n\n\n<li><strong>collage mode<\/strong> (six big 100-yr snapshots), and<\/li>\n\n\n\n<li>an optional <strong>migration overlay<\/strong> you can switch on\/off.<\/li>\n<\/ul>\n\n\n\n<p>You only need to add the data files it looks for (one JSON per century). Details and the data prep script are below.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\">\n\n\n\n<h1 class=\"wp-block-heading\">What\u2019s in the canvas app<\/h1>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Map mode:<\/strong> draws a colored grid (diverging blue\u2192white\u2192red) from your century JSON on top of an interactive world map. The <strong>slider<\/strong> switches centuries; the <strong>Units<\/strong> toggle shows \u00b0C or \u00b0F (it\u2019s the same anomaly scaled).<\/li>\n\n\n\n<li><strong>Comfort band:<\/strong> controls the \u201cjust right\u201d color emphasis (defaults to \u00b10.5 \u00b0C anomaly).<\/li>\n\n\n\n<li><strong>Collage mode:<\/strong> shows large images (one per century) if you drop PNGs in <code>\/images\/<\/code>.<\/li>\n\n\n\n<li><strong>Optional migration overlay:<\/strong> if you provide <code>\/data\/migrations.geojson<\/code>, it\u2019ll draw routes\/points with popups.<\/li>\n<\/ul>\n\n\n\n<p><strong>Files the page expects<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/data\/century_1300.json\n\/data\/century_1400.json\n\/data\/century_1500.json\n\/data\/century_1600.json\n\/data\/century_1700.json\n\/data\/century_1800.json\n# (optional)\n \/images\/century_1300.png ... \/images\/century_1800.png\n \/data\/migrations.geojson\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\">\n\n\n\n<h1 class=\"wp-block-heading\">Where to get the temperature data (1300\u20131900)<\/h1>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Pre-1850 reconstructions (global, annually resolved, 2\u00b0\u00d72\u00b0 grid):<\/strong><br><strong>Last Millennium Reanalysis (LMR v2.1)<\/strong> \u2014 ensemble mean temperature fields for 1\u20132000 CE, anomalies relative to <strong>1951\u20131980<\/strong>. Download \u201cTwo-meter air temperature, full grid ensemble mean\u201d (NetCDF). <a href=\"https:\/\/atmos.washington.edu\/~hakim\/lmr\/LMRv2\/\" target=\"_blank\" rel=\"noreferrer noopener\">Atmospheric and Climate Science<\/a><\/li>\n\n\n\n<li><strong>Cross-checks &amp; regional context:<\/strong> <strong>PAGES 2k<\/strong> (global multiproxy temperature reconstructions over the Common Era). <a href=\"https:\/\/pastglobalchanges.org\/science\/wg\/2k-network\/Phase_2_Databases\/Global_Temp?utm_source=chatgpt.com\" target=\"_blank\" rel=\"noreferrer noopener\">pastglobalchanges.org+1<\/a><\/li>\n\n\n\n<li><strong>Instrument era (from 1850):<\/strong><br><strong>HadCRUT5<\/strong> (5\u00b0 grid) and <strong>Berkeley Earth<\/strong> (lat-lon grid). Both can help validate your 1800s slice and compute a clean <strong>1850\u20131900 baseline<\/strong> if you prefer. <a href=\"https:\/\/www.metoffice.gov.uk\/hadobs\/hadcrut5\/?utm_source=chatgpt.com\" target=\"_blank\" rel=\"noreferrer noopener\">Met Office<\/a><a href=\"https:\/\/berkeleyearth.org\/data\/?utm_source=chatgpt.com\" target=\"_blank\" rel=\"noreferrer noopener\">Berkeley Earth<\/a><\/li>\n<\/ul>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p>Notes you\u2019ll see when you open LMR v2.1: the files follow NOAA 20CR naming; <strong>all fields are anomalies vs. 1951\u20131980<\/strong> and are provided on a <strong>2\u00b0\u00d72\u00b0<\/strong> grid. We\u2019ll re-baseline them to <strong>1850\u20131900<\/strong> so \u201ctoo cold \/ too warm \/ just right\u201d is intuitive for your 1300\u20131800 comparisons. <a href=\"https:\/\/atmos.washington.edu\/~hakim\/lmr\/LMRv2\/\" target=\"_blank\" rel=\"noreferrer noopener\">Atmospheric and Climate Science<\/a><\/p>\n<\/blockquote>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\">\n\n\n\n<h1 class=\"wp-block-heading\">Make the per-century JSON files (copy\u2013paste script)<\/h1>\n\n\n\n<p>Run this locally (Python 3.10+). It reads LMR v2.1 NetCDF, computes <strong>century means<\/strong> (1300s\u20261800s), re-baselines to <strong>1850\u20131899<\/strong>, and writes the JSON files your map expects<\/p>\n\n\n\n<h1 class=\"wp-block-heading\">Make the per-century JSON files (copy\u2013paste script)<\/h1>\n\n\n\n<p>Run this locally (Python 3.10+). It reads LMR v2.1 NetCDF, computes <strong>century means<\/strong> (1300s\u20261800s), re-baselines to <strong>1850\u20131899<\/strong>, and writes the JSON files your map expects.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># requires: pip install xarray netCDF4 numpy pandas\nimport xarray as xr, numpy as np, json, pathlib\n\n# ---- configure these paths ----\nNC_PATH = \"air_MCruns_ensemble_mean_LMRv2.1.nc\"  # LMR v2.1 2m air temp (ensemble mean)\nOUT_DIR = pathlib.Path(\"data\")                     # same folder your index.html serves \/data from\nOUT_DIR.mkdir(exist_ok=True)\n\n# ---- open the dataset ----\nds = xr.open_dataset(NC_PATH)\n\n# LMR follows NOAA 20CR variable naming; 2m air temp is typically \"air\" (K).\n# Some builds might use \"tas\". Pick whatever exists:\nfor var in [\"air\", \"tas\", \"temp\"]:\n    if var in ds.data_vars: \n        V = var; break\nelse:\n    raise SystemExit(f\"No temperature variable found. Vars: {list(ds.data_vars)}\")\n\nda = ds[V]  # dims usually (time, lat, lon) OR (time, MCrun, lat, lon)\n\n# If MCrun dimension exists, average over it (grand mean per year):\nif \"MCrun\" in da.dims:\n    da = da.mean(\"MCrun\")\n\n# Keep only 1300..1899 and 1850..1899 (for baseline)\nda = da.sel(time=slice(1300, 1899))\n\n# Compute the 1850\u20131899 baseline (Kelvin). (Kelvin deltas == Celsius deltas)\nbaseline = da.sel(time=slice(1850, 1899)).mean(\"time\")\n\n# Helper to dump one century file\ndef write_century(start):\n    end = start + 99\n    slab = da.sel(time=slice(start, end)).mean(\"time\")  # K\n    anom_c = (slab - baseline)  # still in K but equal to \u00b0C anomaly\n    # Build compact grid list\n    out = {\n        \"century\": f\"{start}s\",\n        \"baseline\": \"1850-1900\",\n        \"units\": \"C_anomaly\",\n        \"grid\": []\n    }\n    # iterate with .values to keep it fast\n    lats = anom_c[\"lat\"].values\n    lons = anom_c[\"lon\"].values\n    vals = anom_c.values  # shape (lat, lon)\n    # Optional thinning if you ever need: step = 1 for native 2\u00b0\n    step = 1\n    for i in range(0, len(lats), step):\n        for j in range(0, len(lons), step):\n            v = vals[i, j]\n            if np.isfinite(v):\n                out[\"grid\"].append({\n                    \"lat\": float(lats[i]),\n                    \"lon\": float(lons[j]),\n                    \"anom_c\": float(v)\n                })\n    with open(OUT_DIR \/ f\"century_{start}.json\", \"w\", encoding=\"utf-8\") as f:\n        json.dump(out, f, ensure_ascii=False)\n\nfor c0 in (1300,1400,1500,1600,1700,1800):\n    write_century(c0)\n\nprint(\"Done: wrote \/data\/century_*.json\")\n<\/code><\/pre>\n\n\n\n<blockquote class=\"wp-block-quote is-layout-flow wp-block-quote-is-layout-flow\">\n<p>If you\u2019d rather use <strong>HadCRUT5<\/strong> or <strong>Berkeley Earth<\/strong> for the 1800s, compute the 1850\u20131899 mean and anomalies on their grids and either (a) regrid to 2\u00b0\u00d72\u00b0 to match LMR, or (b) leave their native grid \u2014 the viewer will still render it; it just plots colored squares at each (lat,lon). <a href=\"https:\/\/www.metoffice.gov.uk\/hadobs\/hadcrut5\/?utm_source=chatgpt.com\" target=\"_blank\" rel=\"noreferrer noopener\">Met Office<\/a><a href=\"https:\/\/berkeleyearth.org\/data\/?utm_source=chatgpt.com\" target=\"_blank\" rel=\"noreferrer noopener\">Berkeley Earth<\/a><\/p>\n<\/blockquote>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\">\n\n\n\n<h1 class=\"wp-block-heading\">Optional: migration overlay file<\/h1>\n\n\n\n<p>Add <code>\/data\/migrations.geojson<\/code> (GeoJSON FeatureCollection). Example:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"type\": \"FeatureCollection\",\n  \"features\": [\n    {\n      \"type\":\"Feature\",\n      \"properties\":{\"name\":\"Norse retreat from Greenland\", \"years\":\"c. 1400\u20131500\"},\n      \"geometry\":{\"type\":\"LineString\",\"coordinates\":[[-45,61],[-28,64],[-20,64]]}\n    },\n    {\n      \"type\":\"Feature\",\n      \"properties\":{\"name\":\"Scots-Irish to North America\", \"years\":\"1700s\"},\n      \"geometry\":{\"type\":\"LineString\",\"coordinates\":[[-6,55],[-30,53],[-75,40]]}\n    }\n  ]\n}\n<\/code><\/pre>\n\n\n\n<p>(These are illustrative routes; tune coordinates and labels based on your sources.)<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\">\n\n\n\n<h1 class=\"wp-block-heading\">Suggested folder layout for cPanel<\/h1>\n\n\n\n<pre class=\"wp-block-code\"><code>\/public_html\/climate-map\/\n  index.html                # the canvas file I made\n  \/data\/\n    century_1300.json\n    century_1400.json\n    ...\n    century_1800.json\n    migrations.geojson      # optional\n  \/images\/\n    century_1300.png        # optional collage images you render\/export\n    ...\n    century_1800.png\n<\/code><\/pre>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\">\n\n\n\n<h1 class=\"wp-block-heading\">A few \u201cmigration x climate\u201d pointers (useful when adding overlays)<\/h1>\n\n\n\n<p>Climate often nudged \u2014 not single-handedly \u201ccaused\u201d \u2014 migrations. A few well-cited angles you can annotate:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Little Ice Age background (\u22481300\u20131850):<\/strong> multi-century cooling with regional variability; best seen in proxy-based reconstructions (LMR; PAGES 2k). <a href=\"https:\/\/atmos.washington.edu\/~hakim\/lmr\/LMRv2\/\" target=\"_blank\" rel=\"noreferrer noopener\">Atmospheric and Climate Science<\/a><a href=\"https:\/\/pastglobalchanges.org\/science\/wg\/2k-network\/Phase_2_Databases\/Global_Temp?utm_source=chatgpt.com\" target=\"_blank\" rel=\"noreferrer noopener\">pastglobalchanges.org<\/a><\/li>\n\n\n\n<li><strong>Greenland Norse withdrawal (c. 1400s):<\/strong> recent work argues <strong>rising seas<\/strong> and\/or <strong>drying<\/strong> compounded pressures along with cooling; treat it as multi-factor. <a href=\"https:\/\/news.climate.columbia.edu\/2023\/05\/01\/vikings-abandoned-greenland-centuries-ago-in-face-of-rising-seas-says-new-study\/?utm_source=chatgpt.com\" target=\"_blank\" rel=\"noreferrer noopener\">State of the Planet<\/a><a href=\"https:\/\/eos.org\/articles\/evidence-of-drought-provides-clues-to-a-viking-mystery?utm_source=chatgpt.com\" target=\"_blank\" rel=\"noreferrer noopener\">Eos<\/a><\/li>\n\n\n\n<li><strong>Circum-Caribbean\/Yucat\u00e1n:<\/strong> 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). <a href=\"https:\/\/www.cambridge.org\/core\/journals\/quaternary-research\/article\/climate-change-on-the-yucatan-peninsula-during-the-little-ice-age\/20CA842BA300A4C90DBACB8991633E40?utm_source=chatgpt.com\" target=\"_blank\" rel=\"noreferrer noopener\">Cambridge University Press &amp; Assessment<\/a><a href=\"https:\/\/www.nature.com\/articles\/s41467-022-31522-x?utm_source=chatgpt.com\" target=\"_blank\" rel=\"noreferrer noopener\">Nature<\/a><\/li>\n\n\n\n<li><strong>Europe &amp; the Atlantic world (1500\u20131800):<\/strong> large out-migration from Europe driven mainly by empire, economics, conflict \u2014 with climate shocks occasionally shaping harvests and push\/pull factors. <a href=\"https:\/\/en.wikipedia.org\/wiki\/European_emigration?utm_source=chatgpt.com\" target=\"_blank\" rel=\"noreferrer noopener\">Wikipedia<\/a><a href=\"https:\/\/pressbooks.cuny.edu\/immigrationhistory\/chapter\/chapter-1-european-migrations-before-the-american-revolution\/?utm_source=chatgpt.com\" target=\"_blank\" rel=\"noreferrer noopener\">CUNY Pressbooks<\/a><\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\">\n\n\n\n<h1 class=\"wp-block-heading\">Caveats (so your map tells an honest story)<\/h1>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>These are anomalies vs a baseline<\/strong> (we used 1850\u20131900 here). They\u2019re not literal absolute \u00b0C at each grid cell; anomaly differences <strong>do<\/strong> map cleanly to \u00b0F with the standard conversion. <a href=\"https:\/\/atmos.washington.edu\/~hakim\/lmr\/LMRv2\/\" target=\"_blank\" rel=\"noreferrer noopener\">Atmospheric and Climate Science<\/a><\/li>\n\n\n\n<li><strong>Uncertainty varies by region\/century.<\/strong> 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. <a href=\"https:\/\/cp.copernicus.org\/articles\/15\/1251\/2019\/cp-15-1251-2019.pdf\" target=\"_blank\" rel=\"noreferrer noopener\">Copernicus Publications<\/a><\/li>\n\n\n\n<li><strong>Instrument records<\/strong> (HadCRUT5, Berkeley Earth) begin in <strong>1850<\/strong> (or ~1880 for global completeness). Use them to sanity-check your 1800s panel. <a href=\"https:\/\/www.metoffice.gov.uk\/hadobs\/hadcrut5\/?utm_source=chatgpt.com\" target=\"_blank\" rel=\"noreferrer noopener\">Met Office<\/a><a href=\"https:\/\/earthobservatory.nasa.gov\/world-of-change\/global-temperatures?utm_source=chatgpt.com\" target=\"_blank\" rel=\"noreferrer noopener\">Earth Observatory<\/a><\/li>\n<\/ul>\n\n\n\n<p>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 \u201cbuild\u201d.<\/p>\n\n\n\n<p>Analysis errored<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">Always show details<code># Build a single-file, shareable HTML viewer with an embedded (stylized) demo dataset.\n# The file will be saved to \/mnt\/data and returned as a downloadable link.\nimport json, math, random, textwrap, os, pathlib\n\nout_path = pathlib.Path('\/mnt\/data\/climate-1300-1900-viewer.html')\n\n# Generate a stylized demo dataset (coarse 10\u00b0 grid) that roughly mirrors Little Ice Age patterns:\n# - Stronger NH high-latitude cooling in 1600s\u20131700s, milder in 1300s and recovery into 1800s.\n# - Tropics have smaller anomalies.\n# Values are temperature anomalies relative to 1850\u20131900 baseline (\u00b0C).\nrandom.seed(42)\n\ncenturies = [1300, 1400, 1500, 1600, 1700, 1800]\nlats = list(range(-80, 81, 10))   # -80..80 by 10\nlons = list(range(-180, 181, 10)) # -180..180 by 10\n\ndef century_factor(c):\n    # approximate mean anomaly by century (\u00b0C): more negative around 1600s\u20131700s\n    return {\n        1300: -0.35,\n        1400: -0.50,\n        1500: -0.65,\n        1600: -0.90,\n        1700: -0.80,\n        1800: -0.45,\n    }[c]\n\ndef lat_modifier(lat):\n    # stronger anomalies toward poles; NH slightly stronger than SH\n    # scale from 0 at equator to 1 at poles; use sin^p for shape\n    base = abs(math.sin(math.radians(lat))) ** 0.9\n    hemi = 1.15 if lat &gt; 0 else 1.0\n    return base * hemi\n\ndef region_noise(lat, lon, c):\n    # small spatial structure using simple harmonics (deterministic, repeatable)\n    rl = math.radians(lat)\n    ro = math.radians(lon)\n    cf = (c - 1250) \/ 700.0  # normalized century index\n    return 0.08 * (math.sin(2*rl) * math.cos(3*ro + cf) + 0.5*math.sin(ro*0.7))\n\nDATA = {}\nfor c in centuries:\n    records = []\n    base = century_factor(c)\n    for lat in lats:\n        for lon in lons:\n            # smaller anomalies near tropics; add structure and slight random blur\n            anom = base * (0.35 + 0.65 * lat_modifier(lat)) + region_noise(lat, lon, c)\n            anom += random.uniform(-0.03, 0.03)  # subtle noise\n            records.append({\"lat\": lat, \"lon\": lon, \"anom_c\": round(anom, 3)})\n    DATA[str(c)] = {\n        \"century\": f\"{c}s\",\n        \"baseline\": \"1850-1900\",\n        \"units\": \"C_anomaly\",\n        \"grid\": records\n    }\n\n# Build the HTML with everything inline (Leaflet CDN, simple CSS, JS app, and embedded DATA constant).\nhtml = f\"\"\"&lt;!doctype html&gt;\n&lt;html lang=\"en\"&gt;\n&lt;head&gt;\n  &lt;meta charset=\"utf-8\" \/&gt;\n  &lt;meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" \/&gt;\n  &lt;title&gt;1300\u20131900 Temperature Viewer (Demo)&lt;\/title&gt;\n  &lt;link rel=\"preconnect\" href=\"https:\/\/unpkg.com\" \/&gt;\n  &lt;link rel=\"stylesheet\" href=\"https:\/\/unpkg.com\/leaflet@1.9.4\/dist\/leaflet.css\" \/&gt;\n  &lt;style&gt;\n    :root{{--bg:#0b1020;--panel:#121a2f;--muted:#9fb0d3;--text:#e8efff;--accent:#4da3ff;}}\n    *{{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}}\n    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}}\n    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}}\n    .wrap{{display:grid;grid-template-columns:340px 1fr;gap:12px;height:calc(100% - 56px);padding:12px}}\n    @media(max-width:1020px){{.wrap{{grid-template-columns:1fr;grid-template-rows:auto 1fr}}}}\n    aside{{background:var(--panel);border:1px solid #1d2540;border-radius:16px;padding:14px;display:flex;flex-direction:column;gap:14px;min-height:0}}\n    .row{{display:flex;align-items:center;gap:10px;flex-wrap:wrap}} h2{{font-size:14px;margin:0;color:#cfe1ff;letter-spacing:.3px}}\n    .box{{background:#0f1730;border:1px solid #1f2a48;border-radius:12px;padding:10px}}\n    .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}}\n    input[type=range]{{width:100%}}\n    .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}}\n    .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}}\n    .map-wrap{{position:relative;background:#060a18;border:1px solid #1d2540;border-radius:16px;overflow:hidden}}\n    #map{{height:100%;min-height:520px}}\n    .overlay-msg{{position:absolute;inset:0;display:grid;place-items:center;pointer-events:none}}\n    .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}}\n    .toolbar{{display:flex;gap:8px;align-items:center;flex-wrap:wrap}}\n    .collage{{display:none;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px}}\n    .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}}\n    footer{{padding:10px 14px;color:#9fb0d3;font-size:12px;display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap}}\n    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}}\n  &lt;\/style&gt;\n&lt;\/head&gt;\n&lt;body&gt;\n  &lt;header&gt;\n    &lt;h1&gt;1300\u20131900 Temperature Viewer&lt;span class=\"tag\"&gt;demo dataset (stylized)&lt;\/span&gt;&lt;\/h1&gt;\n    &lt;span class=\"pill\"&gt;Baseline: 1850\u20131900 \u00b7 Units: \u00b0C\/\u00b0F anomaly&lt;\/span&gt;\n  &lt;\/header&gt;\n  &lt;div class=\"wrap\"&gt;\n    &lt;aside&gt;\n      &lt;div class=\"row\"&gt;&lt;h2&gt;Century&lt;\/h2&gt;&lt;\/div&gt;\n      &lt;div class=\"box\"&gt;\n        &lt;input id=\"century\" type=\"range\" min=\"0\" max=\"5\" step=\"1\" value=\"5\" \/&gt;\n        &lt;div class=\"row\" style=\"justify-content:space-between;margin-top:6px\"&gt;\n          &lt;span class=\"hint\"&gt;1300s&lt;\/span&gt;&lt;span class=\"hint\"&gt;1400s&lt;\/span&gt;&lt;span class=\"hint\"&gt;1500s&lt;\/span&gt;&lt;span class=\"hint\"&gt;1600s&lt;\/span&gt;&lt;span class=\"hint\"&gt;1700s&lt;\/span&gt;&lt;span class=\"hint\"&gt;1800s&lt;\/span&gt;\n        &lt;\/div&gt;\n        &lt;div class=\"status\" id=\"centuryLabel\"&gt;1800\u20131899&lt;\/div&gt;\n      &lt;\/div&gt;\n\n      &lt;div class=\"row\"&gt;&lt;h2&gt;Units&lt;\/h2&gt;&lt;\/div&gt;\n      &lt;div class=\"seg\" id=\"unitSeg\"&gt;\n        &lt;button data-unit=\"c\" class=\"active\"&gt;\u00b0C anomaly&lt;\/button&gt;\n        &lt;button data-unit=\"f\"&gt;\u00b0F anomaly&lt;\/button&gt;\n      &lt;\/div&gt;\n\n      &lt;div class=\"row\"&gt;&lt;h2&gt;Comfort band&lt;\/h2&gt;&lt;\/div&gt;\n      &lt;div class=\"box\"&gt;\n        &lt;label class=\"hint\"&gt;Neutral band (\u00b1\u00b0C): &lt;strong id=\"bandVal\"&gt;0.5&lt;\/strong&gt;&lt;\/label&gt;\n        &lt;input id=\"band\" type=\"range\" min=\"0.2\" max=\"1.5\" step=\"0.1\" value=\"0.5\" \/&gt;\n        &lt;div class=\"hint\"&gt;Cells between \u2212band and +band highlight as \u201cjust right\u201d.&lt;\/div&gt;\n      &lt;\/div&gt;\n\n      &lt;div class=\"row\"&gt;&lt;h2&gt;Mode&lt;\/h2&gt;&lt;\/div&gt;\n      &lt;div class=\"seg\"&gt;\n        &lt;button id=\"modeMap\" class=\"active\"&gt;Map&lt;\/button&gt;\n        &lt;button id=\"modeCollage\"&gt;Collage&lt;\/button&gt;\n      &lt;\/div&gt;\n\n      &lt;div class=\"row\"&gt;&lt;h2&gt;Share \/ Present&lt;\/h2&gt;&lt;\/div&gt;\n      &lt;div class=\"box toolbar\"&gt;\n        &lt;a id=\"permalink\" class=\"btn\" href=\"#\"&gt;Copy share link&lt;\/a&gt;\n        &lt;a id=\"present\" class=\"btn\" href=\"#\"&gt;Present mode&lt;\/a&gt;\n      &lt;\/div&gt;\n\n      &lt;div class=\"row\"&gt;&lt;h2&gt;Legend&lt;\/h2&gt;&lt;\/div&gt;\n      &lt;div class=\"box\"&gt;\n        &lt;div class=\"legend\" id=\"legend\"&gt;&lt;\/div&gt;\n        &lt;div class=\"row\" style=\"justify-content:space-between;margin-top:6px\"&gt;\n          &lt;span class=\"hint\" id=\"legendMin\"&gt;&lt;\/span&gt;\n          &lt;span class=\"hint\"&gt;colder \u2194 warmer&lt;\/span&gt;\n          &lt;span class=\"hint\" id=\"legendMax\"&gt;&lt;\/span&gt;\n        &lt;\/div&gt;\n      &lt;\/div&gt;\n\n      &lt;div class=\"box hint\"&gt;\n        &lt;strong&gt;Important:&lt;\/strong&gt; This page embeds a &lt;em&gt;stylized demo dataset&lt;\/em&gt; to illustrate the UI and typical patterns of the Little Ice Age (cooler 1600\u20131700s, milder 1800s, polar amplification). Replace with real fields from LMR v2.1 \/ PAGES 2k \/ HadCRUT5 for research &amp; debate use.\n      &lt;\/div&gt;\n    &lt;\/aside&gt;\n\n    &lt;main class=\"map-wrap\"&gt;\n      &lt;div id=\"map\"&gt;&lt;\/div&gt;\n      &lt;section id=\"collage\" class=\"collage\" aria-hidden=\"true\"&gt;&lt;\/section&gt;\n    &lt;\/main&gt;\n  &lt;\/div&gt;\n\n  &lt;footer&gt;\n    &lt;div&gt;Data (demo): synthetic, century means vs 1850\u20131900; grid 10\u00b0\u00d710\u00b0. | Tips: mouseover shows value; zoom to see grid squares.&lt;\/div&gt;\n    &lt;div&gt;&lt;a class=\"btn\" href=\"#\" id=\"aboutBtn\"&gt;About &amp; Sources&lt;\/a&gt;&lt;\/div&gt;\n  &lt;\/footer&gt;\n\n  &lt;script src=\"https:\/\/unpkg.com\/leaflet@1.9.4\/dist\/leaflet.js\"&gt;&lt;\/script&gt;\n  &lt;script&gt;\n  const CENTURIES = [1300,1400,1500,1600,1700,1800];\n  const EMBED = {data: {json.dumps(DATA)}}; \/\/ embedded demo data\n\n  const $ = s =&gt; document.querySelector(s);\n  const $$ = s =&gt; Array.from(document.querySelectorAll(s));\n\n  \/\/ Map init\n  const map = L.map('map', {worldCopyJump:true, zoomControl:true});\n  L.tileLayer('https:\/\/{{s}}.tile.openstreetmap.org\/{{z}}\/{{x}}\/{{y}}.png', {attribution: '&amp;copy; OpenStreetMap'}).addTo(map);\n  map.setView([22, 0], 2);\n\n  \/\/ Canvas overlay\n  const overlayPane = map.getPanes().overlayPane;\n  const canvas = L.DomUtil.create('canvas', 'grid-canvas', overlayPane);\n  const ctx = canvas.getContext('2d');\n  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();}}\n  map.on('resize zoom move', resizeCanvas); resizeCanvas();\n\n  let unit = 'c', neutralBand = 0.5, idx = 5; \/\/ defaults to 1800s\n\n  const palette = ['#0b4b9e','#1e66c1','#3e86d6','#6aa5e7','#a8c7f0','#e6eef7','#f7e0da','#f0b9a9','#e2876e','#cc5d3b','#b93a18'];\n  const minC = -2.0, maxC = 2.0;\n  const toF = c =&gt; c*9\/5 + 32;\n\n  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]; }\n\n  function legendBuild(){\n    const legend = $('#legend'); legend.innerHTML = '';\n    for(const p of palette){ const sw = document.createElement('div'); sw.className='swatch'; sw.style.background=p; legend.appendChild(sw); }\n    $('#legendMin').textContent = unit==='c' ? `${minC.toFixed(1)}\u00b0C` : `${toF(minC).toFixed(1)}\u00b0F`;\n    $('#legendMax').textContent = unit==='c' ? `${maxC.toFixed(1)}\u00b0C` : `${toF(maxC).toFixed(1)}\u00b0F`;\n  }\n  legendBuild();\n\n  function setCenturyLabel(){ const c = CENTURIES[idx]; $('#centuryLabel').textContent = `${c}\u2013${c+99}`; }\n  setCenturyLabel();\n\n  \/\/ Tooltip\n  const tip = L.tooltip({permanent:false, direction:'top', opacity:0.95});\n  function showTip(lat, lon, anomC, pt){\n    const val = unit==='c' ? `${anomC.toFixed(2)} \u00b0C` : `${toF(anomC).toFixed(2)} \u00b0F`;\n    tip.setLatLng([lat, lon]).setContent(`&lt;strong&gt;${val}&lt;\/strong&gt;&lt;div style='font-size:12px;opacity:.8'&gt;lat ${lat}&amp;deg;, lon ${lon}&amp;deg;&lt;\/div&gt;`).addTo(map);\n  }\n  map.on('mouseout', ()=&gt;{ map.removeLayer(tip); });\n\n  \/\/ Draw\n  function clear(){ ctx.clearRect(0,0,canvas.width,canvas.height); }\n  function draw(){\n    clear();\n    const c = CENTURIES[idx];\n    const obj = EMBED.data[String(c)];\n    if(!obj) return;\n    const band = neutralBand;\n    const r = Math.max(3, 1.2*(map.getZoom()+1)); \/\/ square half-size\n    for(const cell of obj.grid){\n      const an = Number(cell.anom_c);\n      const ll = L.latLng(cell.lat, cell.lon);\n      const pt = map.latLngToContainerPoint(ll);\n      ctx.globalAlpha = (Math.abs(an) &lt;= band) ? 0.86 : 1.0;\n      ctx.fillStyle = colorFor(an);\n      ctx.fillRect(pt.x - r, pt.y - r, r*2, r*2);\n    }\n    ctx.globalAlpha = 1;\n  }\n  map.on('move zoom', draw);\n\n  \/\/ Hit testing (nearest cell) for tooltip\n  function onMouseMove(e){\n    const c = CENTURIES[idx]; const obj = EMBED.data[String(c)]; if(!obj) return;\n    const mouse = e.containerPoint;\n    let bestD = 9999, best=null;\n    for(const cell of obj.grid){\n      const pt = map.latLngToContainerPoint([cell.lat, cell.lon]);\n      const dx = pt.x - mouse.x, dy = pt.y - mouse.y;\n      const d = dx*dx + dy*dy;\n      if(d &lt; bestD){ bestD = d; best = [cell, pt]; }\n    }\n    if(best &amp;&amp; bestD &lt; 900){ \/\/ within ~30 px\n      const [cell, pt] = best; showTip(cell.lat, cell.lon, Number(cell.anom_c), pt);\n    }\n  }\n  map.on('mousemove', onMouseMove);\n\n  \/\/ Collage (render static canvases per century to &lt;img&gt;)\n  function rebuildCollage(){\n    const pane = $('#collage'); pane.innerHTML='';\n    for(const c of CENTURIES){\n      const fig = document.createElement('figure');\n      const img = document.createElement('img');\n      img.alt = `${c}s temperature anomaly (demo)`;\n      const cap = document.createElement('figcaption'); cap.textContent = `${c}\u2013${c+99}`;\n      \/\/ render offscreen canvas snapshot\n      const off = document.createElement('canvas'); const octx = off.getContext('2d');\n      off.width = 640; off.height = 320;\n      \/\/ simple equirectangular projection preview\n      const obj = EMBED.data[String(c)]; if(obj){\n        for(const cell of obj.grid){\n          const x = Math.round((cell.lon + 180)\/360 * off.width);\n          const y = Math.round((90 - (cell.lat+90))\/-180 * off.height); \/\/ quick &amp; dirty row mapping\n          octx.fillStyle = colorFor(Number(cell.anom_c));\n          octx.fillRect(x-3, y-3, 6, 6);\n        }\n      }\n      img.src = off.toDataURL('image\/png');\n      fig.appendChild(img); fig.appendChild(cap); pane.appendChild(fig);\n    }\n  }\n  rebuildCollage();\n\n  \/\/ UI\n  $('#century').addEventListener('input', (e)=&gt;{ idx = +e.target.value; setCenturyLabel(); draw(); syncHash(); });\n  $('#band').addEventListener('input', (e)=&gt;{ neutralBand = +e.target.value; $('#bandVal').textContent = neutralBand.toFixed(1); draw(); syncHash(); });\n  $$('#unitSeg button').forEach(btn =&gt; btn.addEventListener('click', (e)=&gt;{\n    $$('#unitSeg button').forEach(b=&gt;b.classList.remove('active')); e.currentTarget.classList.add('active');\n    unit = e.currentTarget.dataset.unit; legendBuild(); syncHash();\n  }));\n  const modeMap = $('#modeMap'), modeCollage = $('#modeCollage');\n  modeMap.addEventListener('click', ()=&gt;{ modeMap.classList.add('active'); modeCollage.classList.remove('active'); $('#map').style.display=''; $('#collage').classList.remove('show'); });\n  modeCollage.addEventListener('click', ()=&gt;{ modeCollage.classList.add('active'); modeMap.classList.remove('active'); $('#map').style.display='none'; $('#collage').classList.add('show'); });\n\n  \/\/ Permalinks via URL hash\n  function syncHash(){\n    const state = new URLSearchParams();\n    state.set('c', String(CENTURIES[idx])); state.set('u', unit); state.set('b', String(neutralBand));\n    state.set('m', $('#collage').classList.contains('show') ? '1' : '0');\n    location.hash = state.toString();\n  }\n  function readHash(){\n    if(!location.hash) return;\n    const q = new URLSearchParams(location.hash.slice(1));\n    const c = Number(q.get('c')); const u = q.get('u'); const b = Number(q.get('b')); const m = q.get('m');\n    const id = CENTURIES.indexOf(c); if(id&gt;=0){ idx = id; $('#century').value = String(idx); setCenturyLabel(); }\n    if(u==='f' || u==='c'){ unit = u; $$('#unitSeg button').forEach(b=&gt;b.classList.toggle('active', b.dataset.unit===u)); legendBuild(); }\n    if(!isNaN(b)){ neutralBand = b; $('#band').value = b; $('#bandVal').textContent = b.toFixed(1); }\n    if(m==='1'){ modeCollage.click(); } else { modeMap.click(); }\n  }\n  window.addEventListener('hashchange', ()=&gt;{ readHash(); draw(); });\n  readHash();\n\n  \/\/ Present mode (minimal UI toggling)\n  const body = document.body;\n  $('#present').addEventListener('click', (e)=&gt;{\n    e.preventDefault(); body.classList.toggle('present');\n    const on = body.classList.contains('present');\n    document.querySelector('header').style.display = on ? 'none' : '';\n    document.querySelector('aside').style.display = on ? 'none' : '';\n  });\n\n  \/\/ Copy permalink\n  $('#permalink').addEventListener('click', (e)=&gt;{\n    e.preventDefault(); syncHash();\n    navigator.clipboard.writeText(location.href).then(()=&gt;{\n      $('#permalink').textContent = 'Link copied!'; setTimeout(()=&gt;$('#permalink').textContent='Copy share link',1500);\n    });\n  });\n\n  \/\/ About \/ sources\n  $('#aboutBtn').addEventListener('click', (e)=&gt;{\n    e.preventDefault();\n    alert('About this demo:\\\\n\\\\n\u2022 Baseline 1850\u20131900; anomalies in \u00b0C\/\u00b0F.\\\\n\u2022 Grid: 10\u00b0x10\u00b0, stylized to reflect typical Little Ice Age patterns (stronger NH high-latitude cooling in 1600\u20131700s).\\\\n\u2022 Replace with real fields (e.g., Last Millennium Reanalysis v2.1) for analysis.');\n  });\n\n  \/\/ Initial draw\n  draw();\n  &lt;\/script&gt;\n&lt;\/body&gt;\n&lt;\/html&gt;\n\"\"\"\n\nout_path.write_text(html, encoding='utf-8')\nout_path.exists(), str(out_path)\n<\/code><\/pre>\n\n\n\n<pre class=\"wp-block-preformatted\">  Cell In[1], line 186\n    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]; }\n    ^\nSyntaxError: f-string: invalid syntax. Perhaps you forgot a comma?\n\n<\/pre>\n\n\n\n<p>Thought for 9s<\/p>\n\n\n\n<p>Analyzed<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">Always show details<code># Rebuild the HTML with a placeholder for embedded data to avoid f-string brace issues.\nimport json, pathlib, math, random\n\nout_path = pathlib.Path('\/mnt\/data\/climate-1300-1900-viewer.html')\n\n# --- generate stylized demo dataset ---\nrandom.seed(42)\ncenturies = [1300, 1400, 1500, 1600, 1700, 1800]\nlats = list(range(-80, 81, 10))\nlons = list(range(-180, 181, 10))\n\ndef century_factor(c):\n    return {1300:-0.35, 1400:-0.50, 1500:-0.65, 1600:-0.90, 1700:-0.80, 1800:-0.45}[c]\n\ndef lat_modifier(lat):\n    base = abs(math.sin(math.radians(lat))) ** 0.9\n    hemi = 1.15 if lat &gt; 0 else 1.0\n    return base * hemi\n\ndef region_noise(lat, lon, c):\n    rl = math.radians(lat); ro = math.radians(lon)\n    cf = (c - 1250) \/ 700.0\n    return 0.08 * (math.sin(2*rl) * math.cos(3*ro + cf) + 0.5*math.sin(ro*0.7))\n\nDATA = {}\nfor c in centuries:\n    records = []\n    base = century_factor(c)\n    for lat in lats:\n        for lon in lons:\n            anom = base * (0.35 + 0.65 * lat_modifier(lat)) + region_noise(lat, lon, c)\n            anom += random.uniform(-0.03, 0.03)\n            records.append({\"lat\": lat, \"lon\": lon, \"anom_c\": round(anom, 3)})\n    DATA[str(c)] = {\"century\": f\"{c}s\", \"baseline\": \"1850-1900\", \"units\": \"C_anomaly\", \"grid\": records}\n\nembedded_json = json.dumps(DATA, separators=(',', ':'))\n\nhtml = \"\"\"&lt;!doctype html&gt;\n&lt;html lang=\"en\"&gt;\n&lt;head&gt;\n  &lt;meta charset=\"utf-8\" \/&gt;\n  &lt;meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" \/&gt;\n  &lt;title&gt;1300\u20131900 Temperature Viewer (Demo)&lt;\/title&gt;\n  &lt;link rel=\"preconnect\" href=\"https:\/\/unpkg.com\" \/&gt;\n  &lt;link rel=\"stylesheet\" href=\"https:\/\/unpkg.com\/leaflet@1.9.4\/dist\/leaflet.css\" \/&gt;\n  &lt;style&gt;\n    :root{--bg:#0b1020;--panel:#121a2f;--muted:#9fb0d3;--text:#e8efff;--accent:#4da3ff;}\n    *{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}\n    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}\n    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}\n    .wrap{display:grid;grid-template-columns:340px 1fr;gap:12px;height:calc(100% - 56px);padding:12px}\n    @media(max-width:1020px){.wrap{grid-template-columns:1fr;grid-template-rows:auto 1fr}}\n    aside{background:var(--panel);border:1px solid #1d2540;border-radius:16px;padding:14px;display:flex;flex-direction:column;gap:14px;min-height:0}\n    .row{display:flex;align-items:center;gap:10px;flex-wrap:wrap} h2{font-size:14px;margin:0;color:#cfe1ff;letter-spacing:.3px}\n    .box{background:#0f1730;border:1px solid #1f2a48;border-radius:12px;padding:10px}\n    .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}\n    input[type=range]{width:100%}\n    .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}\n    .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}\n    .map-wrap{position:relative;background:#060a18;border:1px solid #1d2540;border-radius:16px;overflow:hidden}\n    #map{height:100%;min-height:520px}\n    .overlay-msg{position:absolute;inset:0;display:grid;place-items:center;pointer-events:none}\n    .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}\n    .toolbar{display:flex;gap:8px;align-items:center;flex-wrap:wrap}\n    .collage{display:none;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:10px}\n    .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}\n    footer{padding:10px 14px;color:#9fb0d3;font-size:12px;display:flex;justify-content:space-between;gap:10px;flex-wrap:wrap}\n    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}\n  &lt;\/style&gt;\n&lt;\/head&gt;\n&lt;body&gt;\n  &lt;header&gt;\n    &lt;h1&gt;1300\u20131900 Temperature Viewer&lt;span class=\"tag\"&gt;demo dataset (stylized)&lt;\/span&gt;&lt;\/h1&gt;\n    &lt;span class=\"pill\"&gt;Baseline: 1850\u20131900 \u00b7 Units: \u00b0C\/\u00b0F anomaly&lt;\/span&gt;\n  &lt;\/header&gt;\n  &lt;div class=\"wrap\"&gt;\n    &lt;aside&gt;\n      &lt;div class=\"row\"&gt;&lt;h2&gt;Century&lt;\/h2&gt;&lt;\/div&gt;\n      &lt;div class=\"box\"&gt;\n        &lt;input id=\"century\" type=\"range\" min=\"0\" max=\"5\" step=\"1\" value=\"5\" \/&gt;\n        &lt;div class=\"row\" style=\"justify-content:space-between;margin-top:6px\"&gt;\n          &lt;span class=\"hint\"&gt;1300s&lt;\/span&gt;&lt;span class=\"hint\"&gt;1400s&lt;\/span&gt;&lt;span class=\"hint\"&gt;1500s&lt;\/span&gt;&lt;span class=\"hint\"&gt;1600s&lt;\/span&gt;&lt;span class=\"hint\"&gt;1700s&lt;\/span&gt;&lt;span class=\"hint\"&gt;1800s&lt;\/span&gt;\n        &lt;\/div&gt;\n        &lt;div class=\"status\" id=\"centuryLabel\"&gt;1800\u20131899&lt;\/div&gt;\n      &lt;\/div&gt;\n\n      &lt;div class=\"row\"&gt;&lt;h2&gt;Units&lt;\/h2&gt;&lt;\/div&gt;\n      &lt;div class=\"seg\" id=\"unitSeg\"&gt;\n        &lt;button data-unit=\"c\" class=\"active\"&gt;\u00b0C anomaly&lt;\/button&gt;\n        &lt;button data-unit=\"f\"&gt;\u00b0F anomaly&lt;\/button&gt;\n      &lt;\/div&gt;\n\n      &lt;div class=\"row\"&gt;&lt;h2&gt;Comfort band&lt;\/h2&gt;&lt;\/div&gt;\n      &lt;div class=\"box\"&gt;\n        &lt;label class=\"hint\"&gt;Neutral band (\u00b1\u00b0C): &lt;strong id=\"bandVal\"&gt;0.5&lt;\/strong&gt;&lt;\/label&gt;\n        &lt;input id=\"band\" type=\"range\" min=\"0.2\" max=\"1.5\" step=\"0.1\" value=\"0.5\" \/&gt;\n        &lt;div class=\"hint\"&gt;Cells between \u2212band and +band highlight as \u201cjust right\u201d.&lt;\/div&gt;\n      &lt;\/div&gt;\n\n      &lt;div class=\"row\"&gt;&lt;h2&gt;Mode&lt;\/h2&gt;&lt;\/div&gt;\n      &lt;div class=\"seg\"&gt;\n        &lt;button id=\"modeMap\" class=\"active\"&gt;Map&lt;\/button&gt;\n        &lt;button id=\"modeCollage\"&gt;Collage&lt;\/button&gt;\n      &lt;\/div&gt;\n\n      &lt;div class=\"row\"&gt;&lt;h2&gt;Share \/ Present&lt;\/h2&gt;&lt;\/div&gt;\n      &lt;div class=\"box toolbar\"&gt;\n        &lt;a id=\"permalink\" class=\"btn\" href=\"#\"&gt;Copy share link&lt;\/a&gt;\n        &lt;a id=\"present\" class=\"btn\" href=\"#\"&gt;Present mode&lt;\/a&gt;\n      &lt;\/div&gt;\n\n      &lt;div class=\"row\"&gt;&lt;h2&gt;Legend&lt;\/h2&gt;&lt;\/div&gt;\n      &lt;div class=\"box\"&gt;\n        &lt;div class=\"legend\" id=\"legend\"&gt;&lt;\/div&gt;\n        &lt;div class=\"row\" style=\"justify-content:space-between;margin-top:6px\"&gt;\n          &lt;span class=\"hint\" id=\"legendMin\"&gt;&lt;\/span&gt;\n          &lt;span class=\"hint\"&gt;colder \u2194 warmer&lt;\/span&gt;\n          &lt;span class=\"hint\" id=\"legendMax\"&gt;&lt;\/span&gt;\n        &lt;\/div&gt;\n      &lt;\/div&gt;\n\n      &lt;div class=\"box hint\"&gt;\n        &lt;strong&gt;Important:&lt;\/strong&gt; This page embeds a &lt;em&gt;stylized demo dataset&lt;\/em&gt; to illustrate the UI and typical patterns of the Little Ice Age (cooler 1600\u20131700s, milder 1800s, polar amplification). Replace with real fields from LMR v2.1 \/ PAGES 2k \/ HadCRUT5 for research &amp; debate use.\n      &lt;\/div&gt;\n    &lt;\/aside&gt;\n\n    &lt;main class=\"map-wrap\"&gt;\n      &lt;div id=\"map\"&gt;&lt;\/div&gt;\n      &lt;section id=\"collage\" class=\"collage\" aria-hidden=\"true\"&gt;&lt;\/section&gt;\n    &lt;\/main&gt;\n  &lt;\/div&gt;\n\n  &lt;footer&gt;\n    &lt;div&gt;Data (demo): synthetic, century means vs 1850\u20131900; grid 10\u00b0\u00d710\u00b0. | Tips: mouseover shows value; zoom to see grid squares.&lt;\/div&gt;\n    &lt;div&gt;&lt;a class=\"btn\" href=\"#\" id=\"aboutBtn\"&gt;About &amp; Sources&lt;\/a&gt;&lt;\/div&gt;\n  &lt;\/footer&gt;\n\n  &lt;script src=\"https:\/\/unpkg.com\/leaflet@1.9.4\/dist\/leaflet.js\"&gt;&lt;\/script&gt;\n  &lt;script&gt;\n  const CENTURIES = [1300,1400,1500,1600,1700,1800];\n  const EMBED = {data: __DATA__}; \/\/ embedded demo data\n\n  const $ = s =&gt; document.querySelector(s);\n  const $$ = s =&gt; Array.from(document.querySelectorAll(s));\n\n  \/\/ Map init\n  const map = L.map('map', {worldCopyJump:true, zoomControl:true});\n  L.tileLayer('https:\/\/{s}.tile.openstreetmap.org\/{z}\/{x}\/{y}.png', {attribution: '&amp;copy; OpenStreetMap'}).addTo(map);\n  map.setView([22, 0], 2);\n\n  \/\/ Canvas overlay\n  const overlayPane = map.getPanes().overlayPane;\n  const canvas = L.DomUtil.create('canvas', 'grid-canvas', overlayPane);\n  const ctx = canvas.getContext('2d');\n  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();}\n  map.on('resize zoom move', resizeCanvas); resizeCanvas();\n\n  let unit = 'c', neutralBand = 0.5, idx = 5; \/\/ defaults to 1800s\n\n  const palette = ['#0b4b9e','#1e66c1','#3e86d6','#6aa5e7','#a8c7f0','#e6eef7','#f7e0da','#f0b9a9','#e2876e','#cc5d3b','#b93a18'];\n  const minC = -2.0, maxC = 2.0;\n  const toF = c =&gt; c*9\/5 + 32;\n\n  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]; }\n\n  function legendBuild(){\n    const legend = $('#legend'); legend.innerHTML = '';\n    for(const p of palette){ const sw = document.createElement('div'); sw.className='swatch'; sw.style.background=p; legend.appendChild(sw); }\n    $('#legendMin').textContent = unit==='c' ? `${minC.toFixed(1)}\u00b0C` : `${toF(minC).toFixed(1)}\u00b0F`;\n    $('#legendMax').textContent = unit==='c' ? `${maxC.toFixed(1)}\u00b0C` : `${toF(maxC).toFixed(1)}\u00b0F`;\n  }\n  legendBuild();\n\n  function setCenturyLabel(){ const c = CENTURIES[idx]; $('#centuryLabel').textContent = `${c}\u2013${c+99}`; }\n  setCenturyLabel();\n\n  \/\/ Tooltip\n  const tip = L.tooltip({permanent:false, direction:'top', opacity:0.95});\n  function showTip(lat, lon, anomC){\n    const val = unit==='c' ? `${anomC.toFixed(2)} \u00b0C` : `${toF(anomC).toFixed(2)} \u00b0F`;\n    tip.setLatLng([lat, lon]).setContent(`&lt;strong&gt;${val}&lt;\/strong&gt;&lt;div style='font-size:12px;opacity:.8'&gt;lat ${lat}&amp;deg;, lon ${lon}&amp;deg;&lt;\/div&gt;`).addTo(map);\n  }\n  map.on('mouseout', ()=&gt;{ map.removeLayer(tip); });\n\n  \/\/ Draw\n  function clear(){ ctx.clearRect(0,0,canvas.width,canvas.height); }\n  function draw(){\n    clear();\n    const c = CENTURIES[idx];\n    const obj = EMBED.data[String(c)];\n    if(!obj) return;\n    const band = neutralBand;\n    const r = Math.max(3, 1.2*(map.getZoom()+1)); \/\/ square half-size\n    for(const cell of obj.grid){\n      const an = Number(cell.anom_c);\n      const ll = L.latLng(cell.lat, cell.lon);\n      const pt = map.latLngToContainerPoint(ll);\n      ctx.globalAlpha = (Math.abs(an) &lt;= band) ? 0.86 : 1.0;\n      ctx.fillStyle = colorFor(an);\n      ctx.fillRect(pt.x - r, pt.y - r, r*2, r*2);\n    }\n    ctx.globalAlpha = 1;\n  }\n  map.on('move zoom', draw);\n\n  \/\/ Hit testing (nearest cell) for tooltip\n  function onMouseMove(e){\n    const c = CENTURIES[idx]; const obj = EMBED.data[String(c)]; if(!obj) return;\n    const mouse = e.containerPoint;\n    let bestD = 9999, best=null;\n    for(const cell of obj.grid){\n      const pt = map.latLngToContainerPoint([cell.lat, cell.lon]);\n      const dx = pt.x - mouse.x, dy = pt.y - mouse.y;\n      const d = dx*dx + dy*dy;\n      if(d &lt; bestD){ bestD = d; best = cell; }\n    }\n    if(best &amp;&amp; bestD &lt; 900){ \/\/ ~30 px\n      showTip(best.lat, best.lon, Number(best.anom_c));\n    }\n  }\n  map.on('mousemove', onMouseMove);\n\n  \/\/ Collage (simple snapshots per century)\n  function rebuildCollage(){\n    const pane = $('#collage'); pane.innerHTML='';\n    for(const c of CENTURIES){\n      const fig = document.createElement('figure');\n      const img = document.createElement('img');\n      img.alt = `${c}s temperature anomaly (demo)`;\n      const cap = document.createElement('figcaption'); cap.textContent = `${c}\u2013${c+99}`;\n      const off = document.createElement('canvas'); const octx = off.getContext('2d');\n      off.width = 640; off.height = 320;\n      const obj = EMBED.data[String(c)];\n      if(obj){\n        for(const cell of obj.grid){\n          const x = Math.round((cell.lon + 180)\/360 * off.width);\n          const y = Math.round((90 - (cell.lat + 90) + 90)\/180 * off.height); \/\/ map -90..90 to 0..H\n          octx.fillStyle = colorFor(Number(cell.anom_c));\n          octx.fillRect(x-3, y-3, 6, 6);\n        }\n      }\n      img.src = off.toDataURL('image\/png');\n      fig.appendChild(img); fig.appendChild(cap); pane.appendChild(fig);\n    }\n  }\n  rebuildCollage();\n\n  \/\/ UI\n  $('#century').addEventListener('input', (e)=&gt;{ idx = +e.target.value; setCenturyLabel(); draw(); syncHash(); });\n  $('#band').addEventListener('input', (e)=&gt;{ neutralBand = +e.target.value; $('#bandVal').textContent = neutralBand.toFixed(1); draw(); syncHash(); });\n  $$('#unitSeg button').forEach(btn =&gt; btn.addEventListener('click', (e)=&gt;{\n    $$('#unitSeg button').forEach(b=&gt;b.classList.remove('active')); e.currentTarget.classList.add('active');\n    unit = e.currentTarget.dataset.unit; legendBuild(); syncHash();\n  }));\n  const modeMap = $('#modeMap'), modeCollage = $('#modeCollage');\n  modeMap.addEventListener('click', ()=&gt;{ modeMap.classList.add('active'); modeCollage.classList.remove('active'); $('#map').style.display=''; $('#collage').classList.remove('show'); syncHash(); });\n  modeCollage.addEventListener('click', ()=&gt;{ modeCollage.classList.add('active'); modeMap.classList.remove('active'); $('#map').style.display='none'; $('#collage').classList.add('show'); syncHash(); });\n\n  \/\/ Permalinks via URL hash\n  function syncHash(){\n    const state = new URLSearchParams();\n    state.set('c', String(CENTURIES[idx])); state.set('u', unit); state.set('b', String(neutralBand));\n    state.set('m', $('#collage').classList.contains('show') ? '1' : '0');\n    location.hash = state.toString();\n  }\n  function readHash(){\n    if(!location.hash) return;\n    const q = new URLSearchParams(location.hash.slice(1));\n    const c = Number(q.get('c')); const u = q.get('u'); const b = Number(q.get('b')); const m = q.get('m');\n    const id = CENTURIES.indexOf(c); if(id&gt;=0){ idx = id; $('#century').value = String(idx); setCenturyLabel(); }\n    if(u==='f' || u==='c'){ unit = u; $$('#unitSeg button').forEach(b=&gt;b.classList.toggle('active', b.dataset.unit===u)); legendBuild(); }\n    if(!isNaN(b)){ neutralBand = b; $('#band').value = b; $('#bandVal').textContent = b.toFixed(1); }\n    if(m==='1'){ modeCollage.click(); } else { modeMap.click(); }\n  }\n  window.addEventListener('hashchange', ()=&gt;{ readHash(); draw(); });\n  readHash();\n\n  \/\/ Present mode (minimal UI toggling)\n  const body = document.body;\n  $('#present').addEventListener('click', (e)=&gt;{\n    e.preventDefault(); body.classList.toggle('present');\n    const on = body.classList.contains('present');\n    document.querySelector('header').style.display = on ? 'none' : '';\n    document.querySelector('aside').style.display = on ? 'none' : '';\n  });\n\n  \/\/ Copy permalink\n  $('#permalink').addEventListener('click', (e)=&gt;{\n    e.preventDefault(); syncHash();\n    navigator.clipboard.writeText(location.href).then(()=&gt;{\n      $('#permalink').textContent = 'Link copied!'; setTimeout(()=&gt;$('#permalink').textContent='Copy share link',1500);\n    });\n  });\n\n  \/\/ About \/ sources\n  $('#aboutBtn').addEventListener('click', (e)=&gt;{\n    e.preventDefault();\n    alert('About this demo:\\\\n\\\\n\u2022 Baseline 1850\u20131900; anomalies in \u00b0C\/\u00b0F.\\\\n\u2022 Grid: 10\u00b0\u00d710\u00b0, stylized to reflect typical Little Ice Age patterns (stronger NH high-latitude cooling in 1600\u20131700s).\\\\n\u2022 Replace with real fields (e.g., Last Millennium Reanalysis v2.1) for analysis.');\n  });\n\n  \/\/ Initial draw\n  draw();\n  &lt;\/script&gt;\n&lt;\/body&gt;\n&lt;\/html&gt;\n\"\"\"\n\nhtml = html.replace(\"__DATA__\", embedded_json)\nout_path.write_text(html, encoding='utf-8')\nstr(out_path)\n<\/code><\/pre>\n\n\n\n<p>Result<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">'\/mnt\/data\/climate-1300-1900-viewer.html'<\/pre>\n\n\n\n<p>Got you. I built a single-file, mobile-friendly <strong>viewer<\/strong> you can share right now:<\/p>\n\n\n\n<p><strong><a>Open\/Download the viewer<\/a><\/strong><\/p>\n\n\n\n<p>What it is:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>A polished, one-page app (no setup) with a <strong>century slider (1300s\u21921800s)<\/strong>, <strong>\u00b0C\/\u00b0F toggle<\/strong>, <strong>\u201cjust-right\u201d band<\/strong>, <strong>collage mode<\/strong>, <strong>present mode<\/strong>, and <strong>copy-link<\/strong> button (URL hash saves state).<\/li>\n\n\n\n<li>Includes a <strong>stylized demo dataset<\/strong> (coarse 10\u00b0 grid) showing typical Little Ice Age patterns so your audience can interact immediately.<\/li>\n<\/ul>\n\n\n\n<p>How to use in your debate:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>Click the link above to open it; hit <strong>Present mode<\/strong> to hide the side panel.<\/li>\n\n\n\n<li>Use <strong>Copy share link<\/strong> to send a permalink with your current settings.<\/li>\n\n\n\n<li>If you want <em>real recon data<\/em>, upload the HTML to your site and I\u2019ll swap the embedded demo for century fields from LMR\/PAGES\/HadCRUT (same UI).<\/li>\n<\/ol>\n\n\n\n<!-- ========================= -->\n<!-- EDUCATIONAL RESOURCES & TOOLS -->\n<!-- ========================= -->\n<section class=\"s1l-blog-footer\">\n  <hr>\n\n  <h2>Learn More &amp; Take Action<\/h2>\n\n  <p>\n    Understanding money, credit, and financial systems is the first step toward\n    reclaiming economic power. If you found this breakdown helpful, explore the\n    tools and resources below to continue building financial literacy, credit\n    strength, and digital independence.\n  <\/p>\n\n  <ul class=\"s1l-resource-links\">\n    <li>\n      \ud83d\udd39 <a href=\"https:\/\/studio1live.com\/templates\/\" target=\"_blank\" rel=\"noopener\">\n        Studio1Live Templates Hub\n      <\/a><br>\n      <small>Ready-to-use website templates, tools, and digital builds for businesses,\n      creators, and entrepreneurs.<\/small>\n    <\/li>\n\n    <li>\n      \ud83d\udd39 <a href=\"https:\/\/studio1live.com\/templates\/blog\" target=\"_blank\" rel=\"noopener\">\n        Studio1Live Blog Templates\n      <\/a><br>\n      <small>Pre-built blog and content layouts designed for SEO, authority, and monetization.<\/small>\n    <\/li>\n\n    <li>\n      \ud83d\udd39 <a href=\"https:\/\/studio1live.com\" target=\"_blank\" rel=\"noopener\">\n        Studio1Live.com\n      <\/a><br>\n      <small>The main hub for digital tools, educational resources, and independent platforms.<\/small>\n    <\/li>\n\n    <li>\n      \ud83d\udd39 <a href=\"https:\/\/studio1live.com\/legal\" target=\"_blank\" rel=\"noopener\">\n        Legal AI Coach\n      <\/a><br>\n      <small>Educational legal tools and document guidance for self-advocacy and compliance.<\/small>\n    <\/li>\n\n    <li>\n      \ud83d\udd39 <a href=\"https:\/\/studio1live.com\/picks\" target=\"_blank\" rel=\"noopener\">\n        Studio1Live Picks &amp; Data Tools\n      <\/a><br>\n      <small>Data-driven insights, analytics, and independent research tools.<\/small>\n    <\/li>\n  <\/ul>\n\n  <p>\n    <strong>Disclaimer:<\/strong> This content is for educational purposes only and does not\n    constitute financial or legal advice. Always conduct your own research or consult\n    qualified professionals before making financial decisions.\n  <\/p>\n\n  <p class=\"s1l-signoff\">\n    \u270d\ufe0f Published by <strong>Studio1Live<\/strong> \u2014 building tools, knowledge, and systems\n    for economic independence.\n  <\/p>\n<\/section>\n<\/body>","protected":false},"excerpt":{"rendered":"<p>1300\u20131900 Temperature Viewer (Demo) 1300\u20131900 Temperature Viewerdemo dataset (stylized) Baseline: 1850\u20131900 \u00b7 Units: \u00b0C\/\u00b0F anomaly Century 1300s1400s1500s1600s1700s1800s 1800\u20131899 Units \u00b0C anomaly \u00b0F anomaly Comfort band Neutral band (\u00b1\u00b0C): 0.5 Cells between \u2212band and +band highlight as \u201cjust right\u201d. Mode Map Collage Share \/ Present Copy share link Present mode Legend colder \u2194 warmer Important: This &hellip; <\/p>\n<p class=\"link-more\"><a href=\"https:\/\/fixyourowncredit.studio1live.com\/downloads\/climate-1300-1900-map-slider-collage\/\" class=\"more-link\">Continue reading<span class=\"screen-reader-text\"> &#8220;Climate 1300\u20131900 Map (slider + Collage)&#8221;<\/span><\/a><\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"om_disable_all_campaigns":false,"_monsterinsights_skip_tracking":false,"_monsterinsights_sitenote_active":false,"_monsterinsights_sitenote_note":"","_monsterinsights_sitenote_category":0,"_jetpack_memberships_contains_paid_content":false,"footnotes":""},"categories":[71,68],"tags":[],"class_list":["post-969","post","type-post","status-publish","format-standard","hentry","category-history","category-how-to-build","entry"],"aioseo_notices":[],"jetpack_featured_media_url":"","jetpack_likes_enabled":true,"jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/fixyourowncredit.studio1live.com\/downloads\/wp-json\/wp\/v2\/posts\/969","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/fixyourowncredit.studio1live.com\/downloads\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/fixyourowncredit.studio1live.com\/downloads\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/fixyourowncredit.studio1live.com\/downloads\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/fixyourowncredit.studio1live.com\/downloads\/wp-json\/wp\/v2\/comments?post=969"}],"version-history":[{"count":4,"href":"https:\/\/fixyourowncredit.studio1live.com\/downloads\/wp-json\/wp\/v2\/posts\/969\/revisions"}],"predecessor-version":[{"id":1128,"href":"https:\/\/fixyourowncredit.studio1live.com\/downloads\/wp-json\/wp\/v2\/posts\/969\/revisions\/1128"}],"wp:attachment":[{"href":"https:\/\/fixyourowncredit.studio1live.com\/downloads\/wp-json\/wp\/v2\/media?parent=969"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/fixyourowncredit.studio1live.com\/downloads\/wp-json\/wp\/v2\/categories?post=969"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/fixyourowncredit.studio1live.com\/downloads\/wp-json\/wp\/v2\/tags?post=969"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}