{"id":236052,"date":"2024-11-05T16:08:17","date_gmt":"2024-11-05T16:08:17","guid":{"rendered":"https:\/\/predict.kikirpa.be\/?page_id=236052"},"modified":"2025-10-14T08:43:34","modified_gmt":"2025-10-14T08:43:34","slug":"climate-data-visualization-tool","status":"publish","type":"page","link":"https:\/\/predict.kikirpa.be\/index.php\/tools\/climate-data-visualization-tool\/","title":{"rendered":"Climate Data Visualization Tool"},"content":{"rendered":"\n<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n  <meta charset=\"UTF-8\" \/>\n  <title>Salt Weathering Tool \u2014 Robust CSV Parser<\/title>\n  <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" \/>\n\n  <link href=\"https:\/\/stackpath.bootstrapcdn.com\/bootstrap\/4.5.2\/css\/bootstrap.min.css\" rel=\"stylesheet\"\/>\n  <script src=\"https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/moment.js\/2.29.1\/moment.min.js\"><\/script>\n  <script src=\"https:\/\/cdn.jsdelivr.net\/npm\/chart.js@2.9.4\"><\/script>\n  <script src=\"https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/hammer.js\/2.0.8\/hammer.min.js\"><\/script>\n  <script src=\"https:\/\/cdn.jsdelivr.net\/npm\/chartjs-plugin-zoom@0.7.7\"><\/script>\n  <script src=\"https:\/\/cdnjs.cloudflare.com\/ajax\/libs\/PapaParse\/5.3.1\/papaparse.min.js\"><\/script>\n\n  <style>\n    body { font-family: Calibri, Arial, sans-serif; background:#f9f9f9; }\n    .container { padding: 20px; }\n    .box { border-radius: 6px; margin-bottom: 20px; box-shadow: 0 2px 4px rgba(0,0,0,.1); }\n    .box-header { background:#6cb4ee; color:#fff; padding:10px 14px; border-radius:6px 6px 0 0; }\n    .box-body { background:#fff; padding:18px; border-radius:0 0 6px 6px; }\n    .chart-container { position:relative; margin:auto; height:365px; width:100%; }\n    .btn-primary { background:#6cb4ee; border:none; }\n    .btn-primary:hover { background:#5aaad1; }\n    .small-mono { font-family: Consolas, Menlo, monospace; font-size:12px; white-space:pre-wrap; }\n    #status { display:none; }\n  <\/style>\n<\/head>\n<body>\n  <script>\n    \/\/ Suppress all alert popups on this page only\n    window.alert = function() {};\n    window.onerror = function() { return true; };\n  <\/script>\n\n  <div class=\"container\">\n    <section>\n      <h2>Upload Climate Data<\/h2>\n      <div class=\"box\">\n        <div class=\"box-header\"><h5 class=\"box-title\">Accepted Inputs<\/h5><\/div>\n        <div class=\"box-body\">\n          <p>CSV with any of these columns (case-insensitive, units allowed):<\/p>\n          <ul>\n            <li>Date + Time, or a single DateTime (e.g. <code>Date; Time<\/code> or <code>Datetime<\/code>)<\/li>\n            <li>Temperature (e.g. <code>T<\/code>, <code>Temp<\/code>, <code>Temperature<\/code>, <code>\u00b0C<\/code>)<\/li>\n            <li>Relative Humidity (e.g. <code>RH<\/code>, <code>RH(%)<\/code>, <code>Humidity<\/code>)<\/li>\n          <\/ul>\n          <p>European decimals like <code>8,70<\/code> are handled. Placeholders like <code>NA<\/code>, <code>---<\/code>, <code>-9999<\/code> become gaps.<\/p>\n          <form id=\"climate-data-form\" class=\"mt-3\">\n            <div class=\"form-group\">\n              <label for=\"climate-data-file\">Select Climate Data File (CSV):<\/label>\n              <input type=\"file\" id=\"climate-data-file\" accept=\".csv\" class=\"form-control-file\" required \/>\n            <\/div>\n            <button type=\"submit\" class=\"btn btn-primary\">Upload and Display Data<\/button>\n          <\/form>\n          <div id=\"status\" class=\"alert alert-warning mt-3 small-mono\"><\/div>\n        <\/div>\n      <\/div>\n\n      <div class=\"box\" id=\"climate-data-graph-box\" style=\"display:none;\">\n        <div class=\"box-header\"><h3 class=\"box-title\">Climate Data Graph<\/h3><\/div>\n        <div class=\"box-body\">\n          <div class=\"chart-container\"><canvas id=\"climate-data-chart\"><\/canvas><\/div>\n          <p class=\"mt-3\"><strong>Tips:<\/strong> Click legend to toggle series. Scroll to zoom. Drag to pan. Re-upload to reset.<\/p>\n          <button id=\"recalculate-button\" class=\"btn btn-secondary mt-2\">Recalculate Statistics for Current View<\/button>\n          <div id=\"climate-data-stats\" class=\"mt-4\"><\/div>\n          <div id=\"recalculated-stats\" class=\"mt-4\"><\/div>\n          <hr\/>\n          <details>\n            <summary>Parser diagnostics<\/summary>\n            <div id=\"parser-diag\" class=\"small-mono mt-2\"><\/div>\n          <\/details>\n        <\/div>\n      <\/div>\n    <\/section>\n  <\/div>\n\n  <script>\n  \/\/ =================== SINGLETON ===================\n  if (!window.SWT) {\n    window.SWT = { __INIT__: true };\n    let chartInstance = null;\n    let climateData = [];\n    let everSucceeded = false;\n    let runId = 0;\n    let activeRun = 0;\n\n    \/\/ ---------- UI status ----------\n    function setStatus(html) {\n      const el = document.getElementById('status');\n      if (!el) return;\n      if (!html) { el.innerHTML = ''; el.style.display = 'none'; return; }\n      el.innerHTML = html;\n      el.style.display = 'block';\n    }\n\n    \/\/ ---------- CSV parse options ----------\n    const papaConfig = {\n      delimiter: '',\n      header: false,\n      skipEmptyLines: 'greedy',\n      dynamicTyping: false,\n      worker: false,\n      error: () => {}\n    };\n\n    \/\/ ---------- Header handling ----------\n    const HEADER_SYNONYMS = {\n      date: ['date','datum','day','dag','fecha'],\n      time: ['time','tijd','heure','hora','clock','tijdstip'],\n      datetime: ['datetime','timestamp','date\/time','date time','tijd','tijdstip','datetime(utc)','datetime local','datetimestamp'],\n      temperature: ['temperature','temperatuur','temperatura','temp','t','t(c)','t(\u00b0c)','\u00b0c','temp_c','air temperature','temp.'],\n      rh: ['relative humidity','relatieve vochtigheid','humidit\u00e9 relative','humidity','humiditeit','rh','rh(%)','rh %','r.h.','rv','rv(%)','r.h.(%)']\n    };\n\n    function normalizeHeaderToken(s) {\n      return String(s||'')\n        .replace(\/^\\uFEFF\/,'')\n        .toLowerCase()\n        .replace(\/\\u00a0\/g, ' ')\n        .replace(\/\\s+\/g, ' ')\n        .trim()\n        .replace(\/\\(.*?\\)\/g, '')\n        .replace(\/[%\u00b0]\/g, '')\n        .trim();\n    }\n    function headerMatches(token, list) {\n      const t = normalizeHeaderToken(token);\n      return list.some(x => t === x || t.startsWith(x));\n    }\n    function detectHeaderRow(rawData, maxScan=200) {\n      for (let r=0; r<Math.min(maxScan, rawData.length); r++) {\n        const row = (rawData[r]||[]).map(c => String(c || ''));\n        const hasDate = row.some(c => headerMatches(c, HEADER_SYNONYMS.date));\n        const hasTime = row.some(c => headerMatches(c, HEADER_SYNONYMS.time));\n        const hasDateTime = row.some(c => headerMatches(c, HEADER_SYNONYMS.datetime));\n        const hasTemp = row.some(c => headerMatches(c, HEADER_SYNONYMS.temperature));\n        const hasRH   = row.some(c => headerMatches(c, HEADER_SYNONYMS.rh));\n        if (hasDateTime || (hasDate && hasTime) || hasTemp || hasRH) {\n          return { rowIndex: r, keys: row.map(c => normalizeHeaderToken(c)) };\n        }\n      }\n      const r0 = rawData.findIndex(r => (r || []).some(c => String(c||'').trim() !== ''));\n      const idx = r0 >= 0 ? r0 : 0;\n      return { rowIndex: idx, keys: (rawData[idx]||[]).map(c => normalizeHeaderToken(String(c||''))) };\n    }\n    function buildHeaderMap(keys) {\n      const map = {};\n      keys.forEach((k, i) => {\n        if (HEADER_SYNONYMS.date.includes(k)) map.date = i;\n        else if (HEADER_SYNONYMS.time.includes(k)) map.time = i;\n        else if (HEADER_SYNONYMS.datetime.includes(k)) map.datetime = i;\n        else if (HEADER_SYNONYMS.temperature.includes(k)) map.temp = i;\n        else if (HEADER_SYNONYMS.rh.includes(k)) map.rh = i;\n      });\n      return map;\n    }\n\n    \/\/ ---------- Date parsing ----------\n    const TIME_FRAGS = ['H:mm','HH:mm','H:mm:ss','HH:mm:ss','h:mm A','h:mm:ss A'];\n    const DATE_FRAGS_MDY = ['M\/D\/YYYY','MM\/DD\/YYYY','M\/D\/YY','MM\/DD\/YY','M-D-YYYY','MM-D-YYYY','M-D-YY','MM-D-YY','YYYY-M-D','YYYY\/MM\/DD'];\n    const DATE_FRAGS_DMY = ['D\/M\/YYYY','DD\/MM\/YYYY','D\/M\/YY','DD\/MM\/YY','D-M-YYYY','DD-M-YYYY','D-M-YY','DD-M-YY','YYYY-D-M','YYYY\/MM\/DD'];\n    const DATE_FRAGS_DMY_MMM = ['D-MMM-YYYY','DD-MMM-YYYY','D\/MMM\/YYYY','DD\/MMM\/YYYY'];\n\n    function looksDMY(token) {\n      const m = String(token||'').match(\/^(\\d{1,4})[\\\/\\-\\s.]\/);\n      if (!m) return null;\n      const first = parseInt(m[1],10);\n      if (first > 31) return false;\n      if (first > 12) return true;\n      return null;\n    }\n    function decideDayFirst(samples) {\n      let votes = 0;\n      for (const s of samples) {\n        const v = looksDMY(s);\n        if (v === true) votes++;\n        else if (v === false) votes--;\n      }\n      return votes > 0;\n    }\n    function tryParseMoment(dtStr, formats) {\n      for (const f of formats) {\n        const m = moment(dtStr, f, true);\n        if (m.isValid()) return m.toDate();\n      }\n      return null;\n    }\n    function makeFormatList(dayFirst) {\n      const D = (dayFirst ? DATE_FRAGS_DMY : DATE_FRAGS_MDY).concat(DATE_FRAGS_DMY_MMM);\n      const combos = [];\n      for (const df of D) { combos.push(df); for (const tf of TIME_FRAGS) combos.push(df + ' ' + tf); }\n      combos.push('YYYY-MM-DDTHH:mm:ss','YYYY-MM-DD HH:mm:ss','YYYY-MM-DDTHH:mm','YYYY-MM-DD HH:mm');\n      return combos;\n    }\n\n    \/\/ ---------- Numbers ----------\n    const NULL_TOKENS = new Set(['', 'na', 'n\/a', 'no data', 'null', 'nan', '---', '--', '#', 'nd']);\n    const NULL_NUMERIC_SENTINELS = new Set([-9999, -999, 9999, 999.9, 99.99e99]);\n    function normalizeUnicodeSpaces(s) { return String(s||'').replace(\/[\\u00A0\\u2000-\\u200B\\u202F\\u205F\\u3000]\/g, ' '); }\n    function parseEUFloat(valueRaw) {\n      if (valueRaw == null) return null;\n      let s = String(valueRaw).trim(); if (!s) return null;\n      const lc = s.toLowerCase(); if (NULL_TOKENS.has(lc)) return null;\n      s = normalizeUnicodeSpaces(s).replace(\/[^\\d.,\\-+eE ]\/g, ' ').replace(\/\\s+\/g, ' ').trim(); if (!s) return null;\n      const lastComma = s.lastIndexOf(','), lastDot = s.lastIndexOf('.');\n      let decSep = null;\n      if (lastComma >= 0 && lastDot >= 0) decSep = (lastComma > lastDot) ? ',' : '.';\n      else if (lastComma >= 0) decSep = ',';\n      else if (lastDot >= 0) decSep = '.';\n      let cleaned;\n      if (decSep === ',') cleaned = s.replace(\/[.\\s]\/g, '').replace(',', '.');\n      else if (decSep === '.') cleaned = s.replace(\/[, \\s]\/g, '');\n      else cleaned = s.replace(\/\\s+\/g, '');\n      const num = parseFloat(cleaned);\n      if (!isFinite(num)) return null;\n      if (NULL_NUMERIC_SENTINELS.has(num)) return null;\n      return num;\n    }\n    function extractNumbersEU(cell) {\n      if (!cell) return [];\n      const tokens = String(cell).trim().split(\/[^0-9,\\.\\-+eE]+\/).filter(Boolean);\n      const nums = [];\n      for (const tok of tokens) {\n        const v = parseEUFloat(tok);\n        if (v != null && isFinite(v)) nums.push(v);\n      }\n      return nums;\n    }\n    function extractDateFromCell(cell) {\n      if (!cell) return null;\n      const s = String(cell);\n      const timeRe = \/\\b(\\d{1,2}[:.]\\d{2}(?::\\d{2})?)\\b\/;\n      const dateRe = \/\\b(\\d{1,2}[\\\/\\-]\\d{1,2}[\\\/\\-]\\d{2,4}|[A-Za-z]{3}[\\\/\\-\\s]\\d{1,2}[\\\/\\-\\s]\\d{2,4}|\\d{4}[\\\/\\-]\\d{2}[\\\/\\-]\\d{2})\\b\/;\n      const t = (s.match(timeRe) || [])[1] || '';\n      const d = (s.match(dateRe) || [])[1] || '';\n      if (!t || !d) return null;\n      return (d + ' ' + t).replace(\/\\s+\/g, ' ').trim();\n    }\n\n    \/\/ ---------- Chart helpers ----------\n    function inferCadenceMillis(sortedRecords) {\n      const diffs = [];\n      for (let i=1;i<sortedRecords.length;i++) {\n        const d = sortedRecords[i].ts - sortedRecords[i-1].ts;\n        if (d > 0) diffs.push(d);\n      }\n      if (!diffs.length) return 0;\n      diffs.sort((a,b)=>a-b);\n      const mid = Math.floor(diffs.length\/2);\n      return diffs.length%2 ? diffs[mid] : (diffs[mid-1]+diffs[mid])\/2;\n    }\n    function consolidateDuplicatesByTimestamp(records) {\n      const map = new Map();\n      for (const r of records) {\n        const ms = r.ts.getTime();\n        if (!map.has(ms)) map.set(ms, { ts: r.ts, tempVals: [], rhVals: [] });\n        if (r.temp != null && isFinite(r.temp)) map.get(ms).tempVals.push(r.temp);\n        if (r.rh   != null && isFinite(r.rh))   map.get(ms).rhVals.push(r.rh);\n      }\n      const out = [];\n      for (const {ts, tempVals, rhVals} of map.values()) {\n        const t = tempVals.length ? tempVals.reduce((a,b)=>a+b,0)\/tempVals.length : null;\n        const h = rhVals.length   ? rhVals.reduce((a,b)=>a+b,0)\/rhVals.length   : null;\n        out.push({ ts, temp: t, rh: h });\n      }\n      out.sort((a,b)=>a.ts-b.ts);\n      return out;\n    }\n    function buildChartDataset(sortedRecords, key, gapMs) {\n      const data = [];\n      for (let i=0;i<sortedRecords.length;i++) {\n        const r = sortedRecords[i];\n        if (i>0 && gapMs>0) {\n          const dt = r.ts - sortedRecords[i-1].ts;\n          if (dt > gapMs) data.push({ x: r.ts, y: null });\n        }\n        const y = key === 'temp' ? r.temp : r.rh;\n        data.push({ x: r.ts, y: Number.isFinite(y) ? y : null });\n      }\n      return data;\n    }\n    function basicStats(arr) {\n      const a = arr.filter(v => v != null && isFinite(v));\n      if (!a.length) return { mean: 0, med: 0, min: 0, max: 0 };\n      a.sort((x,y)=>x-y);\n      const n=a.length, mid=Math.floor(n\/2);\n      const mean = a.reduce((x,y)=>x+y,0)\/n;\n      const med  = n%2 ? a[mid] : (a[mid-1]+a[mid])\/2;\n      return { mean, med, min:a[0], max:a[n-1] };\n    }\n    function renderStats(records, title, elementId) {\n      const temps = records.map(r=>r.temp).filter(v => v!=null && isFinite(v));\n      const rhs   = records.map(r=>r.rh).filter(v => v!=null && isFinite(v));\n      const tS = basicStats(temps);\n      const hS = basicStats(rhs);\n      const dateRangeStart = moment(records[0].ts).format('DD\/MM\/YYYY HH:mm:ss');\n      const dateRangeEnd   = moment(records[records.length - 1].ts).format('DD\/MM\/YYYY HH:mm:ss');\n      const html = `\n        <h4>${title}<\/h4>\n        <p><strong>Date\/Time Range:<\/strong> ${dateRangeStart} to ${dateRangeEnd}<\/p>\n        <table class=\"table table-striped table-bordered\">\n          <thead class=\"thead-dark\">\n            <tr><th>Statistic<\/th><th>Relative Humidity (%)<\/th><th>Temperature (\u00b0C)<\/th><\/tr>\n          <\/thead>\n          <tbody>\n            <tr><td><strong>Mean<\/strong><\/td><td>${hS.mean.toFixed(2)}<\/td><td>${tS.mean.toFixed(2)}<\/td><\/tr>\n            <tr><td><strong>Median<\/strong><\/td><td>${hS.med.toFixed(2)}<\/td><td>${tS.med.toFixed(2)}<\/td><\/tr>\n            <tr><td><strong>Minimum<\/strong><\/td><td>${hS.min.toFixed(2)}<\/td><td>${tS.min.toFixed(2)}<\/td><\/tr>\n            <tr><td><strong>Maximum<\/strong><\/td><td>${hS.max.toFixed(2)}<\/td><td>${tS.max.toFixed(2)}<\/td><\/tr>\n          <\/tbody>\n        <\/table>`;\n      document.getElementById(elementId).innerHTML = html;\n    }\n    function drawChart(records, gapMs) {\n      const rhDataset   = buildChartDataset(records, 'rh',   gapMs);\n      const tempDataset = buildChartDataset(records, 'temp', gapMs);\n      if (chartInstance) { chartInstance.destroy(); chartInstance = null; }\n      const ctx = document.getElementById('climate-data-chart').getContext('2d');\n      chartInstance = new Chart(ctx, {\n        type: 'line',\n        data: { datasets: [\n          { label:'Relative Humidity (%)', data: rhDataset, borderColor:'#1f77b4',\n            backgroundColor:'rgba(31,119,180,0.10)', fill:false, yAxisID:'y-axis-rh',\n            borderWidth:1, pointRadius:0, spanGaps:false },\n          { label:'Temperature (\u00b0C)', data: tempDataset, borderColor:'#d62728',\n            backgroundColor:'rgba(214,39,40,0.10)', fill:false, yAxisID:'y-axis-temp',\n            borderWidth:1, pointRadius:0, spanGaps:false }\n        ]},\n        options: {\n          maintainAspectRatio:false,\n          scales:{\n            xAxes:[{ type:'time', time:{ tooltipFormat:'YYYY-MM-DD HH:mm',\n              displayFormats:{ hour:'MMM D HH:mm', day:'MMM D', minute:'HH:mm' } },\n              scaleLabel:{ display:true, labelString:'Date\/Time' }, ticks:{ source:'auto' } }],\n            yAxes:[\n              { id:'y-axis-rh', type:'linear', position:'left',\n                scaleLabel:{ display:true, labelString:'Relative Humidity (%)' },\n                ticks:{ suggestedMin:0, suggestedMax:100 } },\n              { id:'y-axis-temp', type:'linear', position:'right',\n                scaleLabel:{ display:true, labelString:'Temperature (\u00b0C)' }, ticks:{} }\n            ]\n          },\n          tooltips:{ mode:'index', intersect:false },\n          hover:{ mode:'nearest', intersect:true },\n          plugins:{ zoom:{ pan:{ enabled:true, mode:'x' }, zoom:{ enabled:true, mode:'x', speed:0.05 } } },\n          elements:{ line:{ tension:0 } }\n        }\n      });\n    }\n\n    \/\/ ---------- Submit (single bind) ----------\n    document.getElementById('climate-data-form').addEventListener('submit', (ev) => {\n      ev.preventDefault();\n      setStatus('');\n      const file = document.getElementById('climate-data-file').files[0];\n      if (!file) return;\n\n      activeRun = ++runId;\n\n      Papa.parse(file, {\n        ...papaConfig,\n        complete: (res) => {\n          if (activeRun !== runId) return;\n          handleParsedCSV(res.data, file.name, activeRun);\n        }\n      });\n    }, { passive:true });\n\n    \/\/ ---------- Parse handler ----------\n    function handleParsedCSV(rawData, fileName, thisRun) {\n      if (thisRun !== activeRun) return;\n\n      if (!rawData || !rawData.length) {\n        if (!everSucceeded) setStatus('Could not read rows from this file.');\n        return;\n      }\n\n      const { rowIndex, keys } = detectHeaderRow(rawData);\n      const headerMap = buildHeaderMap(keys);\n      const rows = rawData.slice(rowIndex+1);\n\n      const dateSamples = [];\n      for (let i=0;i<Math.min(80, rows.length); i++) {\n        const row = rows[i] || [];\n        const dtCell = headerMap.datetime != null ? row[headerMap.datetime] : null;\n        const dateCell = headerMap.date != null ? row[headerMap.date] : null;\n        const probe = dtCell || dateCell || row[0];\n        if (probe) dateSamples.push(String(probe));\n      }\n      const dayFirst = decideDayFirst(dateSamples);\n      const formats = makeFormatList(dayFirst);\n\n      const records = [];\n      let parsedCount = 0;\n\n      for (let r=0;r<rows.length;r++) {\n        const row = (rows[r]||[]).map(c=>String(c??'').trim());\n        let dtStr = null;\n        if (headerMap.datetime != null) dtStr = row[headerMap.datetime];\n        else if (headerMap.date != null && headerMap.time != null) dtStr = row[headerMap.date] + ' ' + row[headerMap.time];\n        else dtStr = extractDateFromCell(row[0]) || extractDateFromCell(row[1]) || null;\n\n        if (!dtStr) continue;\n        let ts = tryParseMoment(dtStr, formats);\n        if (!ts) { const d = new Date(dtStr); if (!isNaN(d.getTime())) ts = d; }\n        if (!ts) continue;\n\n        let temp = headerMap.temp != null ? parseEUFloat(row[headerMap.temp]) : null;\n        let rh   = headerMap.rh   != null ? parseEUFloat(row[headerMap.rh])   : null;\n\n        if (temp == null && rh == null) {\n          const candidates = [];\n          for (let c=0;c<row.length;c++) {\n            if (c === headerMap.datetime || c === headerMap.date || c === headerMap.time) continue;\n            candidates.push(c);\n          }\n          for (const c of candidates) {\n            const nums = extractNumbersEU(row[c]);\n            if (nums.length >= 2) { temp = nums[0]; rh = nums[1]; break; }\n            if (nums.length === 1 && temp == null) temp = nums[0];\n          }\n        }\n\n        const rhClean = (rh != null && (rh < 0 || rh > 100)) ? null : rh;\n\n        if (temp != null || rhClean != null) {\n          records.push({ ts, temp, rh: rhClean });\n          parsedCount++;\n        }\n      }\n\n      if (!records.length) {\n        if (!everSucceeded) setStatus('Could not find any valid timestamps with values in this file.');\n        return;\n      }\n\n      setStatus('');\n      everSucceeded = true;\n\n      records.sort((a,b)=>a.ts-b.ts);\n      const merged = consolidateDuplicatesByTimestamp(records);\n      const cadenceMs = inferCadenceMillis(merged);\n      const gapMs = cadenceMs ? Math.round(cadenceMs * 2.5) : 0;\n      climateData = merged;\n\n      drawChart(merged, gapMs);\n      renderStats(merged, 'Climate Data Statistics', 'climate-data-stats');\n\n      const firstTs = merged[0].ts, lastTs = merged[merged.length-1].ts;\n      const diag = [\n        `File: ${fileName}`,\n        `Header row index: ${rowIndex}`,\n        `Detected fields: ${JSON.stringify(headerMap)}`,\n        `Day-first (DMY): ${dayFirst}`,\n        `Parsed rows: ${parsedCount}`,\n        `Unique timestamps: ${merged.length}`,\n        `Cadence ~ ${cadenceMs ? Math.round(cadenceMs\/1000) : 0} s`,\n        `Gap threshold ~ ${gapMs ? Math.round(gapMs\/1000) : 0} s`,\n        `Range: ${moment(firstTs).format('YYYY-MM-DD HH:mm:ss')} \u2192 ${moment(lastTs).format('YYYY-MM-DD HH:mm:ss')}`\n      ].join('\\n');\n      document.getElementById('parser-diag').textContent = diag;\n\n      document.getElementById('climate-data-graph-box').style.display = 'block';\n      document.getElementById('recalculate-button').style.display = 'inline-block';\n    }\n\n    \/\/ ---------- Recalc ----------\n    document.getElementById('recalculate-button').addEventListener('click', () => {\n      if (!chartInstance || !climateData.length) return;\n      const xScale = chartInstance.scales['x-axis-0'];\n      const defaultMin = climateData[0].ts.getTime();\n      const defaultMax = climateData[climateData.length - 1].ts.getTime();\n      const minTime = Number.isFinite(xScale?.min) ? xScale.min : defaultMin;\n      const maxTime = Number.isFinite(xScale?.max) ? xScale.max : defaultMax;\n      const filtered = climateData.filter(d => {\n        const t = d.ts.getTime();\n        const hasY = Number.isFinite(d.temp) || Number.isFinite(d.rh);\n        return hasY && t >= minTime && t <= maxTime;\n      });\n      const targetId = 'recalculated-stats';\n      if (filtered.length) renderStats(filtered, 'Recalculated Climate Data Statistics', targetId);\n      else document.getElementById(targetId).innerHTML =\n        '<p class=\"text-muted mb-0\">No data points are visible in the current view.<\/p>';\n    });\n  }\n  <\/script>\n<\/body>\n<\/html>\n\n\n\n<p><\/p>\n","protected":false},"excerpt":{"rendered":"<p>Salt Weathering Tool \u2014 Robust CSV Parser Upload Climate Data Accepted Inputs CSV with any of these columns (case-insensitive, units allowed): Date + Time, or a single DateTime (e.g. Date; Time or Datetime) Temperature (e.g. T, Temp, Temperature, \u00b0C) Relative Humidity (e.g. RH, RH(%), Humidity) European decimals like 8,70 are handled. Placeholders like NA, &#8212;, &hellip;<\/p>\n<p class=\"read-more\"> <a class=\"\" href=\"https:\/\/predict.kikirpa.be\/index.php\/tools\/climate-data-visualization-tool\/\"> <span class=\"screen-reader-text\">Climate Data Visualization Tool<\/span> Read More &raquo;<\/a><\/p>\n","protected":false},"author":1,"featured_media":0,"parent":105,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"","meta":{"site-sidebar-layout":"default","site-content-layout":"default","ast-global-header-display":"","ast-main-header-display":"","ast-hfb-above-header-display":"","ast-hfb-below-header-display":"","ast-hfb-mobile-header-display":"","site-post-title":"","ast-breadcrumbs-content":"","ast-featured-img":"","footer-sml-layout":"","theme-transparent-header-meta":"","adv-header-id-meta":"","stick-header-meta":"","header-above-stick-meta":"","header-main-stick-meta":"","header-below-stick-meta":"","footnotes":""},"class_list":["post-236052","page","type-page","status-publish","hentry"],"jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/predict.kikirpa.be\/index.php\/wp-json\/wp\/v2\/pages\/236052","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/predict.kikirpa.be\/index.php\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/predict.kikirpa.be\/index.php\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/predict.kikirpa.be\/index.php\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/predict.kikirpa.be\/index.php\/wp-json\/wp\/v2\/comments?post=236052"}],"version-history":[{"count":61,"href":"https:\/\/predict.kikirpa.be\/index.php\/wp-json\/wp\/v2\/pages\/236052\/revisions"}],"predecessor-version":[{"id":308291,"href":"https:\/\/predict.kikirpa.be\/index.php\/wp-json\/wp\/v2\/pages\/236052\/revisions\/308291"}],"up":[{"embeddable":true,"href":"https:\/\/predict.kikirpa.be\/index.php\/wp-json\/wp\/v2\/pages\/105"}],"wp:attachment":[{"href":"https:\/\/predict.kikirpa.be\/index.php\/wp-json\/wp\/v2\/media?parent=236052"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}