{"id":5791,"date":"2025-10-28T12:42:46","date_gmt":"2025-10-28T12:42:46","guid":{"rendered":"https:\/\/paragliding-in-madeira.com\/weather\/?page_id=5791"},"modified":"2026-03-12T19:30:32","modified_gmt":"2026-03-12T19:30:32","slug":"metar-history","status":"publish","type":"page","link":"https:\/\/paragliding-in-madeira.com\/weather\/metar-history\/","title":{"rendered":"Metar history"},"content":{"rendered":"\n<figure class=\"wp-block-image aligncenter size-full is-resized has-custom-border is-style-rounded is-style-rounded--1\"><img loading=\"lazy\" decoding=\"async\" width=\"3240\" height=\"1796\" src=\"https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/2025\/10\/rainbow-plane.webp\" alt=\"Rainbow plane\" class=\"wp-image-6129\" style=\"border-style:none;border-width:0px;border-radius:31px;width:555px\" srcset=\"https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/2025\/10\/rainbow-plane.webp 3240w, https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/2025\/10\/rainbow-plane-300x166.webp 300w, https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/2025\/10\/rainbow-plane-1024x568.webp 1024w, https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/2025\/10\/rainbow-plane-768x426.webp 768w, https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/2025\/10\/rainbow-plane-1536x851.webp 1536w, https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/2025\/10\/rainbow-plane-2048x1135.webp 2048w\" sizes=\"auto, (max-width: 3240px) 100vw, 3240px\" \/><\/figure>\n\n\n\n<div class=\"wp-block-group has-global-padding is-layout-constrained wp-block-group-is-layout-constrained\">\n<div class=\"wp-block-buttons is-content-justification-center is-layout-flex wp-container-core-buttons-is-layout-a89b3969 wp-block-buttons-is-layout-flex\">\n<div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\" href=\"https:\/\/www.paragliding-in-madeira.com\/weather\">Home<\/a><\/div>\n\n\n\n<div class=\"wp-block-button\"><a class=\"wp-block-button__link wp-element-button\" href=\"https:\/\/paragliding-in-madeira.com\/weather\/weather-info\/\">Weather info<\/a><\/div>\n<\/div>\n\n\n\n<p class=\"has-text-align-center\"><a href=\"https:\/\/paragliding-in-madeira.com\/weather\/airport-live\/\" title=\"\">Airport<\/a><\/p>\n\n\n\n<div style=\"height:8px\" aria-hidden=\"true\" class=\"wp-block-spacer\"><\/div>\n<\/div>\n\n\n\n    <div style=\"max-width: 600px; margin: auto;\">\n \u00a0 \u00a0 \u00a0<canvas id=\"windGraphSpeed-lpps\" height=\"250\"><\/canvas>\n      <canvas id=\"windGraphDirection-lpps\" height=\"200\"><\/canvas>\n    <\/div>\n\n    <script>\n    document.addEventListener('DOMContentLoaded', function() {\n        \n        \/\/ Use the dynamic station code and JSON file URL\n        const STATION_CODE = 'lpps';\n        const JSON_URL = 'https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/metar-wind-lpps.json';\n        const TIME_OPTIONS = { hour: '2-digit', minute: '2-digit', hour12: false };\n        \n        let windData = [];\n\n \/\/ --- Helper Functions (No change) ---\n        function getDirectionLabels(data) {\n            \/\/ (functions remain the same)\n            return data.map(d => new Date(d.time).toLocaleTimeString([], TIME_OPTIONS));\n        }\n        \n        function formatDirection(value) {\n            if (value === 0) return 'N';\n            if (value === 90) return 'E';\n            if (value === 180) return 'S';\n            if (value === 270) return 'W';\n            if (value === 360) return 'N'; \n            return null;\n        }\n        \n        function getCardinalDirection(deg) {\n            if (deg === 1) return \"VRB\"; \n            const directions = [\"N\", \"NNE\", \"NE\", \"ENE\", \"E\", \"ESE\", \"SE\", \"SSE\", \"S\", \"SSW\", \"SW\", \"WSW\", \"W\", \"WNW\", \"NW\", \"NNW\"];\n            const index = Math.round(deg \/ 22.5) % 16; \n            return directions[index];\n        }\n\n        function prepareDirectionData(data) {\n            const windDirectionData = data.map(d => d.direction === 1 ? null : d.direction); \n            const vrbData = data.map(d => d.direction === 1 ? 0 : null); \n            return { windDirectionData, vrbData };\n        }\n\n    \/\/ --- Mobile Tooltip Auto-hide ---\n        function autoHideTooltip(chart, delay = 5555) {\n            chart.canvas.addEventListener('touchstart', function() {\n                setTimeout(() => {\n                    chart.tooltip.setActiveElements([]);\n                    chart.update();\n                }, delay);\n            });\n        }\n        \n \/\/ --- Chart Setup (Use Dynamic IDs) ---\n        const ctxSpeed = document.getElementById('windGraphSpeed-' + STATION_CODE)?.getContext('2d');\n        const ctxDirection = document.getElementById('windGraphDirection-' + STATION_CODE)?.getContext('2d');\n        \n        if (!ctxSpeed || !ctxDirection) {\n\/\/ This is expected if the shortcode is used multiple times on the same page \n\/\/ but the browser hasn't loaded the elements yet. Should be fine since \n\/\/ DOMContentLoaded is running. Add more specific error if needed.\n            return;\n        }\n        \n  \/\/ Get the full name for the title\n        let stationName = STATION_CODE.toUpperCase();\n        if (STATION_CODE === 'lpma') {\n            stationName = 'Madeira (LPMA)';\n        } else if (STATION_CODE === 'lpps') {\n            stationName = 'Porto Santo (LPPS)';\n        }\n\n        const chartSpeed = new Chart(ctxSpeed, {\n            type: 'line',\n            data: { labels: [], datasets: [\n                { label: 'Wind Speed', data: [], borderColor: 'rgba(54,162,235,1)', backgroundColor: 'rgba(54,162,235,0.1)', tension: 0.25, pointRadius: 3, fill: true },\n                { label: 'Gust', data: [], borderColor: 'rgba(255,165,80,0.8)', backgroundColor: 'rgba(255,165,80,0.1)', tension: 0.25, pointRadius: 3, fill: false }\n            ] },\n            options: {\n                responsive: true,\n                interaction: { mode: 'index', intersect: false },\n                plugins: { \n                    legend: { position: 'top', labels: { boxWidth: 10, color: '#333' } }, \n                    \/\/ Dynamic Title\n                    title: { display: true, text: 'Windspeed for ' + stationName + ' in the last 12 hours (km\/h)', font: { size: 14 } } \n                },\n                scales: {\n \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0y: { position: 'left', beginAtZero: true, grace: '10%', title: { display: false }, grid: { color: 'rgba(0,0,0,0.1)', lineWidth: 1 }, ticks: { color: '#555', autoSkip: false, } },\n                    x: { display: true, grid: { drawBorder: false, drawOnChartArea: true, color: 'rgba(0,0,0,0.05)' }, title: { display: false }, ticks: { display: true, maxRotation: 90, minRotation: 90 } }\n                },\n                layout: { padding: { top: 10, bottom: 0, left: 0, right: 0 } }\n            }\n        });\n        \n        const chartDirection = new Chart(ctxDirection, {\n            type: 'line',\n            data: { labels: [], datasets: [\n                { label: 'VRB Wind', data: [], borderColor: 'rgba(150,150,200,0)', backgroundColor: 'rgba(150,150,200,0.8)', pointRadius: 5, pointStyle: 'circle', showLine: false },\n                { label: 'Wind Direction (deg)', data: [], borderColor: 'rgba(75, 192, 192, 0.9)', backgroundColor: 'rgba(75, 192, 192, 0.1)', tension: 0.25, pointRadius: 4, showLine: false }\n            ] },\n            options: {\n                responsive: true,\n                interaction: { mode: 'index', intersect: false },\n                plugins: { \n                    legend: { display: false }, \n                    \/\/ Dynamic Title\n                    title: { display: true, text: 'Wind Direction for ' + stationName + ' in the last 12 hours', font: { size: 14 } },\n                    tooltip: { \n                        callbacks: {\n                            label: function(context) {\n                                const datasetLabel = context.dataset.label;\n                                const directionDeg = context.parsed.y;\n                                if (datasetLabel === 'VRB Wind') { return 'VRB Wind: Variable'; }\n                                \n                                let cardinal = getCardinalDirection(directionDeg);\n                                \/\/ The speed data needs to come from the chartSpeed object\n                                const speed = chartSpeed.data.datasets[0].data[context.dataIndex]; \n                                if (speed === 0) { cardinal = \"CALM\/VRB\"; }\n                                \n                                return `Wind Direction: ${cardinal} (${directionDeg}deg)`;\n                            },\n                            afterLabel: function(context) { return ''; },\n                            usePointStyle: true,\n                            pointStyle: 'dash' \n                        } \n                    }\n                },\n                scales: {\n                    y: { position: 'left', min: 0, max: 360, title: { display: false }, ticks: { stepSize: 90, callback: formatDirection, color: '#555' }, grid: { color: 'rgba(0,0,0,0.1)', lineWidth: 1 } },\n                    x: { title: { display: false }, ticks: { color: '#555', maxRotation: 90, minRotation: 90 }, grid: { drawBorder: false, drawOnChartArea: true, color: 'rgba(0,0,0,0.05)' } }\n                },\n                layout: { padding: { top: 0, bottom: 10, left: 0, right: 0 } }\n            }\n        });\n\n        \/\/ Apply auto-hide to both charts using the dynamic chart objects\n        autoHideTooltip(chartSpeed, 5555);\n        autoHideTooltip(chartDirection, 5555);\n        \n        \/\/ --- Data Update Logic (No change) ---\n        function updateCharts() {\n            if (!windData || windData.length === 0) return;\n            \n            const labels = getDirectionLabels(windData);\n            const { windDirectionData, vrbData } = prepareDirectionData(windData); \n            \n            chartSpeed.data.labels = labels;\n            chartSpeed.data.datasets[0].data = windData.map(d => d.speed);\n            chartSpeed.data.datasets[1].data = windData.map(d => d.gust);\n            chartSpeed.update();\n\n            chartDirection.data.labels = labels;\n            chartDirection.data.datasets[0].data = vrbData;        \n            chartDirection.data.datasets[1].data = windDirectionData; \n            chartDirection.update();\n        }\n\n        async function fetchWindData() {\n            try {\n                const response = await fetch(JSON_URL);\n                if (!response.ok) throw new Error('Failed to fetch wind data file. Status: ' + response.status + ' from ' + JSON_URL);\n                \n                windData = await response.json(); \n                updateCharts();\n            } catch (e) {\n                console.error(\"Error loading stored wind data for \" + STATION_CODE + \":\", e);\n            }\n        }\n\n\/\/ Initial fetch and set interval for front-end updates\n        fetchWindData();\n        setInterval(fetchWindData, 5 * 60 * 1000);\n        \n    });\n    <\/script>\n    \n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n    <div style=\"max-width: 600px; margin: auto;\">\n \u00a0 \u00a0 \u00a0<canvas id=\"windGraphSpeed-lpma\" height=\"250\"><\/canvas>\n      <canvas id=\"windGraphDirection-lpma\" height=\"200\"><\/canvas>\n    <\/div>\n\n    <script>\n    document.addEventListener('DOMContentLoaded', function() {\n        \n        \/\/ Use the dynamic station code and JSON file URL\n        const STATION_CODE = 'lpma';\n        const JSON_URL = 'https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/metar-wind-lpma.json';\n        const TIME_OPTIONS = { hour: '2-digit', minute: '2-digit', hour12: false };\n        \n        let windData = [];\n\n \/\/ --- Helper Functions (No change) ---\n        function getDirectionLabels(data) {\n            \/\/ (functions remain the same)\n            return data.map(d => new Date(d.time).toLocaleTimeString([], TIME_OPTIONS));\n        }\n        \n        function formatDirection(value) {\n            if (value === 0) return 'N';\n            if (value === 90) return 'E';\n            if (value === 180) return 'S';\n            if (value === 270) return 'W';\n            if (value === 360) return 'N'; \n            return null;\n        }\n        \n        function getCardinalDirection(deg) {\n            if (deg === 1) return \"VRB\"; \n            const directions = [\"N\", \"NNE\", \"NE\", \"ENE\", \"E\", \"ESE\", \"SE\", \"SSE\", \"S\", \"SSW\", \"SW\", \"WSW\", \"W\", \"WNW\", \"NW\", \"NNW\"];\n            const index = Math.round(deg \/ 22.5) % 16; \n            return directions[index];\n        }\n\n        function prepareDirectionData(data) {\n            const windDirectionData = data.map(d => d.direction === 1 ? null : d.direction); \n            const vrbData = data.map(d => d.direction === 1 ? 0 : null); \n            return { windDirectionData, vrbData };\n        }\n\n    \/\/ --- Mobile Tooltip Auto-hide ---\n        function autoHideTooltip(chart, delay = 5555) {\n            chart.canvas.addEventListener('touchstart', function() {\n                setTimeout(() => {\n                    chart.tooltip.setActiveElements([]);\n                    chart.update();\n                }, delay);\n            });\n        }\n        \n \/\/ --- Chart Setup (Use Dynamic IDs) ---\n        const ctxSpeed = document.getElementById('windGraphSpeed-' + STATION_CODE)?.getContext('2d');\n        const ctxDirection = document.getElementById('windGraphDirection-' + STATION_CODE)?.getContext('2d');\n        \n        if (!ctxSpeed || !ctxDirection) {\n\/\/ This is expected if the shortcode is used multiple times on the same page \n\/\/ but the browser hasn't loaded the elements yet. Should be fine since \n\/\/ DOMContentLoaded is running. Add more specific error if needed.\n            return;\n        }\n        \n  \/\/ Get the full name for the title\n        let stationName = STATION_CODE.toUpperCase();\n        if (STATION_CODE === 'lpma') {\n            stationName = 'Madeira (LPMA)';\n        } else if (STATION_CODE === 'lpps') {\n            stationName = 'Porto Santo (LPPS)';\n        }\n\n        const chartSpeed = new Chart(ctxSpeed, {\n            type: 'line',\n            data: { labels: [], datasets: [\n                { label: 'Wind Speed', data: [], borderColor: 'rgba(54,162,235,1)', backgroundColor: 'rgba(54,162,235,0.1)', tension: 0.25, pointRadius: 3, fill: true },\n                { label: 'Gust', data: [], borderColor: 'rgba(255,165,80,0.8)', backgroundColor: 'rgba(255,165,80,0.1)', tension: 0.25, pointRadius: 3, fill: false }\n            ] },\n            options: {\n                responsive: true,\n                interaction: { mode: 'index', intersect: false },\n                plugins: { \n                    legend: { position: 'top', labels: { boxWidth: 10, color: '#333' } }, \n                    \/\/ Dynamic Title\n                    title: { display: true, text: 'Windspeed for ' + stationName + ' in the last 12 hours (km\/h)', font: { size: 14 } } \n                },\n                scales: {\n \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0 \u00a0y: { position: 'left', beginAtZero: true, grace: '10%', title: { display: false }, grid: { color: 'rgba(0,0,0,0.1)', lineWidth: 1 }, ticks: { color: '#555', autoSkip: false, } },\n                    x: { display: true, grid: { drawBorder: false, drawOnChartArea: true, color: 'rgba(0,0,0,0.05)' }, title: { display: false }, ticks: { display: true, maxRotation: 90, minRotation: 90 } }\n                },\n                layout: { padding: { top: 10, bottom: 0, left: 0, right: 0 } }\n            }\n        });\n        \n        const chartDirection = new Chart(ctxDirection, {\n            type: 'line',\n            data: { labels: [], datasets: [\n                { label: 'VRB Wind', data: [], borderColor: 'rgba(150,150,200,0)', backgroundColor: 'rgba(150,150,200,0.8)', pointRadius: 5, pointStyle: 'circle', showLine: false },\n                { label: 'Wind Direction (deg)', data: [], borderColor: 'rgba(75, 192, 192, 0.9)', backgroundColor: 'rgba(75, 192, 192, 0.1)', tension: 0.25, pointRadius: 4, showLine: false }\n            ] },\n            options: {\n                responsive: true,\n                interaction: { mode: 'index', intersect: false },\n                plugins: { \n                    legend: { display: false }, \n                    \/\/ Dynamic Title\n                    title: { display: true, text: 'Wind Direction for ' + stationName + ' in the last 12 hours', font: { size: 14 } },\n                    tooltip: { \n                        callbacks: {\n                            label: function(context) {\n                                const datasetLabel = context.dataset.label;\n                                const directionDeg = context.parsed.y;\n                                if (datasetLabel === 'VRB Wind') { return 'VRB Wind: Variable'; }\n                                \n                                let cardinal = getCardinalDirection(directionDeg);\n                                \/\/ The speed data needs to come from the chartSpeed object\n                                const speed = chartSpeed.data.datasets[0].data[context.dataIndex]; \n                                if (speed === 0) { cardinal = \"CALM\/VRB\"; }\n                                \n                                return `Wind Direction: ${cardinal} (${directionDeg}deg)`;\n                            },\n                            afterLabel: function(context) { return ''; },\n                            usePointStyle: true,\n                            pointStyle: 'dash' \n                        } \n                    }\n                },\n                scales: {\n                    y: { position: 'left', min: 0, max: 360, title: { display: false }, ticks: { stepSize: 90, callback: formatDirection, color: '#555' }, grid: { color: 'rgba(0,0,0,0.1)', lineWidth: 1 } },\n                    x: { title: { display: false }, ticks: { color: '#555', maxRotation: 90, minRotation: 90 }, grid: { drawBorder: false, drawOnChartArea: true, color: 'rgba(0,0,0,0.05)' } }\n                },\n                layout: { padding: { top: 0, bottom: 10, left: 0, right: 0 } }\n            }\n        });\n\n        \/\/ Apply auto-hide to both charts using the dynamic chart objects\n        autoHideTooltip(chartSpeed, 5555);\n        autoHideTooltip(chartDirection, 5555);\n        \n        \/\/ --- Data Update Logic (No change) ---\n        function updateCharts() {\n            if (!windData || windData.length === 0) return;\n            \n            const labels = getDirectionLabels(windData);\n            const { windDirectionData, vrbData } = prepareDirectionData(windData); \n            \n            chartSpeed.data.labels = labels;\n            chartSpeed.data.datasets[0].data = windData.map(d => d.speed);\n            chartSpeed.data.datasets[1].data = windData.map(d => d.gust);\n            chartSpeed.update();\n\n            chartDirection.data.labels = labels;\n            chartDirection.data.datasets[0].data = vrbData;        \n            chartDirection.data.datasets[1].data = windDirectionData; \n            chartDirection.update();\n        }\n\n        async function fetchWindData() {\n            try {\n                const response = await fetch(JSON_URL);\n                if (!response.ok) throw new Error('Failed to fetch wind data file. Status: ' + response.status + ' from ' + JSON_URL);\n                \n                windData = await response.json(); \n                updateCharts();\n            } catch (e) {\n                console.error(\"Error loading stored wind data for \" + STATION_CODE + \":\", e);\n            }\n        }\n\n\/\/ Initial fetch and set interval for front-end updates\n        fetchWindData();\n        setInterval(fetchWindData, 5 * 60 * 1000);\n        \n    });\n    <\/script>\n    \n\n\n\n<div style=\"height:8px\" aria-hidden=\"true\" class=\"wp-block-spacer\"><\/div>\n\n\n\n<figure class=\"wp-block-image aligncenter size-full has-custom-border is-style-rounded is-style-rounded--2\" style=\"margin-right:-15px;margin-left:-15px\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"1111\" src=\"https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/2025\/11\/Madeira-and-Porto-Santo-Airports.png\" alt=\"Madeira and porto santo airports\" class=\"wp-image-6308\" style=\"border-radius:22px\" srcset=\"https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/2025\/11\/Madeira-and-Porto-Santo-Airports.png 1024w, https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/2025\/11\/Madeira-and-Porto-Santo-Airports-277x300.png 277w, https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/2025\/11\/Madeira-and-Porto-Santo-Airports-944x1024.png 944w, https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/2025\/11\/Madeira-and-Porto-Santo-Airports-768x833.png 768w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary><\/summary>\n<p class=\"has-text-align-center\"><sup><a href=\"https:\/\/paragliding-in-madeira.com\/weather\/airport-live\/#wind-limits\" title=\"\"><em>You can see the wind limits on the airport page<\/em><\/a><\/sup><\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Airport info<\/th><\/tr><\/thead><\/table><\/figure>\n\n\n\n<figure class=\"wp-block-table is-style-stripes\" style=\"margin-right:0px;margin-left:0px\"><table><tbody><tr><td><\/td><td>Madeira <\/td><td>Porto Santo<\/td><\/tr><tr><td>Lenght<\/td><td>2780m<\/td><td>3000m<\/td><\/tr><tr><td>Width<\/td><td>45m<\/td><td>45m<\/td><\/tr><tr><td>Height asl<\/td><td>58m<\/td><td>104m<\/td><\/tr><tr><td>First open<\/td><td><small>7\/7\/1964<\/small><\/td><td><small>28\/8\/1960<\/small><\/td><\/tr><tr><td>icao code<\/td><td>LPMA<\/td><td>LPPS<\/td><\/tr><tr><td>Runway<\/td><td>05\/23<\/td><td>36\/18<\/td><\/tr><tr><td>Mainland<\/td><td>965km<\/td><td>908km<\/td><\/tr><\/tbody><\/table><\/figure>\n<\/details>\n\n\n\n<div style=\"height:22px\" aria-hidden=\"true\" class=\"wp-block-spacer\"><\/div>\n\n\n\n<!-- LPPS RAW METAR friendly Decoder Widget -->\n<div id=\"metarw-lpps\" style=\"font-family: Inter, system-ui, sans-serif; max-width: 555px; margin: auto;\">\n  <style>\n    #metarw-lpps * { \nbox-sizing: border-box; \nline-height: 1.4; \nfont-weight: 400; \n}\n    #metarw-lpps a { \ncolor: #1e40af; \ntext-decoration: \nunderline; \n}\n    #metarw-lpps p { \nmargin: 0.75rem 0; \ntext-align: justify; \n}\n    #metarw-lpps .card { \nbackground: #ffffff; \nborder-radius: 16px; \npadding: 1.25rem; \nbox-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05); border: 1px solid #e5e7eb; \n}\n    #metarw-lpps .header { \nmargin-bottom: 1rem; \n}\n    #metarw-lpps .title { \nfont-weight: 700; \ncolor: #1f2937; \nfont-size: 1.05rem; \ndisplay: flex; \nalign-items: center; \n}\n    #metarw-lpps .title span { \nmargin-left: 0.5rem; \nfont-size: 1rem; \ncolor: #4b5563; \nfont-weight: 500; \n}\n    #metarw-lpps #metarw-upd-lpps { \nfont-size: 0.85rem; \ncolor: #6b7280; \nmargin-top: 0.25rem; \n}\n    #metarw-lpps .section-label { \nfont-weight: 600; \nmargin-bottom: 0.2rem; \ndisplay: block; \ncolor: #1a1a1a; \nfont-size: 1.0rem; \nletter-spacing: 0.1em; \n}\n    #metarw-lpps #metarw-raw-lpps { \ndisplay: block; \nbackground-color: #f3f4f6; \ncolor: #1f2937; \nfont-family: monospace; \nfont-size: 0.9rem; \npadding: 0.45rem; \nborder-radius: 8px; \nwhite-space: pre-wrap; \nword-break: keep-all; \nmargin-top: 0.5rem; \n}\n    #metarw-lpps #metarw-decoded-lpps { \nfont-size: 1rem; \nfont-weight: 400; \ncolor: #1f2937; \ndisplay:block; \nmargin-top:0.5rem; \n}\n\n#metarw-lpps strong {\n  font-weight: 600;\n}\n\n    #metarw-lpps #metarw-thermal-lpps { \nfont-size: 0.84rem; \nfont-weight: 500; \ncolor: #1a4e8c; \ndisplay:block; \nmargin-top:0.2rem; \n}\n  <\/style>\n\n  <div class=\"card\">\n    <div class=\"header\">\n      <div class=\"title\">&#127780;&#65039; METAR LPPS <span>(Porto Santo &#8220;live&#8221;)<\/span><\/div>\n    <\/div>\n\n    <p>\n      <span class=\"section-label\">Official:<\/span>\n      <span id=\"metarw-raw-lpps\">Loading\u2026<\/span>\n    <\/p>\n\n    <p>\n      <span class=\"section-label\">Decoded:<\/span>\n      <span id=\"metarw-decoded-lpps\">Loading\u2026<\/span>\n      <span id=\"metarw-thermal-lpps\"><\/span>\n    <\/p>\n\n    <small id=\"metarw-upd-lpps\"><\/small>\n  <\/div>\n<\/div>\n\n<script>\n\/* --- INTEGRATED JAVASCRIPT: ROBUST PARSING FUNCTIONS & CLEAN OUTPUT --- *\/\n\n(async function() {\n  \/\/ --- CONFIG ---\n  const RAW_URL = \"https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/metar-full-data-lpps.json\";\n  const STATION_ELEVATION = 103; \/\/ LPPS station elevation in meters\n\n  \/\/ --- DOM ---\n  const rawEl = document.getElementById('metarw-raw-lpps');\n  const decodedEl = document.getElementById('metarw-decoded-lpps');\n  const thermalEl = document.getElementById('metarw-thermal-lpps');\n  const updEl = document.getElementById('metarw-upd-lpps');\n\n  \/\/ --- Helpers ---\n  const ktsToKmh = k => Math.round(Number(k) * 1.852);\n  const hundredsFeetToMeters = h => Math.round(Number(h) * 30.48);\n  const degToCardinal = deg => deg==='VRB'||isNaN(deg) ? 'variable' : ['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW'][Math.round(((Number(deg)%360)\/22.5))%16];\n  const feetToMeters = f => Math.round(Number(f)*0.3048);\n\n  function computeHumidity(tempC, dewC){\n    if(tempC==null || dewC==null) return null;\n    const a=17.625, b=243.04;\n    const alphaT=(a*tempC)\/(b+tempC);\n    const alphaD=(a*dewC)\/(b+dewC);\n    return Math.round(100*Math.exp(alphaD-alphaT));\n  }\n  \n  const intensityMap = {'+': 'heavy', '-': 'light', 'VC': 'in the vicinity'};\n  const descriptorMap = {'MI':'shallow','PR':'partial','BC':'patches of','DR':'low drifting','BL':'blowing','SH':'showers','TS':'thunderstorm','FZ':'freezing'};\n  const precipMap = {'DZ':'drizzle','RA':'rain','SN':'snow','SG':'snow grains','IC':'ice crystals','PL':'ice pellets','GR':'hail','GS':'small hail','UP':'unknown precipitation'};\n  const obscurationMap = {'BR':'mist','FG':'fog','FU':'smoke','VA':'volcanic ash','DU':'dust','SA':'sand','HZ':'haze','PY':'spray'};\n  const otherMap = {'SQ':'squalls','SS':'sandstorm','DS':'duststorm','PO':'dust\/sand whirls','FC':'tornado or waterspout','NSW':'no significant weather','NOSIG':'no significant change', 'RERA':'recent rain'};\n\n  function normalize(raw){\n    return raw.replace(\/\\s+\/g,' ').trim();\n  }\n\n  function parseMetarTime(raw, referenceDate = new Date()){\n    const m = raw.match(\/\\b(\\d{2})(\\d{2})(\\d{2})Z\\b\/);\n    if(!m) return null;\n    const day = Number(m[1]), hour = Number(m[2]), min = Number(m[3]);\n    let year = referenceDate.getUTCFullYear(), month = referenceDate.getUTCMonth();\n    const candidate = new Date(Date.UTC(year, month, day, hour, min));\n    if(candidate - referenceDate > 2*24*3600*1000){\n      const prev = new Date(Date.UTC(year, month-1, day, hour, min));\n      return prev;\n    }\n    if(referenceDate - candidate > 28*24*3600*1000){\n      const next = new Date(Date.UTC(year, month+1, day, hour, min));\n      return next;\n    }\n    return candidate;\n  }\n\n  \/\/ --- ROBUST WEATHER PARSING FUNCTION ---\n  function parseWeatherGroups(raw){\n    const re = \/([+-])?(VC)?([A-Z]{2,6})\/g;\n    const matches = [];\n    let m;\n    while((m = re.exec(raw)) !== null){\n      const rawToken = m[0];\n      const intensity = m[1] || '';\n      const vicinity = Boolean(m[2]);\n      const code = m[3];\n      const keys = ['TS','SH','DZ','RA','SN','SG','PL','GR','GS','FG','BR','HZ','DU','SA','SQ','FC','IC','UP','RERA','VC','MI','BC','PR','DR','BL','PO','FZ','SS','DS','NSW','NOSIG'];\n      const found = keys.some(k => code.includes(k));\n      if(found){\n        matches.push({ raw: rawToken, intensity, vicinity, code });\n      }\n    }\n    const uniq = [];\n    const seen = new Set();\n    for(const it of matches){\n      const key = it.raw + '|' + it.code;\n      if(seen.has(key)) continue;\n      seen.add(key);\n      uniq.push(it);\n    }\n    const humanized = uniq.map(it => {\n      const code = it.code;\n      if(code === 'RERA') return { ...it, human: otherMap['RERA'] };\n\n      const partRe = \/(TS|SH|DZ|RA|SN|SG|PL|GR|GS|FG|BR|HZ|DU|SA|SQ|FC|IC|UP|MI|BC|PR|DR|BL|PO|FZ|SS|DS|NSW)\/g;\n      const parts = code.match(partRe) || [code];\n      const mapped = parts.map(p => {\n        if(descriptorMap[p]) return descriptorMap[p];\n        if(precipMap[p]) return precipMap[p];\n        if(obscurationMap[p]) return obscurationMap[p];\n        if(otherMap[p]) return otherMap[p];\n        return p.toLowerCase();\n      });\n      const human = mapped.join(' ');\n      \n      let finalHuman = human;\n      if (it.intensity) {\n        finalHuman = `${intensityMap[it.intensity]} ${finalHuman}`;\n      }\n      if (it.vicinity) {\n        finalHuman = `${finalHuman} ${intensityMap['VC']}`;\n      }\n      \n      return { ...it, human: finalHuman.trim() };\n    });\n\n    return humanized;\n  }\n  \n  function parseClouds(raw){\n    const re = \/(FEW|SCT|BKN|OVC|NSC|SKC|CLR)(\\d{3}|000)?(CB|TCU)?\/g;\n    const out = [];\n    let m;\n    while((m = re.exec(raw)) !== null){\n      const cover = m[1];\n      const hundreds = m[2] ? m[2] : null;\n      const extra = m[3] || null;\n      const meters = hundreds ? hundredsFeetToMeters(Number(hundreds)) : null;\n      out.push({ cover, hundreds, meters, extra });\n    }\n    return out;\n  }\n  \n  function parseWind(raw){\n    const m = raw.match(\/\\b((?:VRB|\\d{3}))(\\d{2})(?:G(\\d{2,3}))?KT\\b\/);\n    const variable = raw.match(\/\\b(\\d{3})V(\\d{3})\\b\/);\n    if(!m) return { dir: null };\n    const dir = m[1] === 'VRB' ? 'VRB' : m[1];\n    const speedKts = Number(m[2]);\n    const gustKts = m[3] ? Number(m[3]) : null;\n    const speedKmh = ktsToKmh(speedKts);\n    const gustKmh = gustKts ? ktsToKmh(gustKts) : null;\n    const variableRange = variable ? { from: variable[1], to: variable[2], fromCard: degToCardinal(variable[1]), toCard: degToCardinal(variable[2]) } : null;\n    return { dir, dirCardinal: degToCardinal(dir), speedKts, speedKmh, gustKts, gustKmh, variableRange };\n  }\n  \n  function parseVisibility(raw){\n    if(\/\\bCAVOK\\b\/.test(raw)) return { meters: 10000, human: '10+ km (CAVOK)' };\n    const m = raw.match(\/\\b(\\d{4})\\b\/);\n    if(!m) return { meters: null, human: 'unknown' };\n    const meters = Number(m[1]);\n    const human = meters >= 9999 ? '10+ km' : `${Math.round(meters\/1000*10)\/10} km`;\n    return { meters, human };\n  }\n\n  function parseTempDew(raw){\n    const m = raw.match(\/\\b(M?\\d{2})\\\/(M?\\d{2})\\b\/);\n    if(!m) return null;\n    const parseVal = s => (s.startsWith('M') ? -Number(s.slice(1)) : Number(s));\n    const temp = parseVal(m[1]);\n    const dew = parseVal(m[2]);\n    return { temp, dew };\n  }\n\n  function parsePressure(raw){\n    const q = raw.match(\/\\bQ(\\d{4})\\b\/);\n    if(q) return { hPa: Number(q[1]) };\n    const a = raw.match(\/\\bA(\\d{4})\\b\/);\n    if(a) return { inHg: Number(a[1]) \/ 100 };\n    return null;\n  }\n\n  \/\/ --- REVISED humanizeClouds FUNCTION ---\n  function humanizeClouds(clouds){\n    if(!clouds || clouds.length===0) return 'no significant clouds';\n    const map = { FEW:'a few', SCT:'scattered', BKN:'broken', OVC:'overcast', NSC:'no significant clouds', SKC:'sky clear', CLR:'sky clear' };\n    return clouds.map(c=>{\n      if(c.cover === 'SKC' || c.cover === 'CLR' || c.cover === 'NSC') return map[c.cover];\n      const h = c.meters !== null ? `${Math.round(c.meters)}m` : 'unknown height';\n      \n      let cloudType = ' clouds'; \/\/ Default to \"clouds\"\n      if (c.extra === 'CB') cloudType = ' cumulonimbus';\n      else if (c.extra === 'TCU') cloudType = ' towering cumulus';\n      \n      const label = map[c.cover] || c.cover.toLowerCase();\n      \/\/ Combine: [label] + [cloudType] + \" at \" + [height]\n      return `${label}${cloudType} at ${h}`;\n    }).join(', ');\n  }\n  \n  function estimateCloudBase(temp, dew, stationElevation=STATION_ELEVATION){\n    if(temp==null || dew==null) return null;\n    const spread = temp - dew;\n    const obsTime = new Date();\n    const hourUTC = obsTime.getUTCHours();\n    if(hourUTC < 8 || hourUTC >= 20) return null; \n    return Math.round(spread * 125 + stationElevation);\n  }\n\n\n  \/\/ --- MAIN LOAD AND RENDER LOOP ---\n  async function loadAndRender() {\n    try {\n      rawEl.textContent = 'Loading\u2026';\n      decodedEl.textContent = 'Loading\u2026';\n      thermalEl.textContent = '';\n      updEl.textContent = '';\n\n      const resp = await fetch(`${RAW_URL}?t=${Date.now()}`, { cache: 'no-store' });\n      if(!resp.ok) throw new Error(`Error ${resp.status}`);\n\n      const data = await resp.json();\n      const rec = (Array.isArray(data) ? data[0] : data) || {};\n      const raw = (rec.rawOb || rec.rawTAF || rec.raw || '').toString().trim();\n      if(!raw) throw new Error('Raw METAR not found in response');\n\n      const normalized = normalize(raw);\n      rawEl.textContent = normalized;\n\n      \/\/ --- PARSE ALL GROUPS FROM RAW ---\n      const parsed = {};\n      parsed.reportTime = parseMetarTime(normalized, new Date());\n      parsed.wind = parseWind(normalized);\n      parsed.vis = parseVisibility(normalized);\n      parsed.clouds = parseClouds(normalized);\n      parsed.weatherArr = parseWeatherGroups(normalized);\n      parsed.tempDew = parseTempDew(normalized);\n      parsed.pressure = parsePressure(normalized);\n      parsed.humidity = parsed.tempDew ? computeHumidity(parsed.tempDew.temp, parsed.tempDew.dew) : null;\n      parsed.cloudBase = parsed.tempDew ? estimateCloudBase(parsed.tempDew.temp, parsed.tempDew.dew, STATION_ELEVATION) : null;\n      \n      \n      \/\/ --- FORMATTING DECODED TEXT ---\n      \n      \/\/ Wind\n      let windDirText = parsed.wind.dir === 'VRB' ? 'variable direction' : `${parsed.wind.dirCardinal} (${parsed.wind.dir}\u00b0)`;\n      if (parsed.wind.variableRange) {\n        windDirText += `, varying between ${parsed.wind.variableRange.fromCard} (${parsed.wind.variableRange.from}\u00b0) and ${parsed.wind.variableRange.toCard} (${parsed.wind.variableRange.to}\u00b0)`;\n      }\n      const windSpd = parsed.wind.speedKmh ? `${parsed.wind.speedKmh} km\/h` : 'calm';\n      const gustText = parsed.wind.gustKmh ? `, gusting to ${parsed.wind.gustKmh} km\/h` : '';\n\n      \/\/ Visibility\n      let vis = parsed.vis.human;\n      \n      \/\/ Clouds\n      let cloudsText = humanizeClouds(parsed.clouds);\n\n      \/\/ Weather\n      let weatherText = '';\n      if(parsed.weatherArr.length){\n        weatherText = parsed.weatherArr.map(w => w.human).join(', ');\n      }\n\n      \/\/ Temp & Pressure\n      const temp = parsed.tempDew ? `${parsed.tempDew.temp}\u00b0C` : 'unknown';\n      const dew = parsed.tempDew ? `${parsed.tempDew.dew}\u00b0C` : 'unknown';\n      const pressText = parsed.pressure?.hPa ? `${parsed.pressure.hPa} hPa` : (parsed.pressure?.inHg ? `${parsed.pressure.inHg.toFixed(2)} inHg` : 'unknown');\n      const humidityText = parsed.humidity ? `${parsed.humidity}% humidity` : '';\n      \n      \/\/ --- Decoded text ---\n      decodedEl.innerHTML = `\n  <strong>Wind:<\/strong> ${windDirText} at ${windSpd}${gustText}<br>\n  <strong>Visibility:<\/strong> ${vis} with ${cloudsText}${weatherText ? ` and ${weatherText}` : ''}<br>\n  <strong>Temp:<\/strong> ${temp} \/ Dew point: ${dew}<br>\n  ${humidityText}<br>\n  <strong>Pressure:<\/strong> ${pressText}\n`;\n\n      \/\/ --- Last updated ---\n      const obsTime = parsed.reportTime ? parsed.reportTime : new Date();\n      updEl.textContent = `Last updated: ${obsTime.toLocaleTimeString('en-GB',{hour12:false,timeZone:'UTC',hour:'2-digit',minute:'2-digit'})} UTC`;\n\n      \/\/ --- Thermal lift (daytime only) ---\n      if(parsed.cloudBase){\n        thermalEl.textContent = `Thermal lift potential, ${parsed.cloudBase}m est. cloud base`;\n      } else {\n        thermalEl.textContent = '';\n      }\n\n    } catch(err){\n      console.error('[lpps METAR Widget] Fetch failed:',err);\n      rawEl.textContent='Error fetching METAR.';\n      decodedEl.textContent=err.message;\n      thermalEl.textContent='';\n      updEl.textContent='';\n    }\n  }\n\n  await loadAndRender();\n  setInterval(loadAndRender,30*60*1000);\n})();\n<\/script>\n\n\n\n\n<div style=\"height:8px\" aria-hidden=\"true\" class=\"wp-block-spacer\"><\/div>\n\n\n\n<!-- LPMA RAW METAR friendly Decoder Widget -->\n<div id=\"metarw-lpma\" style=\"font-family: Inter, system-ui, sans-serif; max-width: 555px; margin: auto;\">\n  <style>\n  \n    #metarw-lpma * { \nbox-sizing: border-box; \nline-height: 1.4; \nfont-weight: 400; \n}\n    #metarw-lpma a { \ncolor: #1e40af; \ntext-decoration: \nunderline; \n}\n    #metarw-lpma p { \nmargin: 0.75rem 0; \ntext-align: justify; \n}\n    #metarw-lpma .card { \nbackground: #ffffff; \nborder-radius: 16px; \npadding: 1.25rem; \nbox-shadow: 0 10px 15px -3px rgba(0,0,0,0.1), 0 4px 6px -2px rgba(0,0,0,0.05); border: 1px solid #e5e7eb; \n}\n    #metarw-lpma .header { \nmargin-bottom: 1rem; \n}\n    #metarw-lpma .title { \nfont-weight: 700; \ncolor: #1f2937; \nfont-size: 1.05rem; \ndisplay: flex; \nalign-items: center; \n}\n    #metarw-lpma .title span { \nmargin-left: 0.5rem; \nfont-size: 1rem; \ncolor: #4b5563; \nfont-weight: 500; \n}\n    #metarw-lpma #metarw-upd-lpma { \nfont-size: 0.85rem; \ncolor: #6b7280; \nmargin-top: 0.25rem; \n}\n    #metarw-lpma .section-label { \nfont-weight: 600; \nmargin-bottom: 0.2rem; \ndisplay: block; \ncolor: #1a1a1a; \nfont-size: 1.0rem; \nletter-spacing: 0.1em; \n}\n    #metarw-lpma #metarw-raw-lpma { \ndisplay: block; \nbackground-color: #f3f4f6; \ncolor: #1f2937; \nfont-family: monospace; \nfont-size: 0.9rem; \npadding: 0.45rem; \nborder-radius: 8px; \nwhite-space: pre-wrap; \nword-break: keep-all; \nmargin-top: 0.5rem; \n}\n    #metarw-lpma #metarw-decoded-lpma { \nfont-size: 1rem; \nfont-weight: 400; \ncolor: #1f2937; \ndisplay:block; \nmargin-top:0.5rem; \n}\n\n#metarw-lpma strong {\n  font-weight: 600;\n}\n\n    #metarw-lpma #metarw-thermal-lpma { \nfont-size: 0.84rem; \nfont-weight: 500; \ncolor: #1a4e8c; \ndisplay:block; \nmargin-top:0.2rem; \n}\n  <\/style>\n\n  <div class=\"card\">\n    <div class=\"header\">\n      <div class=\"title\">&#127780;&#65039; METAR LPMA <span>(Madeira &#8220;live&#8221;)<\/span><\/div>\n    <\/div>\n\n    <p>\n      <span class=\"section-label\">Official:<\/span>\n      <span id=\"metarw-raw-lpma\">Loading\u2026<\/span>\n    <\/p>\n\n    <p>\n      <span class=\"section-label\">Decoded:<\/span>\n      <span id=\"metarw-decoded-lpma\">Loading\u2026<\/span>\n      <span id=\"metarw-thermal-lpma\"><\/span>\n    <\/p>\n\n    <small id=\"metarw-upd-lpma\"><\/small>\n  <\/div>\n<\/div>\n\n<script>\n\/* --- INTEGRATED JAVASCRIPT: ROBUST PARSING FUNCTIONS & CLEAN OUTPUT (LPMA) --- *\/\n\n(async function() {\n  \/\/ --- CONFIG ---\n  \/\/ 1. RAW_URL changed to point to LPMA data\n  const RAW_URL = \"https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/metar-full-data-lpma.json\";\n  \/\/ 2. STATION_ELEVATION (LPMA's is ~58m\/192ft, same as LPPS, but kept explicit)\n  const STATION_ELEVATION = 58; \n\n  \/\/ --- DOM ---\n  \/\/ 3. DOM ID variables changed (lpps -> lpma)\n  const rawEl = document.getElementById('metarw-raw-lpma');\n  const decodedEl = document.getElementById('metarw-decoded-lpma');\n  const thermalEl = document.getElementById('metarw-thermal-lpma');\n  const updEl = document.getElementById('metarw-upd-lpma');\n\n  \/\/ --- Helpers (Functions remain identical as they are robust parsers) ---\n  const ktsToKmh = k => Math.round(Number(k) * 1.852);\n  const hundredsFeetToMeters = h => Math.round(Number(h) * 30.48);\n  const degToCardinal = deg => deg==='VRB'||isNaN(deg) ? 'variable' : ['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW'][Math.round(((Number(deg)%360)\/22.5))%16];\n  const feetToMeters = f => Math.round(Number(f)*0.3048);\n\n  function computeHumidity(tempC, dewC){\n    if(tempC==null || dewC==null) return null;\n    const a=17.625, b=243.04;\n    const alphaT=(a*tempC)\/(b+tempC);\n    const alphaD=(a*dewC)\/(b+dewC);\n    return Math.round(100*Math.exp(alphaD-alphaT));\n  }\n  \n  const intensityMap = {'+': 'heavy', '-': 'light', 'VC': 'in the vicinity'};\n  const descriptorMap = {'MI':'shallow','PR':'partial','BC':'patches of','DR':'low drifting','BL':'blowing','SH':'showers','TS':'thunderstorm','FZ':'freezing'};\n  const precipMap = {'DZ':'drizzle','RA':'rain','SN':'snow','SG':'snow grains','IC':'ice crystals','PL':'ice pellets','GR':'hail','GS':'small hail','UP':'unknown precipitation'};\n  const obscurationMap = {'BR':'mist','FG':'fog','FU':'smoke','VA':'volcanic ash','DU':'dust','SA':'sand','HZ':'haze','PY':'spray'};\n  const otherMap = {'SQ':'squalls','SS':'sandstorm','DS':'duststorm','PO':'dust\/sand whirls','FC':'tornado or waterspout','NSW':'no significant weather','NOSIG':'no significant change', 'RERA':'recent rain'};\n\n  function normalize(raw){\n    return raw.replace(\/\\s+\/g,' ').trim();\n  }\n\n  function parseMetarTime(raw, referenceDate = new Date()){\n    const m = raw.match(\/\\b(\\d{2})(\\d{2})(\\d{2})Z\\b\/);\n    if(!m) return null;\n    const day = Number(m[1]), hour = Number(m[2]), min = Number(m[3]);\n    let year = referenceDate.getUTCFullYear(), month = referenceDate.getUTCMonth();\n    const candidate = new Date(Date.UTC(year, month, day, hour, min));\n    if(candidate - referenceDate > 2*24*3600*1000){\n      const prev = new Date(Date.UTC(year, month-1, day, hour, min));\n      return prev;\n    }\n    if(referenceDate - candidate > 28*24*3600*1000){\n      const next = new Date(Date.UTC(year, month+1, day, hour, min));\n      return next;\n    }\n    return candidate;\n  }\n\n  function parseWeatherGroups(raw){\n    const re = \/([+-])?(VC)?([A-Z]{2,6})\/g;\n    const matches = [];\n    let m;\n    while((m = re.exec(raw)) !== null){\n      const rawToken = m[0];\n      const intensity = m[1] || '';\n      const vicinity = Boolean(m[2]);\n      const code = m[3];\n      const keys = ['TS','SH','DZ','RA','SN','SG','PL','GR','GS','FG','BR','HZ','DU','SA','SQ','FC','IC','UP','RERA','VC','MI','BC','PR','DR','BL','PO','FZ','SS','DS','NSW','NOSIG'];\n      const found = keys.some(k => code.includes(k));\n      if(found){\n        matches.push({ raw: rawToken, intensity, vicinity, code });\n      }\n    }\n    const uniq = [];\n    const seen = new Set();\n    for(const it of matches){\n      const key = it.raw + '|' + it.code;\n      if(seen.has(key)) continue;\n      seen.add(key);\n      uniq.push(it);\n    }\n    const humanized = uniq.map(it => {\n      const code = it.code;\n      if(code === 'RERA') return { ...it, human: otherMap['RERA'] };\n\n      const partRe = \/(TS|SH|DZ|RA|SN|SG|PL|GR|GS|FG|BR|HZ|DU|SA|SQ|FC|IC|UP|MI|BC|PR|DR|BL|PO|FZ|SS|DS|NSW)\/g;\n      const parts = code.match(partRe) || [code];\n      const mapped = parts.map(p => {\n        if(descriptorMap[p]) return descriptorMap[p];\n        if(precipMap[p]) return precipMap[p];\n        if(obscurationMap[p]) return obscurationMap[p];\n        if(otherMap[p]) return otherMap[p];\n        return p.toLowerCase();\n      });\n      const human = mapped.join(' ');\n      \n      let finalHuman = human;\n      if (it.intensity) {\n        finalHuman = `${intensityMap[it.intensity]} ${finalHuman}`;\n      }\n      if (it.vicinity) {\n        finalHuman = `${finalHuman} ${intensityMap['VC']}`;\n      }\n      \n      return { ...it, human: finalHuman.trim() };\n    });\n\n    return humanized;\n  }\n  \n  function parseClouds(raw){\n    const re = \/(FEW|SCT|BKN|OVC|NSC|SKC|CLR)(\\d{3}|000)?(CB|TCU)?\/g;\n    const out = [];\n    let m;\n    while((m = re.exec(raw)) !== null){\n      const cover = m[1];\n      const hundreds = m[2] ? m[2] : null;\n      const extra = m[3] || null;\n      const meters = hundreds ? hundredsFeetToMeters(Number(hundreds)) : null;\n      out.push({ cover, hundreds, meters, extra });\n    }\n    return out;\n  }\n  \n  function parseWind(raw){\n    const m = raw.match(\/\\b((?:VRB|\\d{3}))(\\d{2})(?:G(\\d{2,3}))?KT\\b\/);\n    const variable = raw.match(\/\\b(\\d{3})V(\\d{3})\\b\/);\n    if(!m) return { dir: null };\n    const dir = m[1] === 'VRB' ? 'VRB' : m[1];\n    const speedKts = Number(m[2]);\n    const gustKts = m[3] ? Number(m[3]) : null;\n    const speedKmh = ktsToKmh(speedKts);\n    const gustKmh = gustKts ? ktsToKmh(gustKts) : null;\n    const variableRange = variable ? { from: variable[1], to: variable[2], fromCard: degToCardinal(variable[1]), toCard: degToCardinal(variable[2]) } : null;\n    return { dir, dirCardinal: degToCardinal(dir), speedKts, speedKmh, gustKts, gustKmh, variableRange };\n  }\n  \n  function parseVisibility(raw){\n    if(\/\\bCAVOK\\b\/.test(raw)) return { meters: 10000, human: '10+ km (CAVOK)' };\n    const m = raw.match(\/\\b(\\d{4})\\b\/);\n    if(!m) return { meters: null, human: 'unknown' };\n    const meters = Number(m[1]);\n    const human = meters >= 9999 ? '10+ km' : `${Math.round(meters\/1000*10)\/10} km`;\n    return { meters, human };\n  }\n\n  function parseTempDew(raw){\n    const m = raw.match(\/\\b(M?\\d{2})\\\/(M?\\d{2})\\b\/);\n    if(!m) return null;\n    const parseVal = s => (s.startsWith('M') ? -Number(s.slice(1)) : Number(s));\n    const temp = parseVal(m[1]);\n    const dew = parseVal(m[2]);\n    return { temp, dew };\n  }\n\n  function parsePressure(raw){\n    const q = raw.match(\/\\bQ(\\d{4})\\b\/);\n    if(q) return { hPa: Number(q[1]) };\n    const a = raw.match(\/\\bA(\\d{4})\\b\/);\n    if(a) return { inHg: Number(a[1]) \/ 100 };\n    return null;\n  }\n\n  \/\/ --- REVISED humanizeClouds FUNCTION ---\n  function humanizeClouds(clouds){\n    if(!clouds || clouds.length===0) return 'no significant clouds';\n    const map = { FEW:'a few', SCT:'scattered', BKN:'broken', OVC:'overcast', NSC:'no significant clouds', SKC:'sky clear', CLR:'sky clear' };\n    return clouds.map(c=>{\n      if(c.cover === 'SKC' || c.cover === 'CLR' || c.cover === 'NSC') return map[c.cover];\n      const h = c.meters !== null ? `${Math.round(c.meters)}m` : 'unknown height';\n      \n      let cloudType = ' clouds';\n      if (c.extra === 'CB') cloudType = ' cumulonimbus';\n      else if (c.extra === 'TCU') cloudType = ' towering cumulus';\n      \n      const label = map[c.cover] || c.cover.toLowerCase();\n      return `${label}${cloudType} at ${h}`;\n    }).join(', ');\n  }\n  \n  function estimateCloudBase(temp, dew, stationElevation=STATION_ELEVATION){\n    if(temp==null || dew==null) return null;\n    const spread = temp - dew;\n    const obsTime = new Date();\n    const hourUTC = obsTime.getUTCHours();\n    if(hourUTC < 8 || hourUTC >= 20) return null; \n    return Math.round(spread * 125 + stationElevation);\n  }\n\n\n  \/\/ --- MAIN LOAD AND RENDER LOOP ---\n  async function loadAndRender() {\n    try {\n      rawEl.textContent = 'Loading\u2026';\n      decodedEl.textContent = 'Loading\u2026';\n      thermalEl.textContent = '';\n      updEl.textContent = '';\n\n      const resp = await fetch(`${RAW_URL}?t=${Date.now()}`, { cache: 'no-store' });\n      if(!resp.ok) throw new Error(`Error ${resp.status}`);\n\n      const data = await resp.json();\n      const rec = (Array.isArray(data) ? data[0] : data) || {};\n      const raw = (rec.rawOb || rec.rawTAF || rec.raw || '').toString().trim();\n      if(!raw) throw new Error('Raw METAR not found in response');\n\n      const normalized = normalize(raw);\n      rawEl.textContent = normalized;\n\n      \/\/ --- PARSE ALL GROUPS FROM RAW ---\n      const parsed = {};\n      parsed.reportTime = parseMetarTime(normalized, new Date());\n      parsed.wind = parseWind(normalized);\n      parsed.vis = parseVisibility(normalized);\n      parsed.clouds = parseClouds(normalized);\n      parsed.weatherArr = parseWeatherGroups(normalized);\n      parsed.tempDew = parseTempDew(normalized);\n      parsed.pressure = parsePressure(normalized);\n      parsed.humidity = parsed.tempDew ? computeHumidity(parsed.tempDew.temp, parsed.tempDew.dew) : null;\n      parsed.cloudBase = parsed.tempDew ? estimateCloudBase(parsed.tempDew.temp, parsed.tempDew.dew, STATION_ELEVATION) : null;\n      \n      \n      \/\/ --- FORMATTING DECODED TEXT ---\n      \n      \/\/ Wind\n      let windDirText = parsed.wind.dir === 'VRB' ? 'variable direction' : `${parsed.wind.dirCardinal} (${parsed.wind.dir}\u00b0)`;\n      if (parsed.wind.variableRange) {\n        windDirText += `, varying between ${parsed.wind.variableRange.fromCard} (${parsed.wind.variableRange.from}\u00b0) and ${parsed.wind.variableRange.toCard} (${parsed.wind.variableRange.to}\u00b0)`;\n      }\n      const windSpd = parsed.wind.speedKmh ? `${parsed.wind.speedKmh} km\/h` : 'calm';\n      const gustText = parsed.wind.gustKmh ? `, gusting to ${parsed.wind.gustKmh} km\/h` : '';\n\n      \/\/ Visibility\n      let vis = parsed.vis.human;\n      \n      \/\/ Clouds\n      let cloudsText = humanizeClouds(parsed.clouds);\n\n      \/\/ Weather\n      let weatherText = '';\n      if(parsed.weatherArr.length){\n        weatherText = parsed.weatherArr.map(w => w.human).join(', ');\n      }\n\n      \/\/ Temp & Pressure\n      const temp = parsed.tempDew ? `${parsed.tempDew.temp}\u00b0C` : 'unknown';\n      const dew = parsed.tempDew ? `${parsed.tempDew.dew}\u00b0C` : 'unknown';\n      const pressText = parsed.pressure?.hPa ? `${parsed.pressure.hPa} hPa` : (parsed.pressure?.inHg ? `${parsed.pressure.inHg.toFixed(2)} inHg` : 'unknown');\n      const humidityText = parsed.humidity ? `${parsed.humidity}% humidity` : '';\n      \n      \/\/ --- Decoded text ---\n      decodedEl.innerHTML = `\n  <strong>Wind:<\/strong> ${windDirText} at ${windSpd}${gustText}<br>\n  <strong>Visibility:<\/strong> ${vis} with ${cloudsText}${weatherText ? ` and ${weatherText}` : ''}<br>\n  <strong>Temp:<\/strong> ${temp} \/ Dew point: ${dew}<br>\n  ${humidityText}<br>\n  <strong>Pressure:<\/strong> ${pressText}\n`;\n\n      \/\/ --- Last updated ---\n      const obsTime = parsed.reportTime ? parsed.reportTime : new Date();\n      updEl.textContent = `Last updated: ${obsTime.toLocaleTimeString('en-GB',{hour12:false,timeZone:'UTC',hour:'2-digit',minute:'2-digit'})} UTC`;\n\n      \/\/ --- Thermal lift (daytime only) ---\n      if(parsed.cloudBase){\n        thermalEl.textContent = `Thermal lift potential, ${parsed.cloudBase}m est. cloud base`;\n      } else {\n        thermalEl.textContent = '';\n      }\n\n    } catch(err){\n      console.error('[lpma METAR Widget] Fetch failed:',err);\n      rawEl.textContent='Error fetching METAR.';\n      decodedEl.textContent=err.message;\n      thermalEl.textContent='';\n      updEl.textContent='';\n    }\n  }\n\n  await loadAndRender();\n  setInterval(loadAndRender,30*60*1000);\n})();\n<\/script>\n\n\n\n\n<div style=\"height:8px\" aria-hidden=\"true\" class=\"wp-block-spacer\"><\/div>\n\n\n\n<figure class=\"wp-block-image aligncenter size-full is-resized has-custom-border is-style-rounded is-style-rounded--3\"><img loading=\"lazy\" decoding=\"async\" width=\"1888\" height=\"1888\" src=\"https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/2025\/10\/cloud-cover3.webp\" alt=\"Cloud cover3\" class=\"wp-image-6206\" style=\"border-radius:33px;width:444px\" srcset=\"https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/2025\/10\/cloud-cover3.webp 1888w, https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/2025\/10\/cloud-cover3-300x300.webp 300w, https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/2025\/10\/cloud-cover3-1024x1024.webp 1024w, https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/2025\/10\/cloud-cover3-150x150.webp 150w, https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/2025\/10\/cloud-cover3-768x768.webp 768w, https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/2025\/10\/cloud-cover3-1536x1536.webp 1536w\" sizes=\"auto, (max-width: 1888px) 100vw, 1888px\" \/><\/figure>\n\n\n\n<div style=\"height:11px\" aria-hidden=\"true\" class=\"wp-block-spacer\"><\/div>\n\n\n\n<!-- LPPS TAF Gold Decoder Widget -->\n<div id=\"tafw-lpps\" style=\"font-family: Inter, system-ui, sans-serif; max-width: 555px; margin: auto;\">\n  <style>\n\n    \/* Reset and general styling *\/\n    #tafw-lpps * { \nbox-sizing: border-box; \nline-height: 1.4; \nfont-weight: 400; \n}\n    #tafw-lpps a { \ncolor: #1e40af; \ntext-decoration: underline; \n}\n    #tafw-lpps p { \nmargin: 0.75rem 0; \ntext-align: justify; \n}\n\n    \/* Card Container *\/\n    #tafw-lpps .card { \nbackground: #ffffff; \nborder-radius: 16px; \npadding: 1.25rem; \nbox-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); \nborder: 1px solid #e5e7eb; \n}\n\n    \/* Header and Title *\/\n    #tafw-lpps .header { \nmargin-bottom: 1.25rem; \npadding-bottom: 0.5rem; \nborder-bottom: 1px solid #f3f4f6; \n}\n    #tafw-lpps .title { \nfont-weight: 700; \ncolor: #1f2937; \nfont-size: 1.05rem; \ndisplay: flex; \nalign-items: center; \n}\n    #tafw-lpps .title span { \nmargin-left: 0.5rem; \nfont-size: 1rem; \ncolor: #4b5563; \nfont-weight: 500; }\n    #tafw-lpps #tafw-issued-lpps { \nfont-size: 0.85rem; \ncolor: #6b7280; \nmargin-top: 0.25rem; \n}\n\n    \/* Section Labels *\/\n    #tafw-lpps .section-label { \nfont-weight: 600; \nmargin-bottom: 0.2rem; \ndisplay: block; \ncolor: #1a1a1a; \nfont-size: 0.9rem; \nletter-spacing: 0.1em; \n}\n\n    \/* Raw TAF Code Block *\/\n    #tafw-lpps #tafw-raw-lpps { \ndisplay: block; \nbackground-color: #f3f4f6; \ncolor: #1f2937; \nfont-family: monospace; \nfont-size: 0.9rem; \npadding: 0.75rem; \nborder-radius: 8px; \nwhite-space: pre-wrap; \nword-break: keep-all; \nmargin-top: 0.5rem; \n}\n    \/* Decoded Summary *\/\n    #tafw-lpps #tafw-summary-lpps { \nfont-size: 1rem; \nfont-weight: 500; \ncolor: #1f2937; \n}\n\n    \/* Toggle Button *\/\n    #tafw-lpps button { \nbackground: transparent; \nfont-weight: 500; \nfont-family: system-ui, \nsans-serif; \nborder: none; \nfont-size: 1.0em; \ncursor: pointer; \nmargin-top: 0.2em; \n}\n    #tafw-lpps button:hover { \nbackground: transparent; \n}\n\n    \/* Details Section *\/\n    #tafw-lpps #tafw-details-lpps { \nmargin-top: 1rem; \nborder-top: 1px solid #e5e7eb; \npadding-top: 1rem; \nfont-size: 0.95rem; \ncolor: #374151;\n}\n    #tafw-lpps #tafw-details-lpps p { \nmargin: 0.5rem 0; \nline-height: 1.6; \n}\n    #tafw-lpps #tafw-details-lpps strong { \nfont-weight: 600; \ncolor: #1f2937; \n}\n\n  <\/style>\n\n  <div class=\"card\">\n    <div class=\"header\">\n      <div>\n        <div class=\"title\">&#9992;&#65039;  TAF LPPS <span>(Porto Santo Forecast)<\/span><\/div>\n        <div id=\"tafw-issued-lpps\"><\/div>\n      <\/div>\n    <\/div>\n\n    <p>\n      <span class=\"section-label\">Official:<\/span>\n      <span id=\"tafw-raw-lpps\">Loading\u2026<\/span>\n    <\/p>\n\n    <p style=\"margin-top: 1.5rem;\">\n      <span class=\"section-label\">Decoded:<\/span>\n      <span id=\"tafw-summary-lpps\">Loading\u2026<\/span>\n    <\/p>\n\n    <button id=\"tafw-toggle-lpps\" aria-expanded=\"false\" title=\"&#10133;\" style=\"display:none;\">&#10133;<\/button>\n    <div id=\"tafw-details-lpps\" style=\"display:none;\"><\/div>\n  <\/div>\n<\/div>\n\n<script>\n(async function() {\n  const RAW_URL = \"https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/taf-full-data-lpps.json\";\n\n  const rawEl = document.getElementById('tafw-raw-lpps');\n  const issuedEl = document.getElementById('tafw-issued-lpps');\n  const summaryEl = document.getElementById('tafw-summary-lpps');\n  const detailsEl = document.getElementById('tafw-details-lpps');\n  const toggleBtn = document.getElementById('tafw-toggle-lpps');\n\n  const weatherMap = {\n    'MI':'shallow','BC':'patches of','PR':'partial','VC':'vicinity','SH':'showers','TS':'thunderstorms','FZ':'freezing',\n    'DR':'low drifting','BL':'blowing','PO':'dust\/sand whirls','DZ':'drizzle','RA':'rain','SN':'snow','SG':'snow grains',\n    'IC':'ice crystals','PL':'ice pellets','GR':'hail','GS':'small hail\/snow pellets','UP':'unknown precipitation',\n    'BR':'mist','FG':'fog','FU':'smoke','VA':'volcanic ash','DU':'dust','SA':'sand','HZ':'haze','PY':'spray',\n    'SQ':'squalls','SS':'sandstorm','DS':'duststorm','FC':'funnel clouds (tornado\/waterspout)','NSW':'no significant weather'\n  };\n\n  const degToCardinal = deg => deg==='VRB'||isNaN(deg)?'variable':['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW'][Math.round(((Number(deg)%360)\/22.5))%16];\n  const ktsToKmh = k => Math.round(Number(k)*1.852);\n  const hundredsFeetToMeters = h => Math.round(Number(h)*30.48); \n\n  const formatIssueDate = d => !(d instanceof Date)||isNaN(d)?'':\n    `${d.getUTCDate().toString().padStart(2,'0')} ${[\"Jan\",\"Feb\",\"Mar\",\"Apr\",\"May\",\"Jun\",\"Jul\",\"Aug\",\"Sep\",\"Oct\",\"Nov\",\"Dec\"][d.getUTCMonth()]} ${d.getUTCHours().toString().padStart(2,'0')}:${d.getUTCMinutes().toString().padStart(2,'0')} UTC`;\n  const formatBlockDate = d => !(d instanceof Date)||isNaN(d)?'':\n    `${d.getUTCDate().toString().padStart(2,'0')} ${[\"Jan\",\"Feb\",\"Mar\",\"Apr\",\"May\",\"Jun\",\"Jul\",\"Aug\",\"Sep\",\"Oct\",\"Nov\",\"Dec\"][d.getUTCMonth()]} ${d.getUTCHours().toString().padStart(2,'0')}:${d.getUTCMinutes().toString().padStart(2,'0')}`;\n\n  function splitBlocks(raw){\n    const text = raw.replace(\/\\r?\\n\/g,' ').replace(\/\\s+\/g,' ').replace(\/^TAF\\s+\\w+\\s+\/, '').trim();\n    const re = \/\\s\\b(PROB\\d{2}|TEMPO|BECMG|FM)\\s\/g; \n    const matches=[...text.matchAll(re)];\n    if(matches.length===0) return [{type:'MAIN',text:text}];\n    const blocks=[];\n    const mainText=text.slice(0,matches[0].index).trim(); \n    if(mainText.length>0) blocks.push({type:'MAIN',text:mainText});\n    matches.forEach((m,i)=>{\n      const end=matches[i+1]?matches[i+1].index:text.length;\n      blocks.push({type:m[1],text:text.slice(m.index+1,end).trim()});\n    });\n    return blocks.filter(b=>b.text.length>0);\n  }\n\n  const extractPeriod=(blockText,issueDate)=>{\n    const ddhhddhhMatch=blockText.match(\/\\b(\\d{4}\\\/\\d{4})\\b\/); \n    const fmddhhmmMatch=blockText.match(\/\\bFM\\s+(\\d{6})\\b\/); \n    if(ddhhddhhMatch){\n      const token=ddhhddhhMatch[1], sDay=parseInt(token.slice(0,2),10), sHour=parseInt(token.slice(2,4),10), eDay=parseInt(token.slice(5,7),10), eHour=parseInt(token.slice(7,9),10);\n      let year=issueDate.getUTCFullYear(), month=issueDate.getUTCMonth(), endMonth=month, endYear=year;\n      if(eDay<sDay){endMonth=(month+1)%12; if(endMonth===0) endYear++;}\n      return [new Date(Date.UTC(year, month, sDay, sHour)), new Date(Date.UTC(endYear, endMonth, eDay, eHour))];\n    } else if(fmddhhmmMatch){\n      const token=fmddhhmmMatch[1];\n      const day=parseInt(token.slice(0,2),10), hour=parseInt(token.slice(2,4),10), min=parseInt(token.slice(4,6),10);\n      return [new Date(Date.UTC(issueDate.getUTCFullYear(), issueDate.getUTCMonth(), day, hour, min)), null];\n    }\n    return [issueDate,new Date(issueDate.getTime()+24*3600*1000)];\n  };\n\n  const extractWind=t=>{const m=t.match(\/\\b((?:VRB|[\\d]{3}))(\\d{2})(?:G(\\d{2}))?KT\\b\/); if(m) return {dir:m[1],speed:+m[2],gust:m[3]?+m[3]:null}; if(\/\\bNSW\\b\/.test(t)) return {dir:'NSW',speed:0,gust:null}; return null;};\n  const extractVis=t=>{if(\/\\bCAVOK\\b\/.test(t)) return 9999; const m=t.match(\/(?:\\s|^)(\\d{4})(?:\\s|$)\/); return m?+m[1]:null;};\n  const extractClouds=t=>[...t.matchAll(\/(FEW|SCT|BKN|OVC|NSC|SKC|CLR)(\\d{3}|000)?(CB|TCU)?\/g)].map(m=>({type:m[1],hundreds:m[2],extra:m[3]||null}));\n  const extractPrecip=t=>[...t.matchAll(\/\\b([+\\-]?)(VC|MI|BC|PR|SH|TS|FZ|DR|BL|PO|DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|VA|DU|SA|HZ|PY|SQ|SS|DS|FC){1,3}\\b\/g)].map(m=>m[0]);\n\n  function humanizeBlock(block,issueDate,nextBlockStart){\n    const labelMap={MAIN:'Main Forecast',BECMG:'Gradually becoming',TEMPO:'Temporary conditions',FM:'From'};\n    const label=labelMap[block.type]||(block.type.startsWith('PROB')?`${block.type.slice(4)}% chance`:block.type);\n    let [start,end]=extractPeriod(block.text,issueDate);\n    if(block.type==='FM'&&nextBlockStart) end=nextBlockStart; else if(block.type==='FM'&&end===null){const mainBlock=splitBlocks(rawEl.textContent)[0]; if(mainBlock){const mainPeriod=extractPeriod(mainBlock.text,issueDate); end=mainPeriod[1];}}\n    const wind=extractWind(block.text);\n    const vis=extractVis(block.text);\n    const clouds=extractClouds(block.text);\n    const precipCodes=extractPrecip(block.text);\n    const pieces=[];\n    if(wind){if(wind.dir==='NSW'){pieces.push('no significant wind change');}else{const dirTxt=wind.dir==='VRB'?'variable':`${wind.dir}\u00b0 (${degToCardinal(wind.dir)})`; let w=`${dirTxt} at ${ktsToKmh(wind.speed)} km\/h`; if(wind.gust) w+=` (gusts to ${ktsToKmh(wind.gust)} km\/h)`; pieces.push(`wind ${w}`);}}\n    if(vis!==null) pieces.push(`visibility ${vis>=9999?'10+ km':Math.round(vis\/1000)+' km'}`);\n    if(precipCodes.length){const decoded=precipCodes.map(code=>{const intensity=code.startsWith('+')?'heavy ':code.startsWith('-')?'light ':'', baseCode=code.replace(\/^[+\\-]\/,''), weatherParts=baseCode.match(\/[A-Z]{2}\/g)||[baseCode]; const weather=weatherParts.map(w=>weatherMap[w]||w.toLowerCase()).join(' '); return intensity+weather;}).join(' and '); pieces.push(decoded);}\n    if(clouds.length){const cTxt=clouds.map(c=>{const map={FEW:'a few',SCT:'scattered',BKN:'broken',OVC:'overcast',NSC:'no significant clouds',SKC:'sky clear',CLR:'sky clear'}; if(c.type==='SKC'||c.type==='CLR'||c.type==='NSC') return map[c.type]; const h=hundredsFeetToMeters(c.hundreds); const extra=c.extra?(c.extra==='CB'?' (cumulonimbus)':' (towering cumulus)'):''; return `${map[c.type]||c.type.toLowerCase()} clouds around ${h} m${extra}`;}).join(', '); pieces.push(cTxt);}\n    if(!pieces.length) return null;\n    let periodPhrase;\n    if(end===null||block.type==='FM'){periodPhrase=`from ${formatBlockDate(start)}`; if(end!==null) periodPhrase+=` to ${formatBlockDate(end)}`; else if(block.type==='FM') periodPhrase+=` until next change`;}\n    else{periodPhrase=`for ${formatBlockDate(start)} to ${formatBlockDate(end)}`; if(block.type==='TEMPO'||block.type.startsWith('PROB')) periodPhrase=`between ${formatBlockDate(start)} and ${formatBlockDate(end)}`;}\n    return `${label} ${periodPhrase} \u2014 ${pieces.join(', ')}.`;\n  }\n\n  async function loadAndRender(){\n    try{\n      rawEl.textContent=summaryEl.textContent='Loading\u2026';\n      detailsEl.innerHTML=issuedEl.textContent='';\n      toggleBtn.style.display='none';  toggleBtn.innerHTML ='&#10133;';\n      const timestampedUrl=`${RAW_URL}?t=${Date.now()}`;\n      const resp=await fetch(timestampedUrl,{cache:'no-cache'}); if(!resp.ok) throw new Error(`API ${resp.status}`);\n      const data=await resp.json();\n      const taf=data[0]; if(!taf||!taf.rawTAF) throw new Error('TAF data missing or JSON is empty.');\n      const raw=taf.rawTAF.trim();\n      rawEl.textContent=raw.replace(\/^TAF\\s+\\w+\\s+\/,'').trim();\n\n      const issueDate = new Date(taf.issueTime);\n      issuedEl.textContent=`Issued at ${formatIssueDate(issueDate)}`;\n\n      const blocks=splitBlocks(raw);\n      const main=blocks.find(b=>b.type==='MAIN')||blocks[0];\n      summaryEl.textContent=humanizeBlock(main,issueDate)||'No significant information available.';\n\n      const detailBlocks=blocks.filter(b=>b.type!=='MAIN'); \n      const details=[];\n      for(let i=0;i<detailBlocks.length;i++){\n        const currentBlock=detailBlocks[i]; \n        const nextBlockStart=i+1<detailBlocks.length?extractPeriod(detailBlocks[i+1].text,issueDate)[0]:null; \n        const humanized=humanizeBlock(currentBlock,issueDate,nextBlockStart); \n        if(humanized) details.push(humanized);\n      }\n      if(details.length){\n        detailsEl.innerHTML=details.map(d=>`<p>${d.replace(\/^(.*?) \u2014\/,'<strong>$1<\/strong> \u2014')}<\/p>`).join('');\n        toggleBtn.style.display='block';\n      } else detailsEl.innerHTML='<em style=\"color:#666\">No additional temporary or becoming changes reported.<\/em>';\n    }catch(err){\n      console.error('TAF LPPs Widget Error:',err); \n      rawEl.textContent='Error fetching TAF.'; \n      summaryEl.textContent=`Loading failed: ${err.message}.`; \n      detailsEl.innerHTML=''; \n      issuedEl.textContent='';\n    }\n  }\n\n  toggleBtn.addEventListener('click',()=>{\n    const showing=detailsEl.style.display!=='none'; \n    detailsEl.style.display=showing?'none':'block'; \n    toggleBtn.innerHTML = showing?'&#10133;':'&#10134;'; \n    toggleBtn.setAttribute('aria-expanded',!showing);\n  });\n\n  await loadAndRender();\n  setInterval(loadAndRender,30*60*1000);\n})();\n<\/script>\n\n\n\n<div style=\"height:11px\" aria-hidden=\"true\" class=\"wp-block-spacer\"><\/div>\n\n\n\n<!-- LPMA TAF Gold Decoder Widget -->\n<div id=\"tafw-lpma\" style=\"font-family: Inter, system-ui, sans-serif; max-width: 555px; margin: auto;\">\n  <style>\n\n    \/* Reset and general styling *\/\n    #tafw-lpma * { \nbox-sizing: border-box; \nline-height: 1.4; \nfont-weight: 400; \n}\n    #tafw-lpma a { \ncolor: #1e40af; \ntext-decoration: underline; \n}\n    #tafw-lpma p { \nmargin: 0.75rem 0; \ntext-align: justify; \n}\n\n    \/* Card Container *\/\n    #tafw-lpma .card { \nbackground: #ffffff; \nborder-radius: 16px; \npadding: 1.25rem; \nbox-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); \nborder: 1px solid #e5e7eb; \n}\n\n    \/* Header and Title *\/\n    #tafw-lpma .header { \nmargin-bottom: 1.25rem; \npadding-bottom: 0.5rem; \nborder-bottom: 1px solid #f3f4f6; \n}\n    #tafw-lpma .title { \nfont-weight: 700; \ncolor: #1f2937; \nfont-size: 1.05rem; \ndisplay: flex; \nalign-items: center; \n}\n    #tafw-lpma .title span { \nmargin-left: 0.5rem; \nfont-size: 1rem; \ncolor: #4b5563; \nfont-weight: 500; }\n    #tafw-lpma #tafw-issued-lpma { \nfont-size: 0.85rem; \ncolor: #6b7280; \nmargin-top: 0.25rem; \n}\n\n    \/* Section Labels *\/\n    #tafw-lpma .section-label { \nfont-weight: 600; \nmargin-bottom: 0.2rem; \ndisplay: block; \ncolor: #1a1a1a; \nfont-size: 0.9rem; \nletter-spacing: 0.1em; \n}\n\n    \/* Raw TAF Code Block *\/\n    #tafw-lpma #tafw-raw-lpma { \ndisplay: block; \nbackground-color: #f3f4f6; \ncolor: #1f2937; \nfont-family: monospace; \nfont-size: 0.9rem; \npadding: 0.75rem; \nborder-radius: 8px; \nwhite-space: pre-wrap; \nword-break: keep-all; \nmargin-top: 0.5rem; \n}\n    \/* Decoded Summary *\/\n    #tafw-lpma #tafw-summary-lpma { \nfont-size: 1rem; \nfont-weight: 500; \ncolor: #1f2937; \n}\n\n    \/* Toggle Button *\/\n    #tafw-lpma button { \nbackground: transparent; \nfont-weight: 500; \nfont-family: system-ui, \nsans-serif; \nborder: none; \nfont-size: 1.0em; \ncursor: pointer; \nmargin-top: 0.2em; \n}\n    #tafw-lpma button:hover { \nbackground: transparent; \n}\n\n    \/* Details Section *\/\n    #tafw-lpma #tafw-details-lpma { \nmargin-top: 1rem; \nborder-top: 1px solid #e5e7eb; \npadding-top: 1rem; \nfont-size: 0.95rem; \ncolor: #374151;\n}\n    #tafw-lpma #tafw-details-lpma p { \nmargin: 0.5rem 0; \nline-height: 1.6; \n}\n    #tafw-lpma #tafw-details-lpma strong { \nfont-weight: 600; \ncolor: #1f2937; \n}\n\n  <\/style>\n\n  <div class=\"card\">\n    <div class=\"header\">\n      <div>\n        <div class=\"title\">&#9992;&#65039;  TAF LPMA <span>(Madeira Forecast)<\/span><\/div>\n        <div id=\"tafw-issued-lpma\"><\/div>\n      <\/div>\n    <\/div>\n\n    <p>\n      <span class=\"section-label\">Official:<\/span>\n      <span id=\"tafw-raw-lpma\">Loading\u2026<\/span>\n    <\/p>\n\n    <p style=\"margin-top: 1.5rem;\">\n      <span class=\"section-label\">Decoded:<\/span>\n      <span id=\"tafw-summary-lpma\">Loading\u2026<\/span>\n    <\/p>\n\n    <button id=\"tafw-toggle-lpma\" aria-expanded=\"false\" title=\"&#10133;\" style=\"display:none;\">&#10133;<\/button>\n    <div id=\"tafw-details-lpma\" style=\"display:none;\"><\/div>\n  <\/div>\n<\/div>\n\n<script>\n(async function() {\n  const RAW_URL = \"https:\/\/paragliding-in-madeira.com\/weather\/wp-content\/uploads\/taf-full-data-lpma.json\";\n\n  const rawEl = document.getElementById('tafw-raw-lpma');\n  const issuedEl = document.getElementById('tafw-issued-lpma');\n  const summaryEl = document.getElementById('tafw-summary-lpma');\n  const detailsEl = document.getElementById('tafw-details-lpma');\n  const toggleBtn = document.getElementById('tafw-toggle-lpma');\n\n  const weatherMap = {\n    'MI':'shallow','BC':'patches of','PR':'partial','VC':'vicinity','SH':'showers','TS':'thunderstorms','FZ':'freezing',\n    'DR':'low drifting','BL':'blowing','PO':'dust\/sand whirls','DZ':'drizzle','RA':'rain','SN':'snow','SG':'snow grains',\n    'IC':'ice crystals','PL':'ice pellets','GR':'hail','GS':'small hail\/snow pellets','UP':'unknown precipitation',\n    'BR':'mist','FG':'fog','FU':'smoke','VA':'volcanic ash','DU':'dust','SA':'sand','HZ':'haze','PY':'spray',\n    'SQ':'squalls','SS':'sandstorm','DS':'duststorm','FC':'funnel clouds (tornado\/waterspout)','NSW':'no significant weather'\n  };\n\n  const degToCardinal = deg => deg==='VRB'||isNaN(deg)?'variable':['N','NNE','NE','ENE','E','ESE','SE','SSE','S','SSW','SW','WSW','W','WNW','NW','NNW'][Math.round(((Number(deg)%360)\/22.5))%16];\n  const ktsToKmh = k => Math.round(Number(k)*1.852);\n  const hundredsFeetToMeters = h => Math.round(Number(h)*30.48); \n\n  const formatIssueDate = d => !(d instanceof Date)||isNaN(d)?'':\n    `${d.getUTCDate().toString().padStart(2,'0')} ${[\"Jan\",\"Feb\",\"Mar\",\"Apr\",\"May\",\"Jun\",\"Jul\",\"Aug\",\"Sep\",\"Oct\",\"Nov\",\"Dec\"][d.getUTCMonth()]} ${d.getUTCHours().toString().padStart(2,'0')}:${d.getUTCMinutes().toString().padStart(2,'0')} UTC`;\n  const formatBlockDate = d => !(d instanceof Date)||isNaN(d)?'':\n    `${d.getUTCDate().toString().padStart(2,'0')} ${[\"Jan\",\"Feb\",\"Mar\",\"Apr\",\"May\",\"Jun\",\"Jul\",\"Aug\",\"Sep\",\"Oct\",\"Nov\",\"Dec\"][d.getUTCMonth()]} ${d.getUTCHours().toString().padStart(2,'0')}:${d.getUTCMinutes().toString().padStart(2,'0')}`;\n\n  function splitBlocks(raw){\n    const text = raw.replace(\/\\r?\\n\/g,' ').replace(\/\\s+\/g,' ').replace(\/^TAF\\s+\\w+\\s+\/, '').trim();\n    const re = \/\\s\\b(PROB\\d{2}|TEMPO|BECMG|FM)\\s\/g; \n    const matches=[...text.matchAll(re)];\n    if(matches.length===0) return [{type:'MAIN',text:text}];\n    const blocks=[];\n    const mainText=text.slice(0,matches[0].index).trim(); \n    if(mainText.length>0) blocks.push({type:'MAIN',text:mainText});\n    matches.forEach((m,i)=>{\n      const end=matches[i+1]?matches[i+1].index:text.length;\n      blocks.push({type:m[1],text:text.slice(m.index+1,end).trim()});\n    });\n    return blocks.filter(b=>b.text.length>0);\n  }\n\n  const extractPeriod=(blockText,issueDate)=>{\n    const ddhhddhhMatch=blockText.match(\/\\b(\\d{4}\\\/\\d{4})\\b\/); \n    const fmddhhmmMatch=blockText.match(\/\\bFM\\s+(\\d{6})\\b\/); \n    if(ddhhddhhMatch){\n      const token=ddhhddhhMatch[1], sDay=parseInt(token.slice(0,2),10), sHour=parseInt(token.slice(2,4),10), eDay=parseInt(token.slice(5,7),10), eHour=parseInt(token.slice(7,9),10);\n      let year=issueDate.getUTCFullYear(), month=issueDate.getUTCMonth(), endMonth=month, endYear=year;\n      if(eDay<sDay){endMonth=(month+1)%12; if(endMonth===0) endYear++;}\n      return [new Date(Date.UTC(year, month, sDay, sHour)), new Date(Date.UTC(endYear, endMonth, eDay, eHour))];\n    } else if(fmddhhmmMatch){\n      const token=fmddhhmmMatch[1];\n      const day=parseInt(token.slice(0,2),10), hour=parseInt(token.slice(2,4),10), min=parseInt(token.slice(4,6),10);\n      return [new Date(Date.UTC(issueDate.getUTCFullYear(), issueDate.getUTCMonth(), day, hour, min)), null];\n    }\n    return [issueDate,new Date(issueDate.getTime()+24*3600*1000)];\n  };\n\n  const extractWind=t=>{const m=t.match(\/\\b((?:VRB|[\\d]{3}))(\\d{2})(?:G(\\d{2}))?KT\\b\/); if(m) return {dir:m[1],speed:+m[2],gust:m[3]?+m[3]:null}; if(\/\\bNSW\\b\/.test(t)) return {dir:'NSW',speed:0,gust:null}; return null;};\n  const extractVis=t=>{if(\/\\bCAVOK\\b\/.test(t)) return 9999; const m=t.match(\/(?:\\s|^)(\\d{4})(?:\\s|$)\/); return m?+m[1]:null;};\n  const extractClouds=t=>[...t.matchAll(\/(FEW|SCT|BKN|OVC|NSC|SKC|CLR)(\\d{3}|000)?(CB|TCU)?\/g)].map(m=>({type:m[1],hundreds:m[2],extra:m[3]||null}));\n  const extractPrecip=t=>[...t.matchAll(\/\\b([+\\-]?)(VC|MI|BC|PR|SH|TS|FZ|DR|BL|PO|DZ|RA|SN|SG|IC|PL|GR|GS|UP|BR|FG|FU|VA|DU|SA|HZ|PY|SQ|SS|DS|FC){1,3}\\b\/g)].map(m=>m[0]);\n\n  function humanizeBlock(block,issueDate,nextBlockStart){\n    const labelMap={MAIN:'Main Forecast',BECMG:'Gradually becoming',TEMPO:'Temporary conditions',FM:'From'};\n    const label=labelMap[block.type]||(block.type.startsWith('PROB')?`${block.type.slice(4)}% chance`:block.type);\n    let [start,end]=extractPeriod(block.text,issueDate);\n    if(block.type==='FM'&&nextBlockStart) end=nextBlockStart; else if(block.type==='FM'&&end===null){const mainBlock=splitBlocks(rawEl.textContent)[0]; if(mainBlock){const mainPeriod=extractPeriod(mainBlock.text,issueDate); end=mainPeriod[1];}}\n    const wind=extractWind(block.text);\n    const vis=extractVis(block.text);\n    const clouds=extractClouds(block.text);\n    const precipCodes=extractPrecip(block.text);\n    const pieces=[];\n    if(wind){if(wind.dir==='NSW'){pieces.push('no significant wind change');}else{const dirTxt=wind.dir==='VRB'?'variable':`${wind.dir}\u00b0 (${degToCardinal(wind.dir)})`; let w=`${dirTxt} at ${ktsToKmh(wind.speed)} km\/h`; if(wind.gust) w+=` (gusts to ${ktsToKmh(wind.gust)} km\/h)`; pieces.push(`wind ${w}`);}}\n    if(vis!==null) pieces.push(`visibility ${vis>=9999?'10+ km':Math.round(vis\/1000)+' km'}`);\n    if(precipCodes.length){const decoded=precipCodes.map(code=>{const intensity=code.startsWith('+')?'heavy ':code.startsWith('-')?'light ':'', baseCode=code.replace(\/^[+\\-]\/,''), weatherParts=baseCode.match(\/[A-Z]{2}\/g)||[baseCode]; const weather=weatherParts.map(w=>weatherMap[w]||w.toLowerCase()).join(' '); return intensity+weather;}).join(' and '); pieces.push(decoded);}\n    if(clouds.length){const cTxt=clouds.map(c=>{const map={FEW:'a few',SCT:'scattered',BKN:'broken',OVC:'overcast',NSC:'no significant clouds',SKC:'sky clear',CLR:'sky clear'}; if(c.type==='SKC'||c.type==='CLR'||c.type==='NSC') return map[c.type]; const h=hundredsFeetToMeters(c.hundreds); const extra=c.extra?(c.extra==='CB'?' (cumulonimbus)':' (towering cumulus)'):''; return `${map[c.type]||c.type.toLowerCase()} clouds around ${h} m${extra}`;}).join(', '); pieces.push(cTxt);}\n    if(!pieces.length) return null;\n    let periodPhrase;\n    if(end===null||block.type==='FM'){periodPhrase=`from ${formatBlockDate(start)}`; if(end!==null) periodPhrase+=` to ${formatBlockDate(end)}`; else if(block.type==='FM') periodPhrase+=` until next change`;}\n    else{periodPhrase=`for ${formatBlockDate(start)} to ${formatBlockDate(end)}`; if(block.type==='TEMPO'||block.type.startsWith('PROB')) periodPhrase=`between ${formatBlockDate(start)} and ${formatBlockDate(end)}`;}\n    return `${label} ${periodPhrase} \u2014 ${pieces.join(', ')}.`;\n  }\n\n  async function loadAndRender(){\n    try{\n      rawEl.textContent=summaryEl.textContent='Loading\u2026';\n      detailsEl.innerHTML=issuedEl.textContent='';\n      toggleBtn.style.display='none';  toggleBtn.innerHTML ='&#10133;';\n      const timestampedUrl=`${RAW_URL}?t=${Date.now()}`;\n      const resp=await fetch(timestampedUrl,{cache:'no-cache'}); if(!resp.ok) throw new Error(`API ${resp.status}`);\n      const data=await resp.json();\n      const taf=data[0]; if(!taf||!taf.rawTAF) throw new Error('TAF data missing or JSON is empty.');\n      const raw=taf.rawTAF.trim();\n      rawEl.textContent=raw.replace(\/^TAF\\s+\\w+\\s+\/,'').trim();\n\n      const issueDate = new Date(taf.issueTime);\n      issuedEl.textContent=`Issued at ${formatIssueDate(issueDate)}`;\n\n      const blocks=splitBlocks(raw);\n      const main=blocks.find(b=>b.type==='MAIN')||blocks[0];\n      summaryEl.textContent=humanizeBlock(main,issueDate)||'No significant information available.';\n\n      const detailBlocks=blocks.filter(b=>b.type!=='MAIN'); \n      const details=[];\n      for(let i=0;i<detailBlocks.length;i++){\n        const currentBlock=detailBlocks[i]; \n        const nextBlockStart=i+1<detailBlocks.length?extractPeriod(detailBlocks[i+1].text,issueDate)[0]:null; \n        const humanized=humanizeBlock(currentBlock,issueDate,nextBlockStart); \n        if(humanized) details.push(humanized);\n      }\n      if(details.length){\n        detailsEl.innerHTML=details.map(d=>`<p>${d.replace(\/^(.*?) \u2014\/,'<strong>$1<\/strong> \u2014')}<\/p>`).join('');\n        toggleBtn.style.display='block';\n      } else detailsEl.innerHTML='<em style=\"color:#666\">No additional temporary or becoming changes reported.<\/em>';\n    }catch(err){\n      console.error('TAF lpma Widget Error:',err); \n      rawEl.textContent='Error fetching TAF.'; \n      summaryEl.textContent=`Loading failed: ${err.message}.`; \n      detailsEl.innerHTML=''; \n      issuedEl.textContent='';\n    }\n  }\n\n  toggleBtn.addEventListener('click',()=>{\n    const showing=detailsEl.style.display!=='none'; \n    detailsEl.style.display=showing?'none':'block'; \n    toggleBtn.innerHTML = showing?'&#10133;':'&#10134;'; \n    toggleBtn.setAttribute('aria-expanded',!showing);\n  });\n\n  await loadAndRender();\n  setInterval(loadAndRender,30*60*1000);\n})();\n<\/script>\n\n\n\n<div style=\"height:22px\" aria-hidden=\"true\" class=\"wp-block-spacer\"><\/div>\n","protected":false},"excerpt":{"rendered":"<p>Airport &#127780;&#65039; METAR LPPS (Porto Santo &#8220;live&#8221;) Official: Loading\u2026 Decoded: Loading\u2026 &#127780;&#65039; METAR LPMA (Madeira &#8220;live&#8221;) Official: Loading\u2026 Decoded: Loading\u2026 &#9992;&#65039; TAF LPPS (Porto Santo Forecast) Official: Loading\u2026 Decoded: Loading\u2026 &#10133; &#9992;&#65039; TAF LPMA (Madeira Forecast) Official: Loading\u2026 Decoded: Loading\u2026 &#10133;<\/p>\n","protected":false},"author":1,"featured_media":0,"parent":0,"menu_order":0,"comment_status":"closed","ping_status":"closed","template":"","meta":{"slim_seo":{"title":"Metar history - Madeira Island weather","description":"Real time and historical airport metar and taf data with graphs showing the evolution."},"footnotes":""},"class_list":["post-5791","page","type-page","status-publish","hentry"],"_links":{"self":[{"href":"https:\/\/paragliding-in-madeira.com\/weather\/wp-json\/wp\/v2\/pages\/5791","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/paragliding-in-madeira.com\/weather\/wp-json\/wp\/v2\/pages"}],"about":[{"href":"https:\/\/paragliding-in-madeira.com\/weather\/wp-json\/wp\/v2\/types\/page"}],"author":[{"embeddable":true,"href":"https:\/\/paragliding-in-madeira.com\/weather\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/paragliding-in-madeira.com\/weather\/wp-json\/wp\/v2\/comments?post=5791"}],"version-history":[{"count":240,"href":"https:\/\/paragliding-in-madeira.com\/weather\/wp-json\/wp\/v2\/pages\/5791\/revisions"}],"predecessor-version":[{"id":10018,"href":"https:\/\/paragliding-in-madeira.com\/weather\/wp-json\/wp\/v2\/pages\/5791\/revisions\/10018"}],"wp:attachment":[{"href":"https:\/\/paragliding-in-madeira.com\/weather\/wp-json\/wp\/v2\/media?parent=5791"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}