From d2c9948c31b4be9b69c5dac56c25cdda4e7e332b Mon Sep 17 00:00:00 2001 From: Benjamen Miller <115733441+benjamenm03@users.noreply.github.com> Date: Fri, 14 Nov 2025 12:04:48 -0500 Subject: [PATCH 1/5] Add launch day logistics and forecast links --- index.html | 17 ++++++++- launch-day/forecast.md | 57 ++++++++++++++++++++++++++-- launch-day/logistics.md | 84 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 153 insertions(+), 5 deletions(-) create mode 100644 launch-day/logistics.md diff --git a/index.html b/index.html index e38e000..a519775 100644 --- a/index.html +++ b/index.html @@ -132,9 +132,14 @@ } .links { display: grid; - grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 20px; } + @media (max-width: 1100px) { + .links { + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + } + } .link-group h3 { margin-top: 0; color: #00274C; @@ -235,6 +240,11 @@
Soldering tutorial and challenge to gain or improve soldering abilities.
Soldering Challenge +The full project specification, including PDR, Go/No-Go tests, poster, and final report.
@@ -250,6 +260,11 @@Reference pages that may be useful, like C++/Arduino cheat-sheet, Arduino pinout diagrams, and more.
Supplemental ResourcesSee lab access times, launch kit info, and the game plan for Saturday.
+ Launch Plan +no-go if weather calls for a scrub.
+Source: NOAA / NWS Detroit-Pontiac
diff --git a/launch-day/logistics.md b/launch-day/logistics.md new file mode 100644 index 0000000..2536071 --- /dev/null +++ b/launch-day/logistics.md @@ -0,0 +1,84 @@ +--- +layout: spec +title: Launch Day Logistics +--- + +# Launch Day Logistics + + + +no-go if we scrub and the banner will turn red.
+Source: NOAA / NWS Detroit-Pontiac
From 837f5553dcc44c5be07a9f98993f62607b335c24 Mon Sep 17 00:00:00 2001 From: Benjamen Miller <115733441+benjamenm03@users.noreply.github.com> Date: Fri, 14 Nov 2025 12:12:00 -0500 Subject: [PATCH 2/5] Update launch status banner and forecast --- launch-day/forecast.md | 6 +++--- launch-day/logistics.md | 6 +++--- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/launch-day/forecast.md b/launch-day/forecast.md index 48b5f3f..0cc9042 100644 --- a/launch-day/forecast.md +++ b/launch-day/forecast.md @@ -39,15 +39,15 @@ title: Weather Forecastno-go if weather calls for a scrub.
Source: NOAA / NWS Detroit-Pontiac
diff --git a/launch-day/logistics.md b/launch-day/logistics.md index 2536071..c777f6c 100644 --- a/launch-day/logistics.md +++ b/launch-day/logistics.md @@ -39,8 +39,8 @@ title: Launch Day Logisticsno-go if we scrub and the banner will turn red.
Source: NOAA / NWS Detroit-Pontiac
From 64731d2b0e172809254c0a592f0710930c6dae68 Mon Sep 17 00:00:00 2001 From: Benjamen Miller <115733441+benjamenm03@users.noreply.github.com> Date: Fri, 14 Nov 2025 12:30:26 -0500 Subject: [PATCH 3/5] Replace NOAA image embed with live meteogram --- assets/js/noaa-meteogram.js | 229 ++++++++++++++++++++++++++++++++++++ launch-day/forecast.md | 132 +++++++++++++++++++-- launch-day/logistics.md | 132 +++++++++++++++++++-- 3 files changed, 479 insertions(+), 14 deletions(-) create mode 100644 assets/js/noaa-meteogram.js diff --git a/assets/js/noaa-meteogram.js b/assets/js/noaa-meteogram.js new file mode 100644 index 0000000..0500c9e --- /dev/null +++ b/assets/js/noaa-meteogram.js @@ -0,0 +1,229 @@ +(function () { + const SELECTOR = '[data-noaa-meteogram]'; + const SVG_NS = 'http://www.w3.org/2000/svg'; + const HOURS_TO_SHOW = 48; + + function init() { + const widgets = document.querySelectorAll(SELECTOR); + if (!widgets.length) { + return; + } + widgets.forEach((widget) => { + const lat = parseFloat(widget.dataset.lat); + const lon = parseFloat(widget.dataset.lon); + if (Number.isNaN(lat) || Number.isNaN(lon)) { + widget.innerHTML = 'Unable to load NOAA data for this location.
'; + return; + } + loadForecast(widget, lat, lon); + }); + } + + async function loadForecast(container, lat, lon) { + setStatus(container, 'Loading live data from NOAA…'); + try { + const forecast = await fetchForecast(lat, lon); + container.innerHTML = ''; + container.appendChild(buildSummary(forecast)); + container.appendChild(buildChart(forecast)); + container.appendChild(buildUpdatedStamp(forecast)); + } catch (error) { + console.error('NOAA meteogram failed', error); + container.innerHTML = 'NOAA data is temporarily unavailable. Try refreshing in a minute.
'; + } + } + + function setStatus(container, text) { + container.innerHTML = `${text}
`; + } + + async function fetchForecast(lat, lon) { + const pointResponse = await fetch(`https://api.weather.gov/points/${lat},${lon}`); + if (!pointResponse.ok) { + throw new Error('Unable to locate grid point.'); + } + const pointData = await pointResponse.json(); + const hourlyUrl = pointData.properties.forecastHourly; + if (!hourlyUrl) { + throw new Error('Hourly forecast not available.'); + } + const hourlyResponse = await fetch(hourlyUrl); + if (!hourlyResponse.ok) { + throw new Error('Unable to download hourly forecast.'); + } + const hourlyData = await hourlyResponse.json(); + const periods = (hourlyData.properties.periods || []).slice(0, HOURS_TO_SHOW); + if (!periods.length) { + throw new Error('Forecast did not include hourly periods.'); + } + return { + city: pointData.properties.relativeLocation?.properties?.city || 'Ann Arbor', + state: pointData.properties.relativeLocation?.properties?.state || 'MI', + periods, + updated: hourlyData.properties.updated + }; + } + + function buildSummary(forecast) { + const temps = forecast.periods.map((p) => p.temperature); + const winds = forecast.periods.map((p) => parseWind(p.windSpeed)); + const precip = forecast.periods.map((p) => p.probabilityOfPrecipitation?.value ?? 0); + const summary = document.createElement('div'); + summary.className = 'meteogram-summary'; + summary.appendChild(summaryItem('48h High', `${Math.round(Math.max(...temps))}°F`)); + summary.appendChild(summaryItem('48h Low', `${Math.round(Math.min(...temps))}°F`)); + summary.appendChild(summaryItem('Peak Wind', `${Math.round(Math.max(...winds))} mph`)); + summary.appendChild(summaryItem('Max Precip Chance', `${Math.round(Math.max(...precip))}%`)); + return summary; + } + + function summaryItem(label, value) { + const card = document.createElement('div'); + card.className = 'meteogram-summary-item'; + const labelEl = document.createElement('span'); + labelEl.textContent = label; + const valueEl = document.createElement('strong'); + valueEl.textContent = value; + card.appendChild(labelEl); + card.appendChild(valueEl); + return card; + } + + function buildChart(forecast) { + const wrapper = document.createElement('div'); + wrapper.className = 'meteogram-chart-wrapper'; + const svg = createSvgElement('svg', { + viewBox: '0 0 720 320', + class: 'meteogram-chart' + }); + + const margin = { top: 18, right: 24, bottom: 48, left: 48 }; + const tempAreaHeight = 200; + const precipAreaHeight = 90; + const axisGap = 14; + const innerWidth = 720 - margin.left - margin.right; + const periods = forecast.periods; + const steps = Math.max(periods.length - 1, 1); + const xStep = innerWidth / steps; + const temps = periods.map((p) => p.temperature); + const tempMin = Math.min(...temps) - 3; + const tempMax = Math.max(...temps) + 3; + const tempRange = Math.max(tempMax - tempMin, 1); + const precipValues = periods.map((p) => p.probabilityOfPrecipitation?.value ?? 0); + + // temperature grid lines + [0.25, 0.5, 0.75].forEach((fraction) => { + const y = margin.top + tempAreaHeight * fraction; + const line = createSvgElement('line', { + x1: margin.left, + y1: y, + x2: 720 - margin.right, + y2: y, + class: 'meteogram-grid-line' + }); + svg.appendChild(line); + }); + + const tempPath = createSvgElement('path', { + class: 'meteogram-temp-line', + fill: 'none' + }); + const points = periods + .map((period, index) => { + const x = margin.left + index * xStep; + const y = margin.top + tempAreaHeight - ((period.temperature - tempMin) / tempRange) * tempAreaHeight; + return `${index === 0 ? 'M' : 'L'}${x},${y}`; + }) + .join(' '); + tempPath.setAttribute('d', points); + svg.appendChild(tempPath); + + // precipitation bars + const precipBase = margin.top + tempAreaHeight + axisGap; + periods.forEach((period, index) => { + const probability = precipValues[index] / 100; + const barHeight = probability * precipAreaHeight; + const rect = createSvgElement('rect', { + x: margin.left + index * xStep - Math.max(xStep - 4, 2) / 2, + y: precipBase + (precipAreaHeight - barHeight), + width: Math.max(xStep - 4, 2), + height: barHeight, + class: 'meteogram-precip-bar' + }); + svg.appendChild(rect); + }); + + // axis labels + periods.forEach((period, index) => { + if (index % 6 !== 0 && index !== periods.length - 1) { + return; + } + const label = createSvgElement('text', { + x: margin.left + index * xStep, + y: 320 - 16, + class: 'meteogram-axis-label' + }); + label.textContent = formatHourLabel(period.startTime); + svg.appendChild(label); + }); + + const legend = document.createElement('div'); + legend.className = 'meteogram-legend'; + legend.innerHTML = ` + Temperature (°F) + Precipitation chance (%) + `; + + wrapper.appendChild(svg); + wrapper.appendChild(legend); + return wrapper; + } + + function buildUpdatedStamp(forecast) { + const stamp = document.createElement('p'); + stamp.className = 'meteogram-updated'; + const updated = forecast.updated ? new Date(forecast.updated) : new Date(); + stamp.textContent = `Updated ${formatTimestamp(updated)} • ${forecast.city}, ${forecast.state}`; + return stamp; + } + + function parseWind(value) { + if (!value) { + return 0; + } + const matches = value.match(/(\d+)/g); + if (!matches) { + return 0; + } + const numbers = matches.map((n) => Number(n)); + const sum = numbers.reduce((acc, current) => acc + current, 0); + return sum / numbers.length; + } + + function formatHourLabel(value) { + const date = new Date(value); + return date.toLocaleString('en-US', { + weekday: 'short', + hour: 'numeric' + }); + } + + function formatTimestamp(date) { + return date.toLocaleString('en-US', { + weekday: 'short', + hour: 'numeric', + minute: '2-digit', + timeZoneName: 'short' + }); + } + + function createSvgElement(tag, attributes) { + const element = document.createElementNS(SVG_NS, tag); + Object.entries(attributes || {}).forEach(([key, value]) => { + element.setAttribute(key, value); + }); + return element; + } + + document.addEventListener('DOMContentLoaded', init); +})(); diff --git a/launch-day/forecast.md b/launch-day/forecast.md index 0cc9042..2e4b793 100644 --- a/launch-day/forecast.md +++ b/launch-day/forecast.md @@ -30,10 +30,124 @@ title: Weather Forecast font-weight: normal; margin-top: 0.4rem; } -.forecast-frame img { - max-width: 100%; - border: 1px solid #ccc; - border-radius: 6px; +.forecast-frame { + margin-top: 1.5rem; +} + +.meteogram-card { + border: 1px solid #d0d7de; + border-radius: 12px; + padding: 1.25rem; + background: #fff; + box-shadow: 0 10px 25px rgba(15, 23, 42, 0.1); +} + +.meteogram-body { + min-height: 260px; +} + +.meteogram-summary { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.meteogram-summary-item { + flex: 1 1 150px; + border-radius: 8px; + padding: 0.75rem 1rem; + background: #f6f8fa; +} + +.meteogram-summary-item span { + display: block; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #57606a; + margin-bottom: 0.25rem; +} + +.meteogram-summary-item strong { + font-size: 1.4rem; + color: #101828; +} + +.meteogram-chart-wrapper { + width: 100%; +} + +.meteogram-chart { + width: 100%; + height: auto; + display: block; +} + +.meteogram-grid-line { + stroke: #d0d7de; + stroke-dasharray: 4 4; + stroke-width: 1; +} + +.meteogram-temp-line { + stroke: #cf222e; + stroke-width: 3; +} + +.meteogram-precip-bar { + fill: #1f6feb; + opacity: 0.65; +} + +.meteogram-axis-label { + font-size: 0.7rem; + fill: #57606a; + text-anchor: middle; +} + +.meteogram-loading, +.meteogram-error { + text-align: center; + padding: 2rem 0; + color: #57606a; + font-weight: 500; +} + +.meteogram-legend { + display: flex; + gap: 1.25rem; + flex-wrap: wrap; + margin-top: 0.75rem; + font-size: 0.85rem; + color: #57606a; +} + +.meteogram-legend span { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.legend-swatch { + width: 14px; + height: 14px; + border-radius: 999px; + display: inline-block; +} + +.legend-swatch.temp { + background: #cf222e; +} + +.legend-swatch.precip { + background: #1f6feb; +} + +.meteogram-updated { + margin-top: 0.75rem; + font-size: 0.85rem; + color: #57606a; } @@ -45,9 +159,13 @@ title: Weather Forecast Stay on top of the latest National Weather Service model for Mitchell Field West. The plot below updates automatically from NOAA so you can track temperature, wind, sky cover, and precipitation in the 48-hour window around launch.Pulling live data from NOAA…
+Source: NOAA / NWS Detroit-Pontiac
+ + diff --git a/launch-day/logistics.md b/launch-day/logistics.md index c777f6c..6c6d36b 100644 --- a/launch-day/logistics.md +++ b/launch-day/logistics.md @@ -30,10 +30,124 @@ title: Launch Day Logistics font-weight: normal; margin-top: 0.4rem; } -.forecast-frame img { - max-width: 100%; - border: 1px solid #ccc; - border-radius: 6px; +.forecast-frame { + margin-top: 1.5rem; +} + +.meteogram-card { + border: 1px solid #d0d7de; + border-radius: 12px; + padding: 1.25rem; + background: #fff; + box-shadow: 0 10px 25px rgba(15, 23, 42, 0.1); +} + +.meteogram-body { + min-height: 260px; +} + +.meteogram-summary { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.meteogram-summary-item { + flex: 1 1 150px; + border-radius: 8px; + padding: 0.75rem 1rem; + background: #f6f8fa; +} + +.meteogram-summary-item span { + display: block; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #57606a; + margin-bottom: 0.25rem; +} + +.meteogram-summary-item strong { + font-size: 1.4rem; + color: #101828; +} + +.meteogram-chart-wrapper { + width: 100%; +} + +.meteogram-chart { + width: 100%; + height: auto; + display: block; +} + +.meteogram-grid-line { + stroke: #d0d7de; + stroke-dasharray: 4 4; + stroke-width: 1; +} + +.meteogram-temp-line { + stroke: #cf222e; + stroke-width: 3; +} + +.meteogram-precip-bar { + fill: #1f6feb; + opacity: 0.65; +} + +.meteogram-axis-label { + font-size: 0.7rem; + fill: #57606a; + text-anchor: middle; +} + +.meteogram-loading, +.meteogram-error { + text-align: center; + padding: 2rem 0; + color: #57606a; + font-weight: 500; +} + +.meteogram-legend { + display: flex; + gap: 1.25rem; + flex-wrap: wrap; + margin-top: 0.75rem; + font-size: 0.85rem; + color: #57606a; +} + +.meteogram-legend span { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.legend-swatch { + width: 14px; + height: 14px; + border-radius: 999px; + display: inline-block; +} + +.legend-swatch.temp { + background: #cf222e; +} + +.legend-swatch.precip { + background: #1f6feb; +} + +.meteogram-updated { + margin-top: 0.75rem; + font-size: 0.85rem; + color: #57606a; } @@ -76,9 +190,13 @@ Bring the essentials so you can debug on site after your first flight: Stay informed with the live NOAA meteogram for Ann Arbor.Pulling live data from NOAA…
+Source: NOAA / NWS Detroit-Pontiac
+ + From 3b17e80ecde6847c3ef1de5ea9e0855e51732ef2 Mon Sep 17 00:00:00 2001 From: Benjamen Miller <115733441+benjamenm03@users.noreply.github.com> Date: Fri, 14 Nov 2025 13:14:47 -0500 Subject: [PATCH 4/5] Improve launch forecast visualization --- assets/js/noaa-meteogram.js | 255 +++++++++++++++++++++++++++++------- facilities.html | 1 + index.html | 8 +- launch-day/forecast.md | 171 ------------------------ launch-day/logistics.md | 45 ++++++- staff.html | 2 +- 6 files changed, 258 insertions(+), 224 deletions(-) delete mode 100644 launch-day/forecast.md diff --git a/assets/js/noaa-meteogram.js b/assets/js/noaa-meteogram.js index 0500c9e..dcca68c 100644 --- a/assets/js/noaa-meteogram.js +++ b/assets/js/noaa-meteogram.js @@ -2,6 +2,8 @@ const SELECTOR = '[data-noaa-meteogram]'; const SVG_NS = 'http://www.w3.org/2000/svg'; const HOURS_TO_SHOW = 48; + const MS_IN_HOUR = 3600000; + const WEEKEND_START_DAY = 6; // Saturday function init() { const widgets = document.querySelectorAll(SELECTOR); @@ -52,7 +54,10 @@ throw new Error('Unable to download hourly forecast.'); } const hourlyData = await hourlyResponse.json(); - const periods = (hourlyData.properties.periods || []).slice(0, HOURS_TO_SHOW); + const rawPeriods = hourlyData.properties.periods || []; + const weekendWindow = getWeekendWindow(); + const weekendSelection = extractWeekendForecast(rawPeriods, weekendWindow); + const periods = weekendSelection.length ? weekendSelection : rawPeriods.slice(0, HOURS_TO_SHOW); if (!periods.length) { throw new Error('Forecast did not include hourly periods.'); } @@ -60,7 +65,8 @@ city: pointData.properties.relativeLocation?.properties?.city || 'Ann Arbor', state: pointData.properties.relativeLocation?.properties?.state || 'MI', periods, - updated: hourlyData.properties.updated + updated: hourlyData.properties.updated, + window: weekendSelection.length ? weekendWindow : null }; } @@ -68,11 +74,15 @@ const temps = forecast.periods.map((p) => p.temperature); const winds = forecast.periods.map((p) => parseWind(p.windSpeed)); const precip = forecast.periods.map((p) => p.probabilityOfPrecipitation?.value ?? 0); + const gusts = forecast.periods.map((p) => parseWind(p.windGust)); + const windChills = forecast.periods.map((p) => computeWindChill(p.temperature, parseWind(p.windSpeed))); const summary = document.createElement('div'); summary.className = 'meteogram-summary'; summary.appendChild(summaryItem('48h High', `${Math.round(Math.max(...temps))}°F`)); summary.appendChild(summaryItem('48h Low', `${Math.round(Math.min(...temps))}°F`)); + summary.appendChild(summaryItem('Min Wind Chill', `${Math.round(Math.min(...windChills))}°F`)); summary.appendChild(summaryItem('Peak Wind', `${Math.round(Math.max(...winds))} mph`)); + summary.appendChild(summaryItem('Peak Gust', `${Math.round(Math.max(...gusts))} mph`)); summary.appendChild(summaryItem('Max Precip Chance', `${Math.round(Math.max(...precip))}%`)); return summary; } @@ -92,75 +102,113 @@ function buildChart(forecast) { const wrapper = document.createElement('div'); wrapper.className = 'meteogram-chart-wrapper'; + const width = 720; + const margin = { top: 18, right: 24, bottom: 60, left: 52 }; + const tempAreaHeight = 140; + const windAreaHeight = 110; + const precipAreaHeight = 90; + const axisGap = 24; + const height = margin.top + tempAreaHeight + windAreaHeight + precipAreaHeight + axisGap * 2 + margin.bottom; + const innerWidth = width - margin.left - margin.right; const svg = createSvgElement('svg', { - viewBox: '0 0 720 320', + viewBox: `0 0 ${width} ${height}`, class: 'meteogram-chart' }); - const margin = { top: 18, right: 24, bottom: 48, left: 48 }; - const tempAreaHeight = 200; - const precipAreaHeight = 90; - const axisGap = 14; - const innerWidth = 720 - margin.left - margin.right; const periods = forecast.periods; const steps = Math.max(periods.length - 1, 1); const xStep = innerWidth / steps; const temps = periods.map((p) => p.temperature); - const tempMin = Math.min(...temps) - 3; - const tempMax = Math.max(...temps) + 3; - const tempRange = Math.max(tempMax - tempMin, 1); + const windChills = periods.map((p) => computeWindChill(p.temperature, parseWind(p.windSpeed))); + const windSpeeds = periods.map((p) => parseWind(p.windSpeed)); + const windGusts = periods.map((p) => parseWind(p.windGust)); const precipValues = periods.map((p) => p.probabilityOfPrecipitation?.value ?? 0); + const humidityValues = periods.map((p) => p.relativeHumidity?.value ?? 0); - // temperature grid lines - [0.25, 0.5, 0.75].forEach((fraction) => { - const y = margin.top + tempAreaHeight * fraction; - const line = createSvgElement('line', { - x1: margin.left, - y1: y, - x2: 720 - margin.right, - y2: y, - class: 'meteogram-grid-line' - }); - svg.appendChild(line); - }); + const tempValues = [...temps, ...windChills].filter((v) => Number.isFinite(v)); + const tempMin = Math.min(...tempValues) - 3; + const tempMax = Math.max(...tempValues) + 3; + const tempRange = Math.max(tempMax - tempMin, 1); + const windValues = [...windSpeeds, ...windGusts].filter((v) => Number.isFinite(v)); + const windMax = Math.max(10, Math.ceil(Math.max(...windValues, 0) / 5) * 5); - const tempPath = createSvgElement('path', { - class: 'meteogram-temp-line', - fill: 'none' - }); - const points = periods - .map((period, index) => { - const x = margin.left + index * xStep; - const y = margin.top + tempAreaHeight - ((period.temperature - tempMin) / tempRange) * tempAreaHeight; - return `${index === 0 ? 'M' : 'L'}${x},${y}`; - }) - .join(' '); - tempPath.setAttribute('d', points); - svg.appendChild(tempPath); - - // precipitation bars - const precipBase = margin.top + tempAreaHeight + axisGap; + const tempTop = margin.top; + const windTop = tempTop + tempAreaHeight + axisGap; + const precipTop = windTop + windAreaHeight + axisGap; + + addGridLines(svg, margin, width, tempTop, tempAreaHeight); + addGridLines(svg, margin, width, windTop, windAreaHeight); + addGridLines(svg, margin, width, precipTop, precipAreaHeight); + + drawPanelLabel(svg, 'Temperature & Wind Chill (°F)', margin.left, tempTop - 4); + drawPanelLabel(svg, 'Wind Speed & Gusts (mph)', margin.left, windTop - 4); + drawPanelLabel(svg, 'Precipitation & Humidity (%)', margin.left, precipTop - 4); + + svg.appendChild( + createLinePath(periods, xStep, margin, (index) => { + const value = temps[index]; + const y = tempTop + tempAreaHeight - ((value - tempMin) / tempRange) * tempAreaHeight; + return y; + }, 'meteogram-temp-line') + ); + + svg.appendChild( + createLinePath(periods, xStep, margin, (index) => { + const value = windChills[index]; + const y = tempTop + tempAreaHeight - ((value - tempMin) / tempRange) * tempAreaHeight; + return y; + }, 'meteogram-windchill-line') + ); + + svg.appendChild( + createLinePath(periods, xStep, margin, (index) => { + const value = windSpeeds[index]; + const y = windTop + windAreaHeight - (value / Math.max(windMax, 1)) * windAreaHeight; + return y; + }, 'meteogram-wind-line') + ); + + svg.appendChild( + createLinePath(periods, xStep, margin, (index) => { + const value = windGusts[index]; + if (!Number.isFinite(value)) { + return null; + } + const y = windTop + windAreaHeight - (value / Math.max(windMax, 1)) * windAreaHeight; + return y; + }, 'meteogram-gust-line') + ); + + const precipBase = precipTop; + const barWidth = Math.max(xStep - 4, 2); periods.forEach((period, index) => { - const probability = precipValues[index] / 100; + const probability = (precipValues[index] ?? 0) / 100; const barHeight = probability * precipAreaHeight; const rect = createSvgElement('rect', { - x: margin.left + index * xStep - Math.max(xStep - 4, 2) / 2, + x: margin.left + index * xStep - barWidth / 2, y: precipBase + (precipAreaHeight - barHeight), - width: Math.max(xStep - 4, 2), + width: barWidth, height: barHeight, class: 'meteogram-precip-bar' }); svg.appendChild(rect); }); - // axis labels + svg.appendChild( + createLinePath(periods, xStep, margin, (index) => { + const value = humidityValues[index]; + const y = precipTop + precipAreaHeight - (value / 100) * precipAreaHeight; + return y; + }, 'meteogram-humidity-line') + ); + periods.forEach((period, index) => { if (index % 6 !== 0 && index !== periods.length - 1) { return; } const label = createSvgElement('text', { x: margin.left + index * xStep, - y: 320 - 16, + y: height - 18, class: 'meteogram-axis-label' }); label.textContent = formatHourLabel(period.startTime); @@ -170,8 +218,12 @@ const legend = document.createElement('div'); legend.className = 'meteogram-legend'; legend.innerHTML = ` - Temperature (°F) - Precipitation chance (%) + Temperature + Wind Chill + Sustained Wind + Wind Gust + Precipitation Chance + Relative Humidity `; wrapper.appendChild(svg); @@ -183,7 +235,11 @@ const stamp = document.createElement('p'); stamp.className = 'meteogram-updated'; const updated = forecast.updated ? new Date(forecast.updated) : new Date(); - stamp.textContent = `Updated ${formatTimestamp(updated)} • ${forecast.city}, ${forecast.state}`; + let text = `Updated ${formatTimestamp(updated)} • ${forecast.city}, ${forecast.state}`; + if (forecast.window) { + text += ` • Window: ${formatWindowRange(forecast.window)}`; + } + stamp.textContent = text; return stamp; } @@ -191,6 +247,9 @@ if (!value) { return 0; } + if (typeof value === 'number') { + return value; + } const matches = value.match(/(\d+)/g); if (!matches) { return 0; @@ -200,6 +259,21 @@ return sum / numbers.length; } + function computeWindChill(tempF, windMph) { + if (!Number.isFinite(tempF) || !Number.isFinite(windMph)) { + return tempF; + } + if (tempF > 50 || windMph < 3) { + return tempF; + } + const chill = + 35.74 + + 0.6215 * tempF - + 35.75 * Math.pow(windMph, 0.16) + + 0.4275 * tempF * Math.pow(windMph, 0.16); + return Math.round(chill); + } + function formatHourLabel(value) { const date = new Date(value); return date.toLocaleString('en-US', { @@ -217,6 +291,20 @@ }); } + function formatWindowRange(window) { + const startLabel = new Date(window.start).toLocaleString('en-US', { + weekday: 'short', + hour: 'numeric', + minute: '2-digit' + }); + const endLabel = new Date(window.end - 60 * 1000).toLocaleString('en-US', { + weekday: 'short', + hour: 'numeric', + minute: '2-digit' + }); + return `${startLabel} – ${endLabel}`; + } + function createSvgElement(tag, attributes) { const element = document.createElementNS(SVG_NS, tag); Object.entries(attributes || {}).forEach(([key, value]) => { @@ -225,5 +313,78 @@ return element; } + function createLinePath(periods, xStep, margin, yAccessor, className) { + const path = createSvgElement('path', { + class: className, + fill: 'none' + }); + let d = ''; + let active = false; + periods.forEach((_, index) => { + const y = yAccessor(index); + if (y === null || Number.isNaN(y)) { + active = false; + return; + } + const x = margin.left + index * xStep; + if (!active) { + d += `M${x},${y}`; + active = true; + } else { + d += ` L${x},${y}`; + } + }); + path.setAttribute('d', d); + return path; + } + + function addGridLines(svg, margin, width, top, height) { + [0.25, 0.5, 0.75].forEach((fraction) => { + const y = top + height * fraction; + const line = createSvgElement('line', { + x1: margin.left, + y1: y, + x2: width - margin.right, + y2: y, + class: 'meteogram-grid-line' + }); + svg.appendChild(line); + }); + } + + function drawPanelLabel(svg, text, x, y) { + const label = createSvgElement('text', { + x, + y, + class: 'meteogram-panel-label', + 'text-anchor': 'start' + }); + label.textContent = text; + svg.appendChild(label); + } + + function getWeekendWindow() { + const now = new Date(); + const start = new Date(now); + start.setHours(0, 0, 0, 0); + const day = start.getDay(); + const daysUntilSaturday = (WEEKEND_START_DAY - day + 7) % 7; + start.setDate(start.getDate() + daysUntilSaturday); + const end = new Date(start.getTime() + HOURS_TO_SHOW * MS_IN_HOUR); + return { start: start.getTime(), end: end.getTime() }; + } + + function extractWeekendForecast(periods, windowOverride) { + if (!periods.length) { + return []; + } + const window = windowOverride || getWeekendWindow(); + const selection = periods.filter((period) => { + const startTime = new Date(period.startTime).getTime(); + return startTime >= window.start && startTime < window.end; + }); + return selection.slice(0, HOURS_TO_SHOW); + } + document.addEventListener('DOMContentLoaded', init); })(); diff --git a/facilities.html b/facilities.html index 43b4a9f..b985fca 100644 --- a/facilities.html +++ b/facilities.html @@ -162,6 +162,7 @@Check the latest NOAA projection for our launch location.
- View Forecast +Preview the soldering, fabrication, and collaboration spaces we'll use all semester.
+ Explore FacilitiesPulling live data from NOAA…
-Source: NOAA / NWS Detroit-Pontiac
- - diff --git a/launch-day/logistics.md b/launch-day/logistics.md index 6c6d36b..880ed0a 100644 --- a/launch-day/logistics.md +++ b/launch-day/logistics.md @@ -90,11 +90,38 @@ title: Launch Day Logistics stroke-width: 1; } + +.meteogram-panel-label { + font-size: 0.75rem; + fill: #57606a; +} + .meteogram-temp-line { stroke: #cf222e; stroke-width: 3; } +.meteogram-windchill-line { + stroke: #0969da; + stroke-width: 2.5; +} + +.meteogram-wind-line { + stroke: #1a7f37; + stroke-width: 2.5; +} + +.meteogram-gust-line { + stroke: #9a6700; + stroke-width: 2.5; + stroke-dasharray: 6 4; +} + +.meteogram-humidity-line { + stroke: #0a3069; + stroke-width: 2.2; +} + .meteogram-precip-bar { fill: #1f6feb; opacity: 0.65; @@ -140,10 +167,26 @@ title: Launch Day Logistics background: #cf222e; } +.legend-swatch.windchill { + background: #0969da; +} + +.legend-swatch.wind { + background: #1a7f37; +} + +.legend-swatch.gust { + background: #9a6700; +} + .legend-swatch.precip { background: #1f6feb; } +.legend-swatch.humidity { + background: #0a3069; +} + .meteogram-updated { margin-top: 0.75rem; font-size: 0.85rem; @@ -187,7 +230,7 @@ Bring the essentials so you can debug on site after your first flight: ## Weather Outlook -Stay informed with the live NOAA meteogram for Ann Arbor. +Stay informed with the live NOAA meteogram for Ann Arbor. The chart below always locks onto the 48-hour Saturday/Sunday window so you can compare temperature, wind, gusts, humidity, and precipitation potential at a glance.