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 @@

Solder Challenge

Soldering tutorial and challenge to gain or improve soldering abilities.

Soldering Challenge + +

Course Calendar

diff --git a/launch-day/forecast.md b/launch-day/forecast.md index 3805256..48b5f3f 100644 --- a/launch-day/forecast.md +++ b/launch-day/forecast.md @@ -1,4 +1,53 @@ -NWS Graphical Forecast for Ann Arbor +--- +layout: spec +title: Weather Forecast +--- + +# Weather Forecast + + + +
+ We are a GO for launch! + Update this banner to no-go if weather calls for a scrub. +
+ +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. + +
+ NWS Graphical Forecast for Ann Arbor +
+ +

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 + + + +
+ We are a GO for launch! + Switch the class to no-go if we scrub and the banner will turn red. +
+ +## Lab Access Windows (Nov 13-14) + +- **Thursday, Nov 13:** The lab is open now and stays open until 7:30 p.m. Use this time to finish fabrication and assembly. +- **Friday, Nov 14 (3:30–5:30 p.m.):** Final office hours for fabrication/assembly. Put rechargeable batteries on the charger during this window. If you rely on disposable batteries, you will receive a fresh one before launch, but you must check one out from the IAs on Saturday. + +## Saturday Morning Timeline + +- The lab opens at **10:00 a.m.** on Saturday. Use this slot to gather materials, run quick pre-launch checks, and insert a clean microSD card. +- Do **not** attempt major code or hardware changes Saturday morning, and avoid any testing that could damage your payload—there will not be time to recover from issues. + +## Launch Kit Pickup + +- Each team will receive a launch kit on Friday containing an engine, igniters, plugs, and recovery wadding. +- Place your team parts box (clear tackle box) inside the purple cabinets on Friday so staff can load the launch materials. +- Every team starts Saturday with one engine. After the first round of launches we will redistribute any remaining engines to teams that need a second attempt. + +## What to Bring to Mitchell Field West + +Bring the essentials so you can debug on site after your first flight: + +- Laptop computer +- microSD card reader +- Cable adapters and your programming cable + +## Launch Schedule & Location + +- Plan to arrive at the lab between **10:00 a.m. and 11:30 a.m.** on Saturday. Launches begin around **12:00 p.m.** at **Mitchell Field West (softball fields).** +- We will monitor weather closely and send a Canvas + Slack Go/No-Go announcement by **9:30 a.m.** Saturday. + +## Weather Outlook + +Stay informed with the live NOAA meteogram for Ann Arbor. + +
+ NWS Graphical Forecast for Ann Arbor +
+ +

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 Forecast
We are a GO for launch! - Update this banner to no-go if weather calls for a scrub.
+ 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.
NWS Graphical Forecast for Ann Arbor + src="https://forecast.weather.gov/meteograms/Plotter.php?point=42.29,-83.73&zcode=MIZ068&gset=20&g=0&r=0&a=0&pcmd=0&relative=0&wfo=DTX&lat=42.29&lon=-83.73&z=0&tz=EST" + alt="NOAA meteogram showing temperature, wind, humidity, and precipitation for Ann Arbor">

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 Logistics
We are a GO for launch! - Switch the class to no-go if we scrub and the banner will turn red.
+ ## Lab Access Windows (Nov 13-14) @@ -77,8 +77,8 @@ Stay informed with the live NOAA meteogram for Ann Arbor.
NWS Graphical Forecast for Ann Arbor + src="https://forecast.weather.gov/meteograms/Plotter.php?point=42.29,-83.73&zcode=MIZ068&gset=20&g=0&r=0&a=0&pcmd=0&relative=0&wfo=DTX&lat=42.29&lon=-83.73&z=0&tz=EST" + alt="NOAA meteogram showing temperature, wind, humidity, and precipitation for Ann Arbor">

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.
- NOAA meteogram showing temperature, wind, humidity, and precipitation for Ann Arbor +
+
+

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.
- NOAA meteogram showing temperature, wind, humidity, and precipitation 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 @@
  • Syllabus
  • Labs
  • Project
  • +
  • Launch Logistics
  • Staff
  • diff --git a/index.html b/index.html index a519775..f738bb3 100644 --- a/index.html +++ b/index.html @@ -208,7 +208,7 @@
  • Project
  • Sensor Board Spec
  • Staff
  • -
  • Facilities
  • +
  • Launch Logistics
  • @@ -241,9 +241,9 @@

    Solder Challenge

    Soldering Challenge