CYBERWEATHER v7.0
CYD Weather Station - Modular Code Architecture, v7.0 Glass Dashboard + Holiday Animations
CRITICAL: PARTITION SCHEME
This project requires the Huge APP (3MB No OTA/1MB SPIFFS) partition scheme due to the size of all modules.
- In Arduino IDE: Tools → Partition Scheme → Huge APP (3MB No OTA/1MB SPIFFS)
- Arduino CLI:
--fqbn esp32:esp32:esp32:PartitionScheme=huge_app
CYBERWEATHER FEATURES
Indoor + Outdoor Weather
AHT20/BMP280 local sensors + OpenWeatherMap API
5-Day Scrolling Forecast
Animated forecast with weather icons
NASA Moon Phases
32-phase moon rendering with RLEBitmap
Wind Compass Display
Animated compass with direction arrow
RGB LED Weather Effects
Lightning flashes, rain breathing, color cycling
Holiday Animations
New Year's, July 4th, Halloween, Thanksgiving, Christmas
Built-in FTP Server
Wireless file upload to LittleFS — credentials set in config.h
Web Dashboard + Sky Tracker
Barometer trend, air quality, NWS alerts, temp history + Sky Tracker
// FILE INDEX
// MAIN SKETCH
CyberWeather.ino/****************************************************************************************** Δ-ORACLE v5.29 — FINAL LOCKED — 100% COMPILES — WIND + SUNRISE/SUNSET + EVERYTHING WORKS ******************************************************************************************/ #include <Arduino.h> #include <TFT_eSPI.h> #include <WiFi.h> #include <time.h> #include <Wire.h> #include <HTTPClient.h> #include <ArduinoJson.h> #include <LittleFS.h> #include <AsyncTCP.h> #include <ESPAsyncWebServer.h> #include "config.h" #include "BootSequence.h" #include "IndoorWeather.h" #include "BigClock.h" #include "BattMon.h" #include "WiFiSig.h" #include "OutdoorWeather.h" #include "5DayUpdate.h" #include "FTPServer.h" #include "Fireworks.h" #include "Santa.h" #include "Halloween.h" #include "Thanksgiving.h" TFT_eSPI tft = TFT_eSPI(); AsyncWebServer server(class="number">80); const char* ssid = WIFI_SSID; // values live in config.h - keep that file private const char* password = WIFI_PASSWORD; #define BACKLIGHT_PIN 27 // CYD backlight #define BACKLIGHT_20PC 51 const unsigned long WEATHER_INTERVAL = class="number">20UL * class="number">60UL * class="number">1000UL; unsigned long nextLightningPossible = class="number">0; bool inLightningFlash = false; unsigned long lightningOffTime = class="number">0; time_t sunriseUnix = class="number">0; time_t sunsetUnix = class="number">0; // Barometric trend: BMP280 samples on the 20-minute weather cadence. // 12 slots = a 4-hour window; /data reports current-minus-oldest so the page // can show rising/falling. Survives nothing (RAM only) - trend appears ~40 min // after boot and widens to the full window over 4 hours. #define PRESS_HIST_SIZE 12 float pressHist[PRESS_HIST_SIZE] = {class="number">0}; int pressHistIndex = class="number">0; bool pressHistFull = false; void pushPressureSample() { if (!bmpOK) return; pressHist[pressHistIndex] = pressureInHg; pressHistIndex = (pressHistIndex + class="number">1) % PRESS_HIST_SIZE; if (pressHistIndex == class="number">0) pressHistFull = true; } String getMoonPhaseName() { time_t now = time(nullptr); struct tm *ti = localtime(&now); // ONE moon algorithm for the whole project - moonPhaseFraction() lives in OutdoorWeather.h double phase = moonPhaseFraction(ti->tm_year + class="number">1900, ti->tm_mon + class="number">1, ti->tm_mday); // 28 phases for proper alignment: Full Moon at idx 14 = phase 0.5 int idx = (int)(phase * class="number">28 + class="number">0.5) % class="number">28; const char* phases[class="number">28] = { class="number">0, class="number">0,class="number">1,class="number">2, class="number">0,class="number">1,class="number">2, class="number">0, class="number">0,class="number">1,class="number">2, class="number">0,class="number">1,class="number">2, class="number">0, class="number">0,class="number">1,class="number">2, class="number">0,class="number">1,class="number">2, class="number">0, class="number">0,class="number">1,class="number">2, class="number">0,class="number">1,class="number">2 }; return String(phases[idx]); } // Wind direction — integer only, no compile errors String getWindDir(int deg) { if (deg < class="number">0) return class="number">0; const char* dirs[] = {class="number">0,class="number">1,class="number">2,class="number">3,class="number">4,class="number">5,class="number">6,class="number">7,class="number">8,class="number">9,class="number">10,class="number">11,class="number">12,class="number">13,class="number">14,class="number">15}; return String(dirs[(deg + class="number">11) / class="number">22 % class="number">16]); } void setup() { Serial.begin(class="number">115200); tft.init(); tft.setRotation(class="number">0); tft.fillScreen(TFT_BLACK); pinMode(BACKLIGHT_PIN, OUTPUT); analogWrite(BACKLIGHT_PIN, BACKLIGHT_20PC); // FIXED — YOUR CORRECT RGB LED PINS FROM config.h pinMode(LED_R, OUTPUT); pinMode(LED_G, OUTPUT); pinMode(LED_B, OUTPUT); analogWrite(LED_R, class="number">255); analogWrite(LED_G, class="number">255); analogWrite(LED_B, class="number">255); bootSequence(); initIndoorSensors(); // WiFi FIRST (NTP needs the network), and with a timeout - a dead AP at power-up // must never brick the display. Auto-reconnect keeps retrying in the background. WiFi.mode(WIFI_STA); WiFi.setAutoReconnect(true); WiFi.begin(ssid, password); unsigned long wifiStart = millis(); while (WiFi.status() != WL_CONNECTED && millis() - wifiStart < class="number">30000UL) { delay(class="number">500); Serial.print(class="number">0); } if (WiFi.status() == WL_CONNECTED) Serial.println(class="number">0); else Serial.println(class="number">0); // NTP with a timeout - clock shows --:-- until sync instead of hanging boot forever. // DST-aware Central Time (the old fixed -21600 was CST year-round - off an hour all summer) configTime(class="number">0, class="number">0, class="number">0); setenv(class="number">0, class="number">1, class="number">1); tzset(); unsigned long ntpStart = millis(); while (time(nullptr) < class="number">1000000000 && millis() - ntpStart < class="number">20000UL) delay(class="number">500); if (time(nullptr) < class="number">1000000000) Serial.println(class="number">0); fetchOutdoorWeather(); fetchHourlyForecast(); initFiveDayForecast(); fetchAirQuality(); readIndoorSensors(); pushPressureSample(); if (!LittleFS.begin()) { Serial.println(class="number">0); LittleFS.format(); LittleFS.begin(); } server.serveStatic(class="number">0, LittleFS, class="number">1).setDefaultFile(class="number">2); // MAIN DATA ENDPOINT — FULL WEATHER DATA server.on(class="number">0, HTTP_GET, [](AsyncWebServerRequest *request) { readIndoorSensors(); struct tm ti; char sunriseStr[class="number">12] = class="number">0; char sunsetStr[class="number">12] = class="number">0; char timeStr[class="number">12] = class="number">0; time_t now = time(nullptr); localtime_r(&now, &ti); int h = ti.tm_hour % class="number">12; if (h == class="number">0) h = class="number">12; sprintf(timeStr, class="number">0, h, ti.tm_min, ti.tm_sec, ti.tm_hour >= class="number">12 ? class="number">1 : class="number">2); if (sunriseUnix > class="number">0) { localtime_r(&sunriseUnix, &ti); h = ti.tm_hour % class="number">12; if (h == class="number">0) h = class="number">12; sprintf(sunriseStr, class="number">0, h, ti.tm_min, ti.tm_hour >= class="number">12 ? class="number">1 : class="number">2); } if (sunsetUnix > class="number">0) { localtime_r(&sunsetUnix, &ti); h = ti.tm_hour % class="number">12; if (h == class="number">0) h = class="number">12; sprintf(sunsetStr, class="number">0, h, ti.tm_min, ti.tm_hour >= class="number">12 ? class="number">1 : class="number">2); } String json = class="number">0; json += class="number">0 + String(timeStr) + class="number">1; json += class="number">0 + getMoonPhaseName() + class="number">1; json += class="number">0 + String((int)(tempF + class="number">0.5)) + class="number">1; json += class="number">0 + String((int)(humidity + class="number">0.5)) + class="number">1; json += class="number">0 + String(pressureInHg, class="number">2) + class="number">1; json += class="number">0 + String((int)(owTempF + class="number">0.5)) + class="number">1; json += class="number">0 + String(owHumidity) + class="number">1; json += class="number">0 + String((int)(owFeelsLikeF + class="number">0.5)) + class="number">1; json += class="number">0 + String(owPressure) + class="number">1; json += class="number">0 + owCondition + class="number">1; json += class="number">0 + owDescription + class="number">1; json += class="number">0 + String((int)(owWindSpeed * class="number">2.237 + class="number">0.5)) + class="number">1; json += class="number">0 + String((int)(owWindGust * class="number">2.237 + class="number">0.5)) + class="number">1; json += class="number">0 + String(owWindDeg) + class="number">1; json += class="number">0 + getWindDir(owWindDeg) + class="number">1; json += class="number">0 + String(owRain1h, class="number">2) + class="number">1; json += class="number">0 + String(owAQI) + class="number">1; json += class="number">0 + String(owPM25, class="number">1) + class="number">1; json += class="number">0 + String(owO3, class="number">1) + class="number">1; // barometric trend: newest sample minus the oldest in the window, plus how wide the window is { int n = pressHistFull ? PRESS_HIST_SIZE : pressHistIndex; float delta = class="number">0.0; int span = class="number">0; if (n >= class="number">2) { int newest = (pressHistIndex - class="number">1 + PRESS_HIST_SIZE) % PRESS_HIST_SIZE; int oldest = pressHistFull ? pressHistIndex : class="number">0; delta = pressHist[newest] - pressHist[oldest]; span = (n - class="number">1) * class="number">20; } json += class="number">0 + String(delta, class="number">3) + class="number">1; json += class="number">0 + String(span) + class="number">1; } json += class="number">0 + String(sunriseStr) + class="number">1; json += class="number">0 + String(sunsetStr) + class="number">1; json += class="number">0 + String(millis()/class="number">60000) + class="number">1; json += class="number">0; request->send(class="number">200, class="number">0, json); }); // TEMPERATURE HISTORY FOR GRAPHS server.on(class="number">0, HTTP_GET, [](AsyncWebServerRequest *request) { String json = class="number">0; int count = tempHistoryFull ? TEMP_HISTORY_SIZE : tempHistoryIndex; int start = tempHistoryFull ? tempHistoryIndex : class="number">0; for (int i = class="number">0; i < count; i++) { int idx = (start + i) % TEMP_HISTORY_SIZE; if (i > class="number">0) json += class="number">0; json += String((int)(tempHistory[idx] + class="number">0.5)); } json += class="number">0; request->send(class="number">200, class="number">0, json); }); // 5-DAY FORECAST ENDPOINT server.on(class="number">0, HTTP_GET, [](AsyncWebServerRequest *request){ String json = class="number">0; for(int i = class="number">0; i < class="number">5; i++){ if(i > class="number">0) json += class="number">0; json += class="number">0 + forecast[i].day + class="number">0 + String(forecast[i].highF) + class="number">0 + String(forecast[i].lowF) + class="number">0 + forecast[i].condition + class="number">1; } json += class="number">0; request->send(class="number">200, class="number">0, json); }); // HOURLY FORECAST ENDPOINT (serves cached data) server.on(class="number">0, HTTP_GET, [](AsyncWebServerRequest *request){ if (!hourlyReady) { request->send(class="number">200, class="number">0, class="number">1); return; } String json = class="number">0; for (int i = class="number">0; i < class="number">8; i++) { if (i > class="number">0) json += class="number">0; json += class="number">0 + String(hourlyData[i].time) + class="number">0 + String(hourlyData[i].tempF) + class="number">0 + String(hourlyData[i].humidity) + class="number">0 + String(hourlyData[i].precipChance) + class="number">0 + String(hourlyData[i].condition) + class="number">1; } json += class="number">0; request->send(class="number">200, class="number">0, json); }); // LOCATION ENDPOINT (for Sky Tracker) server.on(class="number">0, HTTP_GET, [](AsyncWebServerRequest *request){ String json = class="number">0 LOCATION_NAME class="number">1 + String(LOCATION_LAT, class="number">4) + class="number">2 + String(LOCATION_LNG, class="number">4) + class="number">3; request->send(class="number">200, class="number">0, json); }); server.begin(); initFTPServer(); // Start FTP server on port 21 Serial.print("OBSIDIAN HOLOSCREEN → http://"); Serial.println(WiFi.localIP()); Serial.println("FTP Server → ftp://" + WiFi.localIP().toString() + " (credentials in config.h)"); tft.fillScreen(TFT_BLACK); drawBigClock(); drawIPAddress(); updateWiFiDisplay(); drawOutdoorWeather(); } void loop() { handleFTPServer(); // Handle FTP connections static int lastMinute = -class="number">1; static unsigned long lastIndoorRead = class="number">0; struct tm timeinfo; if (getLocalTime(&timeinfo) && timeinfo.tm_min != lastMinute) { lastMinute = timeinfo.tm_min; drawBigClock(); } if (millis() - lastIndoorRead >= class="number">8000) { lastIndoorRead = millis(); readIndoorSensors(); updateIndoorDisplayOnly(); } updateWiFiDisplay(); updateWeatherRGB(); updateFiveDayForecast(); // scroll animation only - fetching happens ONLY in the block below if (millis() - lastWeatherUpdate >= WEATHER_INTERVAL) { lastWeatherUpdate = millis(); fetchOutdoorWeather(); fetchHourlyForecast(); fetchFiveDayForecast(); fetchAirQuality(); pushPressureSample(); drawOutdoorWeather(); } // New Year's Eve Fireworks Show - triggers at 11:55 PM on Dec 31st if (isNewYearsEveShowTime()) { runFireworksShow(class="number">600000); // 10 minutes of fireworks // Redraw the main display after fireworks tft.fillScreen(TFT_BLACK); drawBigClock(); drawIPAddress(); updateWiFiDisplay(); drawOutdoorWeather(); } // July 4th Fireworks - triggers every hour on the hour if (isJuly4thShowTime()) { runJuly4thCycle(); // One cycle of red/white/blue fireworks // Redraw the main display after fireworks tft.fillScreen(TFT_BLACK); drawBigClock(); drawIPAddress(); updateWiFiDisplay(); drawOutdoorWeather(); } // Christmas Santa - Dec 24 from 5PM, Dec 25 all day, hourly on the hour if (isChristmasShowTime()) { runSantaAnimation(); // One run (2 passes across screen) // Redraw the main display after Santa tft.fillScreen(TFT_BLACK); drawBigClock(); drawIPAddress(); updateWiFiDisplay(); drawOutdoorWeather(); } // Halloween - Oct 31 all day, hourly on the hour if (isHalloweenShowTime()) { runHalloweenAnimation(); // One cycle (2 passes) // Redraw the main display after Halloween tft.fillScreen(TFT_BLACK); drawBigClock(); drawIPAddress(); updateWiFiDisplay(); drawOutdoorWeather(); } // Thanksgiving - 4th Thursday of November, hourly on the hour if (isThanksgivingShowTime()) { runThanksgivingAnimation(); // 20 second animation // Redraw the main display after Thanksgiving tft.fillScreen(TFT_BLACK); drawBigClock(); drawIPAddress(); updateWiFiDisplay(); drawOutdoorWeather(); } delay(class="number">100); } void updateWeatherRGB() { String c = owCondition; c.toLowerCase(); if (c.indexOf(class="number">0) != -class="number">1) { if (!inLightningFlash && millis() > nextLightningPossible) { if (random(class="number">1000) < class="number">8) { inLightningFlash = true; analogWrite(LED_R, class="number">255); analogWrite(LED_G, class="number">255); analogWrite(LED_B, class="number">255); lightningOffTime = millis() + random(class="number">40, class="number">140); nextLightningPossible = millis() + random(class="number">4000, class="number">20000); } } if (inLightningFlash && millis() > lightningOffTime) { analogWrite(LED_R, class="number">0); analogWrite(LED_G, class="number">0); analogWrite(LED_B, class="number">0); inLightningFlash = false; if (random(class="number">10) < class="number">4) { delay(random(class="number">80, class="number">350)); analogWrite(LED_R, class="number">255); analogWrite(LED_G, class="number">255); analogWrite(LED_B, class="number">255); delay(random(class="number">30, class="number">110)); analogWrite(LED_R, class="number">0); analogWrite(LED_G, class="number">0); analogWrite(LED_B, class="number">0); } } return; } else if (c.indexOf(class="number">0) != -class="number">1 || c.indexOf(class="number">1) != -class="number">1) { float breath = (exp(sin(millis() / class="number">2000.0 * PI)) - class="number">0.367879) * class="number">108.0; analogWrite(LED_R, class="number">0); analogWrite(LED_G, class="number">0); analogWrite(LED_B, (uint8_t)breath); } else { static unsigned long lastNormalLED = class="number">0; static int ledColorIndex = class="number">0; const uint8_t colorCycle[class="number">8][class="number">3] = { {class="number">255,class="number">255,class="number">255},{class="number">0,class="number">255,class="number">255},{class="number">0,class="number">255,class="number">0},{class="number">255,class="number">255,class="number">0}, {class="number">255,class="number">0,class="number">0},{class="number">255,class="number">0,class="number">255},{class="number">0,class="number">0,class="number">255},{class="number">128,class="number">128,class="number">255} }; if (millis() - lastNormalLED >= class="number">5000) { lastNormalLED = millis(); ledColorIndex = (ledColorIndex + class="number">1) % class="number">8; analogWrite(LED_R, class="number">255 - colorCycle[ledColorIndex][class="number">0]); analogWrite(LED_G, class="number">255 - colorCycle[ledColorIndex][class="number">1]); analogWrite(LED_B, class="number">255 - colorCycle[ledColorIndex][class="number">2]); } } }
// CONFIGURATION - TEMPLATE, PUT IN YOUR OWN VALUES
config.h#pragma once // ===================================================================== // CyberWeather configuration - ALL secrets and site settings live HERE. // KEEP THIS FILE PRIVATE. Never publish it or paste it into an article. // ===================================================================== // === WIFI === #define WIFI_SSID "YOUR-WIFI-NAME" #define WIFI_PASSWORD "YOUR-WIFI-PASSWORD" // === OPENWEATHERMAP (legacy 2.5 key) === #define OWM_API_KEY "YOUR-OPENWEATHERMAP-KEY" // === FTP SERVER (pick your own password!) === #define FTP_USER "YOUR-FTP-USER" #define FTP_PASSWORD "YOUR-FTP-PASSWORD" // === DISPLAY === #define SCREEN_WIDTH 320 #define SCREEN_HEIGHT 480 #define ROTATION 0 // === LOCATION (weather + Sky Tracker) === #define LOCATION_NAME "Your City, ST" #define LOCATION_LAT XX.XX #define LOCATION_LNG -XX.XX // === RGB MOOD LED === #define LED_R 22 #define LED_G 16 #define LED_B 17
// INDOOR SENSORS
IndoorWeather.h// ==================== INDOOR WEATHER SECTION ==================== // AHT20 + BMP280 COMBINED MODULE // SDA → GPIO 25 // SCL → GPIO 32 // VCC → 3.3V // GND → GND // ============================================================== #include <Wire.h> #include <Adafruit_AHTX0.h> #include <Adafruit_BMP280.h> extern TFT_eSPI tft; // ← FIXED: Declare global TFT from main sketch Adafruit_AHTX0 aht; Adafruit_BMP280 bmp; bool bmpOK = false; float tempF = class="number">0.0; float humidity = class="number">0.0; float pressureInHg = class="number">0.0; int lastIndoorTemp = -class="number">999; int lastIndoorHum = -class="number">999; bool initIndoorSensors() { Wire.begin(class="number">25, class="number">32); // SDA=25, SCL=32 if (!aht.begin(&Wire)) { Serial.println(class="number">0); return false; } if (!bmp.begin(class="number">0x77)) { // ← FIXED: Correct begin() signature for I2C (addr only) Serial.println(class="number">0); } else { bmpOK = true; bmp.setSampling(Adafruit_BMP280::MODE_NORMAL, Adafruit_BMP280::SAMPLING_X2, Adafruit_BMP280::SAMPLING_X16, Adafruit_BMP280::FILTER_X16, Adafruit_BMP280::STANDBY_MS_500); } return true; } void readIndoorSensors() { sensors_event_t h, t; if (aht.getEvent(&h, &t)) { tempF = t.temperature * class="number">9.0 / class="number">5.0 + class="number">32.0; humidity = h.relative_humidity; } // the BMP280 was configured but never read - it earns its keep now if (bmpOK) pressureInHg = bmp.readPressure() / class="number">3386.389; } void updateIndoorDisplayOnly() { int currentTemp = (int)(tempF + class="number">0.5); int currentHum = (int)(humidity + class="number">0.5); if (currentTemp != lastIndoorTemp) { lastIndoorTemp = currentTemp; tft.fillRect(class="number">0, class="number">0, class="number">180, class="number">90, TFT_BLACK); // ← FIXED: tft now accessible char str[class="number">6]; sprintf(str, class="number">0, currentTemp); uint16_t col = (currentTemp <= class="number">65) ? TFT_NAVY : (currentTemp <= class="number">79) ? TFT_CYAN : (currentTemp <= class="number">87) ? TFT_YELLOW : TFT_RED; tft.setTextSize(class="number">6); tft.setTextColor(col, TFT_BLACK); tft.setTextDatum(TL_DATUM); tft.drawString(str, class="number">15, class="number">15); tft.setFreeFont(&FreeSansBold9pt7b); tft.setTextSize(class="number">1); tft.drawString(class="number">0, class="number">15 + (currentTemp >= class="number">10 ? class="number">72 : class="number">60), class="number">8); tft.setFreeFont(NULL); tft.setTextSize(class="number">6); tft.drawString(class="number">0, class="number">15 + (currentTemp >= class="number">10 ? class="number">94 : class="number">82), class="number">15); } if (currentHum != lastIndoorHum) { // ← FIXED: Use correct variable name lastIndoorHum = currentHum; tft.fillRect(tft.width()-class="number">180, class="number">0, class="number">180, class="number">90, TFT_BLACK); char str[class="number">10]; sprintf(str, class="number">0, currentHum); uint16_t col = (currentHum <= class="number">30) ? TFT_RED : (currentHum <= class="number">60) ? TFT_GREEN : TFT_CYAN; tft.setTextSize(class="number">6); tft.setTextColor(col, TFT_BLACK); tft.setTextDatum(TR_DATUM); tft.drawString(str, tft.width()-class="number">15, class="number">15); } }
// OUTDOOR WEATHER + AIR QUALITY
OutdoorWeather.h// ==================== OUTDOOR WEATHER SECTION ==================== // FINAL: Wind +10px right + SUNRISE/SUNSET + OUTDOOR HUMIDITY — 100% CLEAN & COMPILES // Updated: RLEBitmap NASA moon phases (64x64) #include <HTTPClient.h> #include <ArduinoJson.h> #include <math.h> #include <RLEBitmap.h> #include <Moon/MoonPhases_64.h> extern TFT_eSPI tft; // =============== GLOBAL OUTDOOR VARIABLES =============== float owTempF = class="number">0.0; float owFeelsLikeF = class="number">0.0; float owWindSpeed = class="number">0.0; float owWindGust = class="number">0.0; // m/s; 0 when the API omits it int owWindDeg = class="number">0; int owHumidity = class="number">0; int owPressure = class="number">0; float owRain1h = class="number">0.0; // mm in the last hour; 0 when dry String owCondition = class="number">0; String owDescription = class="number">0; int owAQI = class="number">0; // OWM air quality index 1-5; 0 = not fetched yet float owPM25 = class="number">0.0; // ug/m3 float owO3 = class="number">0.0; // ug/m3 unsigned long lastWeatherUpdate = class="number">0; // Temperature history for graphs (last 24 readings = 8 hours at the 20-minute fetch cadence) #define TEMP_HISTORY_SIZE 24 float tempHistory[TEMP_HISTORY_SIZE] = {class="number">0}; int tempHistoryIndex = class="number">0; bool tempHistoryFull = false; // Hourly forecast cache (8 x 3-hour intervals) struct HourlyForecast { char time[class="number">6]; int tempF; int humidity; int precipChance; char condition[class="number">16]; }; HourlyForecast hourlyData[class="number">8]; bool hourlyReady = false; // SUNRISE & SUNSET (defined in main .ino) extern time_t sunriseUnix; extern time_t sunsetUnix; // =============== MOON PHASE (RLEBitmap NASA Images) =============== // Array of mask getter functions for 32 moon phases typedef void (*MaskGetter)(RLEBitmapInfo &); const MaskGetter moonMasks_64[class="number">32] PROGMEM = { get_mask_0_64_RLEBM, get_mask_1_64_RLEBM, get_mask_2_64_RLEBM, get_mask_3_64_RLEBM, get_mask_4_64_RLEBM, get_mask_5_64_RLEBM, get_mask_6_64_RLEBM, get_mask_7_64_RLEBM, get_mask_8_64_RLEBM, get_mask_9_64_RLEBM, get_mask_10_64_RLEBM, get_mask_11_64_RLEBM, get_mask_12_64_RLEBM, get_mask_13_64_RLEBM, get_mask_14_64_RLEBM, get_mask_15_64_RLEBM, get_mask_16_64_RLEBM, get_mask_17_64_RLEBM, get_mask_18_64_RLEBM, get_mask_19_64_RLEBM, get_mask_20_64_RLEBM, get_mask_21_64_RLEBM, get_mask_22_64_RLEBM, get_mask_23_64_RLEBM, get_mask_24_64_RLEBM, get_mask_25_64_RLEBM, get_mask_26_64_RLEBM, get_mask_27_64_RLEBM, get_mask_28_64_RLEBM, get_mask_29_64_RLEBM, get_mask_30_64_RLEBM, get_mask_31_64_RLEBM }; // THE one moon-phase algorithm for the whole project (name + image both derive from it). // Returns phase fraction 0.0-1.0 (0 = new moon, 0.5 = full moon). double moonPhaseFraction(int year, int month, int day) { int a = (class="number">14 - month) / class="number">12; int y = year + class="number">4800 - a; int m = month + class="number">12 * a - class="number">3; double jd = day + (class="number">153 * m + class="number">2) / class="number">5 + class="number">365 * y + y / class="number">4 - y / class="number">100 + y / class="number">400 - class="number">32045; // Known new moon: Jan 6, 2000 at 18:14 UTC = JD 2451550.26 double phase = fmod(jd - class="number">2451550.26, class="number">29.53058867) / class="number">29.53058867; if (phase < class="number">0) phase += class="number">1.0; return phase; } // Returns moon phase 0-31 for the NASA image set (0=new moon, 16=full moon) uint8_t getMoonPhase(int year, int month, int day) { return (uint8_t)(moonPhaseFraction(year, month, day) * class="number">32) % class="number">32; } // Draw NASA moon with RLEBitmap (64x64 centered at x,y) void drawMoon(int x, int y, uint8_t phase) { // Clear area for moon (64x64 centered) int drawX = x - class="number">32; int drawY = y - class="number">32; tft.fillRect(drawX, drawY, class="number">64, class="number">64, TFT_BLACK); // Get moon bitmap and mask RLEBitmapInfo moonBM, maskBM; get_moon_64_RLEBM(moonBM); // Get appropriate mask for this phase MaskGetter getMask = (MaskGetter)pgm_read_ptr(&moonMasks_64[phase]); getMask(maskBM); // Render moon with phase mask renderRLEBitmapWithRLEMask(moonBM, maskBM, drawX, drawY, &tft, true); } // Improved weather icons based on G6EJD/ESP32-e-Paper-Weather-Display void drawSun(int x, int y) { int scale = class="number">6; uint16_t sunYellow = tft.color565(class="number">255, class="number">200, class="number">0); uint16_t sunOrange = tft.color565(class="number">255, class="number">140, class="number">0); // Draw sun rays for (int i = class="number">0; i < class="number">8; i++) { float angle = i * PI / class="number">4; int x1 = x + cos(angle) * (scale * class="number">1.8); int y1 = y + sin(angle) * (scale * class="number">1.8); int x2 = x + cos(angle) * (scale * class="number">3.2); int y2 = y + sin(angle) * (scale * class="number">3.2); tft.drawLine(x1, y1, x2, y2, sunYellow); tft.drawLine(x1+class="number">1, y1, x2+class="number">1, y2, sunYellow); } // Draw sun body tft.fillCircle(x, y, scale * class="number">1.5, sunOrange); tft.fillCircle(x, y, scale * class="number">1.2, sunYellow); } void drawCloud(int x, int y, uint16_t color) { int scale = class="number">5; // Multiple overlapping circles for fluffy cloud tft.fillCircle(x - scale * class="number">2.5, y + scale * class="number">0.5, scale * class="number">1.2, color); tft.fillCircle(x + scale * class="number">2.5, y + scale * class="number">0.5, scale * class="number">1.2, color); tft.fillCircle(x - scale, y - scale * class="number">0.3, scale * class="number">1.5, color); tft.fillCircle(x + scale, y - scale * class="number">0.5, scale * class="number">1.8, color); tft.fillRect(x - scale * class="number">2.5, y, scale * class="number">5, scale * class="number">1.5, color); } void drawCloudy(int x, int y) { drawCloud(x, y, TFT_LIGHTGREY); } void drawRaindrop(int x, int y, int size) { uint16_t blue = tft.color565(class="number">80, class="number">180, class="number">255); // Teardrop shape: circle bottom + triangle top tft.fillCircle(x, y + size/class="number">2, size/class="number">2, blue); tft.fillTriangle(x - size/class="number">2, y + size/class="number">2, x + size/class="number">2, y + size/class="number">2, x, y - size/class="number">2, blue); } void drawRain(int x, int y) { uint16_t darkCloud = tft.color565(class="number">140, class="number">140, class="number">160); drawCloud(x, y - class="number">5, darkCloud); // Draw raindrops for (int i = class="number">0; i < class="number">4; i++) { int rx = x - class="number">15 + i * class="number">10; int ry = y + class="number">18 + (i % class="number">2) * class="number">6; drawRaindrop(rx, ry, class="number">6); } } void drawSnowflake(int x, int y, int size) { // Draw 6-pointed snowflake for (int i = class="number">0; i < class="number">6; i++) { float angle = i * PI / class="number">3; int x2 = x + cos(angle) * size; int y2 = y + sin(angle) * size; tft.drawLine(x, y, x2, y2, TFT_WHITE); // Add small branches int mx = x + cos(angle) * size * class="number">0.6; int my = y + sin(angle) * size * class="number">0.6; int bx1 = mx + cos(angle + class="number">0.5) * size * class="number">0.3; int by1 = my + sin(angle + class="number">0.5) * size * class="number">0.3; int bx2 = mx + cos(angle - class="number">0.5) * size * class="number">0.3; int by2 = my + sin(angle - class="number">0.5) * size * class="number">0.3; tft.drawLine(mx, my, bx1, by1, TFT_WHITE); tft.drawLine(mx, my, bx2, by2, TFT_WHITE); } } void drawSnow(int x, int y) { uint16_t darkCloud = tft.color565(class="number">160, class="number">160, class="number">180); drawCloud(x, y - class="number">5, darkCloud); // Draw snowflakes drawSnowflake(x - class="number">12, y + class="number">20, class="number">5); drawSnowflake(x + class="number">5, y + class="number">25, class="number">4); drawSnowflake(x + class="number">15, y + class="number">18, class="number">5); } void drawLightningBolt(int x, int y) { uint16_t yellow = TFT_YELLOW; // Draw lightning bolt shape tft.fillTriangle(x, y, x + class="number">8, y, x + class="number">2, y + class="number">10, yellow); tft.fillTriangle(x + class="number">2, y + class="number">8, x + class="number">10, y + class="number">8, x - class="number">2, y + class="number">22, yellow); } void drawThunder(int x, int y) { uint16_t darkCloud = tft.color565(class="number">100, class="number">100, class="number">120); drawCloud(x, y - class="number">8, darkCloud); // Lightning bolts drawLightningBolt(x - class="number">8, y + class="number">10); drawLightningBolt(x + class="number">6, y + class="number">14); } // Wind compass with direction arrow and cardinal markers void drawWindIcon(int cx, int cy, int deg) { int radius = class="number">30; uint16_t gray = tft.color565(class="number">80, class="number">80, class="number">100); uint16_t cyan = TFT_CYAN; // Draw compass circle tft.drawCircle(cx, cy, radius, gray); tft.drawCircle(cx, cy, radius-class="number">1, gray); // Draw cardinal direction labels tft.setTextSize(class="number">1); tft.setTextDatum(MC_DATUM); tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.drawString(class="number">0, cx, cy - radius - class="number">6); tft.drawString(class="number">0, cx, cy + radius + class="number">6); tft.drawString(class="number">0, cx + radius + class="number">6, cy); tft.drawString(class="number">0, cx - radius - class="number">6, cy); // Draw tick marks for N/E/S/W tft.drawLine(cx, cy - radius + class="number">4, cx, cy - radius + class="number">8, TFT_WHITE); tft.drawLine(cx, cy + radius - class="number">4, cx, cy + radius - class="number">8, TFT_WHITE); tft.drawLine(cx + radius - class="number">4, cy, cx + radius - class="number">8, cy, TFT_WHITE); tft.drawLine(cx - radius + class="number">4, cy, cx - radius + class="number">8, cy, TFT_WHITE); // Draw rotating arrow float rad = deg * PI / class="number">180.0; int len = class="number">22; // Arrow points in direction wind is coming FROM int tipX = cx + sin(rad) * len; int tipY = cy - cos(rad) * len; int tailX = cx - sin(rad) * (len - class="number">6); int tailY = cy + cos(rad) * (len - class="number">6); // Arrowhead float wingAngle = class="number">0.45; int wingLen = class="number">8; int wing1X = tipX - sin(rad - wingAngle) * wingLen; int wing1Y = tipY + cos(rad - wingAngle) * wingLen; int wing2X = tipX - sin(rad + wingAngle) * wingLen; int wing2Y = tipY + cos(rad + wingAngle) * wingLen; // Draw arrow shaft (thicker) tft.drawLine(tailX, tailY, tipX, tipY, cyan); tft.drawLine(tailX+class="number">1, tailY, tipX+class="number">1, tipY, cyan); tft.drawLine(tailX, tailY+class="number">1, tipX, tipY+class="number">1, cyan); // Draw filled arrowhead tft.fillTriangle(tipX, tipY, wing1X, wing1Y, wing2X, wing2Y, cyan); // Center pivot dot tft.fillCircle(cx, cy, class="number">3, TFT_WHITE); } // Water droplet icon for humidity void drawHumidityIcon(int x, int y) { uint16_t blue = tft.color565(class="number">80, class="number">160, class="number">255); uint16_t lightBlue = tft.color565(class="number">140, class="number">200, class="number">255); int dropHeight = class="number">40; int dropWidth = class="number">24; int bulbRadius = dropWidth / class="number">2; int bulbY = y + dropHeight/class="number">2 - bulbRadius; // Draw the rounded bottom (filled circle) tft.fillCircle(x, bulbY, bulbRadius, blue); // Draw the pointed top (filled triangle) int tipY = y - dropHeight/class="number">2 + class="number">4; tft.fillTriangle( x, tipY, // top point x - bulbRadius, bulbY, // bottom left x + bulbRadius, bulbY, // bottom right blue ); // Add a highlight/shine effect tft.fillCircle(x - class="number">4, bulbY - class="number">2, class="number">4, lightBlue); tft.fillCircle(x - class="number">2, bulbY - class="number">8, class="number">2, lightBlue); } void drawOutdoorWeather() { // Screen: 320x480 - Layout: 3 columns for weather/wind/humidity // Column centers: Left=55, Center=160, Right=265 // Icon row y=130, Number row y=185 const int colLeft = class="number">55; const int colCenter = class="number">160; const int colRight = class="number">265; const int iconY = class="number">130; const int numberY = class="number">185; tft.fillRect(class="number">0, class="number">95, class="number">320, class="number">105, TFT_BLACK); // Clear icon/number area if (owTempF == class="number">0.0 && owCondition == class="number">0) { tft.setTextSize(class="number">3); tft.setTextColor(TFT_ORANGE, TFT_BLACK); tft.setTextDatum(MC_DATUM); tft.drawString(class="number">0, colCenter, iconY); return; } struct tm timeinfo; if (!getLocalTime(&timeinfo)) return; bool isNight = (timeinfo.tm_hour >= class="number">18 || timeinfo.tm_hour < class="number">6); String c = owCondition; c.toLowerCase(); // LEFT COLUMN: Weather icon + Temperature if (isNight) { uint8_t phase = getMoonPhase(timeinfo.tm_year + class="number">1900, timeinfo.tm_mon + class="number">1, timeinfo.tm_mday); drawMoon(colLeft, iconY, phase); } else { if (c.indexOf(class="number">0) != -class="number">1) drawSun(colLeft, iconY); else if (c.indexOf(class="number">0) != -class="number">1 || c.indexOf(class="number">1) != -class="number">1 || c.indexOf(class="number">2) != -class="number">1 || c.indexOf(class="number">3) != -class="number">1) drawThunder(colLeft, iconY); else if (c.indexOf(class="number">0) != -class="number">1 || c.indexOf(class="number">1) != -class="number">1) drawRain(colLeft, iconY); else if (c.indexOf(class="number">0) != -class="number">1) drawSnow(colLeft, iconY); else if (c.indexOf(class="number">0) != -class="number">1 || c.indexOf(class="number">1) != -class="number">1 || c.indexOf(class="number">2) != -class="number">1 || c.indexOf(class="number">3) != -class="number">1 || c.indexOf(class="number">4) != -class="number">1) drawCloudy(colLeft, iconY); else drawCloudy(colLeft, iconY); } // CENTER COLUMN: Wind compass + MPH drawWindIcon(colCenter, iconY, owWindDeg); // RIGHT COLUMN: Humidity droplet + % drawHumidityIcon(colRight, iconY); // Temperature number (left column) - centered with unit int t = (int)(owTempF + class="number">0.5); uint16_t tempCol = (t <= class="number">65) ? TFT_NAVY : (t <= class="number">79) ? TFT_CYAN : (t <= class="number">87) ? TFT_YELLOW : TFT_RED; char buf[class="number">8]; sprintf(buf, class="number">0, t); tft.setTextSize(class="number">3); tft.setTextColor(tempCol, TFT_BLACK); tft.setTextDatum(MC_DATUM); tft.drawString(buf, colLeft, numberY); // Wind MPH (center column) - centered with unit int mph = (int)(owWindSpeed * class="number">2.237 + class="number">0.5); char wbuf[class="number">16]; sprintf(wbuf, class="number">0, mph); tft.setTextSize(class="number">2); tft.setTextColor(TFT_LIGHTGREY, TFT_BLACK); tft.setTextDatum(MC_DATUM); tft.drawString(wbuf, colCenter, numberY); // Humidity % (right column) - centered char hbuf[class="number">16]; sprintf(hbuf, class="number">0, owHumidity); tft.setTextSize(class="number">3); tft.setTextColor(TFT_LIGHTGREY, TFT_BLACK); tft.setTextDatum(MC_DATUM); tft.drawString(hbuf, colRight, numberY); } void fetchOutdoorWeather() { if (WiFi.status() != WL_CONNECTED) return; String url = "http://api.openweathermap.org/data/2.5/weather?lat=" + String(LOCATION_LAT, 4) + class="number">0 + String(LOCATION_LNG, class="number">4) + class="number">1 OWM_API_KEY class="number">2; HTTPClient http; http.begin(url); if (http.GET() == HTTP_CODE_OK) { String payload = http.getString(); DynamicJsonDocument doc(class="number">3072); // a bad/truncated payload must never overwrite good readings with 32F garbage if (deserializeJson(doc, payload) || doc[class="number">0][class="number">1].isNull()) { http.end(); return; } owTempF = doc[class="number">0][class="number">1].as<float>() * class="number">9.0/class="number">5.0 + class="number">32.0; owFeelsLikeF = doc[class="number">0][class="number">1].as<float>() * class="number">9.0/class="number">5.0 + class="number">32.0; owHumidity = doc[class="number">0][class="number">1].as<int>(); owPressure = doc[class="number">0][class="number">1].as<int>(); owCondition = doc[class="number">0][class="number">0][class="number">1].as<String>(); owDescription = doc[class="number">0][class="number">0][class="number">1].as<String>(); owWindSpeed = doc[class="number">0][class="number">1].as<float>(); owWindGust = doc[class="number">0][class="number">1] | class="number">0.0f; owWindDeg = doc[class="number">0][class="number">1] | class="number">0; owRain1h = doc[class="number">0][class="number">1] | class="number">0.0f; sunriseUnix = doc[class="number">0][class="number">1].as<time_t>(); sunsetUnix = doc[class="number">0][class="number">1].as<time_t>(); // Store temperature in history tempHistory[tempHistoryIndex] = owTempF; tempHistoryIndex = (tempHistoryIndex + class="number">1) % TEMP_HISTORY_SIZE; if (tempHistoryIndex == class="number">0) tempHistoryFull = true; } http.end(); } // Air quality - same free OWM key, separate endpoint. AQI scale 1-5 (1=Good .. 5=Very Poor). void fetchAirQuality() { if (WiFi.status() != WL_CONNECTED) return; String url = "http://api.openweathermap.org/data/2.5/air_pollution?lat=" + String(LOCATION_LAT, 4) + class="number">0 + String(LOCATION_LNG, class="number">4) + class="number">1 OWM_API_KEY; HTTPClient http; http.begin(url); if (http.GET() == HTTP_CODE_OK) { String payload = http.getString(); DynamicJsonDocument doc(class="number">2048); // a bad payload must never zero out the last good reading if (!deserializeJson(doc, payload) && !doc[class="number">0][class="number">0][class="number">1][class="number">2].isNull()) { owAQI = doc[class="number">0][class="number">0][class="number">1][class="number">2].as<int>(); owPM25 = doc[class="number">0][class="number">0][class="number">1][class="number">2] | class="number">0.0f; owO3 = doc[class="number">0][class="number">0][class="number">1][class="number">2] | class="number">0.0f; } } http.end(); } void fetchHourlyForecast() { if (WiFi.status() != WL_CONNECTED) return; String url = "http://api.openweathermap.org/data/2.5/forecast?lat=" + String(LOCATION_LAT, 4) + class="number">0 + String(LOCATION_LNG, class="number">4) + class="number">1 OWM_API_KEY class="number">2; HTTPClient http; http.begin(url); if (http.GET() == HTTP_CODE_OK) { String payload = http.getString(); DynamicJsonDocument doc(class="number">8192); if (!deserializeJson(doc, payload)) { for (int i = class="number">0; i < class="number">8; i++) { auto item = doc[class="number">0][i]; time_t dt = item[class="number">0].as<time_t>(); struct tm ti; localtime_r(&dt, &ti); int h = ti.tm_hour % class="number">12; if (h == class="number">0) h = class="number">12; sprintf(hourlyData[i].time, class="number">0, h, ti.tm_hour >= class="number">12 ? class="number">1 : class="number">2); hourlyData[i].tempF = (int)(item[class="number">0][class="number">1].as<float>() * class="number">9.0/class="number">5.0 + class="number">32.0 + class="number">0.5); hourlyData[i].humidity = item[class="number">0][class="number">1].as<int>(); hourlyData[i].precipChance = (int)(item[class="number">0].as<float>() * class="number">100 + class="number">0.5); // For first hour, use current weather observation instead of forecast if (i == class="number">0 && owCondition.length() > class="number">0) { strncpy(hourlyData[i].condition, owCondition.c_str(), class="number">15); } else { const char* cnd = item[class="number">0][class="number">0][class="number">1] | class="number">2; // missing field = "" - never strncpy(NULL) strncpy(hourlyData[i].condition, cnd, class="number">15); } hourlyData[i].condition[class="number">15] = class="number">0; } hourlyReady = true; } } http.end(); } // (the old updateOutdoorWeatherIfNeeded() 10-minute path was never called - removed; // the .ino's 20-minute block is the ONLY fetch authority)
// BIG CLOCK
BigClock.h// ==================== BIG CLOCK SECTION ==================== #include <time.h> extern TFT_eSPI tft; void drawBigClock() { struct tm timeinfo; if (!getLocalTime(&timeinfo)) return; int h = timeinfo.tm_hour % class="number">12; if (h == class="number">0) h = class="number">12; char timeStr[class="number">9]; sprintf(timeStr, class="number">0, h, timeinfo.tm_min); tft.setTextSize(class="number">9); tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.setTextDatum(MC_DATUM); tft.drawString(timeStr, tft.width()/class="number">2 - class="number">30, tft.height() - class="number">135); // AM/PM tft.setTextSize(class="number">4); tft.setTextColor(TFT_CYAN, TFT_BLACK); tft.drawString(timeinfo.tm_hour >= class="number">12 ? class="number">0 : class="number">1, tft.width()/class="number">2 + class="number">100, tft.height() - class="number">123); // Date char dateStr[class="number">20]; strftime(dateStr, sizeof(dateStr), class="number">0, &timeinfo); tft.setTextSize(class="number">3); tft.setTextColor(TFT_LIGHTGREY, TFT_BLACK); tft.drawString(dateStr, tft.width()/class="number">2, tft.height() - class="number">77); } // ==========================================================
// 5-DAY FORECAST
5DayUpdate.h// ==================== 5-DAY FORECAST SECTION ==================== // LOCKED FOREVER — STARTS WITH TODAY — 100% CORRECT DAY NAMES #include <HTTPClient.h> #include <ArduinoJson.h> extern TFT_eSPI tft; extern String owCondition; // Current weather from OutdoorWeather.h // =============== IMPROVED TINY WEATHER ICONS =============== void drawSunSmall(int x, int y) { uint16_t sunYellow = tft.color565(class="number">255, class="number">200, class="number">0); uint16_t sunOrange = tft.color565(class="number">255, class="number">140, class="number">0); // Draw rays for (int i = class="number">0; i < class="number">8; i++) { float angle = i * PI / class="number">4; int x1 = x + cos(angle) * class="number">8; int y1 = y + sin(angle) * class="number">8; int x2 = x + cos(angle) * class="number">12; int y2 = y + sin(angle) * class="number">12; tft.drawLine(x1, y1, x2, y2, sunYellow); } // Sun body tft.fillCircle(x, y, class="number">6, sunOrange); tft.fillCircle(x, y, class="number">4, sunYellow); } void drawCloudSmall(int x, int y) { uint16_t gray = TFT_LIGHTGREY; // Fluffy cloud with overlapping circles tft.fillCircle(x - class="number">8, y + class="number">2, class="number">5, gray); tft.fillCircle(x + class="number">8, y + class="number">2, class="number">5, gray); tft.fillCircle(x - class="number">3, y - class="number">2, class="number">6, gray); tft.fillCircle(x + class="number">4, y - class="number">1, class="number">7, gray); tft.fillRect(x - class="number">8, y, class="number">16, class="number">6, gray); } void drawPartlyCloudySmall(int x, int y) { uint16_t sunYellow = tft.color565(class="number">255, class="number">200, class="number">0); uint16_t sunOrange = tft.color565(class="number">255, class="number">140, class="number">0); uint16_t gray = TFT_LIGHTGREY; // Sun peeking from behind (upper left) // sunY must stay >= band top: the scroll's clear rect starts at y=207 and rows // above it are never erased, so a ray tip drawn past it smears across the screen. // With y=221 and 10px rays, y-4 puts the topmost ray pixel exactly at 207. int sunX = x - class="number">6; int sunY = y - class="number">4; // Draw partial rays (only visible ones) for (int i = class="number">0; i < class="number">5; i++) { float angle = i * PI / class="number">4 - PI/class="number">2; // Upper rays only int x1 = sunX + cos(angle) * class="number">6; int y1 = sunY + sin(angle) * class="number">6; int x2 = sunX + cos(angle) * class="number">10; int y2 = sunY + sin(angle) * class="number">10; tft.drawLine(x1, y1, x2, y2, sunYellow); } // Sun body tft.fillCircle(sunX, sunY, class="number">5, sunOrange); tft.fillCircle(sunX, sunY, class="number">3, sunYellow); // Cloud in front (lower right) int cloudX = x + class="number">4; int cloudY = y + class="number">4; tft.fillCircle(cloudX - class="number">8, cloudY + class="number">2, class="number">5, gray); tft.fillCircle(cloudX + class="number">6, cloudY + class="number">2, class="number">5, gray); tft.fillCircle(cloudX - class="number">3, cloudY - class="number">2, class="number">6, gray); tft.fillCircle(cloudX + class="number">2, cloudY - class="number">1, class="number">6, gray); tft.fillRect(cloudX - class="number">8, cloudY, class="number">14, class="number">6, gray); } void drawRainSmall(int x, int y) { uint16_t darkCloud = tft.color565(class="number">140, class="number">140, class="number">160); uint16_t blue = tft.color565(class="number">80, class="number">180, class="number">255); // Dark cloud tft.fillCircle(x - class="number">8, y, class="number">5, darkCloud); tft.fillCircle(x + class="number">8, y, class="number">5, darkCloud); tft.fillCircle(x - class="number">3, y - class="number">4, class="number">6, darkCloud); tft.fillCircle(x + class="number">4, y - class="number">3, class="number">7, darkCloud); tft.fillRect(x - class="number">8, y - class="number">2, class="number">16, class="number">6, darkCloud); // Raindrops (teardrop shapes) tft.fillCircle(x - class="number">6, y + class="number">10, class="number">2, blue); tft.fillTriangle(x - class="number">6, y + class="number">6, x - class="number">8, y + class="number">10, x - class="number">4, y + class="number">10, blue); tft.fillCircle(x + class="number">2, y + class="number">12, class="number">2, blue); tft.fillTriangle(x + class="number">2, y + class="number">8, x, y + class="number">12, x + class="number">4, y + class="number">12, blue); tft.fillCircle(x + class="number">8, y + class="number">9, class="number">2, blue); tft.fillTriangle(x + class="number">8, y + class="number">5, x + class="number">6, y + class="number">9, x + class="number">10, y + class="number">9, blue); } void drawSnowSmall(int x, int y) { uint16_t grayCloud = tft.color565(class="number">160, class="number">160, class="number">180); // Gray cloud tft.fillCircle(x - class="number">8, y, class="number">5, grayCloud); tft.fillCircle(x + class="number">8, y, class="number">5, grayCloud); tft.fillCircle(x - class="number">3, y - class="number">4, class="number">6, grayCloud); tft.fillCircle(x + class="number">4, y - class="number">3, class="number">7, grayCloud); tft.fillRect(x - class="number">8, y - class="number">2, class="number">16, class="number">6, grayCloud); // Snowflakes (6-point stars) for (int s = class="number">0; s < class="number">3; s++) { int sx = x - class="number">7 + s * class="number">7; int sy = y + class="number">10 + (s % class="number">2) * class="number">3; for (int i = class="number">0; i < class="number">3; i++) { float angle = i * PI / class="number">3; tft.drawLine(sx - cos(angle) * class="number">3, sy - sin(angle) * class="number">3, sx + cos(angle) * class="number">3, sy + sin(angle) * class="number">3, TFT_WHITE); } } } void drawStormSmall(int x, int y) { uint16_t darkCloud = tft.color565(class="number">100, class="number">100, class="number">120); // Very dark cloud tft.fillCircle(x - class="number">8, y - class="number">2, class="number">5, darkCloud); tft.fillCircle(x + class="number">8, y - class="number">2, class="number">5, darkCloud); tft.fillCircle(x - class="number">3, y - class="number">6, class="number">6, darkCloud); tft.fillCircle(x + class="number">4, y - class="number">5, class="number">7, darkCloud); tft.fillRect(x - class="number">8, y - class="number">4, class="number">16, class="number">6, darkCloud); // Lightning bolts tft.fillTriangle(x - class="number">4, y + class="number">2, x + class="number">1, y + class="number">2, x - class="number">2, y + class="number">8, TFT_YELLOW); tft.fillTriangle(x - class="number">2, y + class="number">6, x + class="number">4, y + class="number">6, x - class="number">5, y + class="number">14, TFT_YELLOW); tft.fillTriangle(x + class="number">4, y + class="number">4, x + class="number">8, y + class="number">4, x + class="number">5, y + class="number">10, TFT_YELLOW); tft.fillTriangle(x + class="number">5, y + class="number">8, x + class="number">10, y + class="number">8, x + class="number">2, y + class="number">15, TFT_YELLOW); } void drawFogSmall(int x, int y) { uint16_t fogGray = tft.color565(class="number">180, class="number">180, class="number">190); uint16_t fogLight = tft.color565(class="number">140, class="number">140, class="number">150); // Horizontal fog/mist lines with varying lengths tft.fillRoundRect(x - class="number">14, y - class="number">8, class="number">28, class="number">3, class="number">1, fogGray); tft.fillRoundRect(x - class="number">10, y - class="number">3, class="number">22, class="number">3, class="number">1, fogLight); tft.fillRoundRect(x - class="number">16, y + class="number">2, class="number">30, class="number">3, class="number">1, fogGray); tft.fillRoundRect(x - class="number">12, y + class="number">7, class="number">24, class="number">3, class="number">1, fogLight); tft.fillRoundRect(x - class="number">8, y + class="number">12, class="number">18, class="number">3, class="number">1, fogGray); } // =============== 5-DAY STRUCT =============== struct ForecastDay { String day; int highF; int lowF; String condition; }; ForecastDay forecast[class="number">5]; bool forecastReady = false; static int forecastScrollX = class="number">320; static int lastScrollX = -class="number">9999; static unsigned long lastForecastScroll = class="number">0; // =============== FETCH — REAL CALENDAR DAYS (fixed 2026-08-17) =============== // The API list is 3-hour steps from NOW. The old code sliced it in blocks of 8 // (rolling 24h windows) but labeled them with calendar day names, so an evening // fetch smeared tonight+tomorrow-morning into "today". Now every entry is // bucketed by its actual LOCAL calendar date. void fetchFiveDayForecast() { if (WiFi.status() != WL_CONNECTED) return; String url = "http://api.openweathermap.org/data/2.5/forecast?lat=" + String(LOCATION_LAT, 4) + class="number">0 + String(LOCATION_LNG, class="number">4) + class="number">1 OWM_API_KEY class="number">2; HTTPClient http; http.begin(url); if (http.GET() != HTTP_CODE_OK) { http.end(); return; } String payload = http.getString(); // Deserialize ONLY the three fields we use - the full payload needs ~20KB, // filtered it fits comfortably and can't fail with NoMemory. StaticJsonDocument<class="number">192> filter; filter[class="number">0][class="number">0][class="number">1] = true; filter[class="number">0][class="number">0][class="number">1][class="number">2] = true; filter[class="number">0][class="number">0][class="number">1][class="number">0][class="number">2] = true; DynamicJsonDocument doc(class="number">12288); if (deserializeJson(doc, payload, DeserializationOption::Filter(filter))) { http.end(); return; } http.end(); time_t now = time(nullptr); struct tm todayTm; localtime_r(&now, &todayTm); int todayYday = todayTm.tm_yday, todayYear = todayTm.tm_year; float dayMax[class="number">5], dayMin[class="number">5]; int cClear[class="number">5] = {class="number">0}, cCloud[class="number">5] = {class="number">0}, cRain[class="number">5] = {class="number">0}, cStorm[class="number">5] = {class="number">0}, cSnow[class="number">5] = {class="number">0}, cFog[class="number">5] = {class="number">0}, cAny[class="number">5] = {class="number">0}; for (int i = class="number">0; i < class="number">5; i++) { dayMax[i] = -class="number">1000.0; dayMin[i] = class="number">1000.0; } JsonArray list = doc[class="number">0].as<JsonArray>(); for (JsonObject item : list) { time_t t = item[class="number">0].as<time_t>(); if (t == class="number">0) continue; struct tm ti; localtime_r(&t, &ti); // whole calendar days between this entry and today (handles year rollover) int dayOffset = (int)((t - now) / class="number">86400); // coarse struct tm probe = ti; long diffDays = (probe.tm_year - todayYear) * class="number">365 + (probe.tm_yday - todayYday); if (probe.tm_year != todayYear) diffDays = dayOffset + ((probe.tm_yday < todayYday) ? class="number">1 : class="number">0); // year boundary fallback if (diffDays < class="number">0 || diffDays > class="number">4) continue; int d = (int)diffDays; float temp = item[class="number">0][class="number">1].as<float>(); if (temp > dayMax[d]) dayMax[d] = temp; if (temp < dayMin[d]) dayMin[d] = temp; cAny[d]++; const char* cnd = item[class="number">0][class="number">0][class="number">1] | class="number">2; if (!strcmp(cnd, class="number">0)) cClear[d]++; else if (!strcmp(cnd, class="number">0)) cCloud[d]++; else if (!strcmp(cnd, class="number">0) || !strcmp(cnd, class="number">1)) cRain[d]++; else if (!strcmp(cnd, class="number">0) || !strcmp(cnd, class="number">1) || !strcmp(cnd, class="number">2)) cStorm[d]++; else if (!strcmp(cnd, class="number">0)) cSnow[d]++; else if (!strcmp(cnd, class="number">0) || !strcmp(cnd, class="number">1) || !strcmp(cnd, class="number">2) || !strcmp(cnd, class="number">0) || !strcmp(cnd, class="number">1) || !strcmp(cnd, class="number">2) || !strcmp(cnd, class="number">3)) cFog[d]++; } for (int i = class="number">0; i < class="number">5; i++) { // dominant condition - rain/storm must be a significant portion of the day // (April 2026 tuning: one stray 3-hour storm entry no longer paints the whole day) String cond = class="number">0; if (cStorm[i] >= class="number">2) cond = class="number">0; else if (cSnow[i] >= class="number">2) cond = class="number">0; else if (cRain[i] >= class="number">4) cond = class="number">0; else if (cRain[i] >= class="number">2) cond = class="number">0; else if (cStorm[i] > class="number">0) cond = class="number">0; else if (cSnow[i] > class="number">0 && cSnow[i] >= cClear[i]) cond = class="number">0; else if (cRain[i] > class="number">0 && cCloud[i] >= cClear[i]) cond = class="number">0; else if (cFog[i] > cCloud[i] && cFog[i] > cClear[i]) cond = class="number">0; else if (cCloud[i] > cClear[i]) cond = class="number">0; else if (cCloud[i] > class="number">0 && cClear[i] > class="number">0) cond = class="number">0; // late in the evening today can have few or no forecast entries left - // fall back to the live observation instead of showing garbage if (cAny[i] == class="number">0) { dayMax[i] = dayMin[i] = (owTempF - class="number">32.0) * class="number">5.0 / class="number">9.0; cond = owCondition.length() ? owCondition : class="number">0; } if (i == class="number">0 && owCondition.length() > class="number">0) cond = owCondition; // live observation wins for today forecast[i].highF = (int)(dayMax[i] * class="number">9.0 / class="number">5.0 + class="number">32.0 + class="number">0.5); forecast[i].lowF = (int)(dayMin[i] * class="number">9.0 / class="number">5.0 + class="number">32.0 + class="number">0.5); forecast[i].condition = cond; time_t targetDay = now + (i * class="number">86400L); struct tm dayTm; localtime_r(&targetDay, &dayTm); char dayBuf[class="number">4]; strftime(dayBuf, class="number">4, class="number">0, &dayTm); forecast[i].day = String(dayBuf); } forecastReady = true; forecastScrollX = class="number">320; lastScrollX = -class="number">9999; } // =============== ULTRA-SMOOTH SCROLL (UNCHANGED & LOCKED) =============== void updateFiveDayForecast() { if (!forecastReady) return; if (millis() - lastForecastScroll > class="number">614) { lastForecastScroll = millis(); forecastScrollX -= class="number">15; const int totalWidth = class="number">5 * class="number">106 + class="number">50; if (forecastScrollX < -totalWidth) forecastScrollX = class="number">320; } if (lastScrollX != -class="number">9999) { int moved = forecastScrollX - lastScrollX; if (moved < class="number">0) { tft.fillRect(tft.width() + moved - class="number">10, class="number">207, -moved + class="number">20, class="number">80, TFT_BLACK); } } for (int i = class="number">0; i < class="number">5; i++) { int x = forecastScrollX + i * class="number">106 + class="number">60; if (x > -class="number">150 && x < tft.width() + class="number">150) { tft.fillRect(x - class="number">50, class="number">207, class="number">106, class="number">80, TFT_BLACK); String c = forecast[i].condition; c.toLowerCase(); if (c.indexOf(class="number">0) != -class="number">1) drawSunSmall(x, class="number">221); else if (c.indexOf(class="number">0) != -class="number">1 || c.indexOf(class="number">1) != -class="number">1 || c.indexOf(class="number">2) != -class="number">1 || c.indexOf(class="number">3) != -class="number">1) drawStormSmall(x, class="number">221); else if (c.indexOf(class="number">0) != -class="number">1) drawPartlyCloudySmall(x, class="number">221); else if (c.indexOf(class="number">0) != -class="number">1 || c.indexOf(class="number">1) != -class="number">1) drawRainSmall(x, class="number">221); else if (c.indexOf(class="number">0) != -class="number">1) drawSnowSmall(x, class="number">221); else if (c.indexOf(class="number">0) != -class="number">1 || c.indexOf(class="number">1) != -class="number">1 || c.indexOf(class="number">2) != -class="number">1 || c.indexOf(class="number">3) != -class="number">1 || c.indexOf(class="number">4) != -class="number">1) drawFogSmall(x, class="number">221); else if (c.indexOf(class="number">0) != -class="number">1) drawCloudSmall(x, class="number">221); else drawCloudSmall(x, class="number">221); tft.setTextSize(class="number">2); tft.setTextColor(TFT_CYAN, TFT_BLACK); tft.setTextDatum(MC_DATUM); tft.drawString(forecast[i].day, x, class="number">241); tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.drawString(String(forecast[i].highF) + class="number">0 + String(forecast[i].lowF) + class="number">1, x - class="number">8, class="number">264); } } lastScrollX = forecastScrollX; } // =============== INITIALISE =============== // (the old refreshFiveDayForecastIfNeeded() ran its OWN 10-minute fetch timer on // top of the .ino's 20-minute one - the forecast was being fetched twice as // often as displayed. Removed 2026-08-17: the .ino block is the only fetcher, // updateFiveDayForecast() handles the scroll.) void initFiveDayForecast() { fetchFiveDayForecast(); }
// IP ADDRESS DISPLAY
BattMon.h// ==================== IP ADDRESS DISPLAY — LOCKED ==================== // Device IP Address On-Screen Display (Bottom-Left) #include <WiFi.h> extern TFT_eSPI tft; void drawIPAddress() { const int y = tft.height() - class="number">38; tft.fillRect(class="number">5, y - class="number">20, class="number">170, class="number">50, TFT_BLACK); // Font 2 at size 1: a full 15-char IP is ~120px, well clear of the WiFi // corner whose clear-rect starts wiping at x=150 (size 2 ran to ~166 and // the last octet got eaten). Font passed per-call so no global font leak. tft.setTextSize(class="number">1); tft.setTextColor(TFT_CYAN, TFT_BLACK); tft.setTextDatum(TL_DATUM); tft.drawString(WiFi.localIP().toString(), class="number">10, y + class="number">3, class="number">2); } // ==========================================================
// WIFI SIGNAL
WiFiSig.h// ==================== WIFISIG SECTION ==================== // WiFi Signal Strength + Connection Status (Bottom-Right) // ←←← WIFISIG SECTION START ←←← extern TFT_eSPI tft; int lastRSSI = class="number">0; bool lastWiFiState = false; void drawWiFiStatus() { const int y = tft.height() - class="number">38; tft.fillRect(tft.width() - class="number">170, y - class="number">20, class="number">170, class="number">50, TFT_BLACK); bool connected = (WiFi.status() == WL_CONNECTED); tft.setTextSize(class="number">2); tft.setTextColor(connected ? TFT_GREEN : TFT_RED, TFT_BLACK); tft.setTextDatum(BR_DATUM); tft.drawString(class="number">0, tft.width() - class="number">70, y + class="number">11); int bars = class="number">0; if (connected) { int rssi = WiFi.RSSI(); if (rssi >= -class="number">50) bars = class="number">4; else if (rssi >= -class="number">65) bars = class="number">3; else if (rssi >= -class="number">75) bars = class="number">2; else if (rssi >= -class="number">85) bars = class="number">1; } uint16_t barCol = connected ? TFT_GREEN : TFT_RED; for (int i = class="number">0; i < class="number">4; i++) { int h = (i + class="number">1) * class="number">4; int x = tft.width() - class="number">58 + i * class="number">9; tft.drawRect(x, y + class="number">9 - h + class="number">4, class="number">6, h, barCol); if (i < bars) tft.fillRect(x + class="number">1, y + class="number">10 - h + class="number">4, class="number">4, h - class="number">1, barCol); } // Optional: Show RSSI value in dBm (uncomment if you want it) // if (connected) { // tft.setTextSize(1); // tft.setTextColor(TFT_DARKGREY, TFT_BLACK); // tft.drawString(String(WiFi.RSSI()) + "dBm", tft.width() - 50, y + 25); // } } // Call this function whenever WiFi state or signal changes void updateWiFiDisplay() { static bool firstRun = true; bool currentState = (WiFi.status() == WL_CONNECTED); int currentRSSI = currentState ? WiFi.RSSI() : class="number">0; if (firstRun || currentState != lastWiFiState || abs(currentRSSI - lastRSSI) > class="number">8) { // 3 dBm was inside normal jitter - the corner redrew constantly firstRun = false; lastWiFiState = currentState; lastRSSI = currentRSSI; drawWiFiStatus(); // ← now draws on first boot + any change } } // ←←← WIFISIG SECTION END ←←← // =======================================================
// FTP SERVER
FTPServer.h#ifndef FTPSERVER_H #define FTPSERVER_H /****************************************************************************************** FTP Server Module for CyberWeather Allows wireless file upload/download to LittleFS Usage: - Connect with any FTP client (FileZilla, WinSCP, etc.) - Host: ESP32's IP address - Port: 21 - Username: admin (configurable below) - Password: admin (configurable below) - Mode: Passive (PASV) *******************************************************************************************/ #include <WiFi.h> #include <FS.h> #include <LittleFS.h> using fs::File; // ============ FTP CONFIGURATION ============ // Credentials come from config.h now (they were admin/admin here - changed 2026-08-17) #ifndef FTP_USER #error "config.h must be included before FTPServer.h - it defines FTP_USER / FTP_PASSWORD" #endif #define FTP_PORT 21 #define FTP_DATA_PORT_PASV 50000 // ============ FTP SERVER CLASS ============ class SimpleFTP { public: void begin(); void handle(); bool isConnected() { return clientConnected; } private: WiFiServer ftpServer = WiFiServer(FTP_PORT); WiFiServer dataServer = WiFiServer(FTP_DATA_PORT_PASV); WiFiClient ftpClient; WiFiClient dataClient; bool clientConnected = false; bool userOK = false; bool passOK = false; String currentDir = class="number">0; String renameFrom = class="number">0; // Transfer state File transferFile; bool transferInProgress = false; bool isStoring = false; void sendResponse(int code, String message); void processCommand(String cmd); String getFileName(String path); String getFullPath(String path); void listDirectory(String path); void handleDataTransfer(); }; SimpleFTP ftpSrv; void SimpleFTP::begin() { ftpServer.begin(); dataServer.begin(); Serial.println(class="number">0); } void SimpleFTP::sendResponse(int code, String message) { String response = String(code) + class="number">0 + message + class="number">1; ftpClient.print(response); Serial.print(class="number">0 + response); } String SimpleFTP::getFullPath(String path) { if (path.startsWith(class="number">0)) return path; if (currentDir == class="number">0) return class="number">1 + path; return currentDir + class="number">0 + path; } String SimpleFTP::getFileName(String path) { int lastSlash = path.lastIndexOf(class="number">0); if (lastSlash >= class="number">0) return path.substring(lastSlash + class="number">1); return path; } void SimpleFTP::listDirectory(String path) { File root = LittleFS.open(path); if (!root || !root.isDirectory()) { return; } File file = root.openNextFile(); while (file) { String line = class="number">0; if (file.isDirectory()) { line = class="number">0 + String(file.name()); } else { line = class="number">0 + String(file.size()) + class="number">1 + String(file.name()); } dataClient.println(line); file = root.openNextFile(); } } void SimpleFTP::processCommand(String cmdLine) { cmdLine.trim(); Serial.println(class="number">0 + cmdLine); String cmd = class="number">0; String arg = class="number">0; int spacePos = cmdLine.indexOf(class="number">0); if (spacePos > class="number">0) { cmd = cmdLine.substring(class="number">0, spacePos); arg = cmdLine.substring(spacePos + class="number">1); } else { cmd = cmdLine; } cmd.toUpperCase(); // ---- Authentication ---- if (cmd == class="number">0) { userOK = (arg == FTP_USER); sendResponse(class="number">331, class="number">0); return; } if (cmd == class="number">0) { if (userOK && arg == FTP_PASSWORD) { passOK = true; sendResponse(class="number">230, class="number">0); } else { sendResponse(class="number">530, class="number">0); userOK = false; } return; } // Require authentication for all other commands if (!passOK) { sendResponse(class="number">530, class="number">0); return; } // ---- System Commands ---- if (cmd == class="number">0) { sendResponse(class="number">215, class="number">0); return; } if (cmd == class="number">0) { ftpClient.print(class="number">0); return; } if (cmd == class="number">0 || cmd == class="number">1) { sendResponse(class="number">257, class="number">0 + currentDir + class="number">1); return; } if (cmd == class="number">0 || cmd == class="number">1) { String newDir = getFullPath(arg); if (newDir == class="number">0 || LittleFS.exists(newDir)) { currentDir = newDir; if (currentDir != class="number">0 && currentDir.endsWith(class="number">1)) { currentDir = currentDir.substring(class="number">0, currentDir.length() - class="number">1); } sendResponse(class="number">250, class="number">0 + currentDir); } else { sendResponse(class="number">550, class="number">0); } return; } if (cmd == class="number">0 || cmd == class="number">1) { int lastSlash = currentDir.lastIndexOf(class="number">0); if (lastSlash > class="number">0) { currentDir = currentDir.substring(class="number">0, lastSlash); } else { currentDir = class="number">0; } sendResponse(class="number">250, class="number">0 + currentDir); return; } if (cmd == class="number">0) { sendResponse(class="number">200, class="number">0 + arg); return; } if (cmd == class="number">0) { IPAddress ip = WiFi.localIP(); int p1 = FTP_DATA_PORT_PASV / class="number">256; int p2 = FTP_DATA_PORT_PASV % class="number">256; String response = class="number">0 + String(ip[class="number">0]) + class="number">0 + String(ip[class="number">1]) + class="number">1 + String(ip[class="number">2]) + class="number">0 + String(ip[class="number">3]) + class="number">1 + String(p1) + class="number">0 + String(p2) + class="number">1; sendResponse(class="number">227, response); return; } if (cmd == class="number">0 || cmd == class="number">1) { sendResponse(class="number">150, class="number">0); unsigned long timeout = millis() + class="number">5000; while (!dataServer.hasClient() && millis() < timeout) { delay(class="number">10); } if (dataServer.hasClient()) { dataClient = dataServer.available(); listDirectory(currentDir); dataClient.stop(); sendResponse(class="number">226, class="number">0); } else { sendResponse(class="number">425, class="number">0); } return; } if (cmd == class="number">0) { sendResponse(class="number">150, class="number">0); unsigned long timeout = millis() + class="number">5000; while (!dataServer.hasClient() && millis() < timeout) { delay(class="number">10); } if (dataServer.hasClient()) { dataClient = dataServer.available(); File root = LittleFS.open(currentDir); if (root && root.isDirectory()) { File file = root.openNextFile(); while (file) { dataClient.println(file.name()); file = root.openNextFile(); } } dataClient.stop(); sendResponse(class="number">226, class="number">0); } else { sendResponse(class="number">425, class="number">0); } return; } if (cmd == class="number">0) { String filePath = getFullPath(arg); if (!LittleFS.exists(filePath)) { sendResponse(class="number">550, class="number">0); return; } sendResponse(class="number">150, class="number">0); unsigned long timeout = millis() + class="number">5000; while (!dataServer.hasClient() && millis() < timeout) { delay(class="number">10); } if (dataServer.hasClient()) { dataClient = dataServer.available(); File file = LittleFS.open(filePath, class="number">0); if (file) { uint8_t buf[class="number">512]; while (file.available()) { int bytesRead = file.read(buf, sizeof(buf)); dataClient.write(buf, bytesRead); } file.close(); dataClient.stop(); sendResponse(class="number">226, class="number">0); } else { dataClient.stop(); sendResponse(class="number">550, class="number">0); } } else { sendResponse(class="number">425, class="number">0); } return; } if (cmd == class="number">0) { String filePath = getFullPath(arg); sendResponse(class="number">150, class="number">0); unsigned long timeout = millis() + class="number">5000; while (!dataServer.hasClient() && millis() < timeout) { delay(class="number">10); } if (dataServer.hasClient()) { dataClient = dataServer.available(); File file = LittleFS.open(filePath, class="number">0); if (file) { uint8_t buf[class="number">512]; while (dataClient.connected() || dataClient.available()) { if (dataClient.available()) { int bytesRead = dataClient.read(buf, sizeof(buf)); file.write(buf, bytesRead); } delay(class="number">1); } file.close(); dataClient.stop(); sendResponse(class="number">226, class="number">0); Serial.println(class="number">0 + filePath); } else { dataClient.stop(); sendResponse(class="number">550, class="number">0); } } else { sendResponse(class="number">425, class="number">0); } return; } if (cmd == class="number">0) { String filePath = getFullPath(arg); if (LittleFS.remove(filePath)) { sendResponse(class="number">250, class="number">0); Serial.println(class="number">0 + filePath); } else { sendResponse(class="number">550, class="number">0); } return; } if (cmd == class="number">0 || cmd == class="number">1) { String dirPath = getFullPath(arg); if (LittleFS.mkdir(dirPath)) { sendResponse(class="number">257, class="number">0 + dirPath + class="number">1); } else { sendResponse(class="number">550, class="number">0); } return; } if (cmd == class="number">0 || cmd == class="number">1) { String dirPath = getFullPath(arg); if (LittleFS.rmdir(dirPath)) { sendResponse(class="number">250, class="number">0); } else { sendResponse(class="number">550, class="number">0); } return; } if (cmd == class="number">0) { renameFrom = getFullPath(arg); if (LittleFS.exists(renameFrom)) { sendResponse(class="number">350, class="number">0); } else { sendResponse(class="number">550, class="number">0); renameFrom = class="number">0; } return; } if (cmd == class="number">0) { if (renameFrom.length() > class="number">0) { String renameTo = getFullPath(arg); if (LittleFS.rename(renameFrom, renameTo)) { sendResponse(class="number">250, class="number">0); } else { sendResponse(class="number">550, class="number">0); } renameFrom = class="number">0; } else { sendResponse(class="number">503, class="number">0); } return; } if (cmd == class="number">0) { String filePath = getFullPath(arg); File file = LittleFS.open(filePath, class="number">0); if (file) { sendResponse(class="number">213, String(file.size())); file.close(); } else { sendResponse(class="number">550, class="number">0); } return; } if (cmd == class="number">0) { sendResponse(class="number">200, class="number">0); return; } if (cmd == class="number">0) { sendResponse(class="number">221, class="number">0); ftpClient.stop(); clientConnected = false; userOK = false; passOK = false; return; } // Unknown command sendResponse(class="number">502, class="number">0); } void SimpleFTP::handle() { // Check for new FTP connections if (ftpServer.hasClient()) { if (ftpClient && ftpClient.connected()) { // Already have a client, reject new one WiFiClient newClient = ftpServer.available(); newClient.println(class="number">0); newClient.stop(); } else { ftpClient = ftpServer.available(); clientConnected = true; userOK = false; passOK = false; currentDir = class="number">0; Serial.println(class="number">0 + ftpClient.remoteIP().toString()); sendResponse(class="number">220, class="number">0); } } // Handle existing client if (ftpClient && ftpClient.connected()) { if (ftpClient.available()) { String cmdLine = ftpClient.readStringUntil(class="number">0); processCommand(cmdLine); } } else if (clientConnected) { // Client disconnected clientConnected = false; userOK = false; passOK = false; Serial.println(class="number">0); } } // ============ EASY INTERFACE FUNCTIONS ============ void initFTPServer() { ftpSrv.begin(); } void handleFTPServer() { ftpSrv.handle(); } bool ftpClientConnected() { return ftpSrv.isConnected(); } #endif
// BOOT SEQUENCE HEADER
BootSequence.h#pragma once void bootSequence();
// BOOT SEQUENCE
BootSequence.cpp#include <Arduino.h> #include <TFT_eSPI.h> #include <WiFi.h> #include "config.h" extern TFT_eSPI tft; extern const char* ssid; extern const char* password; // ----- LOCKED KANJI MATRIX (never to be touched) ----- #define MAX_COLS 48 struct Drop { int16_t y; int8_t speed; bool active; }; Drop drops[MAX_COLS]; int16_t oldHeadY[MAX_COLS] = {class="number">0}; void trueMatrixKanji() { tft.fillScreen(TFT_BLACK); int cols = min(tft.width() / class="number">10, MAX_COLS); for (int i = class="number">0; i < cols; i++) { drops[i].y = random(-tft.height(), class="number">0); drops[i].speed = random(class="number">6, class="number">18); drops[i].active = true; oldHeadY[i] = -class="number">500; } unsigned long start = millis(); while (millis() - start < class="number">6000) { tft.startWrite(); for (int i = class="number">0; i < cols; i++) { if (oldHeadY[i] > -class="number">300) { int eraseTop = max(class="number">0, oldHeadY[i] - class="number">300); int eraseH = min(tft.height() - eraseTop, class="number">320); tft.fillRect(i * class="number">10, eraseTop, class="number">10, eraseH, class="number">0); } drops[i].y += drops[i].speed; if (drops[i].y > tft.height() + class="number">50) { drops[i].y = random(-class="number">600, -class="number">50); drops[i].speed = random(class="number">6, class="number">18); } int headY = drops[i].y; oldHeadY[i] = headY; for (int t = class="number">0; t < class="number">15; t++) { int y = headY - t * class="number">18; if (y < class="number">0 || y >= tft.height()) continue; uint8_t b = class="number">255 - (t * class="number">16); if (b < class="number">30) b = class="number">30; uint16_t col = tft.color565(class="number">0, b, b >> class="number">1); tft.setTextColor(col); tft.setTextSize(class="number">1); tft.drawString(String((char)random(class="number">0x30A0, class="number">0x30FF)), i * class="number">10 + class="number">2, y); } if (headY >= class="number">0 && headY < tft.height()) { tft.setTextColor(class="number">0x07FF); tft.setTextSize(class="number">1); tft.drawString(String((char)random(class="number">0x30A0, class="number">0x30FF)), i * class="number">10 + class="number">2, headY); } } tft.endWrite(); delay(class="number">33); } tft.fillScreen(TFT_BLACK); } // ---------------------------------------------------- void bootSequence() { tft.begin(); tft.setRotation(ROTATION); tft.fillScreen(TFT_BLACK); tft.setTextColor(TFT_GREEN); tft.setTextSize(class="number">6); tft.setTextDatum(MC_DATUM); tft.drawString(class="number">0, SCREEN_WIDTH/class="number">2, SCREEN_HEIGHT/class="number">2 - class="number">40); delay(class="number">3000); trueMatrixKanji(); tft.fillScreen(TFT_BLACK); tft.setTextColor(TFT_RED); tft.setTextSize(class="number">4); tft.setTextDatum(MC_DATUM); tft.drawString(class="number">0, SCREEN_WIDTH/class="number">2, SCREEN_HEIGHT/class="number">2 - class="number">40); tft.drawString(class="number">0, SCREEN_WIDTH/class="number">2, SCREEN_HEIGHT/class="number">2 + class="number">10); delay(class="number">2500); tft.fillScreen(TFT_BLACK); tft.setTextSize(class="number">3); tft.setTextColor(TFT_BLUE, TFT_BLACK); tft.setTextDatum(MC_DATUM); tft.drawString(class="number">0, tft.width()/class="number">2, class="number">100); const int barW = tft.width() - class="number">80; const int barH = class="number">50; tft.drawRoundRect(class="number">40, tft.height()/class="number">2-class="number">25, barW, barH, class="number">12, TFT_DARKGREY); tft.fillRoundRect(class="number">42, tft.height()/class="number">2-class="number">23, barW-class="number">4, barH-class="number">4, class="number">12, TFT_BLACK); WiFi.mode(WIFI_STA); WiFi.begin(ssid, password); unsigned long startTime = millis(); int progress = class="number">0; while (WiFi.status() != WL_CONNECTED) { unsigned long elapsed = millis() - startTime; progress = constrain(elapsed / class="number">90, class="number">0, class="number">100); // ← DOUBLED SPEED (was 180) int fillW = map(progress, class="number">0, class="number">100, class="number">0, barW - class="number">8); uint16_t col = (progress < class="number">50) ? TFT_PURPLE : (progress < class="number">90) ? TFT_ORANGE : TFT_CYAN; tft.fillRoundRect(class="number">44, tft.height()/class="number">2-class="number">21, fillW, barH-class="number">8, class="number">10, col); tft.fillRect(class="number">0, tft.height()/class="number">2+class="number">40, tft.width(), class="number">50, TFT_BLACK); tft.setTextSize(class="number">3); tft.setTextColor(TFT_WHITE, TFT_BLACK); tft.drawString(String(progress)+class="number">0, tft.width()/class="number">2, tft.height()/class="number">2+class="number">50); if (elapsed > class="number">20000) { tft.fillScreen(TFT_BLACK); tft.setTextSize(class="number">5); tft.setTextColor(TFT_RED, TFT_BLACK); tft.drawString(class="number">0, tft.width()/class="number">2, tft.height()/class="number">2); while(true) delay(class="number">1000); } delay(class="number">50); } for (int i = class="number">0; i < class="number">3; i++) { tft.fillScreen(TFT_BLACK); tft.setTextSize(class="number">5); tft.setTextColor(TFT_GREEN, TFT_BLACK); tft.drawString(class="number">0, tft.width()/class="number">2, tft.height()/class="number">2 - class="number">40); tft.drawString(class="number">0, tft.width()/class="number">2, tft.height()/class="number">2 + class="number">30); delay(class="number">800); tft.fillScreen(TFT_BLACK); delay(class="number">400); } tft.fillScreen(TFT_BLACK); }
// NEW YEAR + JULY 4TH FIREWORKS
Fireworks.h// ==================== NEW YEAR'S EVE FIREWORKS ==================== // Triggers at 11:55 PM on Dec 31st, runs for 10 minutes // Adapted from codepope/fireworks-esp32 // =================================================================== #pragma once #include <Arduino.h> extern TFT_eSPI tft; // ==================== PARTICLE SYSTEM ==================== #define PCOUNT 1024 bool pa[PCOUNT]; float px[PCOUNT]; float py[PCOUNT]; float vx[PCOUNT]; float vy[PCOUNT]; uint16_t pcl[PCOUNT]; uint8_t plife[PCOUNT]; #define GRAVITY 0.08f #define DRAG 0.995f // Vibrant color palette const uint16_t fireworkColors[] = { TFT_RED, TFT_GOLD, TFT_CYAN, TFT_MAGENTA, TFT_GREEN, TFT_YELLOW, TFT_WHITE, TFT_ORANGE, class="number">0xF81F, class="number">0x07FF, class="number">0xFFE0, class="number">0xF800, class="number">0x001F, class="number">0x07E0, class="number">0xFC00, class="number">0xF8FF }; #define NUM_FIREWORK_COLORS 16 void initParticles() { for (int i = class="number">0; i < PCOUNT; i++) pa[i] = false; } void spawnFireworkWithColor(float x, float y, uint16_t color) { int sparkCount = random(class="number">50, class="number">90); float baseSpeed = random(class="number">35, class="number">55) / class="number">10.0; for (int s = class="number">0; s < sparkCount; s++) { for (int i = class="number">0; i < PCOUNT; i++) { if (!pa[i]) { pa[i] = true; px[i] = x; py[i] = y; float angle = random(class="number">0, class="number">360) * DEG_TO_RAD; float speed = baseSpeed * (random(class="number">60, class="number">120) / class="number">100.0); vx[i] = cos(angle) * speed; vy[i] = sin(angle) * speed; pcl[i] = color; plife[i] = random(class="number">50, class="number">100); break; } } } } // ==================== ROCKETS ==================== struct Rocket { bool active; float x, y, vy; uint16_t color; }; #define MAX_ROCKETS 3 Rocket rockets[MAX_ROCKETS]; void initRockets() { for (int i = class="number">0; i < MAX_ROCKETS; i++) rockets[i].active = false; } void launchRocketWithColor(uint16_t color, float xPos) { for (int i = class="number">0; i < MAX_ROCKETS; i++) { if (!rockets[i].active) { rockets[i].active = true; rockets[i].x = xPos; rockets[i].y = SCREEN_HEIGHT; rockets[i].vy = -random(class="number">10, class="number">14); rockets[i].color = color; break; } } } bool rocketsActive() { for (int i = class="number">0; i < MAX_ROCKETS; i++) if (rockets[i].active) return true; return false; } bool particlesActive() { for (int i = class="number">0; i < PCOUNT; i++) if (pa[i]) return true; return false; } void updateRockets() { for (int i = class="number">0; i < MAX_ROCKETS; i++) { if (rockets[i].active) { tft.fillRect((int)rockets[i].x - class="number">3, (int)rockets[i].y - class="number">3, class="number">7, class="number">10, TFT_BLACK); rockets[i].y += rockets[i].vy; rockets[i].vy += class="number">0.2; if (rockets[i].vy >= -class="number">1 || rockets[i].y < random(class="number">80, class="number">200)) { spawnFireworkWithColor(rockets[i].x, rockets[i].y, rockets[i].color); rockets[i].active = false; } else { tft.fillCircle((int)rockets[i].x, (int)rockets[i].y, class="number">3, rockets[i].color); } } } } void updateParticles() { for (int i = class="number">0; i < PCOUNT; i++) { if (pa[i]) { tft.fillCircle((int)px[i], (int)py[i], class="number">2, TFT_BLACK); px[i] += vx[i]; py[i] += vy[i]; vy[i] += GRAVITY; vx[i] *= DRAG; vy[i] *= DRAG; plife[i]--; if (px[i] < class="number">0 || px[i] >= SCREEN_WIDTH || py[i] < class="number">0 || py[i] >= SCREEN_HEIGHT || plife[i] == class="number">0) { pa[i] = false; } else { uint16_t col = pcl[i]; if (plife[i] < class="number">30) { uint8_t r = ((col >> class="number">11) & class="number">0x1F) * plife[i] / class="number">30; uint8_t g = ((col >> class="number">5) & class="number">0x3F) * plife[i] / class="number">30; uint8_t b = (col & class="number">0x1F) * plife[i] / class="number">30; col = (r << class="number">11) | (g << class="number">5) | b; } tft.fillCircle((int)px[i], (int)py[i], class="number">2, col); if (random(class="number">100) < class="number">5 && plife[i] > class="number">20) { tft.fillCircle((int)px[i] + random(-class="number">2, class="number">3), (int)py[i] + random(-class="number">2, class="number">3), class="number">1, TFT_WHITE); } } } } } // ==================== LAUNCH FUNCTIONS ==================== void launchTwoRockets() { uint16_t color1 = fireworkColors[random(NUM_FIREWORK_COLORS)]; uint16_t color2 = fireworkColors[random(NUM_FIREWORK_COLORS)]; while (color2 == color1) color2 = fireworkColors[random(NUM_FIREWORK_COLORS)]; float x1 = random(class="number">60, SCREEN_WIDTH / class="number">2 - class="number">20); float x2 = random(SCREEN_WIDTH / class="number">2 + class="number">20, SCREEN_WIDTH - class="number">60); launchRocketWithColor(color1, x1); launchRocketWithColor(color2, x2); } void launchThreeRockets() { uint16_t color1 = fireworkColors[random(NUM_FIREWORK_COLORS)]; uint16_t color2 = fireworkColors[random(NUM_FIREWORK_COLORS)]; uint16_t color3 = fireworkColors[random(NUM_FIREWORK_COLORS)]; while (color2 == color1) color2 = fireworkColors[random(NUM_FIREWORK_COLORS)]; while (color3 == color1 || color3 == color2) color3 = fireworkColors[random(NUM_FIREWORK_COLORS)]; float x1 = random(class="number">50, SCREEN_WIDTH / class="number">3 - class="number">10); float x2 = random(SCREEN_WIDTH / class="number">3 + class="number">10, class="number">2 * SCREEN_WIDTH / class="number">3 - class="number">10); float x3 = random(class="number">2 * SCREEN_WIDTH / class="number">3 + class="number">10, SCREEN_WIDTH - class="number">50); launchRocketWithColor(color1, x1); launchRocketWithColor(color2, x2); launchRocketWithColor(color3, x3); } // ==================== DISPLAY ==================== void drawHappyNewYear() { struct tm ti; getLocalTime(&ti); int newYear = ti.tm_year + class="number">1900; if (ti.tm_mon == class="number">11) newYear++; // Still December, show incoming year tft.setTextColor(TFT_GOLD); tft.setTextSize(class="number">4); tft.setTextDatum(MC_DATUM); tft.drawString(class="number">0, SCREEN_WIDTH / class="number">2, class="number">40); tft.drawString(class="number">0, SCREEN_WIDTH / class="number">2, class="number">85); tft.setTextSize(class="number">6); tft.setTextColor(TFT_CYAN); tft.drawString(String(newYear), SCREEN_WIDTH / class="number">2, class="number">140); } // Run one complete show cycle (2-rocket bursts + 3-rocket finale) void runOneShowCycle() { tft.fillScreen(TFT_BLACK); initParticles(); initRockets(); drawHappyNewYear(); // Main show - several 2-rocket bursts for (int burst = class="number">0; burst < class="number">6; burst++) { launchTwoRockets(); while (rocketsActive()) { updateRockets(); delay(class="number">25); drawHappyNewYear(); } while (particlesActive()) { updateParticles(); delay(class="number">25); drawHappyNewYear(); } tft.fillScreen(TFT_BLACK); drawHappyNewYear(); delay(class="number">300); } // Finale - 3 rockets at a time for (int burst = class="number">0; burst < class="number">5; burst++) { launchThreeRockets(); while (rocketsActive()) { updateRockets(); updateParticles(); delay(class="number">20); drawHappyNewYear(); } for (int i = class="number">0; i < class="number">30; i++) { updateParticles(); delay(class="number">20); drawHappyNewYear(); } } // Final fade while (particlesActive()) { updateParticles(); delay(class="number">25); drawHappyNewYear(); } tft.fillScreen(TFT_BLACK); } // Main entry point - runs for 10 minutes void runFireworksShow(unsigned long durationMs) { unsigned long startTime = millis(); while (millis() - startTime < durationMs) { runOneShowCycle(); } tft.fillScreen(TFT_BLACK); } // Check if it's NYE show time (Dec 31, 11:55 PM) bool isNewYearsEveShowTime() { struct tm timeinfo; if (!getLocalTime(&timeinfo)) return false; return (timeinfo.tm_mon == class="number">11 && timeinfo.tm_mday == class="number">31 && timeinfo.tm_hour == class="number">23 && timeinfo.tm_min == class="number">55 && timeinfo.tm_sec < class="number">10); } // ==================== JULY 4TH ==================== // Red, White, Blue palette const uint16_t patrioticColors[] = { TFT_RED, // Red TFT_WHITE, // White class="number">0x001F, // Blue class="number">0xF800, // Bright red class="number">0xFFFF, // White class="number">0x041F, // Royal blue }; #define NUM_PATRIOTIC_COLORS 6 void launchTwoRocketsPatriotic() { uint16_t color1 = patrioticColors[random(NUM_PATRIOTIC_COLORS)]; uint16_t color2 = patrioticColors[random(NUM_PATRIOTIC_COLORS)]; while (color2 == color1) color2 = patrioticColors[random(NUM_PATRIOTIC_COLORS)]; float x1 = random(class="number">60, SCREEN_WIDTH / class="number">2 - class="number">20); float x2 = random(SCREEN_WIDTH / class="number">2 + class="number">20, SCREEN_WIDTH - class="number">60); launchRocketWithColor(color1, x1); launchRocketWithColor(color2, x2); } void launchThreeRocketsPatriotic() { // Always red, white, blue for the finale float x1 = random(class="number">50, SCREEN_WIDTH / class="number">3 - class="number">10); float x2 = random(SCREEN_WIDTH / class="number">3 + class="number">10, class="number">2 * SCREEN_WIDTH / class="number">3 - class="number">10); float x3 = random(class="number">2 * SCREEN_WIDTH / class="number">3 + class="number">10, SCREEN_WIDTH - class="number">50); launchRocketWithColor(TFT_RED, x1); launchRocketWithColor(TFT_WHITE, x2); launchRocketWithColor(class="number">0x001F, x3); // Blue } void drawHappy4th() { tft.setTextColor(TFT_RED); tft.setTextSize(class="number">4); tft.setTextDatum(MC_DATUM); tft.drawString(class="number">0, SCREEN_WIDTH / class="number">2, class="number">40); tft.setTextColor(TFT_WHITE); tft.drawString(class="number">0, SCREEN_WIDTH / class="number">2, class="number">85); tft.setTextSize(class="number">5); tft.setTextColor(class="number">0x001F); // Blue tft.drawString(class="number">0, SCREEN_WIDTH / class="number">2, class="number">135); } // Run one July 4th cycle void runJuly4thCycle() { tft.fillScreen(TFT_BLACK); initParticles(); initRockets(); drawHappy4th(); // Main show - 6 bursts of 2 rockets (red/white/blue) for (int burst = class="number">0; burst < class="number">6; burst++) { launchTwoRocketsPatriotic(); while (rocketsActive()) { updateRockets(); delay(class="number">25); drawHappy4th(); } while (particlesActive()) { updateParticles(); delay(class="number">25); drawHappy4th(); } tft.fillScreen(TFT_BLACK); drawHappy4th(); delay(class="number">300); } // Finale - red, white, blue together for (int burst = class="number">0; burst < class="number">5; burst++) { launchThreeRocketsPatriotic(); while (rocketsActive()) { updateRockets(); updateParticles(); delay(class="number">20); drawHappy4th(); } for (int i = class="number">0; i < class="number">30; i++) { updateParticles(); delay(class="number">20); drawHappy4th(); } } // Final fade while (particlesActive()) { updateParticles(); delay(class="number">25); drawHappy4th(); } tft.fillScreen(TFT_BLACK); } // Check if it's July 4th hourly show time (every hour on the hour) bool isJuly4thShowTime() { struct tm timeinfo; if (!getLocalTime(&timeinfo)) return false; return (timeinfo.tm_mon == class="number">6 && // July (0-indexed) timeinfo.tm_mday == class="number">4 && // 4th timeinfo.tm_min == class="number">0 && // Top of the hour timeinfo.tm_sec < class="number">10); // First 10 seconds } // ==================== END FIREWORKS ====================
// CHRISTMAS SANTA
Santa.h/****************************************************************************************** Santa's Sleigh Animation - Standalone For use in CyberWeather or other projects ******************************************************************************************/ #pragma once #include <Arduino.h> extern TFT_eSPI tft; // ==================== SANTA COLORS ==================== uint16_t santaNightSky; uint16_t santaSleighRed; uint16_t santaSleighGold; uint16_t santaReindeerBrown; uint16_t santaReindeerLight; uint16_t santaAntlerColor; // ==================== STARS ==================== #define SANTA_MAX_STARS 80 int santaStarX[SANTA_MAX_STARS]; int santaStarY[SANTA_MAX_STARS]; uint8_t santaStarBright[SANTA_MAX_STARS]; uint8_t santaStarSize[SANTA_MAX_STARS]; void initSantaStars() { for (int i = class="number">0; i < SANTA_MAX_STARS; i++) { santaStarX[i] = random(SCREEN_WIDTH); santaStarY[i] = random(SCREEN_HEIGHT); santaStarBright[i] = random(class="number">80, class="number">256); santaStarSize[i] = random(class="number">1, class="number">3); } } void drawSantaStars() { for (int i = class="number">0; i < SANTA_MAX_STARS; i++) { uint16_t col = tft.color565(santaStarBright[i], santaStarBright[i], santaStarBright[i]); if (santaStarSize[i] == class="number">1) { tft.drawPixel(santaStarX[i], santaStarY[i], col); } else { tft.fillCircle(santaStarX[i], santaStarY[i], class="number">1, col); } if (random(class="number">100) < class="number">3) { santaStarBright[i] = random(class="number">80, class="number">256); } } } void redrawSantaStarsInArea(int x1, int y1, int x2, int y2) { for (int i = class="number">0; i < SANTA_MAX_STARS; i++) { if (santaStarX[i] >= x1 && santaStarX[i] <= x2 && santaStarY[i] >= y1 && santaStarY[i] <= y2) { uint16_t col = tft.color565(santaStarBright[i], santaStarBright[i], santaStarBright[i]); if (santaStarSize[i] == class="number">1) { tft.drawPixel(santaStarX[i], santaStarY[i], col); } else { tft.fillCircle(santaStarX[i], santaStarY[i], class="number">1, col); } } } } // ==================== MAGIC SPARKLES ==================== #define SANTA_MAX_SPARKLES 30 struct SantaSparkle { float x, y; float vx, vy; uint8_t life; bool active; }; SantaSparkle santaSparkles[SANTA_MAX_SPARKLES]; void initSantaSparkles() { for (int i = class="number">0; i < SANTA_MAX_SPARKLES; i++) { santaSparkles[i].active = false; } } void spawnSantaSparkle(float x, float y) { for (int i = class="number">0; i < SANTA_MAX_SPARKLES; i++) { if (!santaSparkles[i].active) { santaSparkles[i].active = true; santaSparkles[i].x = x + random(-class="number">5, class="number">6); santaSparkles[i].y = y + random(-class="number">5, class="number">6); santaSparkles[i].vx = random(-class="number">10, class="number">5) / class="number">10.0; santaSparkles[i].vy = random(-class="number">5, class="number">10) / class="number">10.0; santaSparkles[i].life = random(class="number">15, class="number">30); break; } } } void updateSantaSparkles() { for (int i = class="number">0; i < SANTA_MAX_SPARKLES; i++) { if (santaSparkles[i].active) { int oldX = (int)santaSparkles[i].x; int oldY = (int)santaSparkles[i].y; santaSparkles[i].x += santaSparkles[i].vx; santaSparkles[i].y += santaSparkles[i].vy; santaSparkles[i].vy += class="number">0.05; santaSparkles[i].life--; tft.fillRect(oldX - class="number">2, oldY - class="number">2, class="number">5, class="number">5, santaNightSky); if (santaSparkles[i].life == class="number">0) { santaSparkles[i].active = false; } else { uint8_t bright = santaSparkles[i].life * class="number">8; uint16_t col = tft.color565(class="number">255, bright + class="number">150, bright); tft.fillCircle((int)santaSparkles[i].x, (int)santaSparkles[i].y, class="number">1, col); } } } } // ==================== SANTA'S SLEIGH ==================== float santaSleighX, santaSleighY; float santaSleighAngle = class="number">0; float santaSleighDirection = class="number">1; void drawSantaReindeer(int x, int y, bool isRudolph) { tft.fillEllipse(x, y, class="number">14, class="number">9, santaReindeerBrown); tft.fillEllipse(x, y - class="number">2, class="number">12, class="number">7, santaReindeerLight); tft.fillEllipse(x + class="number">12, y - class="number">5, class="number">5, class="number">8, santaReindeerBrown); tft.fillCircle(x + class="number">18, y - class="number">10, class="number">7, santaReindeerBrown); tft.fillEllipse(x + class="number">23, y - class="number">9, class="number">4, class="number">3, santaReindeerBrown); if (isRudolph) { tft.fillCircle(x + class="number">27, y - class="number">9, class="number">4, TFT_RED); tft.fillCircle(x + class="number">27, y - class="number">10, class="number">2, tft.color565(class="number">255, class="number">150, class="number">150)); } else { tft.fillCircle(x + class="number">26, y - class="number">9, class="number">2, TFT_BLACK); } tft.fillCircle(x + class="number">19, y - class="number">13, class="number">2, TFT_BLACK); tft.drawPixel(x + class="number">19, y - class="number">14, TFT_WHITE); tft.fillEllipse(x + class="number">14, y - class="number">16, class="number">2, class="number">4, santaReindeerBrown); tft.fillEllipse(x + class="number">20, y - class="number">16, class="number">2, class="number">4, santaReindeerBrown); tft.drawLine(x + class="number">12, y - class="number">17, x + class="number">6, y - class="number">28, santaAntlerColor); tft.drawLine(x + class="number">13, y - class="number">17, x + class="number">7, y - class="number">28, santaAntlerColor); tft.drawLine(x + class="number">7, y - class="number">25, x + class="number">3, y - class="number">22, santaAntlerColor); tft.drawLine(x + class="number">7, y - class="number">28, x + class="number">4, y - class="number">32, santaAntlerColor); tft.drawLine(x + class="number">18, y - class="number">17, x + class="number">22, y - class="number">28, santaAntlerColor); tft.drawLine(x + class="number">19, y - class="number">17, x + class="number">23, y - class="number">28, santaAntlerColor); tft.drawLine(x + class="number">21, y - class="number">25, x + class="number">26, y - class="number">22, santaAntlerColor); tft.drawLine(x + class="number">22, y - class="number">28, x + class="number">26, y - class="number">32, santaAntlerColor); tft.drawLine(x - class="number">8, y + class="number">7, x - class="number">12, y + class="number">18, santaReindeerBrown); tft.drawLine(x - class="number">7, y + class="number">7, x - class="number">11, y + class="number">18, santaReindeerBrown); tft.drawLine(x - class="number">2, y + class="number">8, x + class="number">3, y + class="number">18, santaReindeerBrown); tft.drawLine(x - class="number">1, y + class="number">8, x + class="number">4, y + class="number">18, santaReindeerBrown); tft.drawLine(x + class="number">5, y + class="number">7, x + class="number">2, y + class="number">18, santaReindeerBrown); tft.drawLine(x + class="number">10, y + class="number">6, x + class="number">14, y + class="number">16, santaReindeerBrown); tft.fillCircle(x - class="number">11, y + class="number">18, class="number">2, TFT_BLACK); tft.fillCircle(x + class="number">4, y + class="number">18, class="number">2, TFT_BLACK); tft.fillCircle(x + class="number">2, y + class="number">18, class="number">2, TFT_BLACK); tft.fillCircle(x + class="number">14, y + class="number">16, class="number">2, TFT_BLACK); tft.fillCircle(x - class="number">13, y - class="number">3, class="number">3, santaReindeerLight); } void drawSantaSleigh(int x, int y) { tft.fillRoundRect(x, y, class="number">50, class="number">28, class="number">8, santaSleighRed); tft.fillCircle(x + class="number">5, y + class="number">5, class="number">12, santaSleighRed); tft.fillRect(x + class="number">5, y + class="number">5, class="number">20, class="number">23, santaSleighRed); tft.fillRoundRect(x + class="number">35, y - class="number">5, class="number">20, class="number">33, class="number">5, santaSleighRed); tft.drawRoundRect(x, y, class="number">50, class="number">28, class="number">8, santaSleighGold); tft.drawRoundRect(x + class="number">1, y + class="number">1, class="number">48, class="number">26, class="number">7, santaSleighGold); tft.drawLine(x + class="number">5, y + class="number">5, x + class="number">50, y + class="number">5, santaSleighGold); tft.drawLine(x - class="number">8, y + class="number">32, x + class="number">55, y + class="number">32, santaSleighGold); tft.drawLine(x - class="number">8, y + class="number">33, x + class="number">55, y + class="number">33, santaSleighGold); tft.drawLine(x - class="number">8, y + class="number">32, x - class="number">12, y + class="number">28, santaSleighGold); tft.drawLine(x - class="number">12, y + class="number">28, x - class="number">10, y + class="number">24, santaSleighGold); tft.drawLine(x + class="number">55, y + class="number">32, x + class="number">60, y + class="number">26, santaSleighGold); tft.drawLine(x + class="number">10, y + class="number">28, x + class="number">10, y + class="number">32, santaSleighGold); tft.drawLine(x + class="number">40, y + class="number">28, x + class="number">40, y + class="number">32, santaSleighGold); tft.fillCircle(x + class="number">45, y + class="number">8, class="number">14, tft.color565(class="number">160, class="number">82, class="number">45)); tft.fillCircle(x + class="number">42, y + class="number">5, class="number">10, tft.color565(class="number">139, class="number">69, class="number">19)); tft.fillRect(x + class="number">38, y - class="number">2, class="number">10, class="number">4, santaSleighGold); tft.fillRect(x + class="number">50, y + class="number">2, class="number">6, class="number">6, TFT_GREEN); tft.drawLine(x + class="number">53, y + class="number">2, x + class="number">53, y + class="number">8, TFT_RED); tft.drawLine(x + class="number">50, y + class="number">5, x + class="number">56, y + class="number">5, TFT_RED); tft.fillRect(x + class="number">48, y + class="number">10, class="number">5, class="number">5, TFT_BLUE); tft.drawLine(x + class="number">50, y + class="number">10, x + class="number">50, y + class="number">15, TFT_YELLOW); tft.fillRoundRect(x + class="number">8, y - class="number">8, class="number">22, class="number">20, class="number">5, TFT_RED); tft.fillRect(x + class="number">8, y + class="number">5, class="number">22, class="number">5, TFT_BLACK); tft.fillRect(x + class="number">16, y + class="number">4, class="number">6, class="number">7, santaSleighGold); tft.fillCircle(x + class="number">19, y - class="number">16, class="number">10, tft.color565(class="number">255, class="number">220, class="number">185)); tft.fillCircle(x + class="number">19, y - class="number">8, class="number">8, TFT_WHITE); tft.fillCircle(x + class="number">15, y - class="number">10, class="number">5, TFT_WHITE); tft.fillCircle(x + class="number">23, y - class="number">10, class="number">5, TFT_WHITE); tft.fillRoundRect(x + class="number">12, y - class="number">12, class="number">14, class="number">10, class="number">3, TFT_WHITE); tft.fillCircle(x + class="number">19, y - class="number">16, class="number">6, tft.color565(class="number">255, class="number">220, class="number">185)); tft.fillCircle(x + class="number">19, y - class="number">13, class="number">3, tft.color565(class="number">255, class="number">180, class="number">180)); tft.fillCircle(x + class="number">15, y - class="number">18, class="number">2, TFT_BLACK); tft.fillCircle(x + class="number">23, y - class="number">18, class="number">2, TFT_BLACK); tft.drawPixel(x + class="number">15, y - class="number">19, TFT_WHITE); tft.drawPixel(x + class="number">23, y - class="number">19, TFT_WHITE); tft.fillTriangle(x + class="number">8, y - class="number">24, x + class="number">30, y - class="number">24, x + class="number">32, y - class="number">45, TFT_RED); tft.fillRect(x + class="number">6, y - class="number">26, class="number">26, class="number">5, TFT_WHITE); tft.fillCircle(x + class="number">32, y - class="number">45, class="number">5, TFT_WHITE); tft.fillEllipse(x + class="number">28, y - class="number">5, class="number">6, class="number">4, TFT_RED); tft.fillCircle(x + class="number">34, y - class="number">8, class="number">4, tft.color565(class="number">255, class="number">220, class="number">185)); tft.drawLine(x + class="number">5, y + class="number">10, x - class="number">20, y + class="number">5, santaSleighGold); tft.drawLine(x + class="number">5, y + class="number">12, x - class="number">20, y + class="number">7, santaSleighGold); } void drawSantaAssembly(float baseX, float baseY) { int x = (int)baseX; int y = (int)baseY; tft.drawLine(x - class="number">20, y + class="number">5, x + class="number">75, y - class="number">5, santaSleighGold); tft.drawLine(x - class="number">20, y + class="number">7, x + class="number">75, y - class="number">3, santaSleighGold); drawSantaReindeer(x + class="number">115, y - class="number">5, true); drawSantaReindeer(x + class="number">70, y, false); tft.drawLine(x + class="number">75, y - class="number">3, x + class="number">100, y - class="number">5, santaSleighGold); drawSantaSleigh(x, y); } void eraseSantaSleigh(float x, float y) { tft.fillRect((int)x - class="number">20, (int)y - class="number">55, class="number">185, class="number">100, santaNightSky); } void updateSantaSleighPosition() { santaSleighX += santaSleighDirection * class="number">2.5; santaSleighAngle += class="number">0.04; santaSleighY = class="number">200 + sin(santaSleighAngle) * class="number">25; if (santaSleighDirection > class="number">0 && santaSleighX > SCREEN_WIDTH + class="number">50) { santaSleighX = -class="number">170; santaSleighY = random(class="number">180, class="number">220); santaSleighAngle = random(class="number">0, class="number">628) / class="number">100.0; } else if (santaSleighDirection < class="number">0 && santaSleighX < -class="number">170) { santaSleighX = SCREEN_WIDTH + class="number">50; santaSleighY = random(class="number">180, class="number">220); santaSleighAngle = random(class="number">0, class="number">628) / class="number">100.0; } } // ==================== FALLING PRESENTS ==================== #define SANTA_MAX_PRESENTS 5 struct SantaPresent { float x, y; float vy; int size; uint16_t color; uint16_t ribbonColor; uint16_t bowColor; bool falling; bool landed; }; SantaPresent santaPresents[SANTA_MAX_PRESENTS]; int santaPresentCount = class="number">0; void initSantaPresents() { for (int i = class="number">0; i < SANTA_MAX_PRESENTS; i++) { santaPresents[i].falling = false; santaPresents[i].landed = false; } santaPresentCount = class="number">0; } void spawnSantaPresent(float x, float y) { if (santaPresentCount >= SANTA_MAX_PRESENTS) return; for (int i = class="number">0; i < SANTA_MAX_PRESENTS; i++) { if (!santaPresents[i].falling && !santaPresents[i].landed) { santaPresents[i].x = x; santaPresents[i].y = y; santaPresents[i].vy = class="number">0; santaPresents[i].size = random(class="number">14, class="number">22); santaPresents[i].color = TFT_BLUE; santaPresents[i].ribbonColor = TFT_WHITE; santaPresents[i].bowColor = TFT_RED; santaPresents[i].falling = true; santaPresents[i].landed = false; santaPresentCount++; break; } } } void updateSantaPresents() { int groundY = SCREEN_HEIGHT - class="number">60; for (int i = class="number">0; i < SANTA_MAX_PRESENTS; i++) { if (santaPresents[i].falling) { tft.fillRect((int)santaPresents[i].x - class="number">6, (int)santaPresents[i].y - class="number">10, santaPresents[i].size + class="number">12, santaPresents[i].size + class="number">14, santaNightSky); santaPresents[i].vy += class="number">0.3; santaPresents[i].y += santaPresents[i].vy; if (santaPresents[i].y + santaPresents[i].size >= groundY - class="number">5) { santaPresents[i].y = groundY - santaPresents[i].size - class="number">5; santaPresents[i].falling = false; santaPresents[i].landed = true; } } if (santaPresents[i].falling || santaPresents[i].landed) { int px = (int)santaPresents[i].x; int py = (int)santaPresents[i].y; int sz = santaPresents[i].size; tft.fillRect(px, py, sz, sz, santaPresents[i].color); tft.fillRect(px + sz/class="number">2 - class="number">2, py, class="number">4, sz, santaPresents[i].ribbonColor); tft.fillRect(px, py + sz/class="number">2 - class="number">2, sz, class="number">4, santaPresents[i].ribbonColor); tft.fillCircle(px + sz/class="number">2 - class="number">4, py - class="number">3, class="number">4, santaPresents[i].bowColor); tft.fillCircle(px + sz/class="number">2 + class="number">4, py - class="number">3, class="number">4, santaPresents[i].bowColor); tft.fillCircle(px + sz/class="number">2, py - class="number">2, class="number">3, santaPresents[i].bowColor); } } } void drawSantaLandedPresents() { for (int i = class="number">0; i < SANTA_MAX_PRESENTS; i++) { if (santaPresents[i].landed) { int px = (int)santaPresents[i].x; int py = (int)santaPresents[i].y; int sz = santaPresents[i].size; tft.fillRect(px, py, sz, sz, santaPresents[i].color); tft.fillRect(px + sz/class="number">2 - class="number">2, py, class="number">4, sz, santaPresents[i].ribbonColor); tft.fillRect(px, py + sz/class="number">2 - class="number">2, sz, class="number">4, santaPresents[i].ribbonColor); tft.fillCircle(px + sz/class="number">2 - class="number">4, py - class="number">3, class="number">4, santaPresents[i].bowColor); tft.fillCircle(px + sz/class="number">2 + class="number">4, py - class="number">3, class="number">4, santaPresents[i].bowColor); tft.fillCircle(px + sz/class="number">2, py - class="number">2, class="number">3, santaPresents[i].bowColor); } } } // ==================== CHIMNEY SMOKE ==================== #define SANTA_MAX_SMOKE 15 struct SantaSmokePuff { float x, y; float vx, vy; int size; uint8_t alpha; bool active; }; SantaSmokePuff santaSmoke[SANTA_MAX_SMOKE]; void initSantaSmoke() { for (int i = class="number">0; i < SANTA_MAX_SMOKE; i++) { santaSmoke[i].active = false; } } void spawnSantaSmoke(float x, float y) { for (int i = class="number">0; i < SANTA_MAX_SMOKE; i++) { if (!santaSmoke[i].active) { santaSmoke[i].active = true; santaSmoke[i].x = x + random(-class="number">3, class="number">4); santaSmoke[i].y = y; santaSmoke[i].vx = random(-class="number">5, class="number">6) / class="number">20.0; santaSmoke[i].vy = -random(class="number">8, class="number">15) / class="number">10.0; santaSmoke[i].size = random(class="number">4, class="number">8); santaSmoke[i].alpha = class="number">200; break; } } } void updateSantaSmoke() { for (int i = class="number">0; i < SANTA_MAX_SMOKE; i++) { if (santaSmoke[i].active) { tft.fillCircle((int)santaSmoke[i].x, (int)santaSmoke[i].y, santaSmoke[i].size + class="number">1, santaNightSky); santaSmoke[i].x += santaSmoke[i].vx; santaSmoke[i].y += santaSmoke[i].vy; santaSmoke[i].vx += random(-class="number">2, class="number">3) / class="number">50.0; santaSmoke[i].size += class="number">0.1; santaSmoke[i].alpha -= class="number">5; if (santaSmoke[i].alpha < class="number">30 || santaSmoke[i].y < class="number">250) { santaSmoke[i].active = false; } else { uint8_t gray = class="number">80 + (santaSmoke[i].alpha / class="number">4); uint16_t col = tft.color565(gray, gray, gray); tft.fillCircle((int)santaSmoke[i].x, (int)santaSmoke[i].y, (int)santaSmoke[i].size, col); } } } } // ==================== GROUND ==================== void drawSantaGround() { tft.fillRect(class="number">0, SCREEN_HEIGHT - class="number">60, SCREEN_WIDTH, class="number">60, TFT_WHITE); for (int x = class="number">0; x < SCREEN_WIDTH; x += class="number">20) { tft.fillCircle(x, SCREEN_HEIGHT - class="number">60, class="number">15, TFT_WHITE); } for (int x = class="number">10; x < SCREEN_WIDTH; x += class="number">40) { tft.fillEllipse(x, SCREEN_HEIGHT - class="number">45, class="number">12, class="number">5, tft.color565(class="number">220, class="number">230, class="number">245)); } } // ==================== CHRISTMAS TREE ==================== #define SANTA_NUM_LIGHTS 12 int santaLightX[SANTA_NUM_LIGHTS]; int santaLightY[SANTA_NUM_LIGHTS]; uint16_t santaLightColor[SANTA_NUM_LIGHTS]; bool santaLightOn[SANTA_NUM_LIGHTS]; void initSantaTreeLights(int treeX, int treeY) { uint16_t colors[] = {TFT_RED, TFT_YELLOW, TFT_BLUE, TFT_GREEN, TFT_MAGENTA, TFT_CYAN}; int idx = class="number">0; santaLightX[idx] = treeX - class="number">8; santaLightY[idx] = treeY + class="number">25; santaLightColor[idx] = colors[idx % class="number">6]; idx++; santaLightX[idx] = treeX + class="number">8; santaLightY[idx] = treeY + class="number">30; santaLightColor[idx] = colors[idx % class="number">6]; idx++; santaLightX[idx] = treeX - class="number">20; santaLightY[idx] = treeY + class="number">55; santaLightColor[idx] = colors[idx % class="number">6]; idx++; santaLightX[idx] = treeX; santaLightY[idx] = treeY + class="number">50; santaLightColor[idx] = colors[idx % class="number">6]; idx++; santaLightX[idx] = treeX + class="number">18; santaLightY[idx] = treeY + class="number">58; santaLightColor[idx] = colors[idx % class="number">6]; idx++; santaLightX[idx] = treeX - class="number">30; santaLightY[idx] = treeY + class="number">85; santaLightColor[idx] = colors[idx % class="number">6]; idx++; santaLightX[idx] = treeX - class="number">10; santaLightY[idx] = treeY + class="number">80; santaLightColor[idx] = colors[idx % class="number">6]; idx++; santaLightX[idx] = treeX + class="number">12; santaLightY[idx] = treeY + class="number">88; santaLightColor[idx] = colors[idx % class="number">6]; idx++; santaLightX[idx] = treeX + class="number">28; santaLightY[idx] = treeY + class="number">82; santaLightColor[idx] = colors[idx % class="number">6]; idx++; santaLightX[idx] = treeX - class="number">38; santaLightY[idx] = treeY + class="number">115; santaLightColor[idx] = colors[idx % class="number">6]; idx++; santaLightX[idx] = treeX; santaLightY[idx] = treeY + class="number">110; santaLightColor[idx] = colors[idx % class="number">6]; idx++; santaLightX[idx] = treeX + class="number">35; santaLightY[idx] = treeY + class="number">118; santaLightColor[idx] = colors[idx % class="number">6]; idx++; for (int i = class="number">0; i < SANTA_NUM_LIGHTS; i++) { santaLightOn[i] = random(class="number">2); } } void drawSantaStar(int cx, int cy, int size, uint16_t color) { float outer = size; float inner = size * class="number">0.4; for (int i = class="number">0; i < class="number">5; i++) { float angle1 = (i * class="number">72 - class="number">90) * DEG_TO_RAD; float angle2 = ((i * class="number">72 + class="number">36) - class="number">90) * DEG_TO_RAD; float angle3 = ((i * class="number">72 + class="number">72) - class="number">90) * DEG_TO_RAD; int x1 = cx + cos(angle1) * outer; int y1 = cy + sin(angle1) * outer; int x2 = cx + cos(angle2) * inner; int y2 = cy + sin(angle2) * inner; int x3 = cx + cos(angle3) * outer; int y3 = cy + sin(angle3) * outer; tft.fillTriangle(cx, cy, x1, y1, x2, y2, color); tft.fillTriangle(cx, cy, x2, y2, x3, y3, color); } tft.fillCircle(cx, cy, class="number">3, TFT_WHITE); } void drawSantaChristmasTree(int x, int y) { uint16_t darkGreen = tft.color565(class="number">0, class="number">100, class="number">0); uint16_t green = tft.color565(class="number">0, class="number">140, class="number">0); uint16_t brown = tft.color565(class="number">101, class="number">67, class="number">33); tft.fillRect(x - class="number">12, y + class="number">130, class="number">24, class="number">30, brown); tft.fillRect(x - class="number">8, y + class="number">130, class="number">16, class="number">30, tft.color565(class="number">120, class="number">80, class="number">40)); tft.fillTriangle(x, y + class="number">70, x - class="number">50, y + class="number">130, x + class="number">50, y + class="number">130, darkGreen); tft.fillTriangle(x, y + class="number">75, x - class="number">45, y + class="number">125, x + class="number">45, y + class="number">125, green); tft.fillTriangle(x, y + class="number">40, x - class="number">40, y + class="number">100, x + class="number">40, y + class="number">100, darkGreen); tft.fillTriangle(x, y + class="number">45, x - class="number">35, y + class="number">95, x + class="number">35, y + class="number">95, green); tft.fillTriangle(x, y + class="number">15, x - class="number">30, y + class="number">70, x + class="number">30, y + class="number">70, darkGreen); tft.fillTriangle(x, y + class="number">20, x - class="number">25, y + class="number">65, x + class="number">25, y + class="number">65, green); tft.fillTriangle(x, y - class="number">5, x - class="number">20, y + class="number">40, x + class="number">20, y + class="number">40, darkGreen); tft.fillTriangle(x, y, x - class="number">15, y + class="number">35, x + class="number">15, y + class="number">35, green); drawSantaStar(x, y - class="number">15, class="number">15, TFT_YELLOW); } void drawSantaTreeLights() { for (int i = class="number">0; i < SANTA_NUM_LIGHTS; i++) { if (santaLightOn[i]) { tft.fillCircle(santaLightX[i], santaLightY[i], class="number">4, santaLightColor[i]); tft.fillCircle(santaLightX[i], santaLightY[i], class="number">2, TFT_WHITE); } else { tft.fillCircle(santaLightX[i], santaLightY[i], class="number">3, tft.color565(class="number">60, class="number">60, class="number">60)); } } } void twinkleSantaLights() { for (int i = class="number">0; i < SANTA_NUM_LIGHTS; i++) { if (random(class="number">100) < class="number">15) { santaLightOn[i] = !santaLightOn[i]; } } drawSantaTreeLights(); } // ==================== GINGERBREAD HOUSE ==================== void drawSantaCandyCane(int x, int y) { tft.fillRect(x - class="number">2, y, class="number">4, class="number">25, TFT_WHITE); tft.drawLine(x - class="number">2, y + class="number">2, x - class="number">2, y + class="number">25, TFT_RED); tft.drawLine(x, y + class="number">5, x, y + class="number">25, TFT_RED); tft.drawLine(x + class="number">1, y, x + class="number">1, y + class="number">25, TFT_RED); tft.fillCircle(x + class="number">5, y, class="number">5, TFT_WHITE); tft.fillCircle(x + class="number">5, y, class="number">2, TFT_RED); tft.fillRect(x - class="number">2, y - class="number">2, class="number">6, class="number">4, TFT_WHITE); } void drawSantaGingerbreadHouse(int x, int y) { uint16_t ginger = tft.color565(class="number">180, class="number">100, class="number">50); uint16_t gingerDark = tft.color565(class="number">150, class="number">80, class="number">40); uint16_t icing = TFT_WHITE; tft.fillRect(x - class="number">40, y, class="number">80, class="number">70, ginger); tft.fillRect(x - class="number">35, y + class="number">5, class="number">70, class="number">60, gingerDark); tft.fillTriangle(x, y - class="number">45, x - class="number">50, y, x + class="number">50, y, ginger); tft.fillTriangle(x, y - class="number">40, x - class="number">45, y, x + class="number">45, y, gingerDark); for (int i = -class="number">45; i <= class="number">45; i += class="number">8) { tft.fillCircle(x + i, y + (abs(i) * class="number">45 / class="number">50) - class="number">45 + abs(i)/class="number">5, class="number">5, icing); } tft.fillCircle(x - class="number">30, y - class="number">20, class="number">4, icing); tft.fillCircle(x + class="number">25, y - class="number">25, class="number">4, icing); tft.fillCircle(x, y - class="number">35, class="number">5, icing); tft.fillRoundRect(x - class="number">12, y + class="number">30, class="number">24, class="number">40, class="number">5, tft.color565(class="number">120, class="number">60, class="number">30)); tft.fillCircle(x + class="number">7, y + class="number">50, class="number">3, TFT_YELLOW); tft.drawRoundRect(x - class="number">14, y + class="number">28, class="number">28, class="number">44, class="number">6, icing); tft.drawRoundRect(x - class="number">13, y + class="number">29, class="number">26, class="number">42, class="number">5, icing); tft.fillRect(x - class="number">35, y + class="number">15, class="number">18, class="number">18, tft.color565(class="number">255, class="number">255, class="number">150)); tft.drawRect(x - class="number">36, y + class="number">14, class="number">20, class="number">20, icing); tft.drawLine(x - class="number">26, y + class="number">14, x - class="number">26, y + class="number">34, icing); tft.drawLine(x - class="number">36, y + class="number">24, x - class="number">16, y + class="number">24, icing); tft.fillRect(x + class="number">17, y + class="number">15, class="number">18, class="number">18, tft.color565(class="number">255, class="number">255, class="number">150)); tft.drawRect(x + class="number">16, y + class="number">14, class="number">20, class="number">20, icing); tft.drawLine(x + class="number">26, y + class="number">14, x + class="number">26, y + class="number">34, icing); tft.drawLine(x + class="number">16, y + class="number">24, x + class="number">36, y + class="number">24, icing); tft.fillCircle(x - class="number">30, y + class="number">50, class="number">4, TFT_RED); tft.fillCircle(x + class="number">30, y + class="number">50, class="number">4, TFT_GREEN); tft.fillCircle(x - class="number">25, y + class="number">60, class="number">4, TFT_GREEN); tft.fillCircle(x + class="number">25, y + class="number">60, class="number">4, TFT_RED); tft.fillRect(x + class="number">20, y - class="number">35, class="number">15, class="number">25, ginger); for (int i = class="number">0; i < class="number">15; i += class="number">5) { tft.fillCircle(x + class="number">22 + i, y - class="number">35, class="number">4, icing); } drawSantaCandyCane(x - class="number">20, y + class="number">45); drawSantaCandyCane(x + class="number">20, y + class="number">45); tft.fillCircle(x, y + class="number">75, class="number">5, TFT_RED); tft.fillCircle(x - class="number">15, y + class="number">78, class="number">5, TFT_GREEN); tft.fillCircle(x + class="number">15, y + class="number">78, class="number">5, TFT_YELLOW); } // ==================== MOON ==================== void drawSantaMoon(int x, int y) { tft.fillCircle(x, y, class="number">35, tft.color565(class="number">255, class="number">255, class="number">230)); tft.fillCircle(x, y, class="number">33, tft.color565(class="number">240, class="number">240, class="number">210)); tft.fillCircle(x - class="number">10, y - class="number">8, class="number">8, tft.color565(class="number">220, class="number">220, class="number">190)); tft.fillCircle(x + class="number">12, y + class="number">5, class="number">6, tft.color565(class="number">210, class="number">210, class="number">180)); tft.fillCircle(x - class="number">5, y + class="number">12, class="number">5, tft.color565(class="number">215, class="number">215, class="number">185)); tft.fillCircle(x + class="number">8, y - class="number">12, class="number">4, tft.color565(class="number">225, class="number">225, class="number">195)); } // ==================== TEXT ==================== void drawSantaMerryChristmas() { tft.setTextColor(TFT_RED); tft.setTextSize(class="number">3); tft.setTextDatum(TL_DATUM); tft.drawString(class="number">0, class="number">10, class="number">10); tft.setTextColor(TFT_GREEN); tft.drawString(class="number">0, class="number">10, class="number">40); } // ==================== MAIN SANTA SHOW ==================== void runSantaAnimation() { Serial.println(class="number">0); santaNightSky = tft.color565(class="number">8, class="number">12, class="number">35); santaSleighRed = tft.color565(class="number">200, class="number">30, class="number">30); santaSleighGold = tft.color565(class="number">255, class="number">215, class="number">0); santaReindeerBrown = tft.color565(class="number">139, class="number">90, class="number">43); santaReindeerLight = tft.color565(class="number">180, class="number">130, class="number">80); santaAntlerColor = tft.color565(class="number">90, class="number">60, class="number">30); tft.fillScreen(santaNightSky); drawSantaMoon(SCREEN_WIDTH - class="number">55, class="number">55); initSantaStars(); drawSantaStars(); drawSantaMerryChristmas(); drawSantaGround(); int treeX = class="number">60; int treeY = SCREEN_HEIGHT - class="number">165; drawSantaChristmasTree(treeX, treeY); initSantaTreeLights(treeX, treeY); drawSantaTreeLights(); drawSantaGingerbreadHouse(SCREEN_WIDTH - class="number">70, SCREEN_HEIGHT - class="number">130); initSantaSparkles(); initSantaPresents(); initSantaSmoke(); santaSleighAngle = class="number">0; santaSleighX = -class="number">170; santaSleighY = class="number">200; santaSleighDirection = class="number">1; float lastX = santaSleighX; float lastY = santaSleighY; unsigned long lastSparkle = class="number">0; unsigned long lastPresentDrop = class="number">0; unsigned long lastSmoke = class="number">0; int passCount = class="number">0; bool wasOnScreen = false; int presentsDropped = class="number">0; int chimneyX = SCREEN_WIDTH - class="number">70 + class="number">27; int chimneyY = SCREEN_HEIGHT - class="number">130 - class="number">38; while (passCount < class="number">2) { eraseSantaSleigh(lastX, lastY); redrawSantaStarsInArea((int)lastX - class="number">20, (int)lastY - class="number">55, (int)lastX + class="number">165, (int)lastY + class="number">45); if (lastX + class="number">165 > SCREEN_WIDTH - class="number">90 && lastY - class="number">55 < class="number">90) { drawSantaMoon(SCREEN_WIDTH - class="number">55, class="number">55); } drawSantaLandedPresents(); updateSantaSparkles(); bool isOnScreen = (santaSleighX > -class="number">170 && santaSleighX < SCREEN_WIDTH + class="number">50); if (wasOnScreen && !isOnScreen) { passCount++; } wasOnScreen = isOnScreen; updateSantaSleighPosition(); if (millis() - lastSparkle > class="number">80) { spawnSantaSparkle(santaSleighX + class="number">10, santaSleighY + class="number">30); spawnSantaSparkle(santaSleighX + class="number">40, santaSleighY + class="number">30); lastSparkle = millis(); } if (passCount == class="number">0 && presentsDropped < class="number">1 && santaSleighX > class="number">120 && santaSleighX < SCREEN_WIDTH - class="number">100) { if (millis() - lastPresentDrop > class="number">1500) { spawnSantaPresent(santaSleighX + class="number">25, santaSleighY + class="number">20); lastPresentDrop = millis(); presentsDropped++; } } updateSantaPresents(); drawSantaAssembly(santaSleighX, santaSleighY); lastX = santaSleighX; lastY = santaSleighY; drawSantaMerryChristmas(); twinkleSantaLights(); if (millis() - lastSmoke > class="number">200) { spawnSantaSmoke(chimneyX, chimneyY); lastSmoke = millis(); } updateSantaSmoke(); delay(class="number">35); } Serial.println(class="number">0); tft.fillScreen(TFT_BLACK); } // Check if it's Christmas show time // Christmas Eve (Dec 24) from 5 PM onward, or Christmas Day (Dec 25) all day // Triggers every hour on the hour bool isChristmasShowTime() { struct tm timeinfo; if (!getLocalTime(&timeinfo)) return false; bool isChristmasEve = (timeinfo.tm_mon == class="number">11 && timeinfo.tm_mday == class="number">24 && timeinfo.tm_hour >= class="number">17); bool isChristmasDay = (timeinfo.tm_mon == class="number">11 && timeinfo.tm_mday == class="number">25); if ((isChristmasEve || isChristmasDay) && timeinfo.tm_min == class="number">0 && timeinfo.tm_sec < class="number">10) { return true; } return false; }
// HALLOWEEN
Halloween.h/****************************************************************************************** Halloween Animation - Standalone For use in CyberWeather ******************************************************************************************/ #pragma once #include <Arduino.h> extern TFT_eSPI tft; // ==================== HALLOWEEN COLORS ==================== uint16_t halloweenNightSky; uint16_t halloweenMoonYellow; uint16_t halloweenWitchGreen; uint16_t halloweenPumpkinOrange; uint16_t halloweenWoodBrown; uint16_t halloweenDeadGray; // ==================== HALLOWEEN STARS ==================== #define HALLOWEEN_MAX_STARS 60 int halloweenStarX[HALLOWEEN_MAX_STARS]; int halloweenStarY[HALLOWEEN_MAX_STARS]; uint8_t halloweenStarBright[HALLOWEEN_MAX_STARS]; void initHalloweenStars() { for (int i = class="number">0; i < HALLOWEEN_MAX_STARS; i++) { halloweenStarX[i] = random(SCREEN_WIDTH); halloweenStarY[i] = random(SCREEN_HEIGHT - class="number">150); halloweenStarBright[i] = random(class="number">150, class="number">255); } } void drawHalloweenStars() { for (int i = class="number">0; i < HALLOWEEN_MAX_STARS; i++) { uint16_t col = tft.color565(halloweenStarBright[i], halloweenStarBright[i], halloweenStarBright[i]); tft.drawPixel(halloweenStarX[i], halloweenStarY[i], col); if (random(class="number">100) < class="number">5) { halloweenStarBright[i] = random(class="number">150, class="number">255); } } } // ==================== FULL MOON ==================== void drawHalloweenMoon(int x, int y) { for (int r = class="number">50; r > class="number">40; r--) { uint8_t alpha = (class="number">50 - r) * class="number">8; uint16_t halo = tft.color565(alpha, alpha, alpha / class="number">2); tft.drawCircle(x, y, r, halo); } tft.fillCircle(x, y, class="number">40, tft.color565(class="number">255, class="number">255, class="number">220)); tft.fillCircle(x, y, class="number">38, halloweenMoonYellow); tft.fillCircle(x - class="number">12, y - class="number">10, class="number">8, tft.color565(class="number">200, class="number">190, class="number">140)); tft.fillCircle(x + class="number">15, y + class="number">5, class="number">6, tft.color565(class="number">190, class="number">180, class="number">130)); tft.fillCircle(x - class="number">5, y + class="number">15, class="number">5, tft.color565(class="number">195, class="number">185, class="number">135)); tft.fillCircle(x + class="number">8, y - class="number">15, class="number">4, tft.color565(class="number">205, class="number">195, class="number">145)); } // ==================== SCARY TREE ==================== void drawHalloweenTree(int x, int y) { tft.fillRect(x - class="number">15, y, class="number">30, class="number">80, halloweenWoodBrown); tft.fillRect(x - class="number">12, y, class="number">24, class="number">80, tft.color565(class="number">100, class="number">70, class="number">40)); tft.drawLine(x - class="number">8, y + class="number">10, x - class="number">5, y + class="number">70, tft.color565(class="number">70, class="number">45, class="number">25)); tft.drawLine(x + class="number">5, y + class="number">15, x + class="number">8, y + class="number">75, tft.color565(class="number">70, class="number">45, class="number">25)); tft.fillTriangle(x - class="number">15, y + class="number">10, x - class="number">80, y - class="number">40, x - class="number">70, y - class="number">30, halloweenDeadGray); tft.fillTriangle(x - class="number">15, y + class="number">15, x - class="number">75, y - class="number">35, x - class="number">65, y - class="number">25, halloweenDeadGray); tft.drawLine(x - class="number">50, y - class="number">20, x - class="number">70, y - class="number">50, halloweenDeadGray); tft.drawLine(x - class="number">50, y - class="number">20, x - class="number">40, y - class="number">55, halloweenDeadGray); tft.drawLine(x - class="number">65, y - class="number">30, x - class="number">85, y - class="number">20, halloweenDeadGray); tft.drawLine(x - class="number">75, y - class="number">40, x - class="number">90, y - class="number">60, halloweenDeadGray); tft.fillTriangle(x + class="number">15, y + class="number">5, x + class="number">75, y - class="number">50, x + class="number">65, y - class="number">40, halloweenDeadGray); tft.fillTriangle(x + class="number">15, y + class="number">10, x + class="number">70, y - class="number">45, x + class="number">60, y - class="number">35, halloweenDeadGray); tft.drawLine(x + class="number">45, y - class="number">25, x + class="number">65, y - class="number">60, halloweenDeadGray); tft.drawLine(x + class="number">55, y - class="number">35, x + class="number">80, y - class="number">30, halloweenDeadGray); tft.drawLine(x + class="number">60, y - class="number">40, x + class="number">50, y - class="number">70, halloweenDeadGray); tft.fillTriangle(x, y, x - class="number">30, y - class="number">70, x - class="number">20, y - class="number">65, halloweenDeadGray); tft.fillTriangle(x, y, x + class="number">25, y - class="number">80, x + class="number">15, y - class="number">75, halloweenDeadGray); tft.drawLine(x - class="number">25, y - class="number">65, x - class="number">45, y - class="number">90, halloweenDeadGray); tft.drawLine(x + class="number">20, y - class="number">75, x + class="number">40, y - class="number">100, halloweenDeadGray); tft.drawLine(x - class="number">20, y - class="number">60, x - class="number">10, y - class="number">95, halloweenDeadGray); tft.fillEllipse(x, y + class="number">35, class="number">8, class="number">12, TFT_BLACK); tft.fillCircle(x - class="number">8, y + class="number">25, class="number">3, TFT_BLACK); tft.fillCircle(x + class="number">8, y + class="number">25, class="number">3, TFT_BLACK); } // ==================== HAUNTED HOUSE ==================== void drawHalloweenBat(int x, int y, int size) { tft.fillEllipse(x, y, size/class="number">2, size/class="number">3, TFT_BLACK); tft.fillTriangle(x - size, y - size/class="number">2, x - size/class="number">4, y, x - size/class="number">2, y + size/class="number">3, TFT_BLACK); tft.fillTriangle(x + size, y - size/class="number">2, x + size/class="number">4, y, x + size/class="number">2, y + size/class="number">3, TFT_BLACK); tft.fillTriangle(x - size/class="number">4, y - size/class="number">3, x - size/class="number">6, y - size/class="number">2, x, y - size/class="number">4, TFT_BLACK); tft.fillTriangle(x + size/class="number">4, y - size/class="number">3, x + size/class="number">6, y - size/class="number">2, x, y - size/class="number">4, TFT_BLACK); } void drawHalloweenHouse(int x, int y) { tft.fillRect(x - class="number">50, y, class="number">100, class="number">90, tft.color565(class="number">70, class="number">60, class="number">80)); tft.fillRect(x - class="number">45, y + class="number">5, class="number">90, class="number">80, tft.color565(class="number">55, class="number">45, class="number">65)); tft.fillTriangle(x, y - class="number">60, x - class="number">60, y, x + class="number">60, y, tft.color565(class="number">45, class="number">35, class="number">55)); tft.fillTriangle(x + class="number">5, y - class="number">55, x - class="number">55, y, x + class="number">55, y, tft.color565(class="number">60, class="number">50, class="number">70)); tft.fillRect(x + class="number">25, y - class="number">40, class="number">30, class="number">50, tft.color565(class="number">60, class="number">50, class="number">70)); tft.fillTriangle(x + class="number">40, y - class="number">80, x + class="number">20, y - class="number">40, x + class="number">60, y - class="number">40, tft.color565(class="number">45, class="number">35, class="number">55)); tft.fillRect(x + class="number">32, y - class="number">30, class="number">15, class="number">20, tft.color565(class="number">120, class="number">90, class="number">30)); tft.fillRect(x + class="number">34, y - class="number">28, class="number">11, class="number">16, tft.color565(class="number">200, class="number">150, class="number">50)); tft.drawLine(x + class="number">39, y - class="number">28, x + class="number">39, y - class="number">12, TFT_BLACK); tft.drawLine(x + class="number">34, y - class="number">20, x + class="number">45, y - class="number">20, TFT_BLACK); tft.fillRect(x - class="number">40, y + class="number">20, class="number">25, class="number">30, tft.color565(class="number">15, class="number">15, class="number">25)); tft.drawRect(x - class="number">40, y + class="number">20, class="number">25, class="number">30, tft.color565(class="number">80, class="number">75, class="number">90)); tft.drawLine(x - class="number">28, y + class="number">20, x - class="number">28, y + class="number">50, tft.color565(class="number">80, class="number">75, class="number">90)); tft.drawLine(x - class="number">40, y + class="number">35, x - class="number">15, y + class="number">35, tft.color565(class="number">80, class="number">75, class="number">90)); tft.drawLine(x - class="number">35, y + class="number">25, x - class="number">25, y + class="number">32, tft.color565(class="number">100, class="number">100, class="number">120)); tft.fillRect(x + class="number">15, y + class="number">20, class="number">25, class="number">30, tft.color565(class="number">100, class="number">70, class="number">25)); tft.fillRect(x + class="number">17, y + class="number">22, class="number">21, class="number">26, tft.color565(class="number">180, class="number">130, class="number">45)); tft.drawLine(x + class="number">27, y + class="number">20, x + class="number">27, y + class="number">50, TFT_BLACK); tft.drawLine(x + class="number">15, y + class="number">35, x + class="number">40, y + class="number">35, TFT_BLACK); tft.fillRect(x - class="number">15, y + class="number">40, class="number">30, class="number">50, tft.color565(class="number">50, class="number">35, class="number">25)); tft.drawRect(x - class="number">15, y + class="number">40, class="number">30, class="number">50, tft.color565(class="number">90, class="number">70, class="number">55)); tft.drawRect(x - class="number">13, y + class="number">42, class="number">26, class="number">46, tft.color565(class="number">70, class="number">50, class="number">35)); tft.fillCircle(x + class="number">8, y + class="number">65, class="number">4, tft.color565(class="number">140, class="number">120, class="number">80)); tft.fillCircle(x + class="number">8, y + class="number">65, class="number">2, tft.color565(class="number">100, class="number">85, class="number">60)); tft.fillRect(x - class="number">35, y - class="number">45, class="number">15, class="number">30, tft.color565(class="number">80, class="number">65, class="number">75)); drawHalloweenBat(x - class="number">30, y - class="number">70, class="number">8); drawHalloweenBat(x + class="number">50, y - class="number">50, class="number">6); } // ==================== WITCH ==================== float halloweenWitchX, halloweenWitchY; float halloweenWitchAngle = class="number">0; float halloweenWitchDirection = class="number">1; int halloweenWitchPassCount = class="number">0; void drawHalloweenWitch(int x, int y) { tft.fillRect(x - class="number">40, y + class="number">8, class="number">80, class="number">4, tft.color565(class="number">139, class="number">90, class="number">43)); tft.fillRect(x - class="number">38, y + class="number">9, class="number">76, class="number">2, tft.color565(class="number">160, class="number">110, class="number">60)); for (int i = class="number">0; i < class="number">12; i++) { int bx = x - class="number">35 + (i % class="number">3) - class="number">1; int by = y + class="number">5 + i * class="number">2; tft.drawLine(x - class="number">30, y + class="number">10, bx - class="number">20, by, tft.color565(class="number">180, class="number">140, class="number">60)); } tft.fillRect(x - class="number">38, y + class="number">5, class="number">8, class="number">14, tft.color565(class="number">160, class="number">120, class="number">50)); tft.fillTriangle(x + class="number">5, y - class="number">5, x - class="number">10, y + class="number">25, x + class="number">20, y + class="number">25, TFT_BLACK); tft.fillTriangle(x + class="number">7, y - class="number">3, x - class="number">8, y + class="number">23, x + class="number">18, y + class="number">23, tft.color565(class="number">30, class="number">20, class="number">50)); tft.fillTriangle(x, y, x - class="number">25, y + class="number">20, x - class="number">5, y + class="number">15, TFT_BLACK); tft.fillTriangle(x + class="number">2, y + class="number">2, x - class="number">20, y + class="number">18, x - class="number">2, y + class="number">13, tft.color565(class="number">40, class="number">25, class="number">60)); tft.fillCircle(x + class="number">10, y - class="number">12, class="number">10, tft.color565(class="number">100, class="number">180, class="number">80)); tft.fillCircle(x + class="number">10, y - class="number">12, class="number">8, tft.color565(class="number">140, class="number">220, class="number">120)); tft.fillTriangle(x + class="number">13, y - class="number">14, x + class="number">22, y - class="number">10, x + class="number">15, y - class="number">8, tft.color565(class="number">100, class="number">180, class="number">80)); tft.fillCircle(x + class="number">18, y - class="number">11, class="number">2, tft.color565(class="number">80, class="number">140, class="number">60)); tft.fillCircle(x + class="number">6, y - class="number">14, class="number">3, TFT_RED); tft.fillCircle(x + class="number">12, y - class="number">14, class="number">3, TFT_RED); tft.fillCircle(x + class="number">6, y - class="number">15, class="number">1, TFT_YELLOW); tft.fillCircle(x + class="number">12, y - class="number">15, class="number">1, TFT_YELLOW); tft.drawLine(x + class="number">5, y - class="number">6, x + class="number">15, y - class="number">8, TFT_BLACK); tft.drawLine(x + class="number">15, y - class="number">8, x + class="number">18, y - class="number">6, TFT_BLACK); tft.fillTriangle(x + class="number">10, y - class="number">45, x - class="number">8, y - class="number">18, x + class="number">25, y - class="number">18, TFT_BLACK); tft.fillTriangle(x + class="number">12, y - class="number">43, x - class="number">6, y - class="number">20, x + class="number">23, y - class="number">20, tft.color565(class="number">40, class="number">30, class="number">70)); tft.fillEllipse(x + class="number">10, y - class="number">18, class="number">20, class="number">5, TFT_BLACK); tft.fillRect(x - class="number">2, y - class="number">25, class="number">24, class="number">4, halloweenPumpkinOrange); tft.fillRect(x + class="number">6, y - class="number">26, class="number">8, class="number">6, tft.color565(class="number">200, class="number">180, class="number">50)); tft.fillRect(x + class="number">8, y - class="number">25, class="number">4, class="number">4, TFT_BLACK); tft.fillRect(x + class="number">2, y + class="number">20, class="number">4, class="number">15, TFT_BLACK); tft.fillRect(x + class="number">14, y + class="number">20, class="number">4, class="number">15, TFT_BLACK); tft.fillTriangle(x + class="number">2, y + class="number">35, x + class="number">6, y + class="number">30, x + class="number">12, y + class="number">35, TFT_BLACK); tft.fillTriangle(x + class="number">14, y + class="number">35, x + class="number">18, y + class="number">30, x + class="number">24, y + class="number">35, TFT_BLACK); tft.fillCircle(x + class="number">15, y + class="number">8, class="number">4, halloweenWitchGreen); } void eraseHalloweenWitch(float x, float y) { tft.fillRect((int)x - class="number">65, (int)y - class="number">55, class="number">140, class="number">110, halloweenNightSky); } void updateHalloweenWitchPosition() { halloweenWitchX += halloweenWitchDirection * class="number">2.0; halloweenWitchAngle += class="number">0.05; halloweenWitchY = class="number">170 + sin(halloweenWitchAngle) * class="number">15; if (halloweenWitchPassCount < class="number">1) { if (halloweenWitchDirection > class="number">0 && halloweenWitchX > SCREEN_WIDTH + class="number">50) { halloweenWitchX = -class="number">60; halloweenWitchPassCount++; } else if (halloweenWitchDirection < class="number">0 && halloweenWitchX < -class="number">60) { halloweenWitchX = SCREEN_WIDTH + class="number">50; halloweenWitchPassCount++; } } } // ==================== GROUND ==================== void drawHalloweenGround() { tft.fillRect(class="number">0, SCREEN_HEIGHT - class="number">80, SCREEN_WIDTH, class="number">80, tft.color565(class="number">50, class="number">70, class="number">35)); for (int x = class="number">0; x < SCREEN_WIDTH; x += class="number">15) { int h = random(class="number">8, class="number">20); tft.drawLine(x, SCREEN_HEIGHT - class="number">80, x - class="number">3, SCREEN_HEIGHT - class="number">80 - h, tft.color565(class="number">70, class="number">100, class="number">50)); tft.drawLine(x, SCREEN_HEIGHT - class="number">80, x + class="number">3, SCREEN_HEIGHT - class="number">80 - h + class="number">3, tft.color565(class="number">60, class="number">90, class="number">40)); } } void drawHalloweenGravestone(int x, int y) { tft.fillRoundRect(x - class="number">12, y, class="number">24, class="number">35, class="number">5, tft.color565(class="number">120, class="number">120, class="number">130)); tft.fillRoundRect(x - class="number">10, y + class="number">2, class="number">20, class="number">31, class="number">4, tft.color565(class="number">100, class="number">100, class="number">115)); tft.setTextSize(class="number">1); tft.setTextColor(tft.color565(class="number">40, class="number">40, class="number">50)); tft.setTextDatum(MC_DATUM); tft.drawString(class="number">0, x, y + class="number">15); tft.drawLine(x - class="number">5, y + class="number">5, x - class="number">2, y + class="number">20, tft.color565(class="number">70, class="number">70, class="number">80)); tft.drawLine(x + class="number">3, y + class="number">25, x + class="number">6, y + class="number">32, tft.color565(class="number">70, class="number">70, class="number">80)); } // ==================== PUMPKIN ==================== void drawHalloweenPumpkin(int x, int y, int size) { tft.fillCircle(x, y, size, halloweenPumpkinOrange); tft.fillEllipse(x - size/class="number">2, y, size/class="number">3, size, tft.color565(class="number">220, class="number">120, class="number">20)); tft.fillEllipse(x + size/class="number">2, y, size/class="number">3, size, tft.color565(class="number">220, class="number">120, class="number">20)); tft.fillCircle(x, y, size - class="number">2, tft.color565(class="number">255, class="number">140, class="number">30)); tft.fillRect(x - class="number">3, y - size - class="number">8, class="number">6, class="number">10, tft.color565(class="number">60, class="number">80, class="number">30)); tft.fillRect(x - class="number">2, y - size - class="number">7, class="number">4, class="number">8, tft.color565(class="number">80, class="number">100, class="number">40)); uint16_t glowColor = tft.color565(class="number">255, class="number">200, class="number">50); tft.fillTriangle(x - size/class="number">2, y - size/class="number">4, x - size/class="number">4, y - size/class="number">2, x - size/class="number">4, y, glowColor); tft.fillTriangle(x + size/class="number">2, y - size/class="number">4, x + size/class="number">4, y - size/class="number">2, x + size/class="number">4, y, glowColor); tft.fillTriangle(x, y - class="number">2, x - size/class="number">6, y + size/class="number">4, x + size/class="number">6, y + size/class="number">4, glowColor); tft.fillRect(x - size/class="number">2 + class="number">2, y + size/class="number">3, size - class="number">4, size/class="number">4, glowColor); for (int t = x - size/class="number">2 + class="number">5; t < x + size/class="number">2 - class="number">5; t += class="number">8) { tft.fillRect(t, y + size/class="number">3, class="number">4, size/class="number">6, halloweenPumpkinOrange); } } // ==================== BLACK CAT ==================== void drawHalloweenCat(int x, int y) { tft.fillEllipse(x, y, class="number">18, class="number">10, TFT_BLACK); for (int i = -class="number">15; i <= class="number">15; i += class="number">4) { int furHeight = class="number">8 + (abs(i) % class="number">4) + class="number">2; tft.drawLine(x + i, y - class="number">8, x + i - class="number">2, y - furHeight - class="number">8, TFT_BLACK); tft.drawLine(x + i, y - class="number">8, x + i + class="number">2, y - furHeight - class="number">8, TFT_BLACK); } tft.fillCircle(x + class="number">20, y - class="number">5, class="number">10, TFT_BLACK); tft.fillTriangle(x + class="number">12, y - class="number">10, x + class="number">16, y - class="number">25, x + class="number">20, y - class="number">12, TFT_BLACK); tft.fillTriangle(x + class="number">24, y - class="number">12, x + class="number">28, y - class="number">25, x + class="number">32, y - class="number">10, TFT_BLACK); tft.fillTriangle(x + class="number">14, y - class="number">12, x + class="number">17, y - class="number">20, x + class="number">19, y - class="number">13, tft.color565(class="number">80, class="number">50, class="number">50)); tft.fillTriangle(x + class="number">25, y - class="number">13, x + class="number">27, y - class="number">20, x + class="number">30, y - class="number">12, tft.color565(class="number">80, class="number">50, class="number">50)); tft.fillCircle(x + class="number">16, y - class="number">6, class="number">4, TFT_YELLOW); tft.fillCircle(x + class="number">26, y - class="number">6, class="number">4, TFT_YELLOW); tft.fillRect(x + class="number">15, y - class="number">9, class="number">2, class="number">6, TFT_BLACK); tft.fillRect(x + class="number">25, y - class="number">9, class="number">2, class="number">6, TFT_BLACK); tft.fillTriangle(x + class="number">20, y - class="number">2, x + class="number">18, y + class="number">1, x + class="number">22, y + class="number">1, tft.color565(class="number">60, class="number">40, class="number">40)); tft.drawLine(x + class="number">12, y - class="number">2, x + class="number">2, y - class="number">5, tft.color565(class="number">80, class="number">80, class="number">80)); tft.drawLine(x + class="number">12, y, x + class="number">2, y, tft.color565(class="number">80, class="number">80, class="number">80)); tft.drawLine(x + class="number">28, y - class="number">2, x + class="number">38, y - class="number">5, tft.color565(class="number">80, class="number">80, class="number">80)); tft.drawLine(x + class="number">28, y, x + class="number">38, y, tft.color565(class="number">80, class="number">80, class="number">80)); tft.fillRect(x + class="number">10, y + class="number">5, class="number">5, class="number">15, TFT_BLACK); tft.fillRect(x + class="number">18, y + class="number">5, class="number">5, class="number">15, TFT_BLACK); tft.fillRect(x - class="number">15, y + class="number">3, class="number">6, class="number">17, TFT_BLACK); tft.fillRect(x - class="number">8, y + class="number">5, class="number">5, class="number">15, TFT_BLACK); tft.fillRect(x - class="number">18, y - class="number">5, class="number">5, class="number">20, TFT_BLACK); for (int i = class="number">0; i < class="number">20; i += class="number">3) { tft.drawLine(x - class="number">20, y - class="number">5 + i, x - class="number">28, y - class="number">8 + i, TFT_BLACK); tft.drawLine(x - class="number">13, y - class="number">5 + i, x - class="number">8, y - class="number">8 + i, TFT_BLACK); } tft.fillCircle(x - class="number">16, y - class="number">8, class="number">5, TFT_BLACK); tft.fillTriangle(x - class="number">20, y - class="number">12, x - class="number">16, y - class="number">22, x - class="number">12, y - class="number">12, TFT_BLACK); tft.fillTriangle(x - class="number">22, y - class="number">10, x - class="number">18, y - class="number">18, x - class="number">14, y - class="number">10, TFT_BLACK); } // ==================== TEXT ==================== void drawHalloweenText() { tft.setTextColor(halloweenPumpkinOrange); tft.setTextSize(class="number">3); tft.setTextDatum(TL_DATUM); tft.drawString(class="number">0, class="number">10, class="number">15); tft.drawString(class="number">0, class="number">10, class="number">45); } // ==================== MAIN HALLOWEEN SHOW ==================== void runHalloweenAnimation() { Serial.println(class="number">0); halloweenNightSky = tft.color565(class="number">40, class="number">25, class="number">80); halloweenMoonYellow = tft.color565(class="number">255, class="number">255, class="number">220); halloweenWitchGreen = tft.color565(class="number">150, class="number">255, class="number">120); halloweenPumpkinOrange = tft.color565(class="number">255, class="number">160, class="number">0); halloweenWoodBrown = tft.color565(class="number">160, class="number">100, class="number">60); halloweenDeadGray = tft.color565(class="number">140, class="number">130, class="number">120); tft.fillScreen(halloweenNightSky); initHalloweenStars(); drawHalloweenStars(); drawHalloweenMoon(SCREEN_WIDTH - class="number">70, class="number">70); drawHalloweenText(); drawHalloweenGround(); drawHalloweenTree(class="number">70, SCREEN_HEIGHT - class="number">140); drawHalloweenHouse(SCREEN_WIDTH - class="number">80, SCREEN_HEIGHT - class="number">170); drawHalloweenGravestone(class="number">50, SCREEN_HEIGHT - class="number">75); drawHalloweenGravestone(class="number">95, SCREEN_HEIGHT - class="number">72); drawHalloweenCat(SCREEN_WIDTH / class="number">2 + class="number">30, SCREEN_HEIGHT - class="number">50); drawHalloweenPumpkin(class="number">270, SCREEN_HEIGHT - class="number">95, class="number">15); halloweenWitchX = -class="number">60; halloweenWitchY = class="number">170; halloweenWitchAngle = class="number">0; halloweenWitchDirection = class="number">1; halloweenWitchPassCount = class="number">0; float lastX = halloweenWitchX; float lastY = halloweenWitchY; while (halloweenWitchPassCount < class="number">1 || halloweenWitchX < SCREEN_WIDTH + class="number">60) { eraseHalloweenWitch(lastX, lastY); drawHalloweenStars(); if (lastX + class="number">50 > SCREEN_WIDTH - class="number">120 && lastY - class="number">50 < class="number">120) { drawHalloweenMoon(SCREEN_WIDTH - class="number">70, class="number">70); } updateHalloweenWitchPosition(); drawHalloweenWitch((int)halloweenWitchX, (int)halloweenWitchY); lastX = halloweenWitchX; lastY = halloweenWitchY; drawHalloweenText(); delay(class="number">40); } Serial.println(class="number">0); tft.fillScreen(TFT_BLACK); } // Check if it's Halloween show time (October 31st, every hour on the hour) bool isHalloweenShowTime() { struct tm timeinfo; if (!getLocalTime(&timeinfo)) return false; return (timeinfo.tm_mon == class="number">9 && // October (0-indexed) timeinfo.tm_mday == class="number">31 && // 31st timeinfo.tm_min == class="number">0 && // Top of the hour timeinfo.tm_sec < class="number">10); // First 10 seconds }
// THANKSGIVING
Thanksgiving.h/****************************************************************************************** Thanksgiving Animation - For CyberWeather ******************************************************************************************/ #pragma once #include <Arduino.h> extern TFT_eSPI tft; // ==================== THANKSGIVING COLORS ==================== uint16_t tgAutumnSky; uint16_t tgAutumnGround; uint16_t tgTurkeyBrown; uint16_t tgTurkeyRed; uint16_t tgLeafOrange; uint16_t tgLeafRed; uint16_t tgLeafYellow; uint16_t tgLeafBrown; uint16_t tgCornYellow; // ==================== FALL TREE WITH BRANCHES ==================== uint16_t tgBranchColor; void tgDrawBranch(float x1, float y1, float angle, float length, int depth) { if (depth <= class="number">0 || length < class="number">4) return; float x2 = x1 + cos(angle) * length; float y2 = y1 + sin(angle) * length; int thickness = depth; for (int t = -thickness/class="number">2; t <= thickness/class="number">2; t++) { tft.drawLine((int)x1 + t, (int)y1, (int)x2 + t, (int)y2, tgBranchColor); } float shrink = class="number">0.7; float angleSpread = class="number">0.5; tgDrawBranch(x2, y2, angle - angleSpread, length * shrink, depth - class="number">1); tgDrawBranch(x2, y2, angle + angleSpread, length * shrink, depth - class="number">1); if (depth > class="number">2 && length > class="number">15) { tgDrawBranch(x2, y2, angle, length * shrink * class="number">0.8, depth - class="number">1); } } void tgDrawLeafCluster(int x, int y, int size, uint16_t color) { tft.fillCircle(x, y, size, color); } void tgDrawFallTree(int x, int y) { tgBranchColor = tft.color565(class="number">70, class="number">45, class="number">25); int trunkHeight = class="number">60; int trunkBase = class="number">16; int trunkTop = class="number">8; for (int i = class="number">0; i < trunkHeight; i++) { float ratio = (float)i / trunkHeight; int width = trunkBase - (int)(ratio * (trunkBase - trunkTop)); uint16_t tc = tft.color565(class="number">65 + (int)(ratio * class="number">15), class="number">40 + (int)(ratio * class="number">10), class="number">22 + (int)(ratio * class="number">8)); tft.drawFastHLine(x - width, y + class="number">40 + i, width * class="number">2, tc); } float startAngle = -PI / class="number">2; tgDrawBranch(x, y + class="number">40, startAngle - class="number">0.3, class="number">45, class="number">5); tgDrawBranch(x, y + class="number">40, startAngle + class="number">0.3, class="number">45, class="number">5); tgDrawBranch(x, y + class="number">40, startAngle, class="number">50, class="number">5); tgDrawBranch(x - class="number">8, y + class="number">60, startAngle - class="number">0.8, class="number">30, class="number">4); tgDrawBranch(x + class="number">8, y + class="number">65, startAngle + class="number">0.7, class="number">28, class="number">4); uint16_t colors[] = {tgLeafOrange, tgLeafRed, tgLeafYellow, tgLeafBrown, tft.color565(class="number">220, class="number">120, class="number">30), tft.color565(class="number">200, class="number">80, class="number">25)}; int foliagePoints[][class="number">3] = { {x - class="number">45, y - class="number">25, class="number">18}, {x - class="number">30, y - class="number">45, class="number">16}, {x - class="number">50, y - class="number">10, class="number">14}, {x + class="number">45, y - class="number">25, class="number">17}, {x + class="number">35, y - class="number">40, class="number">15}, {x + class="number">50, y - class="number">5, class="number">13}, {x, y - class="number">55, class="number">20}, {x - class="number">15, y - class="number">50, class="number">14}, {x + class="number">18, y - class="number">48, class="number">15}, {x - class="number">25, y - class="number">35, class="number">12}, {x + class="number">28, y - class="number">32, class="number">13}, {x, y - class="number">40, class="number">16}, {x - class="number">38, y - class="number">15, class="number">11}, {x + class="number">40, y - class="number">12, class="number">12}, {x - class="number">10, y - class="number">60, class="number">10}, {x + class="number">12, y - class="number">58, class="number">11}, {x, y - class="number">65, class="number">12} }; for (int i = class="number">0; i < class="number">17; i++) { tgDrawLeafCluster(foliagePoints[i][class="number">0], foliagePoints[i][class="number">1], foliagePoints[i][class="number">2], colors[i % class="number">6]); } for (int i = class="number">0; i < class="number">10; i++) { int fx = x + random(-class="number">45, class="number">45); int fy = y - class="number">20 + random(-class="number">40, class="number">10); tgDrawLeafCluster(fx, fy, random(class="number">6, class="number">10), colors[random(class="number">6)]); } } // ==================== WALKING TURKEY ==================== float tgWalkingTurkeyX; int tgWalkingTurkeyDir = class="number">1; int tgTurkeyFrame = class="number">0; int tgPeckState = class="number">0; int tgPeckTimer = class="number">0; void tgDrawWalkingTurkey(int x, int y, int frame, int dir, bool pecking) { tft.fillEllipse(x, y, class="number">20, class="number">15, tgTurkeyBrown); tft.fillEllipse(x, y, class="number">17, class="number">12, tft.color565(class="number">120, class="number">75, class="number">45)); int tailX = x - dir * class="number">15; for (int i = -class="number">2; i <= class="number">2; i++) { int fx = tailX + i * class="number">3; int fy = y - class="number">8 - abs(i) * class="number">2; uint16_t fc = (i % class="number">2 == class="number">0) ? tgLeafOrange : tgLeafRed; if (abs(i) == class="number">2) fc = tgLeafBrown; tft.fillEllipse(fx, fy, class="number">4, class="number">10, fc); } int wingX = x - dir * class="number">5; tft.fillEllipse(wingX, y + class="number">2, class="number">10, class="number">8, tft.color565(class="number">100, class="number">60, class="number">35)); int headOffsetY = pecking ? class="number">10 : -class="number">5; int headOffsetX = pecking ? dir * class="number">10 : dir * class="number">12; int neckX = x + dir * class="number">8; int neckEndX = x + headOffsetX; int neckEndY = y + headOffsetY; tft.drawLine(neckX, y - class="number">5, neckEndX, neckEndY, tft.color565(class="number">180, class="number">100, class="number">80)); tft.drawLine(neckX + class="number">1, y - class="number">5, neckEndX + class="number">1, neckEndY, tft.color565(class="number">180, class="number">100, class="number">80)); tft.fillCircle(neckEndX + dir * class="number">5, neckEndY, class="number">7, tft.color565(class="number">180, class="number">100, class="number">80)); int beakX = neckEndX + dir * class="number">12; int beakY = neckEndY + (pecking ? class="number">3 : class="number">0); tft.fillTriangle(neckEndX + dir * class="number">5, neckEndY, beakX, beakY - class="number">2, beakX, beakY + class="number">2, tgCornYellow); tft.fillCircle(neckEndX + dir * class="number">6, neckEndY + class="number">5, class="number">4, tgTurkeyRed); tft.fillCircle(neckEndX + dir * class="number">5, neckEndY + class="number">8, class="number">3, tgTurkeyRed); tft.fillEllipse(neckEndX + dir * class="number">8, neckEndY - class="number">2, class="number">2, class="number">5, tgTurkeyRed); tft.fillCircle(neckEndX + dir * class="number">3, neckEndY - class="number">3, class="number">2, TFT_WHITE); tft.fillCircle(neckEndX + dir * class="number">3, neckEndY - class="number">3, class="number">1, TFT_BLACK); int legOffset = (frame % class="number">2 == class="number">0) ? class="number">3 : -class="number">3; int leg1X = x - class="number">5 + legOffset; int leg2X = x + class="number">5 - legOffset; tft.drawLine(leg1X, y + class="number">12, leg1X - class="number">2, y + class="number">25, tgCornYellow); tft.drawLine(leg1X - class="number">2, y + class="number">25, leg1X - class="number">6, y + class="number">27, tgCornYellow); tft.drawLine(leg1X - class="number">2, y + class="number">25, leg1X + class="number">2, y + class="number">27, tgCornYellow); tft.drawLine(leg2X, y + class="number">12, leg2X + class="number">2, y + class="number">25, tgCornYellow); tft.drawLine(leg2X + class="number">2, y + class="number">25, leg2X - class="number">2, y + class="number">27, tgCornYellow); tft.drawLine(leg2X + class="number">2, y + class="number">25, leg2X + class="number">6, y + class="number">27, tgCornYellow); } void tgEraseWalkingTurkey(int x, int y) { tft.fillRect(x - class="number">35, y - class="number">25, class="number">70, class="number">55, tgAutumnGround); } void tgUpdateWalkingTurkey() { int y = SCREEN_HEIGHT - class="number">60; tgEraseWalkingTurkey((int)tgWalkingTurkeyX, y); if (tgPeckState > class="number">0) { tgPeckTimer++; if (tgPeckTimer > class="number">8) { tgPeckTimer = class="number">0; tgPeckState++; if (tgPeckState > class="number">3) { tgPeckState = class="number">0; } } } else { tgWalkingTurkeyX += tgWalkingTurkeyDir * class="number">1.5; if (tgWalkingTurkeyX > SCREEN_WIDTH - class="number">40) { tgWalkingTurkeyDir = -class="number">1; } else if (tgWalkingTurkeyX < class="number">40) { tgWalkingTurkeyDir = class="number">1; } if (random(class="number">100) < class="number">3) { tgPeckState = class="number">1; tgPeckTimer = class="number">0; } tgTurkeyFrame++; } bool isPecking = (tgPeckState > class="number">0); tgDrawWalkingTurkey((int)tgWalkingTurkeyX, y, tgTurkeyFrame, tgWalkingTurkeyDir, isPecking); } // ==================== PICNIC TABLE ==================== void tgDrawPicnicTable(int x, int y) { uint16_t woodColor = tft.color565(class="number">140, class="number">90, class="number">50); uint16_t woodLight = tft.color565(class="number">170, class="number">115, class="number">65); uint16_t woodDark = tft.color565(class="number">100, class="number">65, class="number">35); tft.fillRect(x - class="number">70, y, class="number">140, class="number">12, woodColor); tft.fillRect(x - class="number">68, y + class="number">1, class="number">44, class="number">10, woodLight); tft.fillRect(x - class="number">22, y + class="number">1, class="number">44, class="number">10, woodColor); tft.fillRect(x + class="number">24, y + class="number">1, class="number">44, class="number">10, woodLight); tft.drawLine(x - class="number">23, y, x - class="number">23, y + class="number">12, woodDark); tft.drawLine(x + class="number">23, y, x + class="number">23, y + class="number">12, woodDark); tft.fillRect(x - class="number">55, y + class="number">12, class="number">8, class="number">35, woodColor); tft.fillRect(x + class="number">47, y + class="number">12, class="number">8, class="number">35, woodColor); tft.fillRect(x - class="number">50, y + class="number">30, class="number">100, class="number">6, woodDark); tft.fillRect(x - class="number">80, y + class="number">25, class="number">25, class="number">8, woodColor); tft.fillRect(x + class="number">55, y + class="number">25, class="number">25, class="number">8, woodColor); tft.fillRect(x - class="number">75, y + class="number">33, class="number">6, class="number">20, woodDark); tft.fillRect(x - class="number">62, y + class="number">33, class="number">6, class="number">20, woodDark); tft.fillRect(x + class="number">56, y + class="number">33, class="number">6, class="number">20, woodDark); tft.fillRect(x + class="number">69, y + class="number">33, class="number">6, class="number">20, woodDark); } // ==================== COOKED TURKEY ==================== void tgDrawCookedTurkey(int x, int y) { tft.fillEllipse(x, y + class="number">15, class="number">45, class="number">12, tft.color565(class="number">200, class="number">200, class="number">210)); tft.fillEllipse(x, y + class="number">15, class="number">40, class="number">10, tft.color565(class="number">220, class="number">220, class="number">230)); tft.drawEllipse(x, y + class="number">15, class="number">45, class="number">12, tft.color565(class="number">150, class="number">150, class="number">160)); tft.fillEllipse(x, y, class="number">35, class="number">25, tft.color565(class="number">160, class="number">100, class="number">50)); tft.fillEllipse(x, y, class="number">32, class="number">22, tft.color565(class="number">180, class="number">120, class="number">60)); tft.fillEllipse(x, y - class="number">3, class="number">28, class="number">18, tft.color565(class="number">200, class="number">140, class="number">70)); tft.fillEllipse(x - class="number">30, y + class="number">5, class="number">8, class="number">15, tft.color565(class="number">170, class="number">110, class="number">55)); tft.fillEllipse(x + class="number">30, y + class="number">5, class="number">8, class="number">15, tft.color565(class="number">170, class="number">110, class="number">55)); tft.fillEllipse(x - class="number">32, y + class="number">5, class="number">6, class="number">12, tft.color565(class="number">190, class="number">130, class="number">65)); tft.fillEllipse(x + class="number">32, y + class="number">5, class="number">6, class="number">12, tft.color565(class="number">190, class="number">130, class="number">65)); tft.fillRect(x - class="number">34, y + class="number">15, class="number">4, class="number">10, tft.color565(class="number">240, class="number">235, class="number">220)); tft.fillRect(x + class="number">30, y + class="number">15, class="number">4, class="number">10, tft.color565(class="number">240, class="number">235, class="number">220)); tft.fillCircle(x - class="number">32, y + class="number">25, class="number">3, tft.color565(class="number">240, class="number">235, class="number">220)); tft.fillCircle(x + class="number">32, y + class="number">25, class="number">3, tft.color565(class="number">240, class="number">235, class="number">220)); tft.fillCircle(x - class="number">15, y + class="number">10, class="number">4, tft.color565(class="number">50, class="number">120, class="number">50)); tft.fillCircle(x + class="number">12, y + class="number">12, class="number">3, tft.color565(class="number">60, class="number">130, class="number">55)); tft.fillCircle(x + class="number">5, y + class="number">8, class="number">3, tft.color565(class="number">55, class="number">125, class="number">52)); } // ==================== BREAD LOAF ==================== void tgDrawBreadLoaf(int x, int y) { tft.fillRoundRect(x - class="number">20, y, class="number">40, class="number">25, class="number">8, tft.color565(class="number">200, class="number">160, class="number">100)); tft.fillRoundRect(x - class="number">18, y + class="number">2, class="number">36, class="number">20, class="number">6, tft.color565(class="number">220, class="number">180, class="number">120)); tft.fillEllipse(x, y + class="number">5, class="number">18, class="number">8, tft.color565(class="number">180, class="number">140, class="number">80)); tft.fillEllipse(x, y + class="number">6, class="number">15, class="number">6, tft.color565(class="number">200, class="number">160, class="number">95)); tft.drawLine(x - class="number">10, y + class="number">3, x - class="number">5, y + class="number">8, tft.color565(class="number">160, class="number">120, class="number">70)); tft.drawLine(x, y + class="number">2, x, y + class="number">9, tft.color565(class="number">160, class="number">120, class="number">70)); tft.drawLine(x + class="number">5, y + class="number">3, x + class="number">10, y + class="number">8, tft.color565(class="number">160, class="number">120, class="number">70)); } // ==================== GROUND ==================== void tgDrawGround() { tft.fillRect(class="number">0, SCREEN_HEIGHT - class="number">90, SCREEN_WIDTH, class="number">90, tgAutumnGround); for (int x = class="number">0; x < SCREEN_WIDTH; x += class="number">12) { int h = random(class="number">10, class="number">20); tft.drawLine(x, SCREEN_HEIGHT - class="number">90, x - class="number">3, SCREEN_HEIGHT - class="number">90 - h, tft.color565(class="number">140, class="number">120, class="number">60)); tft.drawLine(x, SCREEN_HEIGHT - class="number">90, x + class="number">3, SCREEN_HEIGHT - class="number">90 - h + class="number">3, tft.color565(class="number">130, class="number">110, class="number">50)); } } // ==================== TEXT ==================== void tgDrawText() { tft.setTextColor(tgLeafOrange); tft.setTextSize(class="number">3); tft.setTextDatum(TC_DATUM); tft.drawString(class="number">0, SCREEN_WIDTH / class="number">2, class="number">20); tft.setTextColor(tgLeafRed); tft.drawString(class="number">0, SCREEN_WIDTH / class="number">2, class="number">50); } // ==================== MAIN ANIMATION ==================== void runThanksgivingAnimation() { Serial.println(class="number">0); tgAutumnSky = tft.color565(class="number">135, class="number">170, class="number">200); tgAutumnGround = tft.color565(class="number">80, class="number">120, class="number">50); tgTurkeyBrown = tft.color565(class="number">139, class="number">90, class="number">60); tgTurkeyRed = tft.color565(class="number">200, class="number">60, class="number">60); tgLeafOrange = tft.color565(class="number">255, class="number">140, class="number">0); tgLeafRed = tft.color565(class="number">200, class="number">50, class="number">30); tgLeafYellow = tft.color565(class="number">255, class="number">200, class="number">50); tgLeafBrown = tft.color565(class="number">160, class="number">100, class="number">50); tgCornYellow = tft.color565(class="number">255, class="number">210, class="number">80); // Draw sky gradient for (int y = class="number">0; y < SCREEN_HEIGHT - class="number">90; y++) { float ratio = (float)y / (SCREEN_HEIGHT - class="number">90); int r = class="number">135 + ratio * class="number">40; int g = class="number">170 - ratio * class="number">30; int b = class="number">200 - ratio * class="number">60; tft.drawFastHLine(class="number">0, y, SCREEN_WIDTH, tft.color565(r, g, b)); } tgDrawGround(); tgDrawFallTree(class="number">70, SCREEN_HEIGHT - class="number">170); tgDrawPicnicTable(SCREEN_WIDTH / class="number">2 + class="number">30, SCREEN_HEIGHT - class="number">140); tgDrawCookedTurkey(SCREEN_WIDTH / class="number">2 + class="number">30, SCREEN_HEIGHT - class="number">165); tgDrawBreadLoaf(SCREEN_WIDTH / class="number">2 + class="number">80, SCREEN_HEIGHT - class="number">155); tgDrawText(); tgWalkingTurkeyX = class="number">60; tgWalkingTurkeyDir = class="number">1; tgTurkeyFrame = class="number">0; tgPeckState = class="number">0; unsigned long startTime = millis(); while (millis() - startTime < class="number">20000) { tgUpdateWalkingTurkey(); delay(class="number">80); } Serial.println(class="number">0); tft.fillScreen(TFT_BLACK); } // Calculate if today is Thanksgiving (4th Thursday of November) bool isThanksgivingDay() { struct tm timeinfo; if (!getLocalTime(&timeinfo)) return false; // Must be November (month 10 in 0-indexed) if (timeinfo.tm_mon != class="number">10) return false; // Must be Thursday (tm_wday == 4) if (timeinfo.tm_wday != class="number">4) return false; // 4th Thursday is between day 22-28 if (timeinfo.tm_mday >= class="number">22 && timeinfo.tm_mday <= class="number">28) { return true; } return false; } // Check if it's Thanksgiving show time (Thanksgiving day, every hour on the hour) bool isThanksgivingShowTime() { struct tm timeinfo; if (!getLocalTime(&timeinfo)) return false; if (!isThanksgivingDay()) return false; // Top of the hour, first 10 seconds return (timeinfo.tm_min == class="number">0 && timeinfo.tm_sec < class="number">10); }
// WEB DASHBOARD v7.0
data/index.html<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Delta Weather</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;500;600;700;800&family=IBM+Plex+Mono:wght@400;500&display=swap');
:root {
--bg: #0e1116;
--bg-glow: radial-gradient(1200px 600px at 50% -10%, rgba(232,161,61,0.07), transparent 60%);
--card: #151a22;
--card2: #1a2029;
--border: #242c38;
--ink: #e8ecf1;
--muted: #97a3b4;
--faint: #5f6b7c;
--accent: #e8a13d;
--accent-soft: rgba(232,161,61,0.14);
--cool: #7da7d9;
--cool-soft: rgba(125,167,217,0.12);
--good: #58b88a;
--warn: #d9a441;
--bad: #d95d5d;
--radius: 14px;
--shadow: 0 1px 2px rgba(0,0,0,0.4), 0 10px 30px rgba(0,0,0,0.25);
}
body.light {
--bg: #eef1f5;
--bg-glow: radial-gradient(1200px 600px at 50% -10%, rgba(178,106,0,0.06), transparent 60%);
--card: #ffffff;
--card2: #f4f6f9;
--border: #dbe1e9;
--ink: #1c2430;
--muted: #58657a;
--faint: #8a95a6;
--accent: #b26a00;
--accent-soft: rgba(178,106,0,0.10);
--cool: #3d6ea8;
--cool-soft: rgba(61,110,168,0.10);
--shadow: 0 1px 2px rgba(20,30,45,0.08), 0 8px 24px rgba(20,30,45,0.08);
}
* { margin:0; padding:0; box-sizing:border-box; }
html, body {
min-height:100vh;
background: var(--bg);
color: var(--ink);
font-family: 'Outfit', system-ui, sans-serif;
overflow-x: hidden;
}
body::before {
content:''; position:fixed; inset:0; background: var(--bg-glow);
pointer-events:none; z-index:0;
}
.mono { font-family:'IBM Plex Mono', Consolas, monospace; }
.num { font-variant-numeric: tabular-nums; }
/* ---------- header ---------- */
header {
display:flex; justify-content:space-between; align-items:center;
padding: 14px 22px; position:relative; z-index:2;
}
.wordmark { font-weight:700; font-size:1.05rem; letter-spacing:.24em; text-transform:uppercase; color:var(--muted); }
.wordmark b { color:var(--accent); font-weight:800; }
.header-buttons { display:flex; gap:8px; }
.btn {
background:transparent; border:1px solid var(--border); color:var(--muted);
padding:7px 14px; border-radius:999px; cursor:pointer;
font-family:inherit; font-size:.8rem; font-weight:500; letter-spacing:.06em;
text-decoration:none; transition: color .2s, border-color .2s;
}
.btn:hover { color:var(--accent); border-color:var(--accent); }
/* ---------- alert banner ---------- */
.alerts { margin: 4px 22px 10px; display:none; position:relative; z-index:2; }
.alerts.active { display:block; }
.alert-item {
border:1px solid var(--border); border-left:4px solid var(--warn);
background:var(--card); border-radius:10px; padding:12px 16px; margin-bottom:8px;
box-shadow:var(--shadow);
}
.alert-item.sev-high { border-left-color:var(--bad); }
.alert-event { font-weight:700; font-size:.95rem; letter-spacing:.03em; }
.alert-item.sev-high .alert-event { color:var(--bad); }
.alert-item:not(.sev-high) .alert-event { color:var(--warn); }
.alert-detail { font-size:.82rem; color:var(--muted); margin-top:3px; }
.alert-src { font-size:.7rem; color:var(--faint); margin-top:4px; letter-spacing:.08em; text-transform:uppercase; }
/* ---------- hero ---------- */
.hero {
display:grid; grid-template-columns: 1fr 1fr; gap:16px;
padding: 8px 22px 6px; position:relative; z-index:1; align-items:center;
}
.clock-block { text-align:center; }
.time {
font-size: clamp(3.4rem, 12vw, 6.4rem); font-weight:300; letter-spacing:.02em;
line-height:1; color:var(--ink);
}
.time .ampm { font-size:.35em; color:var(--accent); font-weight:600; letter-spacing:.1em; }
.date { margin-top:8px; color:var(--muted); font-size:clamp(.9rem, 2.5vw, 1.05rem); letter-spacing:.04em; }
.now-block {
display:flex; align-items:center; justify-content:center; gap:20px;
background:var(--card); border:1px solid var(--border); border-radius:var(--radius);
padding:18px 22px; box-shadow:var(--shadow);
}
.now-icon { width:84px; height:84px; flex-shrink:0; }
.now-icon svg { width:100%; height:100%; }
.now-temp { font-size:clamp(2.6rem, 8vw, 4rem); font-weight:200; line-height:1; }
.now-temp .deg { color:var(--accent); font-weight:400; }
.now-cond { text-transform:capitalize; font-weight:600; font-size:1.02rem; margin-bottom:4px; }
.now-sub { font-size:.82rem; color:var(--muted); line-height:1.6; }
.now-sub b { color:var(--ink); font-weight:600; }
/* ---------- card grid ---------- */
.grid {
display:grid; grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
gap:14px; padding: 14px 22px; position:relative; z-index:1;
}
.card {
background:var(--card); border:1px solid var(--border); border-radius:var(--radius);
padding:18px; box-shadow:var(--shadow);
}
.card-title {
font-size:.72rem; font-weight:700; letter-spacing:.22em; text-transform:uppercase;
color:var(--faint); margin-bottom:14px;
}
.card-title .tag { float:right; color:var(--accent); letter-spacing:.08em; }
.row {
display:flex; justify-content:space-between; align-items:baseline;
padding:6px 0; border-bottom:1px dashed var(--border); font-size:.9rem;
}
.row:last-child { border-bottom:none; }
.row .lab { color:var(--muted); }
.row .val { font-weight:600; font-size:1.02rem; }
.row .unit { color:var(--faint); font-size:.78rem; font-weight:400; margin-left:2px; }
/* barometer */
.baro-main { display:flex; align-items:center; gap:14px; margin-bottom:10px; }
.baro-value { font-size:2.4rem; font-weight:300; }
.baro-value .unit { font-size:.9rem; color:var(--faint); }
.baro-trend { display:flex; align-items:center; gap:8px; }
.trend-arrow { font-size:1.5rem; line-height:1; font-weight:700; }
.trend-arrow.rising { color:var(--good); }
.trend-arrow.falling { color:var(--bad); }
.trend-arrow.steady { color:var(--muted); }
.trend-text { font-size:.8rem; color:var(--muted); line-height:1.4; }
.trend-text b { color:var(--ink); }
/* wind */
.wind-wrap { display:flex; align-items:center; gap:18px; }
.compass { width:96px; height:96px; position:relative; flex-shrink:0; }
.compass-ring { width:100%; height:100%; border:2px solid var(--border); border-radius:50%; position:relative; }
.compass-dirs span { position:absolute; transform:translate(-50%,-50%); font-size:.65rem; color:var(--faint); }
.compass-dirs .n { top:9px; left:50%; color:var(--accent); font-weight:700; }
.compass-dirs .s { bottom:-3px; left:50%; }
.compass-dirs .e { right:1px; top:50%; }
.compass-dirs .w { left:9px; top:50%; }
.compass-arrow {
position:absolute; top:50%; left:50%; width:3px; height:36px;
background:linear-gradient(to top, transparent, var(--accent));
transform-origin:center bottom; transform:translate(-50%,-100%) rotate(0deg);
border-radius:2px; transition:transform .5s ease;
}
.compass-center { position:absolute; top:50%; left:50%; width:8px; height:8px; background:var(--accent); border-radius:50%; transform:translate(-50%,-50%); }
.wind-stats { flex:1; }
/* air quality */
.aqi-main { display:flex; align-items:center; gap:12px; margin-bottom:10px; }
.aqi-dot { width:14px; height:14px; border-radius:50%; flex-shrink:0; }
.aqi-label { font-size:1.3rem; font-weight:600; }
.aqi-note { font-size:.75rem; color:var(--faint); margin-top:8px; line-height:1.5; }
/* sun & moon */
.sun-moon { display:flex; justify-content:space-around; text-align:center; }
.sun-moon-icon { width:46px; height:46px; margin:0 auto 4px; }
.sun-moon-icon svg { width:100%; height:100%; }
.sun-moon-time { font-weight:600; font-size:.95rem; }
.sun-moon .lab { font-size:.72rem; color:var(--faint); letter-spacing:.08em; text-transform:uppercase; }
.moon-phase { width:56px; height:56px; margin:12px auto 4px; }
.moon-phase svg { width:100%; height:100%; }
.moon-phase-name { text-align:center; font-size:.78rem; color:var(--muted); }
/* sparkline */
.spark-wrap { position:relative; }
.spark-svg { width:100%; height:96px; display:block; }
.spark-meta { display:flex; justify-content:space-between; font-size:.72rem; color:var(--faint); margin-top:6px; }
.spark-meta b { color:var(--ink); font-weight:600; }
.spark-tip {
position:absolute; pointer-events:none; display:none;
background:var(--card2); border:1px solid var(--border); border-radius:6px;
padding:4px 8px; font-size:.72rem; color:var(--ink); white-space:nowrap;
transform:translate(-50%,-130%); box-shadow:var(--shadow);
}
/* hourly strip */
.card.wide { grid-column: 1 / -1; }
.hourly-strip { display:flex; gap:10px; overflow-x:auto; padding-bottom:4px; }
.hourly-strip::-webkit-scrollbar { height:4px; }
.hourly-strip::-webkit-scrollbar-thumb { background:var(--border); border-radius:2px; }
.hourly-cell {
flex:1; min-width:86px; text-align:center; padding:12px 8px;
background:var(--card2); border:1px solid var(--border); border-radius:10px;
}
.hourly-time { font-size:.75rem; color:var(--accent); font-weight:600; letter-spacing:.06em; }
.hourly-icon { width:34px; height:34px; margin:6px auto; }
.hourly-icon svg { width:100%; height:100%; }
.hourly-temp { font-weight:600; font-size:.95rem; }
.hourly-pop { font-size:.72rem; color:var(--cool); margin-top:4px; }
/* radar */
.radar-container { width:100%; height:230px; overflow:hidden; border-radius:10px; }
.radar-img { width:100%; height:100%; object-fit:cover; }
/* 5-day */
.forecast-grid { display:flex; gap:10px; }
.forecast-day {
flex:1; text-align:center; padding:14px 6px;
background:var(--card2); border:1px solid var(--border); border-radius:10px;
}
.forecast-dayname { font-weight:700; font-size:.85rem; letter-spacing:.06em; }
.forecast-date { font-size:.7rem; color:var(--faint); margin-bottom:8px; }
.forecast-icon { width:44px; height:44px; margin:0 auto 8px; }
.forecast-icon svg { width:100%; height:100%; }
.forecast-temps { font-size:.9rem; }
.forecast-high { color:var(--accent); font-weight:600; }
.forecast-low { color:var(--cool); }
footer { text-align:center; padding:16px; color:var(--faint); font-size:.75rem; letter-spacing:.08em; position:relative; z-index:1; }
@media (max-width: 760px) {
.hero { grid-template-columns:1fr; }
.grid { grid-template-columns:1fr; padding:12px 14px; }
header { padding:12px 14px; }
.alerts { margin:4px 14px 8px; }
.hero { padding:6px 14px; }
.forecast-grid { flex-wrap:wrap; }
.forecast-day { flex:0 0 calc(33.33% - 7px); min-width:88px; }
}
</style>
</head>
<body>
<header>
<div class="wordmark"><b>Δ</b> Delta Weather</div>
<div class="header-buttons">
<a href="sky.html" class="btn">Sky Tracker</a>
<button class="btn" id="themeBtn" onclick="toggleTheme()">Light</button>
</div>
</header>
<div class="alerts" id="alerts"></div>
<section class="hero">
<div class="clock-block">
<div class="time num" id="clock">--:--</div>
<div class="date" id="date">Loading...</div>
</div>
<div class="now-block">
<div class="now-icon" id="weatherIcon"></div>
<div>
<div class="now-cond" id="condition">--</div>
<div class="now-sub">
Feels like <b><span id="feelsLike">--</span>°</b> ·
Dew point <b><span id="dewPoint">--</span>°</b><br>
Humidity <b><span id="outdoorHum">--</span>%</b> ·
Rain 1h <b><span id="rain1h">0</span>"</b>
</div>
</div>
<div class="now-temp num"><span id="mainTemp">--</span><span class="deg">°</span></div>
</div>
</section>
<div class="grid">
<div class="card">
<div class="card-title">Barometer <span class="tag">BMP280</span></div>
<div class="baro-main">
<div class="baro-value num"><span id="baroValue">--.--</span><span class="unit"> inHg</span></div>
<div class="baro-trend">
<div class="trend-arrow steady" id="trendArrow">→</div>
<div class="trend-text" id="trendText">measuring...</div>
</div>
</div>
<div class="row"><span class="lab">Station pressure</span><span class="val num"><span id="pressure">--</span><span class="unit">hPa</span></span></div>
<div class="row"><span class="lab">Indoor temp</span><span class="val num"><span id="indoorTemp">--</span><span class="unit">°F</span></span></div>
<div class="row"><span class="lab">Indoor humidity</span><span class="val num"><span id="indoorHum">--</span><span class="unit">%</span></span></div>
</div>
<div class="card">
<div class="card-title">Wind</div>
<div class="wind-wrap">
<div class="compass">
<div class="compass-ring">
<div class="compass-dirs">
<span class="n">N</span><span class="s">S</span><span class="e">E</span><span class="w">W</span>
</div>
<div class="compass-arrow" id="windArrow"></div>
<div class="compass-center"></div>
</div>
</div>
<div class="wind-stats">
<div class="row"><span class="lab">Speed</span><span class="val num"><span id="windSpeed">--</span><span class="unit">mph</span></span></div>
<div class="row"><span class="lab">Gusts</span><span class="val num"><span id="windGust">--</span><span class="unit">mph</span></span></div>
<div class="row"><span class="lab">From</span><span class="val" id="windDir">--</span></div>
</div>
</div>
</div>
<div class="card">
<div class="card-title">Air Quality <span class="tag" id="aqiTag"></span></div>
<div class="aqi-main">
<div class="aqi-dot" id="aqiDot" style="background:var(--faint)"></div>
<div class="aqi-label" id="aqiLabel">--</div>
</div>
<div class="row"><span class="lab">Fine particles PM2.5</span><span class="val num"><span id="pm25">--</span><span class="unit">µg/m³</span></span></div>
<div class="row"><span class="lab">Ozone O₃</span><span class="val num"><span id="o3">--</span><span class="unit">µg/m³</span></span></div>
<div class="aqi-note">OpenWeatherMap index: 1 Good · 2 Fair · 3 Moderate · 4 Poor · 5 Very Poor</div>
</div>
<div class="card">
<div class="card-title">Sun & Moon</div>
<div class="sun-moon">
<div>
<div class="sun-moon-icon" id="sunriseIcon"></div>
<div class="sun-moon-time num" id="sunrise">--:--</div>
<div class="lab">Sunrise</div>
</div>
<div>
<div class="sun-moon-icon" id="sunsetIcon"></div>
<div class="sun-moon-time num" id="sunset">--:--</div>
<div class="lab">Sunset</div>
</div>
</div>
<div class="moon-phase" id="moonPhase"></div>
<div class="moon-phase-name" id="moonPhaseName">--</div>
</div>
<div class="card">
<div class="card-title">Outdoor Temp <span class="tag">last 8 hrs</span></div>
<div class="spark-wrap">
<svg class="spark-svg" id="sparkSvg" preserveAspectRatio="none"></svg>
<div class="spark-tip" id="sparkTip"></div>
</div>
<div class="spark-meta">
<span>low <b class="num" id="sparkMin">--</b>°</span>
<span>now <b class="num" id="sparkNow">--</b>°</span>
<span>high <b class="num" id="sparkMax">--</b>°</span>
</div>
</div>
<div class="card">
<div class="card-title">Local Radar</div>
<div class="radar-container">
<img src="https://radar.weather.gov/ridge/standard/KHGX_loop.gif" alt="Radar" class="radar-img">
</div>
</div>
<div class="card wide">
<div class="card-title">Hourly</div>
<div class="hourly-strip" id="hourlyForecast">
<div style="color:var(--faint);font-size:.85rem;padding:10px">Loading...</div>
</div>
</div>
<div class="card wide">
<div class="card-title">5-Day Forecast</div>
<div class="forecast-grid" id="forecast"></div>
</div>
</div>
<footer>DELTA WEATHER v7.0 · <span id="uptimeNote"></span></footer>
<script>
// ---------- SVG weather icons (kept from v6) ----------
const weatherIcons = {
Clear: `<svg viewBox="0 0 100 100"><defs><radialGradient id="sunGrad"><stop offset="0%" stop-color="#ffeb3b"/><stop offset="100%" stop-color="#ff9800"/></radialGradient></defs><circle cx="50" cy="50" r="20" fill="url(#sunGrad)"><animate attributeName="r" values="18;22;18" dur="2s" repeatCount="indefinite"/></circle><g stroke="#ffeb3b" stroke-width="3" stroke-linecap="round"><line x1="50" y1="15" x2="50" y2="5"><animate attributeName="y1" values="15;12;15" dur="2s" repeatCount="indefinite"/></line><line x1="50" y1="85" x2="50" y2="95"><animate attributeName="y1" values="85;88;85" dur="2s" repeatCount="indefinite"/></line><line x1="15" y1="50" x2="5" y2="50"/><line x1="85" y1="50" x2="95" y2="50"/><line x1="25" y1="25" x2="18" y2="18"/><line x1="75" y1="25" x2="82" y2="18"/><line x1="25" y1="75" x2="18" y2="82"/><line x1="75" y1="75" x2="82" y2="82"/></g></svg>`,
ClearNight: `<svg viewBox="0 0 100 100"><defs><radialGradient id="nightMoonGrad"><stop offset="0%" stop-color="#ffffd0"/><stop offset="100%" stop-color="#e8e8c8"/></radialGradient></defs><path d="M 62 12 A 38 38 0 1 0 62 88 A 30 30 0 1 1 62 12 Z" fill="url(#nightMoonGrad)"/><g fill="#7da7d9"><circle cx="22" cy="25" r="2"><animate attributeName="opacity" values="1;0.3;1" dur="2.5s" repeatCount="indefinite"/></circle><circle cx="35" cy="70" r="1.5"><animate attributeName="opacity" values="0.4;1;0.4" dur="3s" repeatCount="indefinite"/></circle><circle cx="15" cy="52" r="1.5"><animate attributeName="opacity" values="1;0.4;1" dur="3.5s" repeatCount="indefinite"/></circle></g></svg>`,
Sunrise: `<svg viewBox="0 0 100 100"><defs><linearGradient id="sunriseGrad" x1="0%" y1="100%" x2="0%" y2="0%"><stop offset="0%" stop-color="#ff6b35"/><stop offset="100%" stop-color="#ffeb3b"/></linearGradient></defs><circle cx="50" cy="70" r="22" fill="url(#sunriseGrad)"/><g stroke="#ffeb3b" stroke-width="2" stroke-linecap="round"><line x1="50" y1="38" x2="50" y2="28"/><line x1="25" y1="55" x2="18" y2="48"/><line x1="75" y1="55" x2="82" y2="48"/><line x1="15" y1="70" x2="5" y2="70"/><line x1="85" y1="70" x2="95" y2="70"/></g><line x1="0" y1="70" x2="100" y2="70" stroke="#97a3b4" stroke-width="2"/></svg>`,
Sunset: `<svg viewBox="0 0 100 100"><defs><linearGradient id="sunsetGrad" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stop-color="#ffeb3b"/><stop offset="100%" stop-color="#ff4757"/></linearGradient></defs><circle cx="50" cy="70" r="22" fill="url(#sunsetGrad)"/><g stroke="#ff6b35" stroke-width="2" stroke-linecap="round" opacity="0.8"><line x1="50" y1="38" x2="50" y2="28"/><line x1="25" y1="55" x2="18" y2="48"/><line x1="75" y1="55" x2="82" y2="48"/></g><line x1="0" y1="70" x2="100" y2="70" stroke="#ff6b35" stroke-width="2"/></svg>`,
Clouds: `<svg viewBox="0 0 100 100"><defs><linearGradient id="cloudGrad" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stop-color="#eceff1"/><stop offset="100%" stop-color="#b0bec5"/></linearGradient></defs><g fill="url(#cloudGrad)"><ellipse cx="35" cy="55" rx="20" ry="15"><animate attributeName="cx" values="35;37;35" dur="4s" repeatCount="indefinite"/></ellipse><ellipse cx="55" cy="50" rx="25" ry="18"/><ellipse cx="70" cy="58" rx="18" ry="12"><animate attributeName="cx" values="70;68;70" dur="4s" repeatCount="indefinite"/></ellipse><ellipse cx="50" cy="60" rx="30" ry="15"/></g></svg>`,
Rain: `<svg viewBox="0 0 100 100"><defs><linearGradient id="rainCloud" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stop-color="#78909c"/><stop offset="100%" stop-color="#546e7a"/></linearGradient></defs><g fill="url(#rainCloud)"><ellipse cx="35" cy="35" rx="18" ry="13"/><ellipse cx="55" cy="32" rx="22" ry="15"/><ellipse cx="70" cy="38" rx="15" ry="10"/><ellipse cx="50" cy="42" rx="28" ry="12"/></g><g fill="#4fc3f7"><ellipse cx="30" cy="65" rx="3" ry="5"><animate attributeName="cy" values="55;75;55" dur="1s" repeatCount="indefinite"/><animate attributeName="opacity" values="1;0;1" dur="1s" repeatCount="indefinite"/></ellipse><ellipse cx="45" cy="70" rx="3" ry="5"><animate attributeName="cy" values="60;80;60" dur="1.2s" repeatCount="indefinite"/><animate attributeName="opacity" values="1;0;1" dur="1.2s" repeatCount="indefinite"/></ellipse><ellipse cx="60" cy="68" rx="3" ry="5"><animate attributeName="cy" values="58;78;58" dur="0.9s" repeatCount="indefinite"/><animate attributeName="opacity" values="1;0;1" dur="0.9s" repeatCount="indefinite"/></ellipse><ellipse cx="75" cy="65" rx="3" ry="5"><animate attributeName="cy" values="55;75;55" dur="1.1s" repeatCount="indefinite"/><animate attributeName="opacity" values="1;0;1" dur="1.1s" repeatCount="indefinite"/></ellipse></g></svg>`,
Snow: `<svg viewBox="0 0 100 100"><defs><linearGradient id="snowCloud" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stop-color="#cfd8dc"/><stop offset="100%" stop-color="#90a4ae"/></linearGradient></defs><g fill="url(#snowCloud)"><ellipse cx="35" cy="35" rx="18" ry="13"/><ellipse cx="55" cy="32" rx="22" ry="15"/><ellipse cx="70" cy="38" rx="15" ry="10"/><ellipse cx="50" cy="42" rx="28" ry="12"/></g><g fill="#fff" stroke="#b3e5fc" stroke-width="0.5"><circle cx="30" cy="62" r="4"><animate attributeName="cy" values="55;75;55" dur="3s" repeatCount="indefinite"/></circle><circle cx="50" cy="70" r="3"><animate attributeName="cy" values="60;80;60" dur="2.5s" repeatCount="indefinite"/></circle><circle cx="70" cy="65" r="4"><animate attributeName="cy" values="58;78;58" dur="3.2s" repeatCount="indefinite"/></circle></g></svg>`,
Thunderstorm: `<svg viewBox="0 0 100 100"><defs><linearGradient id="stormCloud" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stop-color="#455a64"/><stop offset="100%" stop-color="#263238"/></linearGradient></defs><g fill="url(#stormCloud)"><ellipse cx="35" cy="30" rx="18" ry="13"/><ellipse cx="55" cy="27" rx="22" ry="15"/><ellipse cx="70" cy="33" rx="15" ry="10"/><ellipse cx="50" cy="37" rx="28" ry="12"/></g><polygon points="55,45 45,65 52,65 42,85 60,60 52,60 62,45" fill="#ffeb3b"><animate attributeName="opacity" values="1;0.3;1" dur="0.5s" repeatCount="indefinite"/></polygon><g fill="#4fc3f7"><ellipse cx="30" cy="70" rx="2" ry="4"><animate attributeName="cy" values="60;80;60" dur="1s" repeatCount="indefinite"/></ellipse><ellipse cx="75" cy="68" rx="2" ry="4"><animate attributeName="cy" values="58;78;58" dur="1.1s" repeatCount="indefinite"/></ellipse></g></svg>`,
Drizzle: `<svg viewBox="0 0 100 100"><defs><linearGradient id="drizzleCloud" x1="0%" y1="0%" x2="0%" y2="100%"><stop offset="0%" stop-color="#90a4ae"/><stop offset="100%" stop-color="#607d8b"/></linearGradient></defs><g fill="url(#drizzleCloud)"><ellipse cx="35" cy="35" rx="18" ry="13"/><ellipse cx="55" cy="32" rx="22" ry="15"/><ellipse cx="70" cy="38" rx="15" ry="10"/><ellipse cx="50" cy="42" rx="28" ry="12"/></g><g fill="#81d4fa"><circle cx="35" cy="60" r="2"><animate attributeName="cy" values="55;70;55" dur="2s" repeatCount="indefinite"/></circle><circle cx="50" cy="65" r="2"><animate attributeName="cy" values="58;73;58" dur="2.2s" repeatCount="indefinite"/></circle><circle cx="65" cy="62" r="2"><animate attributeName="cy" values="55;70;55" dur="1.8s" repeatCount="indefinite"/></circle></g></svg>`,
Mist: `<svg viewBox="0 0 100 100"><g stroke="#b0bec5" stroke-width="4" stroke-linecap="round" fill="none"><line x1="20" y1="35" x2="80" y2="35"><animate attributeName="x1" values="20;25;20" dur="3s" repeatCount="indefinite"/></line><line x1="15" y1="50" x2="85" y2="50"><animate attributeName="x1" values="15;10;15" dur="4s" repeatCount="indefinite"/></line><line x1="25" y1="65" x2="75" y2="65"><animate attributeName="x1" values="25;30;25" dur="3.5s" repeatCount="indefinite"/></line></g></svg>`,
default: `<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="30" fill="none" stroke="currentColor" stroke-width="2"/><text x="50" y="55" text-anchor="middle" font-size="14" fill="currentColor">?</text></svg>`
};
const moonSvgIcons = [
`<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="#1a1a2e" stroke="#333" stroke-width="2"/></svg>`,
`<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="#1a1a2e"/><path d="M50,10 A40,40 0 0,1 50,90 A25,40 0 0,0 50,10" fill="#f5f5dc"/></svg>`,
`<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="#1a1a2e"/><path d="M50,10 A40,40 0 0,1 50,90 L50,10" fill="#f5f5dc"/></svg>`,
`<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="#f5f5dc"/><path d="M50,10 A40,40 0 0,0 50,90 A25,40 0 0,1 50,10" fill="#1a1a2e"/></svg>`,
`<svg viewBox="0 0 100 100"><defs><radialGradient id="moonGlow"><stop offset="0%" stop-color="#ffffd0"/><stop offset="100%" stop-color="#f5f5dc"/></radialGradient></defs><circle cx="50" cy="50" r="40" fill="url(#moonGlow)"/><circle cx="35" cy="40" r="5" fill="#e0e0c0" opacity="0.5"/><circle cx="60" cy="55" r="8" fill="#e0e0c0" opacity="0.4"/><circle cx="45" cy="65" r="4" fill="#e0e0c0" opacity="0.3"/></svg>`,
`<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="#f5f5dc"/><path d="M50,10 A40,40 0 0,1 50,90 A25,40 0 0,0 50,10" fill="#1a1a2e"/></svg>`,
`<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="#1a1a2e"/><path d="M50,10 A40,40 0 0,0 50,90 L50,10" fill="#f5f5dc"/></svg>`,
`<svg viewBox="0 0 100 100"><circle cx="50" cy="50" r="40" fill="#1a1a2e"/><path d="M50,10 A40,40 0 0,0 50,90 A25,40 0 0,1 50,10" fill="#f5f5dc"/></svg>`
];
// ---------- theme ----------
let isDark = true;
function toggleTheme() {
isDark = !isDark;
document.body.classList.toggle('light', !isDark);
document.getElementById('themeBtn').textContent = isDark ? 'Light' : 'Dark';
localStorage.setItem('theme', isDark ? 'dark' : 'light');
}
if (localStorage.getItem('theme') === 'light') toggleTheme();
// ---------- clock ----------
function updateClock() {
const now = new Date();
let h = now.getHours() % 12 || 12;
const m = String(now.getMinutes()).padStart(2, '0');
const ampm = now.getHours() >= 12 ? 'PM' : 'AM';
document.getElementById('clock').innerHTML = `${h}:${m}<span class="ampm"> ${ampm}</span>`;
const days = ['Sunday','Monday','Tuesday','Wednesday','Thursday','Friday','Saturday'];
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
document.getElementById('date').textContent = `${days[now.getDay()]}, ${months[now.getMonth()]} ${now.getDate()}, ${now.getFullYear()}`;
}
setInterval(updateClock, 1000);
updateClock();
// ---------- helpers ----------
function clockToMinutes(s) {
if (!s) return null;
const m = String(s).match(/(\d+)(?::(\d+))?(?::\d+)?\s*(AM|PM)/i);
if (!m) return null;
let h = parseInt(m[1], 10) % 12;
if (/pm/i.test(m[3])) h += 12;
return h * 60 + parseInt(m[2] || '0', 10);
}
let sunTimes = { rise: null, set: null };
function isNightAt(mins) {
if (mins === null) return false;
const rise = (sunTimes.rise !== null) ? sunTimes.rise : 390;
const set = (sunTimes.set !== null) ? sunTimes.set : 1230;
return mins < rise || mins >= set;
}
function isNightNow(d) {
let now = clockToMinutes(d.time);
if (now === null) { const b = new Date(); now = b.getHours() * 60 + b.getMinutes(); }
return isNightAt(now);
}
function getWeatherIcon(condition, night) {
if (!condition) return weatherIcons.default;
const c = condition.toLowerCase();
if (c.includes('thunder') || c.includes('storm')) return weatherIcons.Thunderstorm;
if (c.includes('drizzle')) return weatherIcons.Drizzle;
if (c.includes('rain')) return weatherIcons.Rain;
if (c.includes('snow')) return weatherIcons.Snow;
if (c.includes('mist') || c.includes('fog') || c.includes('haze')) return weatherIcons.Mist;
if (c.includes('clear') || c === 'sunny') return night ? weatherIcons.ClearNight : weatherIcons.Clear;
if (c.includes('partlycloudy') || c.includes('partly')) return weatherIcons.Clouds;
if (c.includes('cloud')) return weatherIcons.Clouds;
return night ? weatherIcons.ClearNight : weatherIcons.Clear;
}
// Magnus formula dew point, degrees F in and out
function dewPointF(tF, rh) {
if (!rh || rh <= 0) return null;
const t = (tF - 32) * 5 / 9;
const a = 17.62, b = 243.12;
const g = Math.log(rh / 100) + a * t / (b + t);
return Math.round((b * g / (a - g)) * 9 / 5 + 32);
}
// ---------- NWS alerts (real watches/warnings; local thresholds as fallback) ----------
let nwsLoc = null, nwsOk = false;
async function updateNWSAlerts() {
try {
if (!nwsLoc) nwsLoc = await fetch('/location').then(r => r.json());
const r = await fetch(`https://api.weather.gov/alerts/active?point=${nwsLoc.lat},${nwsLoc.lng}`,
{ headers: { 'Accept': 'application/geo+json' } });
const j = await r.json();
nwsOk = true;
const feats = (j.features || []).map(f => f.properties)
.sort((a, b) => (a.severity === 'Extreme' ? -1 : a.severity === 'Severe' ? 0 : 1) -
(b.severity === 'Extreme' ? -1 : b.severity === 'Severe' ? 0 : 1));
renderAlerts(feats.map(p => ({
event: p.event,
detail: p.headline || '',
high: (p.severity === 'Extreme' || p.severity === 'Severe'),
src: 'National Weather Service'
})));
} catch (e) {
nwsOk = false; // /data cycle keeps the local fallback alive
}
}
function localFallbackAlerts(d) {
const list = [];
if (d.outdoor_temp >= 100) list.push({ event: 'Extreme Heat', detail: d.outdoor_temp + '°F outside', high: true, src: 'local thresholds' });
if (d.outdoor_temp <= 32) list.push({ event: 'Freeze', detail: d.outdoor_temp + '°F outside', high: true, src: 'local thresholds' });
if (d.windSpeed >= 30) list.push({ event: 'High Wind', detail: d.windSpeed + ' mph sustained', high: false, src: 'local thresholds' });
return list;
}
function renderAlerts(list) {
const div = document.getElementById('alerts');
if (!list.length) { div.classList.remove('active'); div.innerHTML = ''; return; }
div.innerHTML = list.map(a => `
<div class="alert-item ${a.high ? 'sev-high' : ''}">
<div class="alert-event">${a.event}</div>
${a.detail ? `<div class="alert-detail">${a.detail}</div>` : ''}
<div class="alert-src">${a.src}</div>
</div>`).join('');
div.classList.add('active');
}
// ---------- AQI ----------
const AQI_META = {
1: { label: 'Good', color: '#58b88a' },
2: { label: 'Fair', color: '#9fbf6a' },
3: { label: 'Moderate', color: '#d9a441' },
4: { label: 'Poor', color: '#d97941' },
5: { label: 'Very Poor', color: '#d95d5d' }
};
// ---------- main data ----------
function updateData() {
fetch('/data').then(r => r.json()).then(d => {
const set = (id, v) => { document.getElementById(id).textContent = v; };
set('indoorTemp', d.indoor_temp);
set('indoorHum', d.indoor_hum);
set('mainTemp', d.outdoor_temp);
set('outdoorHum', d.outdoor_hum ?? '--');
set('feelsLike', d.feels_like ?? d.outdoor_temp);
set('pressure', d.pressure ?? '--');
set('condition', d.description || d.condition || '--');
set('windSpeed', d.windSpeed ?? '--');
set('windGust', (d.windGust && d.windGust > (d.windSpeed || 0)) ? d.windGust : '--');
set('windDir', d.windDir || '--');
set('sunrise', d.sunrise || '--:--');
set('sunset', d.sunset || '--:--');
set('rain1h', d.rain_1h ? (d.rain_1h / 25.4).toFixed(2) : '0');
const dp = dewPointF(d.outdoor_temp, d.outdoor_hum);
set('dewPoint', dp === null ? '--' : dp);
document.getElementById('uptimeNote').textContent = 'station up ' + (d.uptime || '');
sunTimes.rise = clockToMinutes(d.sunrise);
sunTimes.set = clockToMinutes(d.sunset);
document.getElementById('weatherIcon').innerHTML = getWeatherIcon(d.condition, isNightNow(d));
if (d.windDeg !== undefined)
document.getElementById('windArrow').style.transform = `translate(-50%, -100%) rotate(${d.windDeg}deg)`;
// barometer
if (d.indoor_pressure && d.indoor_pressure > 20) {
set('baroValue', Number(d.indoor_pressure).toFixed(2));
const delta = d.press_delta || 0, span = d.press_span_min || 0;
const arrow = document.getElementById('trendArrow');
const text = document.getElementById('trendText');
if (span < 40) {
arrow.className = 'trend-arrow steady'; arrow.innerHTML = '→';
text.textContent = 'measuring trend...';
} else {
const hrs = (span / 60).toFixed(1);
const fast = Math.abs(delta) >= 0.06;
if (delta >= 0.02) { arrow.className = 'trend-arrow rising'; arrow.innerHTML = fast ? '↑↑' : '↑'; text.innerHTML = `<b>rising${fast ? ' fast' : ''}</b><br>+${delta.toFixed(2)} inHg / ${hrs}h`; }
else if (delta <= -0.02) { arrow.className = 'trend-arrow falling'; arrow.innerHTML = fast ? '↓↓' : '↓'; text.innerHTML = `<b>falling${fast ? ' fast' : ''}</b><br>${delta.toFixed(2)} inHg / ${hrs}h`; }
else { arrow.className = 'trend-arrow steady'; arrow.innerHTML = '→'; text.innerHTML = `<b>steady</b><br>${delta >= 0 ? '+' : ''}${delta.toFixed(2)} inHg / ${hrs}h`; }
}
} else {
set('baroValue', '--.--');
document.getElementById('trendText').textContent = 'sensor offline';
}
// air quality
const aq = AQI_META[d.aqi];
if (aq) {
document.getElementById('aqiDot').style.background = aq.color;
document.getElementById('aqiLabel').textContent = aq.label;
document.getElementById('aqiTag').textContent = 'index ' + d.aqi + ' of 5';
set('pm25', d.pm25 ?? '--');
set('o3', d.o3 ?? '--');
}
// moon
const phases = ["New Moon","Waxing Crescent I","Waxing Crescent II","Waxing Crescent III","Waxing Crescent IV","Waxing Crescent V","Waxing Crescent VI","First Quarter","Waxing Gibbous I","Waxing Gibbous II","Waxing Gibbous III","Waxing Gibbous IV","Waxing Gibbous V","Waxing Gibbous VI","Full Moon","Waning Gibbous I","Waning Gibbous II","Waning Gibbous III","Waning Gibbous IV","Waning Gibbous V","Waning Gibbous VI","Last Quarter","Waning Crescent I","Waning Crescent II","Waning Crescent III","Waning Crescent IV","Waning Crescent V","Waning Crescent VI"];
const idx = phases.indexOf(d.moon);
let moonIdx = 4;
if (idx === 0) moonIdx = 0;
else if (idx >= 1 && idx <= 6) moonIdx = 1;
else if (idx === 7) moonIdx = 2;
else if (idx >= 8 && idx <= 13) moonIdx = 3;
else if (idx === 14) moonIdx = 4;
else if (idx >= 15 && idx <= 20) moonIdx = 5;
else if (idx === 21) moonIdx = 6;
else if (idx >= 22 && idx <= 27) moonIdx = 7;
const names = ["New Moon","Waxing Crescent","First Quarter","Waxing Gibbous","Full Moon","Waning Gibbous","Last Quarter","Waning Crescent"];
document.getElementById('moonPhase').innerHTML = moonSvgIcons[moonIdx] || moonSvgIcons[4];
document.getElementById('moonPhaseName').textContent = names[moonIdx] || d.moon || 'Unknown';
// when NWS is unreachable the homemade thresholds still guard the banner
if (!nwsOk) renderAlerts(localFallbackAlerts(d));
}).catch(() => {});
}
// ---------- temp sparkline (single series - values stay in text ink, line carries the hue) ----------
let sparkData = [];
function updateHistory() {
fetch('/history').then(r => r.json()).then(data => {
if (!data || data.length < 2) return;
sparkData = data;
drawSpark();
}).catch(() => {});
}
function drawSpark() {
const svg = document.getElementById('sparkSvg');
if (!sparkData.length) return;
const W = 300, H = 96, PAD = 6;
const min = Math.min(...sparkData), max = Math.max(...sparkData);
const range = (max - min) || 1;
const x = i => PAD + i * (W - 2 * PAD) / (sparkData.length - 1);
const y = v => H - PAD - (v - min) * (H - 2 * PAD) / range;
const pts = sparkData.map((v, i) => `${x(i).toFixed(1)},${y(v).toFixed(1)}`).join(' ');
const accent = getComputedStyle(document.body).getPropertyValue('--accent').trim();
svg.setAttribute('viewBox', `0 0 ${W} ${H}`);
svg.innerHTML =
`<defs><linearGradient id="sparkFill" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="${accent}" stop-opacity="0.25"/>
<stop offset="100%" stop-color="${accent}" stop-opacity="0"/>
</linearGradient></defs>
<polygon points="${PAD},${H - PAD} ${pts} ${W - PAD},${H - PAD}" fill="url(#sparkFill)"/>
<polyline points="${pts}" fill="none" stroke="${accent}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round"/>
<circle cx="${x(sparkData.length - 1)}" cy="${y(sparkData[sparkData.length - 1])}" r="3.5" fill="${accent}"/>`;
document.getElementById('sparkMin').textContent = min;
document.getElementById('sparkMax').textContent = max;
document.getElementById('sparkNow').textContent = sparkData[sparkData.length - 1];
}
// hover: nearest sample, "how long ago" from the 20-minute cadence
(function wireSparkHover() {
const svg = document.getElementById('sparkSvg');
const tip = document.getElementById('sparkTip');
svg.addEventListener('mousemove', ev => {
if (sparkData.length < 2) return;
const rect = svg.getBoundingClientRect();
const frac = (ev.clientX - rect.left) / rect.width;
const i = Math.max(0, Math.min(sparkData.length - 1, Math.round(frac * (sparkData.length - 1))));
const minsAgo = (sparkData.length - 1 - i) * 20;
const ago = minsAgo === 0 ? 'now' : (minsAgo >= 60 ? `${Math.floor(minsAgo / 60)}h${minsAgo % 60 ? (minsAgo % 60) + 'm' : ''} ago` : `${minsAgo}m ago`);
tip.textContent = `${sparkData[i]}°F · ${ago}`;
tip.style.left = (i / (sparkData.length - 1)) * rect.width + 'px';
tip.style.top = '30px';
tip.style.display = 'block';
});
svg.addEventListener('mouseleave', () => { tip.style.display = 'none'; });
})();
// ---------- forecast + hourly ----------
function updateForecast() {
fetch('/forecast').then(r => r.json()).then(data => {
const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];
const today = new Date();
document.getElementById('forecast').innerHTML = data.map((day, i) => {
const date = new Date(today); date.setDate(today.getDate() + i);
return `<div class="forecast-day">
<div class="forecast-dayname">${day.day}</div>
<div class="forecast-date">${months[date.getMonth()]} ${date.getDate()}</div>
<div class="forecast-icon">${getWeatherIcon(day.condition)}</div>
<div class="forecast-temps num"><span class="forecast-high">${day.highF}°</span> / <span class="forecast-low">${day.lowF}°</span></div>
</div>`;
}).join('');
}).catch(() => {});
}
function updateHourly() {
fetch('/hourly').then(r => r.json()).then(data => {
if (!data.length) return;
document.getElementById('hourlyForecast').innerHTML = data.map(h => `
<div class="hourly-cell">
<div class="hourly-time">${h.time}</div>
<div class="hourly-icon">${getWeatherIcon(h.condition, isNightAt(clockToMinutes(h.time)))}</div>
<div class="hourly-temp num">${h.temp}°</div>
<div class="hourly-pop num">${h.precip}% rain</div>
</div>`).join('');
}).catch(() => {});
}
// ---------- boot ----------
document.getElementById('sunriseIcon').innerHTML = weatherIcons.Sunrise;
document.getElementById('sunsetIcon').innerHTML = weatherIcons.Sunset;
document.getElementById('moonPhase').innerHTML = moonSvgIcons[4];
updateData(); updateForecast(); updateHourly(); updateHistory(); updateNWSAlerts();
setInterval(updateData, 10000);
setInterval(updateForecast, 300000);
setInterval(updateHourly, 300000);
setInterval(updateHistory, 300000);
setInterval(updateNWSAlerts, 300000);
</script>
</body>
</html>
// SKY TRACKER
data/sky.html<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<title>Sky Tracker - Delta Weather</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;500;700;900&family=Share+Tech+Mono&display=swap');
:root {
--bg: #050510;
--bg2: #0a0a1a;
--accent: #da70d6;
--accent2: #9932cc;
--text: #e0e0e0;
--card: rgba(15,15,35,0.95);
--border: rgba(218,112,214,0.3);
--glow: 0 0 20px rgba(218,112,214,0.3);
}
.light {
--bg: #e8eef5;
--bg2: #f5f7fa;
--accent: #8b008b;
--accent2: #800080;
--text: #1a1a2e;
--card: rgba(255,255,255,0.95);
--border: rgba(139,0,139,0.3);
--glow: 0 0 15px rgba(139,0,139,0.2);
}
* { margin:0; padding:0; box-sizing:border-box; }
html, body {
min-height:100vh;
background: var(--bg);
color: var(--text);
font-family: 'Share Tech Mono', monospace;
overflow-x: hidden;
}
/* Starfield background */
.starfield {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
}
.star-bg {
position: absolute;
background: white;
border-radius: 50%;
animation: twinkle 3s infinite;
}
@keyframes twinkle {
0%, 100% { opacity: 0.3; }
50% { opacity: 1; }
}
/* Header */
header {
position: relative;
z-index: 10;
display: flex;
justify-content: space-between;
align-items: center;
padding: 15px 20px;
background: var(--card);
border-bottom: 1px solid var(--border);
box-shadow: var(--glow);
}
.logo {
font-family: 'Orbitron', sans-serif;
font-size: clamp(1rem, 3vw, 1.5rem);
font-weight: 900;
color: var(--accent);
text-shadow: 0 0 10px var(--accent);
}
.header-buttons {
display: flex;
gap: 10px;
align-items: center;
}
.nav-btn {
background: var(--card);
border: 1px solid var(--border);
color: var(--accent);
padding: 8px 15px;
border-radius: 20px;
cursor: pointer;
font-family: inherit;
font-size: 0.85rem;
transition: all 0.3s;
text-decoration: none;
}
.nav-btn:hover { background: var(--accent); color: var(--bg); }
/* Main content */
.content {
position: relative;
z-index: 5;
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
/* Time and location */
.status-bar {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
padding: 15px 20px;
background: var(--card);
border-radius: 12px;
border: 1px solid var(--border);
margin-bottom: 20px;
}
.status-item {
text-align: center;
}
.status-label {
font-size: 0.7rem;
opacity: 0.6;
text-transform: uppercase;
letter-spacing: 1px;
}
.status-value {
font-family: 'Orbitron', sans-serif;
font-size: 1.1rem;
color: var(--accent);
}
/* Sky dome */
.sky-dome-container {
position: relative;
margin-bottom: 20px;
}
.sky-dome {
position: relative;
width: 100%;
max-width: 800px;
height: 400px;
margin: 0 auto;
background: radial-gradient(ellipse at bottom, #1a1a3e 0%, #0a0a15 60%, #000005 100%);
border-radius: 400px 400px 0 0;
overflow: hidden;
border: 2px solid var(--border);
box-shadow: var(--glow), inset 0 0 100px rgba(0,0,0,0.5);
}
.light .sky-dome {
background: radial-gradient(ellipse at bottom, #87ceeb 0%, #4a90c2 60%, #2060a0 100%);
}
.horizon-line {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 4px;
background: linear-gradient(90deg, #1a3a1a, #2d5a2d, #3a7a3a, #2d5a2d, #1a3a1a);
}
.cardinal-markers {
position: absolute;
bottom: 10px;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
padding: 0 20px;
font-family: 'Orbitron', sans-serif;
font-size: 0.9rem;
color: var(--accent);
}
.cardinal-markers span { opacity: 0.8; }
.cardinal-markers .south {
position: absolute;
left: 50%;
transform: translateX(-50%);
bottom: 30px;
}
.zenith-marker {
position: absolute;
top: 15px;
left: 50%;
transform: translateX(-50%);
font-size: 0.7rem;
color: var(--accent);
opacity: 0.5;
}
.altitude-rings {
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100%;
height: 100%;
pointer-events: none;
}
.alt-ring {
position: absolute;
bottom: 0;
left: 50%;
border: 1px dashed rgba(218,112,214,0.15);
border-radius: 50% 50% 0 0;
border-bottom: none;
}
.alt-ring.r30 { width: 66%; height: 33%; transform: translateX(-50%); }
.alt-ring.r60 { width: 33%; height: 66%; transform: translateX(-50%); }
.alt-label {
position: absolute;
right: 10px;
font-size: 0.6rem;
color: var(--accent);
opacity: 0.4;
}
.alt-label.l30 { bottom: 33%; }
.alt-label.l60 { bottom: 66%; }
/* Celestial objects in dome */
.celestial-obj {
position: absolute;
transform: translate(-50%, -50%);
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
z-index: 10;
}
.celestial-obj:hover {
transform: translate(-50%, -50%) scale(1.3);
z-index: 20;
}
.celestial-obj:hover .obj-name {
opacity: 1;
font-size: 0.75rem;
}
.obj-dot {
border-radius: 50%;
margin: 0 auto 2px;
box-shadow: 0 0 10px currentColor, 0 0 20px currentColor;
animation: glow 2s infinite;
}
@keyframes glow {
0%, 100% { box-shadow: 0 0 8px currentColor, 0 0 15px currentColor; }
50% { box-shadow: 0 0 15px currentColor, 0 0 30px currentColor; }
}
.obj-dot.sun { width: 30px; height: 30px; background: #ffd700; color: #ffd700; }
.obj-dot.moon { width: 24px; height: 24px; background: #f5f5dc; color: #f5f5dc; }
.obj-dot.planet { width: 12px; height: 12px; }
.obj-dot.star { width: 6px; height: 6px; background: #fff; color: #fff; }
.obj-dot.bright-star { width: 8px; height: 8px; }
.obj-dot.iss { width: 14px; height: 14px; background: #ff4444; color: #ff4444; animation: iss-blink 1s infinite; }
@keyframes iss-blink { 0%,100%{opacity:1} 50%{opacity:0.5} }
.obj-name {
font-size: 0.65rem;
color: var(--text);
opacity: 0.7;
white-space: nowrap;
text-shadow: 0 0 5px var(--bg);
}
/* Planet colors */
.mercury { background: #b5b5b5; color: #b5b5b5; }
.venus { background: #ffe4b5; color: #ffe4b5; }
.mars { background: #ff6347; color: #ff6347; }
.jupiter { background: #daa520; color: #daa520; }
.saturn { background: #f4d58d; color: #f4d58d; }
.uranus { background: #7fffd4; color: #7fffd4; }
.neptune { background: #4169e1; color: #4169e1; }
/* Info panels */
.panels-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.panel {
background: var(--card);
border-radius: 12px;
padding: 20px;
border: 1px solid var(--border);
box-shadow: var(--glow);
}
.panel-title {
font-family: 'Orbitron', sans-serif;
font-size: 0.9rem;
color: var(--accent);
margin-bottom: 15px;
text-transform: uppercase;
letter-spacing: 2px;
border-bottom: 1px solid var(--border);
padding-bottom: 10px;
}
/* Planet list */
.object-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 400px;
overflow-y: auto;
}
.object-list::-webkit-scrollbar { width: 4px; }
.object-list::-webkit-scrollbar-track { background: var(--bg); border-radius: 2px; }
.object-list::-webkit-scrollbar-thumb { background: var(--accent); border-radius: 2px; }
.object-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px;
background: var(--bg);
border-radius: 8px;
border: 1px solid var(--border);
transition: all 0.3s;
}
.object-item:hover {
border-color: var(--accent);
box-shadow: 0 0 10px rgba(218,112,214,0.2);
}
.object-item.below-horizon {
opacity: 0.5;
}
.obj-icon {
width: 16px;
height: 16px;
border-radius: 50%;
flex-shrink: 0;
box-shadow: 0 0 8px currentColor;
}
.obj-info {
flex: 1;
}
.obj-title {
font-family: 'Orbitron', sans-serif;
font-size: 0.95rem;
color: var(--text);
margin-bottom: 4px;
}
.obj-details {
font-size: 0.75rem;
opacity: 0.7;
display: flex;
gap: 15px;
}
.obj-status {
font-size: 0.7rem;
padding: 3px 10px;
border-radius: 12px;
font-family: 'Orbitron', sans-serif;
}
.obj-status.visible {
background: rgba(0,255,100,0.2);
color: #0f6;
}
.obj-status.rising {
background: rgba(255,200,0,0.2);
color: #fc0;
}
.obj-status.setting {
background: rgba(255,100,0,0.2);
color: #f64;
}
.obj-status.below {
background: rgba(100,100,100,0.2);
color: #888;
}
/* Constellation panel - full width at bottom */
.constellations-panel {
margin: 0 20px 20px;
width: calc(100% - 40px);
max-width: 1360px;
margin-left: auto;
margin-right: auto;
}
.constellation-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: center;
}
.constellation-item {
padding: 12px 20px;
background: var(--bg);
border-radius: 8px;
border: 1px solid var(--border);
text-align: center;
min-width: 140px;
flex: 0 1 auto;
}
.constellation-name {
font-family: 'Orbitron', sans-serif;
font-size: 0.9rem;
color: var(--accent);
margin-bottom: 3px;
}
.constellation-info {
font-size: 0.7rem;
opacity: 0.6;
}
/* Events panel */
.event-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 0;
border-bottom: 1px solid var(--border);
}
.event-item:last-child { border-bottom: none; }
.event-name {
display: flex;
align-items: center;
gap: 8px;
}
.event-icon { font-size: 1.2rem; }
.event-time {
font-family: 'Orbitron', sans-serif;
color: var(--accent);
}
/* Twilight indicator */
.twilight-bar {
height: 30px;
background: linear-gradient(90deg,
#000020 0%,
#1a1a4a 15%,
#4a3060 30%,
#ff6b35 45%,
#ffd700 50%,
#ff6b35 55%,
#4a3060 70%,
#1a1a4a 85%,
#000020 100%
);
border-radius: 15px;
position: relative;
margin-top: 10px;
border: 1px solid var(--border);
}
.twilight-marker {
position: absolute;
top: -5px;
width: 4px;
height: 40px;
background: var(--accent);
border-radius: 2px;
box-shadow: 0 0 10px var(--accent);
}
.twilight-labels {
display: flex;
justify-content: space-between;
font-size: 0.65rem;
opacity: 0.5;
margin-top: 5px;
}
/* Selected object details */
.selected-details {
display: none;
padding: 15px;
background: var(--bg2);
border-radius: 8px;
margin-top: 15px;
border: 1px solid var(--accent);
}
.selected-details.active { display: block; }
.detail-title {
font-family: 'Orbitron', sans-serif;
font-size: 1.2rem;
color: var(--accent);
margin-bottom: 10px;
}
.detail-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 10px;
}
.detail-item {
text-align: center;
padding: 10px;
background: var(--card);
border-radius: 6px;
}
.detail-label {
font-size: 0.7rem;
opacity: 0.6;
margin-bottom: 3px;
}
.detail-value {
font-family: 'Orbitron', sans-serif;
color: var(--accent);
}
/* Footer */
footer {
text-align: center;
padding: 20px;
opacity: 0.5;
font-size: 0.8rem;
position: relative;
z-index: 5;
}
/* Mobile */
@media (max-width: 600px) {
.sky-dome { height: 280px; }
.panels-grid { grid-template-columns: 1fr; }
.status-bar { flex-direction: column; text-align: center; }
.constellations-panel { margin: 0 10px 20px; width: calc(100% - 20px); }
.constellation-item { min-width: 120px; padding: 10px 15px; }
}
</style>
</head>
<body>
<div class="starfield" id="starfield"></div>
<header>
<div class="logo">Sky Tracker</div>
<div class="header-buttons">
<a href="index.html" class="nav-btn">BACK TO WEATHER</a>
<button class="nav-btn" onclick="toggleTheme()">LIGHT MODE</button>
</div>
</header>
<div class="content">
<div class="status-bar">
<div class="status-item">
<div class="status-label">Local Time</div>
<div class="status-value" id="currentTime">--:--:--</div>
</div>
<div class="status-item">
<div class="status-label">Sidereal Time</div>
<div class="status-value" id="siderealTime">--:--:--</div>
</div>
<div class="status-item">
<div class="status-label">Location</div>
<div class="status-value" id="location">Acquiring...</div>
</div>
<div class="status-item">
<div class="status-label">Sky Condition</div>
<div class="status-value" id="skyCondition">--</div>
</div>
</div>
<div class="sky-dome-container">
<div class="sky-dome" id="skyDome">
<div class="altitude-rings">
<div class="alt-ring r30"></div>
<div class="alt-ring r60"></div>
<span class="alt-label l30">30°</span>
<span class="alt-label l60">60°</span>
</div>
<div class="zenith-marker">ZENITH (90°)</div>
<div class="cardinal-markers">
<span>E</span>
<span class="south">S</span>
<span>W</span>
</div>
<div class="horizon-line"></div>
</div>
</div>
<div class="panels-grid">
<div class="panel">
<div class="panel-title">Planets</div>
<div class="object-list" id="planetList">
<div style="opacity:0.5;text-align:center;padding:20px;">Calculating positions...</div>
</div>
</div>
<div class="panel">
<div class="panel-title">Bright Stars</div>
<div class="object-list" id="starList">
<div style="opacity:0.5;text-align:center;padding:20px;">Calculating positions...</div>
</div>
</div>
<div class="panel">
<div class="panel-title">Sun & Moon</div>
<div id="sunMoonInfo">
<div class="event-item">
<div class="event-name"><span class="event-icon">☀️</span> Sunrise</div>
<div class="event-time" id="sunriseTime">--:--</div>
</div>
<div class="event-item">
<div class="event-name"><span class="event-icon">🌅</span> Sunset</div>
<div class="event-time" id="sunsetTime">--:--</div>
</div>
<div class="event-item">
<div class="event-name"><span class="event-icon">🌙</span> Moonrise</div>
<div class="event-time" id="moonriseTime">--:--</div>
</div>
<div class="event-item">
<div class="event-name"><span class="event-icon">🌑</span> Moonset</div>
<div class="event-time" id="moonsetTime">--:--</div>
</div>
<div class="event-item">
<div class="event-name"><span class="event-icon" id="moonIcon">🌕</span> Moon Phase</div>
<div class="event-time" id="moonPhase">--</div>
</div>
<div class="event-item">
<div class="event-name"><span class="event-icon">💡</span> Illumination</div>
<div class="event-time" id="moonIllum">--%</div>
</div>
</div>
<div class="twilight-bar">
<div class="twilight-marker" id="twilightMarker" style="left:50%"></div>
</div>
<div class="twilight-labels">
<span>Midnight</span>
<span>Sunrise</span>
<span>Noon</span>
<span>Sunset</span>
<span>Midnight</span>
</div>
</div>
<div class="panel">
<div class="panel-title">International Space Station</div>
<div id="issInfo">
<div class="event-item">
<div class="event-name"><span class="event-icon">🛰️</span> Status</div>
<div class="event-time" id="issStatus">Tracking...</div>
</div>
<div class="event-item">
<div class="event-name"><span class="event-icon">📍</span> Position</div>
<div class="event-time" id="issPosition">--</div>
</div>
<div class="event-item">
<div class="event-name"><span class="event-icon">📐</span> Altitude</div>
<div class="event-time" id="issAltitude">--</div>
</div>
<div class="event-item">
<div class="event-name"><span class="event-icon">🧭</span> Azimuth</div>
<div class="event-time" id="issAzimuth">--</div>
</div>
<div class="event-item">
<div class="event-name"><span class="event-icon">📏</span> Distance</div>
<div class="event-time" id="issDistance">--</div>
</div>
<div class="event-item">
<div class="event-name"><span class="event-icon">⚡</span> Speed</div>
<div class="event-time" id="issSpeed">--</div>
</div>
</div>
</div>
</div>
<div class="panel constellations-panel">
<div class="panel-title">Visible Constellations</div>
<div class="constellation-grid" id="constellations">
<div style="opacity:0.5;text-align:center;padding:20px;grid-column:1/-1;">Calculating...</div>
</div>
</div>
<div class="selected-details" id="selectedDetails">
<div class="detail-title" id="detailTitle">Object Name</div>
<div class="detail-grid" id="detailGrid"></div>
</div>
</div>
<footer>Sky Tracker v2.0 - Delta Weather</footer>
<script>
// Generate starfield background
function createStarfield() {
const container = document.getElementById('starfield');
for (let i = 0; i < 100; i++) {
const star = document.createElement('div');
star.className = 'star-bg';
star.style.left = Math.random() * 100 + '%';
star.style.top = Math.random() * 100 + '%';
star.style.width = star.style.height = (Math.random() * 2 + 1) + 'px';
star.style.animationDelay = Math.random() * 3 + 's';
container.appendChild(star);
}
}
createStarfield();
// Theme toggle
let isDark = true;
function toggleTheme() {
isDark = !isDark;
document.body.classList.toggle('light', !isDark);
document.querySelector('.nav-btn:last-child').textContent = isDark ? 'LIGHT MODE' : 'DARK MODE';
localStorage.setItem('theme', isDark ? 'dark' : 'light');
}
if (localStorage.getItem('theme') === 'light') toggleTheme();
// Observer location (Conroe, TX)
let observerLat = 30.31;
let observerLng = -95.46;
// Planet orbital data (J2000)
const planets = {
Mercury: { a: 0.387, e: 0.2056, i: 7.00, L: 252.25, lp: 77.46, node: 48.33, period: 87.97 },
Venus: { a: 0.723, e: 0.0068, i: 3.39, L: 181.98, lp: 131.53, node: 76.68, period: 224.7 },
Mars: { a: 1.524, e: 0.0934, i: 1.85, L: 355.43, lp: 336.04, node: 49.56, period: 686.98 },
Jupiter: { a: 5.203, e: 0.0485, i: 1.30, L: 34.33, lp: 14.33, node: 100.46, period: 4332.59 },
Saturn: { a: 9.537, e: 0.0539, i: 2.49, L: 50.08, lp: 93.06, node: 113.64, period: 10759.22 },
Uranus: { a: 19.19, e: 0.0473, i: 0.77, L: 314.20, lp: 173.00, node: 74.00, period: 30688.5 },
Neptune: { a: 30.07, e: 0.0086, i: 1.77, L: 304.22, lp: 48.12, node: 131.79, period: 60182 }
};
// Extended star catalog
const stars = [
{ name: 'Sirius', ra: 6.75, dec: -16.72, mag: -1.46, constellation: 'Canis Major' },
{ name: 'Canopus', ra: 6.40, dec: -52.70, mag: -0.72, constellation: 'Carina' },
{ name: 'Arcturus', ra: 14.26, dec: 19.18, mag: -0.05, constellation: 'Bootes' },
{ name: 'Vega', ra: 18.62, dec: 38.78, mag: 0.03, constellation: 'Lyra' },
{ name: 'Capella', ra: 5.28, dec: 46.00, mag: 0.08, constellation: 'Auriga' },
{ name: 'Rigel', ra: 5.24, dec: -8.20, mag: 0.18, constellation: 'Orion' },
{ name: 'Procyon', ra: 7.66, dec: 5.22, mag: 0.34, constellation: 'Canis Minor' },
{ name: 'Betelgeuse', ra: 5.92, dec: 7.41, mag: 0.45, constellation: 'Orion' },
{ name: 'Altair', ra: 19.85, dec: 8.87, mag: 0.76, constellation: 'Aquila' },
{ name: 'Aldebaran', ra: 4.60, dec: 16.51, mag: 0.87, constellation: 'Taurus' },
{ name: 'Spica', ra: 13.42, dec: -11.16, mag: 0.98, constellation: 'Virgo' },
{ name: 'Antares', ra: 16.49, dec: -26.43, mag: 1.06, constellation: 'Scorpius' },
{ name: 'Pollux', ra: 7.76, dec: 28.03, mag: 1.16, constellation: 'Gemini' },
{ name: 'Fomalhaut', ra: 22.96, dec: -29.62, mag: 1.17, constellation: 'Piscis Austrinus' },
{ name: 'Deneb', ra: 20.69, dec: 45.28, mag: 1.25, constellation: 'Cygnus' },
{ name: 'Regulus', ra: 10.14, dec: 11.97, mag: 1.36, constellation: 'Leo' },
{ name: 'Castor', ra: 7.58, dec: 31.89, mag: 1.58, constellation: 'Gemini' },
{ name: 'Polaris', ra: 2.53, dec: 89.26, mag: 1.98, constellation: 'Ursa Minor' }
];
// Constellations with approximate RA centers
const constellations = [
{ name: 'Orion', ra: 5.5, dec: 0, best: 'Winter' },
{ name: 'Ursa Major', ra: 11, dec: 55, best: 'Spring' },
{ name: 'Scorpius', ra: 16.5, dec: -30, best: 'Summer' },
{ name: 'Cygnus', ra: 20.5, dec: 40, best: 'Summer' },
{ name: 'Leo', ra: 10.5, dec: 15, best: 'Spring' },
{ name: 'Gemini', ra: 7, dec: 25, best: 'Winter' },
{ name: 'Taurus', ra: 4.5, dec: 18, best: 'Winter' },
{ name: 'Virgo', ra: 13, dec: -5, best: 'Spring' },
{ name: 'Aquila', ra: 19.5, dec: 5, best: 'Summer' },
{ name: 'Lyra', ra: 18.8, dec: 35, best: 'Summer' },
{ name: 'Pegasus', ra: 22.5, dec: 20, best: 'Autumn' },
{ name: 'Andromeda', ra: 1, dec: 38, best: 'Autumn' }
];
// Math helpers
const rad = d => d * Math.PI / 180;
const deg = r => r * 180 / Math.PI;
function getJulianDate(date) {
const y = date.getUTCFullYear();
const m = date.getUTCMonth() + 1;
const d = date.getUTCDate() + date.getUTCHours()/24 + date.getUTCMinutes()/1440 + date.getUTCSeconds()/86400;
const a = Math.floor((14 - m) / 12);
const yy = y + 4800 - a;
const mm = m + 12 * a - 3;
return d + Math.floor((153*mm+2)/5) + 365*yy + Math.floor(yy/4) - Math.floor(yy/100) + Math.floor(yy/400) - 32045;
}
function getLocalSiderealTime(jd, lng) {
const T = (jd - 2451545.0) / 36525;
let LST = 280.46061837 + 360.98564736629 * (jd - 2451545.0) + 0.000387933*T*T + lng;
return ((LST % 360) + 360) % 360;
}
function getPlanetPosition(name, jd) {
const p = planets[name];
const n = 0.9856076686 / (p.a * Math.sqrt(p.a));
const M = rad((p.L + n * (jd - 2451545.0)) % 360);
let E = M;
for (let i = 0; i < 10; i++) {
E = M + p.e * Math.sin(E);
}
const v = 2 * Math.atan2(Math.sqrt(1+p.e) * Math.sin(E/2), Math.sqrt(1-p.e) * Math.cos(E/2));
const r = p.a * (1 - p.e * Math.cos(E));
const lp = rad(p.lp);
const node = rad(p.node);
const inc = rad(p.i);
const x = r * (Math.cos(node) * Math.cos(v + lp - node) - Math.sin(node) * Math.sin(v + lp - node) * Math.cos(inc));
const y = r * (Math.sin(node) * Math.cos(v + lp - node) + Math.cos(node) * Math.sin(v + lp - node) * Math.cos(inc));
const z = r * Math.sin(v + lp - node) * Math.sin(inc);
const Me = rad((357.529 + 0.98560028 * (jd - 2451545.0)) % 360);
const re = 1.00014 - 0.01671 * Math.cos(Me);
const Le = rad((280.461 + 0.98564736 * (jd - 2451545.0)) % 360);
const xe = re * Math.cos(Le);
const ye = re * Math.sin(Le);
const xg = x - xe;
const yg = y - ye;
const zg = z;
const dist = Math.sqrt(xg*xg + yg*yg + zg*zg);
const ra = deg(Math.atan2(yg, xg)) / 15;
const dec = deg(Math.asin(zg / dist));
return { ra: ((ra % 24) + 24) % 24, dec, dist };
}
function raDecToAltAz(ra, dec, lst, lat) {
const ha = rad((lst - ra * 15 + 360) % 360);
const decRad = rad(dec);
const latRad = rad(lat);
const sinAlt = Math.sin(decRad) * Math.sin(latRad) + Math.cos(decRad) * Math.cos(latRad) * Math.cos(ha);
const alt = deg(Math.asin(sinAlt));
const cosAz = (Math.sin(decRad) - Math.sin(latRad) * sinAlt) / (Math.cos(latRad) * Math.cos(rad(alt)));
let az = deg(Math.acos(Math.max(-1, Math.min(1, cosAz))));
if (Math.sin(ha) > 0) az = 360 - az;
return { alt, az };
}
function getSunPosition(jd) {
const n = jd - 2451545.0;
const L = (280.46 + 0.9856474 * n) % 360;
const g = rad((357.528 + 0.9856003 * n) % 360);
const lambda = L + 1.915 * Math.sin(g) + 0.02 * Math.sin(2*g);
const eps = rad(23.439 - 0.0000004 * n);
const ra = deg(Math.atan2(Math.cos(eps) * Math.sin(rad(lambda)), Math.cos(rad(lambda)))) / 15;
const dec = deg(Math.asin(Math.sin(eps) * Math.sin(rad(lambda))));
return { ra: ((ra % 24) + 24) % 24, dec };
}
function getMoonPosition(jd) {
const T = (jd - 2451545.0) / 36525;
const L = (218.32 + 481267.883 * T) % 360;
const M = (134.9 + 477198.85 * T) % 360;
const F = (93.3 + 483202.02 * T) % 360;
const lng = L + 6.29 * Math.sin(rad(M));
const lat = 5.13 * Math.sin(rad(F));
const eps = 23.439;
const ra = deg(Math.atan2(Math.sin(rad(lng)) * Math.cos(rad(eps)) - Math.tan(rad(lat)) * Math.sin(rad(eps)), Math.cos(rad(lng)))) / 15;
const dec = deg(Math.asin(Math.sin(rad(lat)) * Math.cos(rad(eps)) + Math.cos(rad(lat)) * Math.sin(rad(eps)) * Math.sin(rad(lng))));
return { ra: ((ra % 24) + 24) % 24, dec };
}
// Calculate moonrise and moonset times for today
function getMoonRiseSet(lat, lng) {
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
let moonrise = null;
let moonset = null;
let prevAlt = null;
// Check every 10 minutes throughout the day
for (let mins = 0; mins <= 1440; mins += 10) {
const checkTime = new Date(today.getTime() + mins * 60000);
const jd = getJulianDate(checkTime);
const lst = getLocalSiderealTime(jd, lng);
const moon = getMoonPosition(jd);
const altAz = raDecToAltAz(moon.ra, moon.dec, lst, lat);
if (prevAlt !== null) {
// Moon rising (was below, now above horizon)
if (prevAlt <= 0 && altAz.alt > 0 && !moonrise) {
// Interpolate for more accurate time
const fraction = -prevAlt / (altAz.alt - prevAlt);
const riseTime = new Date(today.getTime() + (mins - 10 + fraction * 10) * 60000);
moonrise = riseTime;
}
// Moon setting (was above, now below horizon)
if (prevAlt > 0 && altAz.alt <= 0 && !moonset) {
const fraction = prevAlt / (prevAlt - altAz.alt);
const setTime = new Date(today.getTime() + (mins - 10 + fraction * 10) * 60000);
moonset = setTime;
}
}
prevAlt = altAz.alt;
}
return { rise: moonrise, set: moonset };
}
function getMoonPhase(jd) {
const T = (jd - 2451545.0) / 36525;
const D = (297.85 + 445267.1115 * T) % 360;
const phase = (1 - Math.cos(rad(D))) / 2;
const age = ((D % 360) + 360) % 360 / 12.19;
// Synodic month = 29.53 days
// New Moon = day 0, First Quarter = 7.38, Full Moon = 14.77, Last Quarter = 22.15
let phaseName;
if (age < 1.85) phaseName = 'New Moon';
else if (age < 6.38) phaseName = 'Waxing Crescent';
else if (age < 8.38) phaseName = 'First Quarter';
else if (age < 13.77) phaseName = 'Waxing Gibbous';
else if (age < 15.77) phaseName = 'Full Moon';
else if (age < 21.15) phaseName = 'Waning Gibbous';
else if (age < 23.15) phaseName = 'Last Quarter';
else phaseName = 'Waning Crescent';
return { illumination: Math.round(phase * 100), name: phaseName };
}
function addObjectToDome(container, name, altAz, colorClass, type) {
const obj = document.createElement('div');
obj.className = 'celestial-obj';
obj.onclick = () => showObjectDetails(name, altAz, type);
// Convert alt/az to position (E=0%, S=50%, W=100%)
let x = ((altAz.az - 90) / 180) * 100;
if (x < 0) x += 200;
if (x > 100) x -= 200;
x = Math.max(3, Math.min(97, x));
const y = 100 - (altAz.alt / 90) * 95;
obj.style.left = x + '%';
obj.style.top = Math.max(3, y) + '%';
const sizeClass = type === 'sun' ? 'sun' : type === 'moon' ? 'moon' : type === 'star' ? 'star' : 'planet';
obj.innerHTML = `<div class="obj-dot ${sizeClass} ${colorClass}"></div><div class="obj-name">${name}</div>`;
container.appendChild(obj);
}
function showObjectDetails(name, altAz, type) {
const details = document.getElementById('selectedDetails');
const title = document.getElementById('detailTitle');
const grid = document.getElementById('detailGrid');
title.textContent = name;
let html = `
<div class="detail-item">
<div class="detail-label">Altitude</div>
<div class="detail-value">${altAz.alt.toFixed(1)}°</div>
</div>
<div class="detail-item">
<div class="detail-label">Azimuth</div>
<div class="detail-value">${altAz.az.toFixed(1)}°</div>
</div>
<div class="detail-item">
<div class="detail-label">Direction</div>
<div class="detail-value">${getCompassDirection(altAz.az)}</div>
</div>
<div class="detail-item">
<div class="detail-label">Status</div>
<div class="detail-value">${altAz.alt > 0 ? 'Visible' : 'Below Horizon'}</div>
</div>
`;
grid.innerHTML = html;
details.classList.add('active');
}
function getCompassDirection(az) {
const dirs = ['N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE', 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'];
return dirs[Math.round(az / 22.5) % 16];
}
function getObjectStatus(alt, prevAlt) {
if (alt <= 0) return { text: 'Below', class: 'below' };
if (prevAlt !== undefined && alt > prevAlt) return { text: 'Rising', class: 'rising' };
if (prevAlt !== undefined && alt < prevAlt) return { text: 'Setting', class: 'setting' };
return { text: 'Visible', class: 'visible' };
}
let previousAlts = {};
function updateSkyTracker() {
const now = new Date();
const jd = getJulianDate(now);
const lst = getLocalSiderealTime(jd, observerLng);
// Update time displays
document.getElementById('currentTime').textContent = now.toLocaleTimeString();
const lstHours = lst / 15;
const lstH = Math.floor(lstHours);
const lstM = Math.floor((lstHours - lstH) * 60);
const lstS = Math.floor(((lstHours - lstH) * 60 - lstM) * 60);
document.getElementById('siderealTime').textContent =
`${String(lstH).padStart(2,'0')}:${String(lstM).padStart(2,'0')}:${String(lstS).padStart(2,'0')}`;
// Sun position for day/night
const sun = getSunPosition(jd);
const sunAltAz = raDecToAltAz(sun.ra, sun.dec, lst, observerLat);
let skyCondition;
if (sunAltAz.alt > 0) skyCondition = 'Daylight';
else if (sunAltAz.alt > -6) skyCondition = 'Civil Twilight';
else if (sunAltAz.alt > -12) skyCondition = 'Nautical Twilight';
else if (sunAltAz.alt > -18) skyCondition = 'Astronomical Twilight';
else skyCondition = 'Night';
document.getElementById('skyCondition').textContent = skyCondition;
// Twilight marker position
const dayPercent = (now.getHours() * 60 + now.getMinutes()) / 1440 * 100;
document.getElementById('twilightMarker').style.left = dayPercent + '%';
// Clear dome
const dome = document.getElementById('skyDome');
dome.querySelectorAll('.celestial-obj').forEach(el => el.remove());
// Add Sun if above horizon
if (sunAltAz.alt > -5) {
addObjectToDome(dome, 'Sun', sunAltAz, '', 'sun');
}
// Moon
const moon = getMoonPosition(jd);
const moonAltAz = raDecToAltAz(moon.ra, moon.dec, lst, observerLat);
if (moonAltAz.alt > 0) {
addObjectToDome(dome, 'Moon', moonAltAz, '', 'moon');
}
// Moon phase info
const phase = getMoonPhase(jd);
document.getElementById('moonPhase').textContent = phase.name;
document.getElementById('moonIllum').textContent = phase.illumination + '%';
// Update moon icon to match phase
const moonIcons = {
'New Moon': '🌑',
'Waxing Crescent': '🌒',
'First Quarter': '🌓',
'Waxing Gibbous': '🌔',
'Full Moon': '🌕',
'Waning Gibbous': '🌖',
'Last Quarter': '🌗',
'Waning Crescent': '🌘'
};
document.getElementById('moonIcon').textContent = moonIcons[phase.name] || '🌙';
// Calculate and display moonrise/moonset
const moonTimes = getMoonRiseSet(observerLat, observerLng);
if (moonTimes.rise) {
const rh = moonTimes.rise.getHours();
const rm = moonTimes.rise.getMinutes();
const rh12 = rh % 12 || 12;
const rampm = rh >= 12 ? 'PM' : 'AM';
document.getElementById('moonriseTime').textContent = `${rh12}:${String(rm).padStart(2,'0')} ${rampm}`;
} else {
document.getElementById('moonriseTime').textContent = '--:--';
}
if (moonTimes.set) {
const sh = moonTimes.set.getHours();
const sm = moonTimes.set.getMinutes();
const sh12 = sh % 12 || 12;
const sampm = sh >= 12 ? 'PM' : 'AM';
document.getElementById('moonsetTime').textContent = `${sh12}:${String(sm).padStart(2,'0')} ${sampm}`;
} else {
document.getElementById('moonsetTime').textContent = '--:--';
}
// Planets
let planetHtml = '';
Object.keys(planets).forEach(name => {
const pos = getPlanetPosition(name, jd);
const altAz = raDecToAltAz(pos.ra, pos.dec, lst, observerLat);
const status = getObjectStatus(altAz.alt, previousAlts[name]);
previousAlts[name] = altAz.alt;
const colorClass = name.toLowerCase();
if (altAz.alt > 0) {
addObjectToDome(dome, name, altAz, colorClass, 'planet');
}
planetHtml += `
<div class="object-item ${altAz.alt <= 0 ? 'below-horizon' : ''}">
<div class="obj-icon ${colorClass}"></div>
<div class="obj-info">
<div class="obj-title">${name}</div>
<div class="obj-details">
<span>Alt: ${altAz.alt.toFixed(1)}°</span>
<span>Az: ${altAz.az.toFixed(1)}°</span>
</div>
</div>
<span class="obj-status ${status.class}">${status.text}</span>
</div>
`;
});
document.getElementById('planetList').innerHTML = planetHtml;
// Stars (only at night)
let starHtml = '';
const showStars = sunAltAz.alt < -6;
stars.forEach(star => {
const altAz = raDecToAltAz(star.ra, star.dec, lst, observerLat);
const status = getObjectStatus(altAz.alt, previousAlts[star.name]);
previousAlts[star.name] = altAz.alt;
if (showStars && altAz.alt > 5) {
addObjectToDome(dome, star.name, altAz, '', 'star');
}
if (altAz.alt > -10) {
starHtml += `
<div class="object-item ${altAz.alt <= 0 ? 'below-horizon' : ''}">
<div class="obj-icon" style="background:#fff;color:#fff;box-shadow:0 0 8px #fff;"></div>
<div class="obj-info">
<div class="obj-title">${star.name}</div>
<div class="obj-details">
<span>${star.constellation}</span>
<span>Mag: ${star.mag}</span>
</div>
</div>
<span class="obj-status ${status.class}">${status.text}</span>
</div>
`;
}
});
document.getElementById('starList').innerHTML = starHtml || '<div style="opacity:0.5;text-align:center;padding:20px;">Stars visible after dark</div>';
// Constellations
let constHtml = '';
constellations.forEach(c => {
const altAz = raDecToAltAz(c.ra, c.dec, lst, observerLat);
if (altAz.alt > 10) {
constHtml += `
<div class="constellation-item">
<div class="constellation-name">${c.name}</div>
<div class="constellation-info">${altAz.alt.toFixed(0)}° alt • ${c.best}</div>
</div>
`;
}
});
document.getElementById('constellations').innerHTML = constHtml || '<div style="opacity:0.5;text-align:center;padding:20px;grid-column:1/-1;">No major constellations above horizon</div>';
}
// Get location from ESP32 or use default
function initLocation() {
// Try to get location from server first
fetch('/location').then(r => r.json()).then(data => {
if (data.lat && data.lng) {
observerLat = data.lat;
observerLng = data.lng;
document.getElementById('location').textContent = data.name || `${observerLat.toFixed(2)}°, ${observerLng.toFixed(2)}°`;
} else {
document.getElementById('location').textContent = 'Conroe, TX';
}
updateSkyTracker();
}).catch(() => {
// Server endpoint not available, use defaults
document.getElementById('location').textContent = 'Conroe, TX';
updateSkyTracker();
});
}
// Fetch sunrise/sunset from main weather data
fetch('/data').then(r => r.json()).then(d => {
document.getElementById('sunriseTime').textContent = d.sunrise || '--:--';
document.getElementById('sunsetTime').textContent = d.sunset || '--:--';
}).catch(() => {});
// ISS Tracking
let issData = null;
function calculateISSAltAz(issLat, issLng, issAltKm) {
// Convert ISS geocentric position to observer's topocentric alt/az
const R_EARTH = 6371; // km
// Convert to radians
const obsLatRad = rad(observerLat);
const obsLngRad = rad(observerLng);
const issLatRad = rad(issLat);
const issLngRad = rad(issLng);
// Observer position (on Earth's surface)
const obsX = R_EARTH * Math.cos(obsLatRad) * Math.cos(obsLngRad);
const obsY = R_EARTH * Math.cos(obsLatRad) * Math.sin(obsLngRad);
const obsZ = R_EARTH * Math.sin(obsLatRad);
// ISS position
const issR = R_EARTH + issAltKm;
const issX = issR * Math.cos(issLatRad) * Math.cos(issLngRad);
const issY = issR * Math.cos(issLatRad) * Math.sin(issLngRad);
const issZ = issR * Math.sin(issLatRad);
// Vector from observer to ISS
const dx = issX - obsX;
const dy = issY - obsY;
const dz = issZ - obsZ;
const distance = Math.sqrt(dx*dx + dy*dy + dz*dz);
// Transform to local horizon coordinates (East-North-Up)
const sinLat = Math.sin(obsLatRad);
const cosLat = Math.cos(obsLatRad);
const sinLng = Math.sin(obsLngRad);
const cosLng = Math.cos(obsLngRad);
// Rotation matrix to ENU
const east = -sinLng * dx + cosLng * dy;
const north = -sinLat * cosLng * dx - sinLat * sinLng * dy + cosLat * dz;
const up = cosLat * cosLng * dx + cosLat * sinLng * dy + sinLat * dz;
// Calculate altitude and azimuth
const alt = deg(Math.asin(up / distance));
let az = deg(Math.atan2(east, north));
if (az < 0) az += 360;
return { alt, az, distance };
}
function updateISS() {
// Use the free "Where the ISS at?" API
fetch('https://api.wheretheiss.at/v1/satellites/25544')
.then(r => r.json())
.then(data => {
issData = data;
const altAz = calculateISSAltAz(data.latitude, data.longitude, data.altitude);
// Update info panel
document.getElementById('issPosition').textContent =
`${Math.abs(data.latitude).toFixed(1)}°${data.latitude >= 0 ? 'N' : 'S'}, ${Math.abs(data.longitude).toFixed(1)}°${data.longitude >= 0 ? 'E' : 'W'}`;
if (altAz.alt > 0) {
document.getElementById('issStatus').innerHTML = '<span style="color:#0f6">VISIBLE</span>';
document.getElementById('issAltitude').textContent = altAz.alt.toFixed(1) + '°';
document.getElementById('issAzimuth').textContent = altAz.az.toFixed(1) + '° ' + getCompassDirection(altAz.az);
} else {
document.getElementById('issStatus').innerHTML = '<span style="color:#888">Below Horizon (' + altAz.alt.toFixed(1) + '°)</span>';
document.getElementById('issAltitude').textContent = altAz.alt.toFixed(1) + '°';
document.getElementById('issAzimuth').textContent = altAz.az.toFixed(1) + '° ' + getCompassDirection(altAz.az);
}
document.getElementById('issDistance').textContent = Math.round(altAz.distance) + ' km';
document.getElementById('issSpeed').textContent = Math.round(data.velocity) + ' km/h';
// Add ISS to dome if visible
const dome = document.getElementById('skyDome');
const existingISS = dome.querySelector('.iss-marker');
if (existingISS) existingISS.remove();
if (altAz.alt > 0) {
addObjectToDome(dome, 'ISS', altAz, '', 'iss');
// Mark the new element
const issEl = dome.querySelector('.celestial-obj:last-child');
if (issEl) issEl.classList.add('iss-marker');
}
})
.catch(err => {
document.getElementById('issStatus').textContent = 'Offline';
});
}
// Initialize
initLocation();
setInterval(updateSkyTracker, 1000);
// Update ISS every 5 seconds (it moves fast!)
updateISS();
setInterval(updateISS, 5000);
</script>
</body>
</html>