{"id":3215,"date":"2025-09-16T12:43:58","date_gmt":"2025-09-16T09:43:58","guid":{"rendered":"https:\/\/rosti.fi\/saa\/?page_id=3215"},"modified":"2025-10-05T11:08:29","modified_gmt":"2025-10-05T08:08:29","slug":"24h-kurvor","status":"publish","type":"page","link":"https:\/\/rosti.fi\/saa\/24h-kurvor\/?lang=sv","title":{"rendered":"24 h kurvor"},"content":{"rendered":"\n<div class=\"chart-grid\">\n  <div class=\"chart-container\"><canvas id=\"chart-temp\"><\/canvas><\/div>\n  <div class=\"chart-container\"><canvas id=\"chart-bar\"><\/canvas><\/div>\n  <div class=\"chart-container\"><canvas id=\"chart-wind\"><\/canvas><\/div>\n  <div class=\"chart-container\"><canvas id=\"chart-hum\"><\/canvas><\/div>\n  <div class=\"chart-container\"><canvas id=\"chart-winddir-line\"><\/canvas><\/div>\n  <div class=\"chart-container\"><canvas id=\"chart-solaruv\"><\/canvas><\/div>\n  <div class=\"chart-container\"><canvas id=\"chart-wind-rose\"><\/canvas><\/div>\n  <div class=\"chart-container\"><canvas id=\"chart-rain\"><\/canvas><\/div>\n<\/div>\n\n<style>\n.chart-grid {\n  display: flex;\n  flex-wrap: wrap;\n  justify-content: center;\n  gap: 20px;\n  max-width: 960px;\n  margin: 0 auto;\n}\n.chart-container {\n  flex: 1 1 100%;\n  max-width: 450px;\n  background: rgba(255,255,255,0.85); \/* vaalea tausta *\/\n  padding: 8px;\n  border-radius: 6px;\n}\n.chart-container canvas {\n  display: block;\n  width: 100% !important;\n  height: auto !important;\n}\n@media (min-width: 768px) {\n  .chart-container {\n    flex: 1 1 calc(50% - 20px);\n  }\n}\n<\/style>\n\n<script src=\"https:\/\/cdn.jsdelivr.net\/npm\/chart.js@4.4.3\"><\/script>\n<script>\n(function(){\n  \"use strict\";\n\n  const API_BASE = \"\/saa\/charts\/graphdata.php\";\n  const LANG = (document.documentElement.lang?.substring(0,2) || \"fi\").toLowerCase();\n  const COLORS = [\n    \"#2563eb\",\"#16a34a\",\"#f59e0b\",\"#ef4444\",\n    \"#8b5cf6\",\"#0ea5e9\",\"#22c55e\",\"#f97316\",\n    \"#f43f5e\",\"#84cc16\",\"#06b6d4\",\"#eab308\"\n  ];\n\n  const LEGEND_SMALL = {\n    position: \"right\",\n    labels: { font:{size:8}, boxWidth:4, boxHeight:1, padding:6 }\n  };\n\n  \/* ---------- LOKALISAATIO ---------- *\/\n  const I18N = {\n    fi: { windDirAxis: \"Ilmansuunta\" },\n    sv: { windDirAxis: \"V\u00e4derstreck\" },\n    en: { windDirAxis: \"Direction\" }\n  };\n  const L = I18N[LANG] || I18N.en;\n\n  \/* ---------- YLEISET APUFUNKTIOT ---------- *\/\n\n  function lineDatasets(dsets){\n    return dsets.map((ds,i)=>({\n      label: ds.label + (ds.unit ? ` (${ds.unit})` : \"\"),\n      data: ds.values,\n      borderColor: COLORS[i % COLORS.length],\n      backgroundColor: COLORS[i % COLORS.length] + \"33\",\n      fill: false,\n      borderWidth: 2,\n      pointRadius: 0,\n      tension: 0.2\n    }));\n  }\n\n  function autoY(allVals, opts={}){\n    const vals = allVals.filter(v=>v!==null && v!==undefined && !Number.isNaN(v));\n    let min = vals.length ? Math.min(...vals) : 0;\n    let max = vals.length ? Math.max(...vals) : 1;\n    const pad = (max-min)*0.05 || 1;\n    min = Math.floor(min - pad);\n    max = Math.ceil(max + pad);\n    if(opts.floor0) min = 0;\n    if(opts.cap100 && max < 100) max = 100;\n    if(opts.range) { min=opts.range[0]; max=opts.range[1]; }\n    return {min,max};\n  }\n\n  async function fetchJSON(params){\n    const url = API_BASE + \"?\" + new URLSearchParams(params).toString();\n    const r = await fetch(url);\n    if(!r.ok) throw new Error(\"HTTP \"+r.status);\n    return await r.json();\n  }\n\n  \/* ---------- ILMANSUUNTA ---------- *\/\n\n  const DIRS16 = [\"N\",\"NNE\",\"NE\",\"ENE\",\"E\",\"ESE\",\"SE\",\"SSE\",\n                  \"S\",\"SSW\",\"SW\",\"WSW\",\"W\",\"WNW\",\"NW\",\"NNW\"];\n\n  function norm360(d){ return ((d % 360) + 360) % 360; }\n\n  function degToDir(d){\n    if (d == null || Number.isNaN(d)) return \"\";\n    const a = norm360(d);\n    return DIRS16[Math.round(a \/ 22.5) % 16];\n  }\n\n  \/**\n   * Normalisoi 0..360 ja katkaisee viivan wrap-kohdissa (hyppy > 180\u00b0).\n   * N\u00e4in v\u00e4ltet\u00e4\u00e4n ruma pystysuora viiva 0\u2194360.\n   *\/\n  function normalizeWithWrapBreaks(arr, breakThreshold=180){\n    const out = [];\n    let prev = null;\n    for (let i=0;i<arr.length;i++){\n      const raw = arr[i];\n      if (raw == null || Number.isNaN(raw)) { out.push(null); prev = null; continue; }\n      const v = norm360(raw);\n      if (prev == null){ out.push(v); prev = v; continue; }\n      const diff = Math.abs(v - prev);\n      if (diff > breakThreshold){\n        \/\/ katkaisu: lis\u00e4t\u00e4\u00e4n null ennen uutta segmentti\u00e4\n        out.push(null);\n      }\n      out.push(v);\n      prev = v;\n    }\n    return out;\n  }\n\n  \/* ---------- PIIRT\u00c4J\u00c4T ---------- *\/\n\n  function drawLine(canvasId, data, yOpts={}){\n    const ctx=document.getElementById(canvasId);\n    if(!ctx) return;\n    const all = data.datasets.flatMap(ds=>ds.values);\n    const y = autoY(all,yOpts);\n    new Chart(ctx,{\n      type:\"line\",\n      data:{ labels:data.labels, datasets:lineDatasets(data.datasets) },\n      options:{\n        responsive:true,\n        maintainAspectRatio:true,\n        aspectRatio:2,\n        plugins:{\n          title:{ display:true, text:data.title, align:\"center\", color:\"#000\", font:{size:14, weight:\"bold\"} },\n          legend: LEGEND_SMALL\n        },\n        scales:{\n          x:{ ticks:{ maxRotation:90,minRotation:90,autoSkip:true,maxTicksLimit:16 } },\n          y:{\n            min:y.min,max:y.max,\n            ticks:{ callback:v=>(Math.abs(v%1)<1e-9 ? v : Number(v).toFixed(1)) }\n          }\n        }\n      }\n    });\n  }\n\n  function drawSolarUV(canvasId, data){\n    const ctx = document.getElementById(canvasId);\n    if(!ctx) return;\n    const solar = data.datasets[0];\n    const uv = data.datasets[1];\n\n    new Chart(ctx,{\n      type:\"line\",\n      data:{\n        labels:data.labels,\n        datasets:[\n          {\n            label: solar.label + (solar.unit ? ` (${solar.unit})` : \"\"),\n            data: solar.values,\n            borderColor: COLORS[0],\n            backgroundColor: COLORS[0]+\"33\",\n            yAxisID: \"ySolar\",\n            borderWidth: 2,\n            pointRadius: 0,\n            tension: 0.2\n          },\n          {\n            label: uv.label + (uv.unit ? ` (${uv.unit})` : \"\"),\n            data: uv.values,\n            borderColor: COLORS[1],\n            backgroundColor: COLORS[1]+\"33\",\n            yAxisID: \"yUV\",\n            borderWidth: 2,\n            pointRadius: 0,\n            tension: 0.2\n          }\n        ]\n      },\n      options:{\n        responsive:true,\n        maintainAspectRatio:true,\n        aspectRatio:2,\n        plugins:{\n          title:{ display:true, text:data.title, align:\"center\" },\n          legend: LEGEND_SMALL\n        },\n        scales:{\n          x:{ ticks:{ maxRotation:90, minRotation:90, autoSkip:true, maxTicksLimit:16 } },\n          ySolar:{\n            type:\"linear\",\n            position:\"left\",\n            ticks:{ callback:v=>(Math.abs(v%1)<1e-9 ? v : Number(v).toFixed(0)) },\n            title:{ display:true, text:solar.unit || \"\" }\n          },\n          yUV:{\n            type:\"linear\",\n            position:\"right\",\n            grid:{ drawOnChartArea:false },\n            min:0,\n            max:12,\n            title:{ display:true, text:uv.unit || \"UV\" }\n          }\n        }\n      }\n    });\n  }\n\n  function drawWindRose(canvasId, roseJson){\n    const ctx=document.getElementById(canvasId);\n    if(!ctx) return;\n\n    const labels=[\"N\",\"NNE\",\"NE\",\"ENE\",\"E\",\"ESE\",\"SE\",\"SSE\",\n                  \"S\",\"SSW\",\"SW\",\"WSW\",\"W\",\"WNW\",\"NW\",\"NNW\"];\n    const ds = roseJson.datasets[0] || {};\n    const values = ds.values || [];\n    const maxVal = Math.max(...values, 1);\n\n    new Chart(ctx,{\n      type:\"polarArea\",\n      data:{\n        labels,\n        datasets:[{\n          label: ds.label + (ds.unit ? ` (${ds.unit})` : \"\"),\n          data: values,\n          backgroundColor: labels.map((_,i)=>COLORS[i % COLORS.length]+\"66\"),\n          borderColor: labels.map((_,i)=>COLORS[i % COLORS.length]),\n          borderWidth: 1\n        }]\n      },\n      options:{\n        responsive:true,\n        maintainAspectRatio:true,\n        aspectRatio:1,\n        plugins:{\n          title:{ display:true, text:roseJson.title, align:\"center\" },\n          legend:{ display:false }\n        },\n        scales:{\n          r:{\n            beginAtZero:true,\n            min:0,\n            max:maxVal,\n            ticks:{ display:true, precision:0 },\n            angleLines:{ display:true }\n          }\n        }\n      },\n      plugins:[{\n        id: 'windrose-labels',\n        afterDraw(chart){\n          const {ctx, chartArea:{width, height, left, top}} = chart;\n          const centerX = left + width\/2;\n          const centerY = top + height\/2;\n          const radius = Math.min(width, height)\/2 - 12;\n\n          ctx.save();\n          ctx.font = \"10px sans-serif\";\n          ctx.fillStyle = \"#000\";\n          ctx.textAlign = \"center\";\n          ctx.textBaseline = \"middle\";\n\n          labels.forEach((lbl,i)=>{\n            const angle = (i*22.5 - 90) * Math.PI\/180;\n            const x = centerX + Math.cos(angle)*radius;\n            const y = centerY + Math.sin(angle)*radius;\n            ctx.fillText(lbl, x, y);\n          });\n          ctx.restore();\n        }\n      }]\n    });\n  }\n\n  \/**\n   * Tuulen suunta \u2013 asteikko aina 0\u2026360 (N alhaalla, N ylh\u00e4\u00e4ll\u00e4),\n   * 45\u00b0 tikit, tikeiss\u00e4 vain N\/NE\/E\u2026, tooltipissa vain suunta.\n   * Wrap-kohdat katkaistaan (null), jotta ei piirret\u00e4 pystysuoraa hyppy\u00e4.\n   *\/\n  function drawWindDirLine(canvasId, data){\n    const ctx = document.getElementById(canvasId);\n    if (!ctx) return;\n\n    \/\/ Normalisoi ja katkaise wrap-kohdissa\n    const dsets = data.datasets.map((ds, i) => {\n      const vals = (ds.values || []).slice();\n      const processed = normalizeWithWrapBreaks(vals); \/\/ 0..360 + null-segmentit\n      return {\n        label: ds.label + (ds.unit ? ` (${ds.unit})` : \"\"),\n        data: processed,\n        borderColor: COLORS[i % COLORS.length],\n        backgroundColor: COLORS[i % COLORS.length] + \"33\",\n        fill: false,\n        borderWidth: 2,\n        pointRadius: 0,\n        tension: 0.2,\n        spanGaps: false \/\/ \u00e4l\u00e4 yhdist\u00e4 nullien yli\n      };\n    });\n\n    new Chart(ctx, {\n      type: \"line\",\n      data: { labels: data.labels, datasets: dsets },\n      options: {\n        responsive: true,\n        maintainAspectRatio: true,\n        aspectRatio: 2,\n        plugins: {\n          title: { display: true, text: data.title, align: \"center\", color: \"#000\", font: { size: 14, weight: \"bold\" } },\n          legend: LEGEND_SMALL,\n          tooltip: {\n            callbacks: {\n              label(ctx){\n                const val = ctx.parsed.y;\n                return `${ctx.dataset.label}: ${degToDir(val)}`;\n              }\n            }\n          }\n        },\n        scales: {\n          x: { ticks:{ maxRotation:90, minRotation:90, autoSkip:true, maxTicksLimit:16 } },\n          y: {\n            min: 0,\n            max: 360,\n            grid: { drawTicks: true },\n            ticks: {\n              callback: (v) => degToDir(v)  \/\/ ei numeroita, vain N\/NE\/E...\n            },\n            title: { display:true, text: L.windDirAxis }\n          }\n        }\n      },\n      \/\/ PLUGIN: pakota tarkasti 0,45,...,360 tikit skaalalle 'y'\n      plugins: [{\n        id: 'force-45deg-ticks',\n        afterBuildTicks(chart, args){\n          const scale = args.scale;\n          if (!scale || scale.id !== 'y') return;\n          const ticks = [];\n          for (let v = 0; v <= 360; v += 45){\n            ticks.push({ value: v });\n          }\n          args.ticks = ticks;\n          scale.ticks = ticks;\n        }\n      }]\n    });\n  }\n\n  \/* ---------- HAKU &#038; PIIRTO ---------- *\/\n\n  fetchJSON({t:\"temp_combo\",l:LANG}).then(json=>drawLine(\"chart-temp\",json)).catch(console.error);\n  fetchJSON({t:\"bar\",l:LANG}).then(json=>drawLine(\"chart-bar\",json)).catch(console.error);\n  fetchJSON({t:\"wind_combo\",l:LANG}).then(json=>drawLine(\"chart-wind\",json)).catch(console.error);\n  fetchJSON({t:\"hum_out\",l:LANG}).then(json=>drawLine(\"chart-hum\",json,{floor0:true,cap100:true})).catch(console.error);\n\n  \/\/ Tuulen suunta: kiinte\u00e4 0\u2026360 asteikko, 45\u00b0 tikit, ei numeroita, wrap-katko\n  fetchJSON({t:\"wind_dir\",l:LANG})\n    .then(json => drawWindDirLine(\"chart-winddir-line\", json))\n    .catch(console.error);\n\n  fetchJSON({t:\"solar_uv\",l:LANG}).then(json=>drawSolarUV(\"chart-solaruv\",json)).catch(console.error);\n  fetchJSON({t:\"wind_rose\",l:LANG}).then(json=>drawWindRose(\"chart-wind-rose\",json)).catch(console.error);\n\n  fetchJSON({t:\"rain\",l:LANG}).then(json=>{\n    const ctx=document.getElementById(\"chart-rain\");\n    const ds=json.datasets?.[0]||{label:\"Sadem\u00e4\u00e4r\u00e4\",unit:\"mm\",values:[]};\n    const y=autoY(ds.values,{floor0:true});\n    new Chart(ctx,{\n      type:\"bar\",\n      data:{ labels:json.labels,\n        datasets:[{ label:ds.label+(ds.unit?` (${ds.unit})`:\"\"),\n          data:ds.values,\n          backgroundColor:COLORS[3]+\"66\",\n          borderColor:COLORS[3],\n          borderWidth:1 }]\n      },\n      options:{\n        responsive:true,\n        maintainAspectRatio:true,\n        aspectRatio:1.6,\n        plugins:{\n          title:{ display:true,text:json.title,align:\"center\",color:\"#000\",font:{size:14,weight:\"bold\"} },\n          legend: LEGEND_SMALL\n        },\n        scales:{\n          x:{ ticks:{ maxRotation:90,minRotation:90,autoSkip:true,maxTicksLimit:16 } },\n          y:{ min:y.min,max:y.max,beginAtZero:true,\n            ticks:{ callback:v=>(Math.abs(v%1)<1e-9 ? v : Number(v).toFixed(1)) } }\n        }\n      }\n    });\n  }).catch(console.error);\n\n})();\n<\/script>\n\n","protected":false},"excerpt":{"rendered":"","protected":false},"author":1,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"","meta":{"footnotes":""},"class_list":["post-3215","page","type-page","status-publish","hentry","post"],"_links":{"self":[{"href":"https:\/\/rosti.fi\/saa\/wp-json\/wp\/v2\/pages\/3215","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rosti.fi\/saa\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/rosti.fi\/saa\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/rosti.fi\/saa\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/rosti.fi\/saa\/wp-json\/wp\/v2\/comments?post=3215"}],"version-history":[{"count":3,"href":"https:\/\/rosti.fi\/saa\/wp-json\/wp\/v2\/pages\/3215\/revisions"}],"predecessor-version":[{"id":3298,"href":"https:\/\/rosti.fi\/saa\/wp-json\/wp\/v2\/pages\/3215\/revisions\/3298"}],"wp:attachment":[{"href":"https:\/\/rosti.fi\/saa\/wp-json\/wp\/v2\/media?parent=3215"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}