Free Weather Data with National Weather Service API

Learn to build weather applications using the free NWS API with Scittle and ClojureScript - no API key required!
Author
Published

November 7, 2025

Keywords

weather, nws, api, free, scittle, browser-native

Free Weather Data with National Weather Service API

About This Project

This is part of my ongoing exploration of browser-native development with Scittle. In my previous articles on Building Browser-Native Presentations and Python + ClojureScript Integration, I’ve been sharing how to create interactive, educational content without build tools.

Today, I want to show you something practical: building weather applications using the National Weather Service API. What makes this special? The NWS API is:

  • Completely free - No API keys, no registration, no rate limits
  • Official and accurate - Data directly from the U.S. government
  • Comprehensive - Current conditions, forecasts, alerts, and more
  • Well-documented - Clear endpoints and data structures

But here’s the real magic: we’ll build everything with Scittle - meaning zero build tools, zero configuration, just ClojureScript running directly in your browser. And as a bonus, all our functions will use keyword arguments for clarity and ease of use.

Whether you’re learning ClojureScript, exploring APIs, or building weather apps, this guide will show you how to do it the simple way. Let’s dive in!

Why the National Weather Service API?

Before we dive into code, let’s understand why the NWS API is such a great choice:

Free and Accessible

Most weather APIs require: - Signing up for an account - Managing API keys - Dealing with rate limits - Paying for more requests

The NWS API requires none of that. Just make HTTP requests and get data. Perfect for learning, prototyping, or building personal projects.

Official Government Data

The data comes directly from NOAA (National Oceanic and Atmospheric Administration), the same source that powers weather forecasts across the United States. You’re getting:

  • Real-time observations from weather stations
  • Professional meteorological forecasts
  • Severe weather alerts and warnings
  • Historical weather data

Rich Feature Set

The API provides:

  • Points API - Convert coordinates to forecast zones
  • Forecast API - 7-day forecasts with detailed periods
  • Hourly Forecasts - Hour-by-hour predictions
  • Current Observations - Real-time weather station data
  • Weather Alerts - Watches, warnings, and advisories
  • Grid Data - Raw forecast model output

Educational Value

The API’s design teaches important concepts:

  • RESTful API architecture
  • Coordinate-based services
  • Asynchronous data fetching
  • Error handling in real-world scenarios

Understanding the API Architecture

The NWS API uses a two-step process to get weather data:

graph LR A[Latitude/Longitude] --> B[Points API] B --> C[Forecast URLs] C --> D[Forecast Data] C --> E[Hourly Data] C --> F[Grid Data] C --> G[Stations] G --> H[Current Observations]

Step 1: Get the Points

First, you query the /points/{lat},{lon} endpoint with your coordinates. This returns URLs for various forecast products specific to that location.

Step 2: Fetch the Data

Use the returned URLs to fetch the specific weather data you need: forecasts, hourly predictions, current conditions, etc.

This design is smart because: - Forecasts are generated for grid points, not exact coordinates - It allows the API to scale efficiently - You get all relevant endpoints in one initial request

Why Keyword Arguments?

Throughout this article, you’ll notice all our functions use keyword arguments instead of positional arguments. Here’s why:

(kind/code
 ";; Traditional positional arguments
(fetch-points 40.7128 -74.0060
  (fn [result]
    (if (:success result)
      (handle-success (:data result))
      (handle-error (:error result)))))

;; Keyword arguments style
(fetch-points
  {:lat 40.7128
   :lon -74.0060
   :on-success handle-success
   :on-error handle-error})")
;; Traditional positional arguments
(fetch-points 40.7128 -74.0060
  (fn [result]
    (if (:success result)
      (handle-success (:data result))
      (handle-error (:error result)))))

;; Keyword arguments style
(fetch-points
  {:lat 40.7128
   :lon -74.0060
   :on-success handle-success
   :on-error handle-error})

Benefits of keyword arguments:

  1. Self-documenting - Clear what each value represents
  2. Flexible - Order doesn’t matter
  3. Optional parameters - Easy to add defaults
  4. Extensible - Add new options without breaking existing code
  5. Beginner-friendly - Easier to understand and use

This is especially valuable when teaching or sharing code examples.

Setting Up: What We Need

For this tutorial, you need absolutely nothing installed locally! We’ll use:

  • Scittle - ClojureScript interpreter (from CDN)
  • Reagent - React wrapper for ClojureScript (from CDN)
  • NWS API - Free weather data (no key needed)
  • Your browser - That’s it!

All demos will be embedded right in this article. You can: - Run them immediately - View the source code - Copy and adapt for your own projects

Coming Up

In the following sections, we’ll build progressively complex examples:

  1. Simple Weather Lookup - Basic API call and display
  2. Current Conditions - Detailed weather information
  3. 7-Day Forecast - Visual forecast cards
  4. Hourly Forecast - Time-based predictions
  5. Weather Alerts - Severe weather warnings
  6. Complete Dashboard - Full-featured weather app

Each example will be fully functional and ready to use. Let’s get started!


Ready to build? Let’s start with the core API layer in the next section.

The API Layer

Before we build demos, let’s look at our API functions. We’ve created a complete API layer that uses keyword arguments for all functions.

(ns scittle.weather.weather-api
  "National Weather Service API integration with keyword arguments.
   
   All functions use keyword argument maps for clarity and flexibility.
   No API key required - completely free!
   
   Main API Reference: https://www.weather.gov/documentation/services-web-api"
  (:require [clojure.string :as str]))

;; ============================================================================
;; Constants
;; ============================================================================

(def weather-api-base-url
  "Base URL for NWS API"
  "https://api.weather.gov")

;; ============================================================================
;; Core Helper Functions
;; ============================================================================

(defn fetch-json
  "Fetch JSON data from a URL with error handling.
  
  Uses browser's native fetch API - no external dependencies.
  
  Args (keyword map):
    :url        - URL to fetch (string, required)
    :on-success - Success callback receiving parsed data (fn, required)
    :on-error   - Error callback receiving error message (fn, optional)
    
  The success callback receives the parsed JSON as a Clojure map.
  The error callback receives an error message string.
  
  Example:
    (fetch-json
      {:url \"https://api.weather.gov/points/40,-74\"
       :on-success #(js/console.log \"Data:\" %)
       :on-error #(js/console.error \"Failed:\" %)})"
  [{:keys [url on-success on-error]
    :or {on-error #(js/console.error "Fetch error:" %)}}]
  (-> (js/fetch url)
      (.then (fn [response]
               (if (.-ok response)
                 (.then (.json response)
                        (fn [data]
                          (on-success (js->clj data :keywordize-keys true))))
                 (on-error (str "HTTP Error: " (.-status response))))))
      (.catch (fn [error]
                (on-error (.-message error))))))

;; ============================================================================
;; Points API - Get forecast endpoints for coordinates
;; ============================================================================

(defn fetch-points
  "Get NWS grid points and forecast URLs for given coordinates.
  
  This is typically the first API call - it returns URLs for all weather
  products available at the given location.
  
  Args (keyword map):
    :lat        - Latitude (number, required, range: -90 to 90)
    :lon        - Longitude (number, required, range: -180 to 180)
    :on-success - Success callback receiving location data (fn, required)
    :on-error   - Error callback receiving error message (fn, optional)
  
  Success callback receives a map with:
    :forecast             - URL for 7-day forecast
    :forecastHourly       - URL for hourly forecast
    :forecastGridData     - URL for raw grid data
    :observationStations  - URL for nearby weather stations
    :forecastOffice       - NWS office identifier
    :gridId               - Grid identifier
    :gridX, :gridY        - Grid coordinates
    :city, :state         - Location name
  
  Example:
    (fetch-points
      {:lat 40.7128
       :lon -74.0060
       :on-success (fn [data]
                     (js/console.log \"City:\" (:city data))
                     (fetch-forecast {:url (:forecast data) ...}))
       :on-error (fn [error]
                   (js/console.error \"Error:\" error))})"
  [{:keys [lat lon on-success on-error]
    :or {on-error #(js/console.error "Points API error:" %)}}]
  (let [url (str weather-api-base-url "/points/" lat "," lon)]
    (fetch-json
     {:url url
      :on-success (fn [result]
                    (let [properties (get-in result [:properties])]
                      (on-success
                       {:forecast (:forecast properties)
                        :forecastHourly (:forecastHourly properties)
                        :forecastGridData (:forecastGridData properties)
                        :observationStations (:observationStations properties)
                        :forecastOffice (:forecastOffice properties)
                        :gridId (:gridId properties)
                        :gridX (:gridX properties)
                        :gridY (:gridY properties)
                        :city (:city (get-in result [:properties :relativeLocation :properties]))
                        :state (:state (get-in result [:properties :relativeLocation :properties]))})))
      :on-error on-error})))

;; ============================================================================
;; Forecast API - Get 7-day forecast
;; ============================================================================

(defn fetch-forecast
  "Fetch 7-day forecast from a forecast URL.
  
  Returns periods (typically 14 periods: day/night for 7 days).
  
  Args (keyword map):
    :url        - Forecast URL from points API (string, required)
    :on-success - Success callback receiving forecast periods (fn, required)
    :on-error   - Error callback receiving error message (fn, optional)
  
  Success callback receives a vector of period maps, each containing:
    :number              - Period number
    :name                - Period name (e.g., \"Tonight\", \"Friday\")
    :temperature         - Temperature value
    :temperatureUnit     - Unit (F or C)
    :windSpeed           - Wind speed string
    :windDirection       - Wind direction
    :icon                - Weather icon URL
    :shortForecast       - Brief description
    :detailedForecast    - Detailed description
  
  Example:
    (fetch-forecast
      {:url forecast-url
       :on-success (fn [periods]
                     (doseq [period (take 3 periods)]
                       (js/console.log (:name period) \"-\" (:shortForecast period))))
       :on-error (fn [error]
                   (js/console.error \"Forecast error:\" error))})"
  [{:keys [url on-success on-error]
    :or {on-error #(js/console.error "Forecast API error:" %)}}]
  (fetch-json
   {:url url
    :on-success (fn [result]
                  (on-success (get-in result [:properties :periods])))
    :on-error on-error}))

;; ============================================================================
;; Hourly Forecast API
;; ============================================================================

(defn fetch-hourly-forecast
  "Fetch hourly forecast from a forecast URL.
  
  Args (keyword map):
    :url        - Hourly forecast URL from points API (string, required)
    :on-success - Success callback receiving hourly periods (fn, required)
    :on-error   - Error callback receiving error message (fn, optional)
  
  Success callback receives a vector of hourly period maps.
  Each period has the same structure as the regular forecast.
  
  Example:
    (fetch-hourly-forecast
      {:url hourly-url
       :on-success (fn [periods]
                     (js/console.log \"Next 12 hours:\")
                     (doseq [period (take 12 periods)]
                       (js/console.log (:startTime period) \"-\" (:temperature period) \"°F\")))
       :on-error (fn [error]
                   (js/console.error \"Hourly forecast error:\" error))})"
  [{:keys [url on-success on-error]
    :or {on-error #(js/console.error "Hourly forecast API error:" %)}}]
  (fetch-json
   {:url url
    :on-success (fn [result]
                  (on-success (get-in result [:properties :periods])))
    :on-error on-error}))

;; ============================================================================
;; Observation Stations API
;; ============================================================================

(defn fetch-observation-stations
  "Get list of observation stations near a location.
  
  Args (keyword map):
    :url        - Observation stations URL from points API (string, required)
    :on-success - Success callback receiving station list (fn, required)
    :on-error   - Error callback receiving error message (fn, optional)
  
  Success callback receives a vector of station maps, each containing:
    :stationIdentifier - Station ID (e.g., \"KJFK\")
    :name              - Station name
    :elevation         - Elevation data
  
  Example:
    (fetch-observation-stations
      {:url stations-url
       :on-success (fn [stations]
                     (let [first-station (first stations)]
                       (js/console.log \"Nearest station:\" (:name first-station))
                       (fetch-current-observations
                         {:station-id (:stationIdentifier first-station)
                          :on-success ...})))
       :on-error (fn [error]
                   (js/console.error \"Stations error:\" error))})"
  [{:keys [url on-success on-error]
    :or {on-error #(js/console.error "Observation stations API error:" %)}}]
  (fetch-json
   {:url url
    :on-success (fn [result]
                  (on-success
                   (map #(get-in % [:properties])
                        (get-in result [:features]))))
    :on-error on-error}))

;; ============================================================================
;; Current Observations API
;; ============================================================================

(defn fetch-current-observations
  "Get current weather observations from a station.
  
  Args (keyword map):
    :station-id - Station identifier (string, required, e.g., \"KJFK\")
    :on-success - Success callback receiving observation data (fn, required)
    :on-error   - Error callback receiving error message (fn, optional)
  
  Success callback receives a map with current conditions:
    :temperature          - Current temperature with :value and :unitCode
    :dewpoint            - Dewpoint temperature
    :windDirection       - Wind direction in degrees
    :windSpeed           - Wind speed
    :barometricPressure  - Pressure
    :relativeHumidity    - Humidity percentage
    :visibility          - Visibility distance
    :textDescription     - Weather description
    :timestamp           - Observation time
  
  Example:
    (fetch-current-observations
      {:station-id \"KJFK\"
       :on-success (fn [obs]
                     (js/console.log \"Temperature:\"
                                     (get-in obs [:temperature :value]) \"°C\")
                     (js/console.log \"Conditions:\" (:textDescription obs)))
       :on-error (fn [error]
                   (js/console.error \"Observations error:\" error))})"
  [{:keys [station-id on-success on-error]
    :or {on-error #(js/console.error "Current observations API error:" %)}}]
  (let [url (str weather-api-base-url "/stations/" station-id "/observations/latest")]
    (fetch-json
     {:url url
      :on-success (fn [result]
                    (on-success (get-in result [:properties])))
      :on-error on-error})))

;; ============================================================================
;; Alerts API - Weather alerts
;; ============================================================================

(defn fetch-alerts-for-point
  "Fetch active weather alerts for a specific location.
  
  Args (keyword map):
    :lat        - Latitude (number, required)
    :lon        - Longitude (number, required)
    :on-success - Success callback receiving alerts list (fn, required)
    :on-error   - Error callback receiving error message (fn, optional)
  
  Success callback receives a vector of alert maps, each containing:
    :event       - Alert type (e.g., \"Tornado Warning\")
    :headline    - Brief headline
    :description - Full alert description
    :severity    - Severity level (Extreme/Severe/Moderate/Minor)
    :urgency     - Urgency level
    :certainty   - Certainty level
    :onset       - Start time
    :ends        - End time
  
  Example:
    (fetch-alerts-for-point
      {:lat 40.7128
       :lon -74.0060
       :on-success (fn [alerts]
                     (if (empty? alerts)
                       (js/console.log \"No active alerts\")
                       (doseq [alert alerts]
                         (js/console.log (:severity alert) \"-\" (:event alert)))))
       :on-error (fn [error]
                   (js/console.error \"Alerts error:\" error))})"
  [{:keys [lat lon on-success on-error]
    :or {on-error #(js/console.error "Alerts API error:" %)}}]
  (let [url (str weather-api-base-url "/alerts/active?point=" lat "," lon)]
    (fetch-json
     {:url url
      :on-success (fn [result]
                    (on-success
                     (map #(get-in % [:properties])
                          (get-in result [:features]))))
      :on-error on-error})))

;; ============================================================================
;; Complete Weather Data - Convenience function
;; ============================================================================

(defn fetch-complete-weather
  "Fetch comprehensive weather data for given coordinates.
  
  This is a convenience function that:
  1. Fetches points data
  2. Fetches 7-day forecast
  3. Fetches hourly forecast
  4. Fetches observation stations
  5. Fetches current observations from nearest station
  6. Fetches active alerts
  
  All data is collected and returned in a single callback.
  
  Args (keyword map):
    :lat        - Latitude (number, required)
    :lon        - Longitude (number, required)
    :on-success - Success callback receiving complete weather data (fn, required)
    :on-error   - Error callback receiving error message (fn, optional)
  
  Success callback receives a map with:
    :points   - Location and grid information
    :forecast - 7-day forecast periods
    :hourly   - Hourly forecast periods
    :stations - Nearby weather stations
    :current  - Current observations
    :alerts   - Active weather alerts
  
  Example:
    (fetch-complete-weather
      {:lat 40.7128
       :lon -74.0060
       :on-success (fn [weather]
                     (js/console.log \"Location:\" 
                                     (get-in weather [:points :city]))
                     (js/console.log \"Current temp:\"
                                     (get-in weather [:current :temperature :value]))
                     (js/console.log \"Forecast periods:\"
                                     (count (:forecast weather))))
       :on-error (fn [error]
                   (js/console.error \"Complete weather error:\" error))})"
  [{:keys [lat lon on-success on-error]
    :or {on-error #(js/console.error "Complete weather error:" %)}}]
  (let [results (atom {})]
    ;; First get the points data
    (fetch-points
     {:lat lat
      :lon lon
      :on-success
      (fn [points-data]
        (swap! results assoc :points points-data)

         ;; Fetch 7-day forecast
        (when (:forecast points-data)
          (fetch-forecast
           {:url (:forecast points-data)
            :on-success (fn [forecast-data]
                          (swap! results assoc :forecast forecast-data))}))

         ;; Fetch hourly forecast
        (when (:forecastHourly points-data)
          (fetch-hourly-forecast
           {:url (:forecastHourly points-data)
            :on-success (fn [hourly-data]
                          (swap! results assoc :hourly hourly-data))}))

         ;; Fetch observation stations and current observations
        (when (:observationStations points-data)
          (fetch-observation-stations
           {:url (:observationStations points-data)
            :on-success
            (fn [stations-data]
              (swap! results assoc :stations stations-data)
                ;; Get current observations from first station
              (when-let [station-id (:stationIdentifier (first stations-data))]
                (fetch-current-observations
                 {:station-id station-id
                  :on-success (fn [obs-data]
                                (swap! results assoc :current obs-data)
                                   ;; Return all collected data
                                (on-success @results))})))}))

         ;; Fetch alerts for this point
        (fetch-alerts-for-point
         {:lat lat
          :lon lon
          :on-success (fn [alerts-data]
                        (swap! results assoc :alerts alerts-data))}))

      :on-error on-error})))

;; ============================================================================
;; Utility Functions
;; ============================================================================

(defn get-weather-icon
  "Map NWS icon URLs to emoji representations.
  
  Args:
    icon-url - NWS icon URL (string)
  
  Returns:
    Emoji string representing the weather condition.
  
  Example:
    (get-weather-icon \"https://api.weather.gov/icons/land/day/rain\")
    ;; => \"🌧️\""
  [icon-url]
  (when icon-url
    (let [icon-name (-> icon-url
                        (str/split #"/")
                        last
                        (str/split #"\?")
                        first
                        (str/replace #"\..*$" ""))]
      (cond
        (str/includes? icon-name "skc") "☀️" ; Clear
        (str/includes? icon-name "few") "🌤️" ; Few clouds
        (str/includes? icon-name "sct") "⛅" ; Scattered clouds
        (str/includes? icon-name "bkn") "🌥️" ; Broken clouds
        (str/includes? icon-name "ovc") "☁️" ; Overcast
        (str/includes? icon-name "rain") "🌧️" ; Rain
        (str/includes? icon-name "snow") "❄️" ; Snow
        (str/includes? icon-name "tsra") "⛈️" ; Thunderstorm
        (str/includes? icon-name "fog") "🌫️" ; Fog
        (str/includes? icon-name "wind") "💨" ; Windy
        (str/includes? icon-name "hot") "🌡️" ; Hot
        (str/includes? icon-name "cold") "🥶" ; Cold
        :else "🌡️")))) ; Default

This API layer provides all the functions we need:

  • fetch-points - Convert coordinates to NWS grid points
  • fetch-forecast - Get 7-day forecast
  • fetch-hourly-forecast - Get hourly predictions
  • fetch-current-observations - Real-time weather data
  • fetch-alerts-for-point - Active weather alerts
  • fetch-complete-weather - Convenience function for all data

Notice how every function follows the same pattern: a single keyword argument map with :on-success and :on-error callbacks. This makes the code self-documenting and easy to use.

Demo 1: Simple Weather Lookup

Let’s start with the simplest possible weather app. This demo:

  • Accepts latitude and longitude input
  • Fetches weather data using the NWS API
  • Displays location, temperature, and forecast
  • Includes quick-access buttons for major cities
  • Shows loading and error states

Key features:

  • Uses keyword arguments: {:lat 40.7128 :lon -74.0060 :on-success ... :on-error ...}
  • Native browser fetch (no external libraries)
  • Simple, clean Reagent components
  • Inline styles (no CSS files needed)
(ns scittle.weather.simple-lookup
  "Simple weather lookup demo - minimal example showing basic API usage.
   
   This is the simplest possible weather app:
   - Two input fields for coordinates
   - One button to fetch weather
   - Display location and temperature
   
   Demonstrates:
   - Basic API call with keyword arguments
   - Loading state
   - Error handling
   - Minimal Reagent UI"
  (:require [reagent.core :as r]
            [reagent.dom :as rdom]
            [clojure.string :as str]))

;; ============================================================================
;; Inline API Functions (simplified for this demo)
;; ============================================================================

(defn fetch-json
  "Fetch JSON from URL with error handling."
  [{:keys [url on-success on-error]}]
  (-> (js/fetch url)
      (.then (fn [response]
               (if (.-ok response)
                 (.then (.json response)
                        (fn [data]
                          (on-success (js->clj data :keywordize-keys true))))
                 (on-error (str "HTTP Error: " (.-status response))))))
      (.catch (fn [error]
                (on-error (.-message error))))))

(defn fetch-weather-data
  "Fetch basic weather data for coordinates."
  [{:keys [lat lon on-success on-error]}]
  (let [points-url (str "https://api.weather.gov/points/" lat "," lon)]
    (fetch-json
     {:url points-url
      :on-success
      (fn [points-result]
         ;; Got points, now get forecast
        (let [properties (get-in points-result [:properties])
              forecast-url (:forecast properties)
              city (:city (get-in points-result [:properties :relativeLocation :properties]))
              state (:state (get-in points-result [:properties :relativeLocation :properties]))]

           ;; Fetch the forecast
          (fetch-json
           {:url forecast-url
            :on-success
            (fn [forecast-result]
              (let [periods (get-in forecast-result [:properties :periods])
                    first-period (first periods)]
                (on-success
                 {:city city
                  :state state
                  :temperature (:temperature first-period)
                  :temperatureUnit (:temperatureUnit first-period)
                  :shortForecast (:shortForecast first-period)
                  :detailedForecast (:detailedForecast first-period)})))
            :on-error on-error})))
      :on-error on-error})))

;; ============================================================================
;; Styles
;; ============================================================================

(def card-style
  {:background "#ffffff"
   :border "1px solid #e0e0e0"
   :border-radius "8px"
   :padding "20px"
   :box-shadow "0 2px 4px rgba(0,0,0,0.1)"
   :margin-bottom "20px"})

(def input-style
  {:width "100%"
   :padding "10px"
   :border "1px solid #ddd"
   :border-radius "4px"
   :font-size "14px"
   :margin-bottom "10px"})

(def button-style
  {:background "#2196f3"
   :color "white"
   :border "none"
   :padding "12px 24px"
   :border-radius "4px"
   :cursor "pointer"
   :font-size "16px"
   :width "100%"
   :transition "background 0.3s"})

(def button-hover-style
  (merge button-style
         {:background "#1976d2"}))

(def button-disabled-style
  (merge button-style
         {:background "#ccc"
          :cursor "not-allowed"}))

;; ============================================================================
;; Components
;; ============================================================================

(defn loading-spinner
  "Simple loading indicator."
  []
  [:div {:style {:text-align "center"
                 :padding "40px"}}
   [:div {:style {:display "inline-block"
                  :width "40px"
                  :height "40px"
                  :border "4px solid #f3f3f3"
                  :border-top "4px solid #2196f3"
                  :border-radius "50%"
                  :animation "spin 1s linear infinite"}}]
   [:style "@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }"]
   [:p {:style {:margin-top "15px"
                :color "#666"}}
    "Fetching weather data..."]])

(defn error-display
  "Display error message."
  [{:keys [error on-retry]}]
  [:div {:style (merge card-style
                       {:background "#ffebee"
                        :border "1px solid #ef5350"})}
   [:h4 {:style {:margin-top 0
                 :color "#c62828"}}
    "⚠️ Error"]
   [:p {:style {:color "#666"}}
    error]
   (when on-retry
     [:button {:on-click on-retry
               :style {:background "#f44336"
                       :color "white"
                       :border "none"
                       :padding "8px 16px"
                       :border-radius "4px"
                       :cursor "pointer"
                       :margin-top "10px"}}
      "Try Again"])])

(defn weather-result
  "Display weather results."
  [{:keys [data]}]
  [:div {:style card-style}
   [:h2 {:style {:margin-top 0
                 :color "#2196f3"}}
    "📍 " (:city data) ", " (:state data)]

   [:div {:style {:text-align "center"
                  :margin "30px 0"}}
    [:div {:style {:font-size "48px"
                   :font-weight "bold"
                   :color "#333"}}
     (:temperature data) "°" (:temperatureUnit data)]
    [:div {:style {:font-size "18px"
                   :color "#666"
                   :margin-top "10px"}}
     (:shortForecast data)]]

   [:div {:style {:background "#f5f5f5"
                  :padding "15px"
                  :border-radius "4px"
                  :margin-top "20px"}}
    [:p {:style {:margin 0
                 :line-height 1.6
                 :color "#555"}}
     (:detailedForecast data)]]])

(defn input-form
  "Input form for coordinates."
  [{:keys [lat lon on-lat-change on-lon-change on-submit loading? disabled?]}]
  [:div {:style card-style}
   [:h3 {:style {:margin-top 0}}
    "🌍 Enter Coordinates"]
   [:p {:style {:color "#666"
                :font-size "14px"
                :margin-bottom "15px"}}
    "Enter latitude and longitude to get weather data"]

   [:div
    [:label {:style {:display "block"
                     :margin-bottom "5px"
                     :color "#555"
                     :font-weight "500"}}
     "Latitude"]
    [:input {:type "number"
             :step "0.0001"
             :placeholder "e.g., 40.7128"
             :value @lat
             :on-change #(on-lat-change (.. % -target -value))
             :disabled loading?
             :style (merge input-style
                           (when loading? {:opacity 0.6}))}]]

   [:div
    [:label {:style {:display "block"
                     :margin-bottom "5px"
                     :color "#555"
                     :font-weight "500"}}
     "Longitude"]
    [:input {:type "number"
             :step "0.0001"
             :placeholder "e.g., -74.0060"
             :value @lon
             :on-change #(on-lon-change (.. % -target -value))
             :disabled loading?
             :style (merge input-style
                           (when loading? {:opacity 0.6}))}]]

   [:button {:on-click on-submit
             :disabled (or loading? disabled?)
             :style (cond
                      loading? button-disabled-style
                      disabled? button-disabled-style
                      :else button-style)}
    (if loading?
      "Loading..."
      "Get Weather")]])

(defn quick-locations
  "Quick access buttons for major cities."
  [{:keys [on-select loading?]}]
  [:div {:style {:margin-top "20px"}}
   [:p {:style {:color "#666"
                :font-size "14px"
                :margin-bottom "10px"}}
    "Or try these cities:"]
   [:div {:style {:display "flex"
                  :flex-wrap "wrap"
                  :gap "8px"}}
    (for [[city lat lon] [["Charlotte, NC" 35.2271 -80.8431]
                          ["Miami, FL" 25.7617 -80.1918]
                          ["Denver, CO" 39.7392 -104.9903]
                          ["New York, NY" 40.7128 -74.0060]
                          ["Los Angeles, CA" 34.0522 -118.2437]
                          ["Chicago, IL" 41.8781 -87.6298]]]
      ^{:key city}
      [:button {:on-click #(on-select lat lon)
                :disabled loading?
                :style {:padding "6px 12px"
                        :background (if loading? "#ccc" "transparent")
                        :color (if loading? "#999" "#2196f3")
                        :border "1px solid #2196f3"
                        :border-radius "20px"
                        :cursor (if loading? "not-allowed" "pointer")
                        :font-size "13px"
                        :transition "all 0.2s"}}
       city])]])

;; ============================================================================
;; Main Component
;; ============================================================================

(defn main-component
  "Main weather lookup component."
  []
  (let [lat (r/atom "35.2271")
        lon (r/atom "-80.8431")
        loading? (r/atom false)
        error (r/atom nil)
        weather-data (r/atom nil)

        fetch-weather (fn [latitude longitude]
                        (reset! loading? true)
                        (reset! error nil)
                        (reset! weather-data nil)

                        (fetch-weather-data
                         {:lat latitude
                          :lon longitude
                          :on-success (fn [data]
                                        (reset! loading? false)
                                        (reset! weather-data data))
                          :on-error (fn [err]
                                      (reset! loading? false)
                                      (reset! error err))}))]

    (fn []
      [:div {:style {:max-width "600px"
                     :margin "0 auto"
                     :padding "20px"
                     :font-family "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif"}}
       [:h1 {:style {:text-align "center"
                     :color "#333"
                     :margin-bottom "30px"}}
        "☀️ Simple Weather Lookup"]

       ;; Input Form
       [input-form
        {:lat lat
         :lon lon
         :on-lat-change #(reset! lat %)
         :on-lon-change #(reset! lon %)
         :on-submit #(when (and (not (str/blank? @lat))
                                (not (str/blank? @lon)))
                       (fetch-weather @lat @lon))
         :loading? @loading?
         :disabled? (or (str/blank? @lat)
                        (str/blank? @lon))}]

       ;; Quick Locations
       [quick-locations
        {:on-select (fn [latitude longitude]
                      (reset! lat (str latitude))
                      (reset! lon (str longitude))
                      (fetch-weather latitude longitude))
         :loading? @loading?}]

       ;; Loading State
       (when @loading?
         [loading-spinner])

       ;; Error Display
       (when @error
         [error-display
          {:error @error
           :on-retry #(when (and (not (str/blank? @lat))
                                 (not (str/blank? @lon)))
                        (fetch-weather @lat @lon))}])

       ;; Weather Results
       (when @weather-data
         [weather-result {:data @weather-data}])

       ;; Instructions
       (when (and (not @loading?)
                  (not @error)
                  (not @weather-data))
         [:div {:style {:text-align "center"
                        :margin-top "40px"
                        :color "#999"
                        :font-size "14px"}}
          [:p "Enter coordinates above or click a city to get started"]
          [:p {:style {:margin-top "10px"}}
           "Uses the free NWS API - no API key required!"]])])))

;; ============================================================================
;; Mount
;; ============================================================================

(defn ^:export init []
  (rdom/render [main-component]
               (js/document.getElementById "simple-lookup-demo")))

;; Auto-initialize when script loads
(init)

Try It Live

Click a city button or enter coordinates to see it in action!

(kind/hiccup
 [:div#simple-lookup-demo {:style {:min-height "500px"}}
  [:script {:type "application/x-scittle"
            :src "simple_lookup.cljs"}]])

Demo 2: Current Weather Conditions

Now let’s build something more detailed. This demo shows comprehensive current conditions with all available metrics from the weather station.

What makes this demo powerful:

  • Temperature unit conversion - Toggle between Fahrenheit, Celsius, and Kelvin
  • Complete metrics grid - Humidity, wind, pressure, visibility, dewpoint
  • Conditional data - Heat index and wind chill when applicable
  • Station information - See which weather station is reporting
  • Large display format - Beautiful, scannable layout

Technical features:

  • Two-step API process: coordinates → station → observations
  • Helper functions for unit conversion (C→F, C→K)
  • Wind direction formatting (degrees → compass direction)
  • Distance conversion (meters → miles)
  • Responsive grid layout

The data flow:

graph LR A[Coordinates] --> B[Get Station] B --> C[Station ID] C --> D[Latest Observations] D --> E[Display All Metrics]
(ns scittle.weather.current-conditions
  "Display detailed current weather conditions with all available metrics.
   Demonstrates unit conversion, comprehensive data display, and state management."
  (:require [reagent.core :as r]
            [reagent.dom :as rdom]
            [clojure.string :as str]))

;; ============================================================================
;; API Functions (Inline from weather_api.cljs)
;; ============================================================================

(defn fetch-json
  "Fetch JSON from URL with error handling."
  [{:keys [url on-success on-error]
    :or {on-error #(js/console.error "Fetch error:" %)}}]
  (-> (js/fetch url)
      (.then (fn [response]
               (if (.-ok response)
                 (.json response)
                 (throw (js/Error. (str "HTTP " (.-status response)))))))
      (.then (fn [data]
               (on-success (js->clj data :keywordize-keys true))))
      (.catch on-error)))

(defn get-weather-station
  "Get nearest weather station for coordinates."
  [{:keys [lat lon on-success on-error]}]
  (fetch-json
   {:url (str "https://api.weather.gov/points/" lat "," lon)
    :on-success (fn [data]
                  (let [station-url (get-in data [:properties :observationStations])]
                    (fetch-json
                     {:url station-url
                      :on-success (fn [stations]
                                    (let [station-id (-> stations
                                                         :features
                                                         first
                                                         :properties
                                                         :stationIdentifier)]
                                      (on-success station-id)))
                      :on-error on-error})))
    :on-error on-error}))

(defn get-latest-observations
  "Get latest observations from station."
  [{:keys [station-id on-success on-error]}]
  (fetch-json
   {:url (str "https://api.weather.gov/stations/" station-id "/observations/latest")
    :on-success on-success
    :on-error on-error}))

;; ============================================================================
;; Temperature Conversion Utilities
;; ============================================================================

(defn celsius-to-fahrenheit [c]
  "Convert Celsius to Fahrenheit"
  (when c (+ (* c 1.8) 32)))

(defn celsius-to-kelvin [c]
  "Convert Celsius to Kelvin"
  (when c (+ c 273.15)))

(defn format-temp
  "Format temperature based on unit (F, C, or K)"
  [celsius unit]
  (when celsius
    (case unit
      "F" (str (Math/round (celsius-to-fahrenheit celsius)) "°F")
      "C" (str (Math/round celsius) "°C")
      "K" (str (Math/round (celsius-to-kelvin celsius)) "K")
      (str (Math/round celsius) "°C"))))

;; ============================================================================
;; Wind & Distance Utilities
;; ============================================================================

(defn format-wind-direction
  "Convert degrees to compass direction"
  [degrees]
  (when degrees
    (let [directions ["N" "NNE" "NE" "ENE" "E" "ESE" "SE" "SSE"
                      "S" "SSW" "SW" "WSW" "W" "WNW" "NW" "NNW"]
          index (mod (Math/round (/ degrees 22.5)) 16)]
      (nth directions index))))

(defn meters-to-miles [m]
  "Convert meters to miles"
  (when m (* m 0.000621371)))

(defn mps-to-mph [mps]
  "Convert meters per second to miles per hour"
  (when mps (* mps 2.237)))

;; ============================================================================
;; Styles
;; ============================================================================

(def container-style
  {:max-width "800px"
   :margin "0 auto"
   :padding "20px"
   :font-family "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"})

(def card-style
  {:background "#ffffff"
   :border "1px solid #e0e0e0"
   :border-radius "12px"
   :padding "24px"
   :margin-bottom "20px"
   :box-shadow "0 2px 8px rgba(0,0,0,0.1)"})

(def header-style
  {:text-align "center"
   :margin-bottom "30px"})

(def title-style
  {:font-size "28px"
   :font-weight "600"
   :color "#1a1a1a"
   :margin "0 0 10px 0"})

(def subtitle-style
  {:font-size "14px"
   :color "#666"
   :margin 0})

(def input-group-style
  {:display "flex"
   :gap "10px"
   :margin-bottom "20px"
   :flex-wrap "wrap"})

(def input-style
  {:padding "10px 15px"
   :border "1px solid #ddd"
   :border-radius "6px"
   :font-size "14px"
   :flex "1"
   :min-width "120px"})

(def button-style
  {:padding "10px 20px"
   :background "#3b82f6"
   :color "white"
   :border "none"
   :border-radius "6px"
   :cursor "pointer"
   :font-size "14px"
   :font-weight "500"
   :transition "background 0.2s"})

(def button-hover-style
  (merge button-style {:background "#2563eb"}))

(def quick-buttons-style
  {:display "flex"
   :gap "8px"
   :flex-wrap "wrap"
   :margin-bottom "20px"})

(def quick-button-style
  {:padding "8px 16px"
   :background "#f3f4f6"
   :border "1px solid #e5e7eb"
   :border-radius "6px"
   :cursor "pointer"
   :font-size "13px"
   :transition "all 0.2s"})

(def temp-display-style
  {:text-align "center"
   :padding "30px 0"
   :border-bottom "1px solid #e0e0e0"})

(def large-temp-style
  {:font-size "72px"
   :font-weight "300"
   :color "#1a1a1a"
   :margin "0"
   :line-height "1"})

(def condition-text-style
  {:font-size "20px"
   :color "#4b5563"
   :margin "10px 0 20px 0"})

(def unit-toggle-style
  {:display "flex"
   :justify-content "center"
   :gap "8px"
   :margin-top "15px"})

(def unit-button-style
  {:padding "6px 16px"
   :border "1px solid #ddd"
   :border-radius "6px"
   :background "#fff"
   :cursor "pointer"
   :font-size "14px"
   :transition "all 0.2s"})

(def unit-button-active-style
  (merge unit-button-style
         {:background "#3b82f6"
          :color "white"
          :border-color "#3b82f6"}))

(def metrics-grid-style
  {:display "grid"
   :grid-template-columns "repeat(auto-fit, minmax(200px, 1fr))"
   :gap "20px"
   :padding "20px 0"})

(def metric-card-style
  {:padding "15px"
   :background "#f9fafb"
   :border-radius "8px"
   :border "1px solid #e5e7eb"})

(def metric-label-style
  {:font-size "13px"
   :color "#6b7280"
   :margin "0 0 5px 0"
   :font-weight "500"
   :text-transform "uppercase"
   :letter-spacing "0.5px"})

(def metric-value-style
  {:font-size "24px"
   :color "#1a1a1a"
   :margin "0"
   :font-weight "500"})

(def station-info-style
  {:margin-top "20px"
   :padding-top "20px"
   :border-top "1px solid #e0e0e0"
   :font-size "13px"
   :color "#6b7280"
   :text-align "center"})

(def loading-style
  {:text-align "center"
   :padding "40px"
   :color "#6b7280"})

(def error-style
  {:background "#fef2f2"
   :border "1px solid #fecaca"
   :color "#dc2626"
   :padding "12px"
   :border-radius "6px"
   :margin "10px 0"})

;; ============================================================================
;; Quick City Locations
;; ============================================================================

(def quick-cities
  [{:name "Charlotte, NC" :lat 35.2271 :lon -80.8431}
   {:name "Miami, FL" :lat 25.7617 :lon -80.1918}
   {:name "Denver, CO" :lat 39.7392 :lon -104.9903}
   {:name "Seattle, WA" :lat 47.6062 :lon -122.3321}
   {:name "New York, NY" :lat 40.7128 :lon -74.0060}
   {:name "Los Angeles, CA" :lat 34.0522 :lon -118.2437}
   {:name "Chicago, IL" :lat 41.8781 :lon -87.6298}])

;; ============================================================================
;; Components
;; ============================================================================

(defn loading-spinner []
  [:div {:style loading-style}
   [:div {:style {:font-size "40px" :margin-bottom "10px"}} "⏳"]
   [:div "Loading current conditions..."]])

(defn error-message [{:keys [message]}]
  [:div {:style error-style}
   [:strong "Error: "] message])

(defn unit-toggle-buttons [{:keys [current-unit on-change]}]
  [:div {:style unit-toggle-style}
   (for [unit ["F" "C" "K"]]
     ^{:key unit}
     [:button
      {:style (if (= unit current-unit)
                unit-button-active-style
                unit-button-style)
       :on-click #(on-change unit)}
      unit])])

(defn metric-card [{:keys [label value]}]
  [:div {:style metric-card-style}
   [:div {:style metric-label-style} label]
   [:div {:style metric-value-style} (or value "—")]])

(defn temperature-display [{:keys [temp-c unit condition]}]
  [:div {:style temp-display-style}
   [:div {:style large-temp-style}
    (format-temp temp-c unit)]
   [:div {:style condition-text-style}
    (or condition "No data")]])

(defn metrics-grid [{:keys [observations unit]}]
  (let [props (:properties observations)
        temp-c (:value (:temperature props))
        dewpoint-c (:value (:dewpoint props))
        wind-speed-mps (:value (:windSpeed props))
        wind-dir (:value (:windDirection props))
        humidity (:value (:relativeHumidity props))
        pressure (:value (:barometricPressure props))
        visibility-m (:value (:visibility props))
        heat-index-c (:value (:heatIndex props))
        wind-chill-c (:value (:windChill props))]

    [:div {:style metrics-grid-style}
     [metric-card
      {:label "Humidity"
       :value (when humidity (str (Math/round humidity) "%"))}]

     [metric-card
      {:label "Wind"
       :value (when wind-speed-mps
                (str (Math/round (mps-to-mph wind-speed-mps)) " mph "
                     (when wind-dir
                       (str "from " (format-wind-direction wind-dir)))))}]

     [metric-card
      {:label "Pressure"
       :value (when pressure
                (str (Math/round (/ pressure 100)) " mb"))}]

     [metric-card
      {:label "Visibility"
       :value (when visibility-m
                (let [miles (meters-to-miles visibility-m)]
                  (str (Math/round miles) " mi")))}]

     [metric-card
      {:label "Dewpoint"
       :value (format-temp dewpoint-c unit)}]

     (when heat-index-c
       [metric-card
        {:label "Heat Index"
         :value (format-temp heat-index-c unit)}])

     (when wind-chill-c
       [metric-card
        {:label "Wind Chill"
         :value (format-temp wind-chill-c unit)}])]))

(defn station-info [{:keys [observations]}]
  (let [props (:properties observations)
        station (:station props)
        timestamp (:timestamp props)
        station-id (last (str/split station #"/"))]
    [:div {:style station-info-style}
     [:div "Station: " station-id]
     [:div "Last Updated: "
      (when timestamp
        (.toLocaleString (js/Date. timestamp)))]]))

(defn weather-display [{:keys [observations unit on-unit-change]}]
  (let [props (:properties observations)
        temp-c (:value (:temperature props))
        condition (:textDescription props)]
    [:div {:style card-style}
     [temperature-display
      {:temp-c temp-c
       :unit unit
       :condition condition}]

     [unit-toggle-buttons
      {:current-unit unit
       :on-change on-unit-change}]

     [metrics-grid
      {:observations observations
       :unit unit}]

     [station-info
      {:observations observations}]]))

(defn location-input [{:keys [lat lon on-lat-change on-lon-change on-fetch]}]
  [:div
   [:div {:style input-group-style}
    [:input {:type "number"
             :placeholder "Latitude"
             :value lat
             :on-change #(on-lat-change (.. % -target -value))
             :step "0.0001"
             :style input-style}]
    [:input {:type "number"
             :placeholder "Longitude"
             :value lon
             :on-change #(on-lon-change (.. % -target -value))
             :step "0.0001"
             :style input-style}]
    [:button {:on-click on-fetch
              :style button-style
              :on-mouse-over #(set! (.. % -target -style -background) "#2563eb")
              :on-mouse-out #(set! (.. % -target -style -background) "#3b82f6")}
     "Get Conditions"]]])

(defn quick-city-buttons [{:keys [cities on-select]}]
  [:div {:style quick-buttons-style}
   (for [city cities]
     ^{:key (:name city)}
     [:button
      {:style quick-button-style
       :on-click #(on-select city)
       :on-mouse-over #(set! (.. % -target -style -background) "#e5e7eb")
       :on-mouse-out #(set! (.. % -target -style -background) "#f3f4f6")}
      (:name city)])])

;; ============================================================================
;; Main Component
;; ============================================================================

(defn main-component []
  (let [lat (r/atom "35.2271")
        lon (r/atom "-80.8431")
        observations (r/atom nil)
        loading? (r/atom false)
        error (r/atom nil)
        unit (r/atom "F")

        fetch-conditions
        (fn []
          (reset! loading? true)
          (reset! error nil)
          (reset! observations nil)

          (get-weather-station
           {:lat @lat
            :lon @lon
            :on-success
            (fn [station-id]
              (get-latest-observations
               {:station-id station-id
                :on-success
                (fn [obs-data]
                  (reset! observations obs-data)
                  (reset! loading? false))
                :on-error
                (fn [err]
                  (reset! error (str "Failed to fetch observations: " err))
                  (reset! loading? false))}))
            :on-error
            (fn [err]
              (reset! error (str "Failed to find weather station: " err))
              (reset! loading? false))}))

        select-city
        (fn [city]
          (reset! lat (str (:lat city)))
          (reset! lon (str (:lon city)))
          (fetch-conditions))]

    (fn []
      [:div {:style container-style}
       [:div {:style header-style}
        [:h1 {:style title-style} "☀️ Current Weather Conditions"]
        [:p {:style subtitle-style}
         "Detailed weather metrics from NOAA National Weather Service"]]

       [:div {:style card-style}
        [location-input
         {:lat @lat
          :lon @lon
          :on-lat-change #(reset! lat %)
          :on-lon-change #(reset! lon %)
          :on-fetch fetch-conditions}]

        [quick-city-buttons
         {:cities quick-cities
          :on-select select-city}]]

       (cond
         @loading? [loading-spinner]
         @error [error-message {:message @error}]
         @observations [weather-display
                        {:observations @observations
                         :unit @unit
                         :on-unit-change #(reset! unit %)}])])))

;; ============================================================================
;; Mount
;; ============================================================================

(defn ^:export init []
  (when-let [el (js/document.getElementById "current-conditions-demo")]
    (rdom/render [main-component] el)))

(init)

Try It Live

Click a city or enter coordinates, then use the F/C/K buttons to change units!

(kind/hiccup
 [:div#current-conditions-demo {:style {:min-height "700px"}}
  [:script {:type "application/x-scittle"
            :src "current_conditions.cljs"}]])

Demo 3: 7-Day Forecast Viewer

Now we’re getting visual! This demo displays weather forecasts as beautiful cards in a responsive grid layout.

What makes this demo special:

  • Visual weather cards - Each period gets its own card with emoji weather icons
  • Smart icon mapping - Automatically selects emojis based on forecast text
  • Flexible viewing - Toggle between 7 days or all 14 periods (day + night)
  • Hover effects - Cards lift and shadow when you hover
  • Responsive grid - Automatically adjusts to screen size
  • Rich information - Temperature, conditions, wind, precipitation chance

Technical highlights:

  • Weather icon mapping with regex pattern matching
  • CSS Grid with auto-fill for responsive layout
  • Form-2 Reagent components for hover state
  • Conditional rendering (precipitation only when > 0%)
  • Toggle controls for view modes

What you’ll see on each card:

  • Period name (Tonight, Friday, Saturday, etc.)
  • Weather emoji (⛈️, 🌧️, ☀️, ⛅, etc.)
  • Temperature (with F/C/K conversion)
  • Short forecast text
  • Wind information (speed and direction)
  • Precipitation probability (when applicable)
(ns scittle.weather.forecast-viewer
  "7-day weather forecast with visual cards and period toggles.
   Demonstrates grid layouts, emoji icons, and responsive design."
  (:require [reagent.core :as r]
            [reagent.dom :as rdom]
            [clojure.string :as str]))

;; ============================================================================
;; API Functions (Inline)
;; ============================================================================

(defn fetch-json
  "Fetch JSON from URL with error handling."
  [{:keys [url on-success on-error]
    :or {on-error #(js/console.error "Fetch error:" %)}}]
  (-> (js/fetch url)
      (.then (fn [response]
               (if (.-ok response)
                 (.json response)
                 (throw (js/Error. (str "HTTP " (.-status response)))))))
      (.then (fn [data]
               (on-success (js->clj data :keywordize-keys true))))
      (.catch on-error)))

(defn get-forecast-url
  "Get forecast URL for coordinates."
  [{:keys [lat lon on-success on-error]}]
  (fetch-json
   {:url (str "https://api.weather.gov/points/" lat "," lon)
    :on-success (fn [data]
                  (let [forecast-url (get-in data [:properties :forecast])]
                    (on-success forecast-url)))
    :on-error on-error}))

(defn get-forecast-data
  "Get forecast data from forecast URL."
  [{:keys [url on-success on-error]}]
  (fetch-json
   {:url url
    :on-success on-success
    :on-error on-error}))

;; ============================================================================
;; Weather Icon Mapping
;; ============================================================================

(defn get-weather-icon
  "Map weather conditions to emoji icons."
  [short-forecast]
  (let [forecast-lower (str/lower-case (or short-forecast ""))]
    (cond
      (re-find #"thunder|tstorm" forecast-lower) "⛈️"
      (re-find #"rain|shower" forecast-lower) "🌧️"
      (re-find #"snow|flurr" forecast-lower) "❄️"
      (re-find #"sleet|ice" forecast-lower) "🌨️"
      (re-find #"fog|mist" forecast-lower) "🌫️"
      (re-find #"cloud" forecast-lower) "☁️"
      (re-find #"partly|mostly" forecast-lower) "⛅"
      (re-find #"clear|sunny" forecast-lower) "☀️"
      (re-find #"wind" forecast-lower) "💨"
      :else "🌤️")))

;; ============================================================================
;; Temperature Conversion
;; ============================================================================

(defn fahrenheit-to-celsius [f]
  "Convert Fahrenheit to Celsius"
  (when f (* (- f 32) 0.5556)))

(defn fahrenheit-to-kelvin [f]
  "Convert Fahrenheit to Kelvin"
  (when f (+ (fahrenheit-to-celsius f) 273.15)))

(defn format-temp
  "Format temperature based on unit (F, C, or K)"
  [fahrenheit unit]
  (when fahrenheit
    (case unit
      "F" (str fahrenheit "°F")
      "C" (str (Math/round (fahrenheit-to-celsius fahrenheit)) "°C")
      "K" (str (Math/round (fahrenheit-to-kelvin fahrenheit)) "K")
      (str fahrenheit "°F"))))

;; ============================================================================
;; Styles
;; ============================================================================

(def container-style
  {:max-width "1200px"
   :margin "0 auto"
   :padding "20px"
   :font-family "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"})

(def card-style
  {:background "#ffffff"
   :border "1px solid #e0e0e0"
   :border-radius "12px"
   :padding "24px"
   :margin-bottom "20px"
   :box-shadow "0 2px 8px rgba(0,0,0,0.1)"})

(def header-style
  {:text-align "center"
   :margin-bottom "30px"})

(def title-style
  {:font-size "28px"
   :font-weight "600"
   :color "#1a1a1a"
   :margin "0 0 10px 0"})

(def subtitle-style
  {:font-size "14px"
   :color "#666"
   :margin 0})

(def input-group-style
  {:display "flex"
   :gap "10px"
   :margin-bottom "20px"
   :flex-wrap "wrap"})

(def input-style
  {:padding "10px 15px"
   :border "1px solid #ddd"
   :border-radius "6px"
   :font-size "14px"
   :flex "1"
   :min-width "120px"})

(def button-style
  {:padding "10px 20px"
   :background "#3b82f6"
   :color "white"
   :border "none"
   :border-radius "6px"
   :cursor "pointer"
   :font-size "14px"
   :font-weight "500"
   :transition "background 0.2s"})

(def quick-buttons-style
  {:display "flex"
   :gap "8px"
   :flex-wrap "wrap"
   :margin-bottom "20px"})

(def quick-button-style
  {:padding "8px 16px"
   :background "#f3f4f6"
   :border "1px solid #e5e7eb"
   :border-radius "6px"
   :cursor "pointer"
   :font-size "13px"
   :transition "all 0.2s"})

(def controls-bar-style
  {:display "flex"
   :justify-content "space-between"
   :align-items "center"
   :margin-bottom "20px"
   :flex-wrap "wrap"
   :gap "15px"})

(def toggle-group-style
  {:display "flex"
   :gap "8px"})

(def toggle-button-style
  {:padding "8px 16px"
   :border "1px solid #ddd"
   :border-radius "6px"
   :background "#fff"
   :cursor "pointer"
   :font-size "14px"
   :transition "all 0.2s"})

(def toggle-button-active-style
  (merge toggle-button-style
         {:background "#3b82f6"
          :color "white"
          :border-color "#3b82f6"}))

(def forecast-grid-style
  {:display "grid"
   :grid-template-columns "repeat(auto-fill, minmax(200px, 1fr))"
   :gap "15px"
   :margin-top "20px"})

(def forecast-card-style
  {:background "#ffffff"
   :border "1px solid #e5e7eb"
   :border-radius "10px"
   :padding "20px"
   :text-align "center"
   :transition "all 0.2s"
   :box-shadow "0 1px 3px rgba(0,0,0,0.1)"})

(def forecast-card-hover-style
  (merge forecast-card-style
         {:box-shadow "0 4px 12px rgba(0,0,0,0.15)"
          :transform "translateY(-2px)"}))

(def period-name-style
  {:font-size "16px"
   :font-weight "600"
   :color "#1a1a1a"
   :margin "0 0 10px 0"})

(def weather-icon-style
  {:font-size "48px"
   :margin "10px 0"})

(def temp-style
  {:font-size "32px"
   :font-weight "600"
   :color "#3b82f6"
   :margin "10px 0"})

(def forecast-text-style
  {:font-size "14px"
   :color "#4b5563"
   :margin "10px 0 5px 0"
   :line-height "1.4"})

(def wind-style
  {:font-size "13px"
   :color "#6b7280"
   :margin "5px 0"})

(def precip-style
  {:font-size "13px"
   :color "#3b82f6"
   :margin "5px 0"
   :font-weight "500"})

(def loading-style
  {:text-align "center"
   :padding "40px"
   :color "#6b7280"})

(def error-style
  {:background "#fef2f2"
   :border "1px solid #fecaca"
   :color "#dc2626"
   :padding "12px"
   :border-radius "6px"
   :margin "10px 0"})

(def location-display-style
  {:font-size "18px"
   :font-weight "500"
   :color "#1a1a1a"
   :margin-bottom "10px"})

;; ============================================================================
;; Quick City Locations
;; ============================================================================

(def quick-cities
  [{:name "Charlotte, NC" :lat 35.2271 :lon -80.8431}
   {:name "Miami, FL" :lat 25.7617 :lon -80.1918}
   {:name "Denver, CO" :lat 39.7392 :lon -104.9903}
   {:name "Seattle, WA" :lat 47.6062 :lon -122.3321}
   {:name "New York, NY" :lat 40.7128 :lon -74.0060}
   {:name "Los Angeles, CA" :lat 34.0522 :lon -118.2437}
   {:name "Chicago, IL" :lat 41.8781 :lon -87.6298}])

;; ============================================================================
;; Components
;; ============================================================================

(defn loading-spinner []
  [:div {:style loading-style}
   [:div {:style {:font-size "40px" :margin-bottom "10px"}} "⏳"]
   [:div "Loading forecast data..."]])

(defn error-message [{:keys [message]}]
  [:div {:style error-style}
   [:strong "Error: "] message])

(defn location-input [{:keys [lat lon on-lat-change on-lon-change on-fetch]}]
  [:div
   [:div {:style input-group-style}
    [:input {:type "number"
             :placeholder "Latitude"
             :value lat
             :on-change #(on-lat-change (.. % -target -value))
             :step "0.0001"
             :style input-style}]
    [:input {:type "number"
             :placeholder "Longitude"
             :value lon
             :on-change #(on-lon-change (.. % -target -value))
             :step "0.0001"
             :style input-style}]
    [:button {:on-click on-fetch
              :style button-style
              :on-mouse-over #(set! (.. % -target -style -background) "#2563eb")
              :on-mouse-out #(set! (.. % -target -style -background) "#3b82f6")}
     "Get Forecast"]]])

(defn quick-city-buttons [{:keys [cities on-select]}]
  [:div {:style quick-buttons-style}
   (for [city cities]
     ^{:key (:name city)}
     [:button
      {:style quick-button-style
       :on-click #(on-select city)
       :on-mouse-over #(set! (.. % -target -style -background) "#e5e7eb")
       :on-mouse-out #(set! (.. % -target -style -background) "#f3f4f6")}
      (:name city)])])

(defn controls-bar [{:keys [show-all? on-toggle-periods unit on-unit-change]}]
  [:div {:style controls-bar-style}
   [:div {:style toggle-group-style}
    [:button
     {:style (if-not show-all? toggle-button-active-style toggle-button-style)
      :on-click #(on-toggle-periods false)}
     "Show 7 Days"]
    [:button
     {:style (if show-all? toggle-button-active-style toggle-button-style)
      :on-click #(on-toggle-periods true)}
     "Show 14 Periods"]]

   [:div {:style toggle-group-style}
    (for [u ["F" "C" "K"]]
      ^{:key u}
      [:button
       {:style (if (= u unit) toggle-button-active-style toggle-button-style)
        :on-click #(on-unit-change u)}
       u])]])

(defn forecast-card [{:keys [period unit]}]
  (let [hovering? (r/atom false)]
    (fn [{:keys [period unit]}]
      (let [{:keys [name temperature windSpeed windDirection
                    shortForecast detailedForecast probabilityOfPrecipitation]} period
            precip-value (get-in probabilityOfPrecipitation [:value])
            icon (get-weather-icon shortForecast)]
        [:div
         {:style (if @hovering? forecast-card-hover-style forecast-card-style)
          :on-mouse-enter #(reset! hovering? true)
          :on-mouse-leave #(reset! hovering? false)}

         [:div {:style period-name-style} name]
         [:div {:style weather-icon-style} icon]
         [:div {:style temp-style} (format-temp temperature unit)]
         [:div {:style forecast-text-style} shortForecast]

         (when (and windSpeed (not= windSpeed ""))
           [:div {:style wind-style}
            "💨 " windSpeed " " (or windDirection "")])

         (when (and precip-value (> precip-value 0))
           [:div {:style precip-style}
            "💧 " precip-value "% chance"])]))))

(defn forecast-grid [{:keys [periods unit show-all?]}]
  (let [displayed-periods (if show-all? periods (take 7 periods))]
    [:div {:style forecast-grid-style}
     (for [period displayed-periods]
       ^{:key (:number period)}
       [forecast-card {:period period :unit unit}])]))

(defn forecast-display [{:keys [forecast-data location-name unit show-all? on-toggle-periods on-unit-change]}]
  (let [periods (get-in forecast-data [:properties :periods])]
    [:div
     [:div {:style location-display-style} "📍 " location-name]

     [controls-bar
      {:show-all? show-all?
       :on-toggle-periods on-toggle-periods
       :unit unit
       :on-unit-change on-unit-change}]

     [forecast-grid
      {:periods periods
       :unit unit
       :show-all? show-all?}]]))

;; ============================================================================
;; Main Component
;; ============================================================================

(defn main-component []
  (let [lat (r/atom "35.2271")
        lon (r/atom "-80.8431")
        location-name (r/atom "Charlotte, NC")
        forecast-data (r/atom nil)
        loading? (r/atom false)
        error (r/atom nil)
        unit (r/atom "F")
        show-all? (r/atom false)

        fetch-forecast
        (fn []
          (reset! loading? true)
          (reset! error nil)
          (reset! forecast-data nil)

          (get-forecast-url
           {:lat @lat
            :lon @lon
            :on-success
            (fn [forecast-url]
              (get-forecast-data
               {:url forecast-url
                :on-success
                (fn [data]
                  (reset! forecast-data data)
                  (reset! loading? false))
                :on-error
                (fn [err]
                  (reset! error (str "Failed to fetch forecast: " err))
                  (reset! loading? false))}))
            :on-error
            (fn [err]
              (reset! error (str "Failed to find location: " err))
              (reset! loading? false))}))

        select-city
        (fn [city]
          (reset! lat (str (:lat city)))
          (reset! lon (str (:lon city)))
          (reset! location-name (:name city))
          (fetch-forecast))]

    (fn []
      [:div {:style container-style}
       [:div {:style header-style}
        [:h1 {:style title-style} "📅 7-Day Weather Forecast"]
        [:p {:style subtitle-style}
         "Visual forecast cards with detailed period information"]]

       [:div {:style card-style}
        [location-input
         {:lat @lat
          :lon @lon
          :on-lat-change #(reset! lat %)
          :on-lon-change #(reset! lon %)
          :on-fetch fetch-forecast}]

        [quick-city-buttons
         {:cities quick-cities
          :on-select select-city}]]

       (cond
         @loading? [loading-spinner]
         @error [error-message {:message @error}]
         @forecast-data [:div {:style card-style}
                         [forecast-display
                          {:forecast-data @forecast-data
                           :location-name @location-name
                           :unit @unit
                           :show-all? @show-all?
                           :on-toggle-periods #(reset! show-all? %)
                           :on-unit-change #(reset! unit %)}]])])))

;; ============================================================================
;; Mount
;; ============================================================================

(defn ^:export init []
  (when-let [el (js/document.getElementById "forecast-viewer-demo")]
    (rdom/render [main-component] el)))

(init)

Try It Live

Select a city, toggle between 7 and 14 periods, and watch the cards rearrange! Try hovering over cards to see the lift effect.

(kind/hiccup
 [:div#forecast-viewer-demo {:style {:min-height "800px"}}
  [:script {:type "application/x-scittle"
            :src "forecast_viewer.cljs"}]])

Demo 4: Hourly Forecast Timeline

This demo takes interactivity to the next level with a scrollable hourly timeline and dynamic controls.

Interactive features:

  • Hour slider controls - Choose between 6h, 12h, 24h, or 48h views
  • Horizontal scrolling timeline - Swipe or drag to browse hours
  • Current hour highlighting - Blue border marks “Now” with 🔵
  • Auto-scroll on load - Automatically centers on current hour
  • Time formatting - Clean display like “2 PM”, “11 AM”
  • Rich hour cards - Each shows weather icon, temp, precipitation, wind

Technical innovations:

  • ISO 8601 time parsing and formatting
  • Current hour detection (matches day and hour)
  • Smooth scroll behavior with scrollIntoView
  • Horizontal flex layout with flex-shrink: 0
  • Responsive card sizing (min-width: 140px)
  • Wind speed extraction from NWS string format

User experience highlights:

  • Visual scanning - See weather trends at a glance
  • Touch-friendly - Works great on mobile with swipe scrolling
  • Contextual information - Only shows precipitation when > 0%
  • Hover feedback - Cards lift up when you mouse over them
  • Current time anchor - “🔵 Now” label makes orientation easy

What each hour card displays:

  • Time label (or “🔵 Now” for current hour)
  • Weather emoji matching conditions
  • Temperature with unit conversion
  • Precipitation percentage (when > 0%)
  • Wind speed in mph
(ns scittle.weather.hourly-forecast
  "Hourly weather forecast with interactive timeline and slider controls.
   Demonstrates horizontal scrolling, time formatting, and dynamic data display."
  (:require [reagent.core :as r]
            [reagent.dom :as rdom]
            [clojure.string :as str]))

;; ============================================================================
;; API Functions (Inline)
;; ============================================================================

(defn fetch-json
  "Fetch JSON from URL with error handling."
  [{:keys [url on-success on-error]
    :or {on-error #(js/console.error "Fetch error:" %)}}]
  (-> (js/fetch url)
      (.then (fn [response]
               (if (.-ok response)
                 (.json response)
                 (throw (js/Error. (str "HTTP " (.-status response)))))))
      (.then (fn [data]
               (on-success (js->clj data :keywordize-keys true))))
      (.catch on-error)))

(defn get-hourly-forecast-url
  "Get hourly forecast URL for coordinates."
  [{:keys [lat lon on-success on-error]}]
  (fetch-json
   {:url (str "https://api.weather.gov/points/" lat "," lon)
    :on-success (fn [data]
                  (let [hourly-url (get-in data [:properties :forecastHourly])]
                    (on-success hourly-url)))
    :on-error on-error}))

(defn get-hourly-forecast-data
  "Get hourly forecast data from URL."
  [{:keys [url on-success on-error]}]
  (fetch-json
   {:url url
    :on-success on-success
    :on-error on-error}))

;; ============================================================================
;; Weather Icon Mapping
;; ============================================================================

(defn get-weather-icon
  "Map weather conditions to emoji icons."
  [short-forecast]
  (let [forecast-lower (str/lower-case (or short-forecast ""))]
    (cond
      (re-find #"thunder|tstorm" forecast-lower) "⛈️"
      (re-find #"rain|shower" forecast-lower) "🌧️"
      (re-find #"snow|flurr" forecast-lower) "❄️"
      (re-find #"sleet|ice" forecast-lower) "🌨️"
      (re-find #"fog|mist" forecast-lower) "🌫️"
      (re-find #"cloud" forecast-lower) "☁️"
      (re-find #"partly|mostly" forecast-lower) "⛅"
      (re-find #"clear|sunny" forecast-lower) "☀️"
      (re-find #"wind" forecast-lower) "💨"
      :else "🌤️")))

;; ============================================================================
;; Time Utilities
;; ============================================================================

(defn parse-iso-time
  "Parse ISO 8601 time string to JS Date."
  [iso-string]
  (js/Date. iso-string))

(defn format-hour-time
  "Format Date object to hour display (e.g., '2 PM', '11 AM')."
  [date]
  (let [hours (.getHours date)
        period (if (< hours 12) "AM" "PM")
        display-hour (cond
                       (= hours 0) 12
                       (> hours 12) (- hours 12)
                       :else hours)]
    (str display-hour " " period)))

(defn format-day-time
  "Format Date object to day and time (e.g., 'Fri 2 PM')."
  [date]
  (let [days ["Sun" "Mon" "Tue" "Wed" "Thu" "Fri" "Sat"]
        day-name (nth days (.getDay date))
        time (format-hour-time date)]
    (str day-name " " time)))

(defn is-current-hour?
  "Check if the given date is within the current hour."
  [date]
  (let [now (js/Date.)
        date-hour (.getHours date)
        now-hour (.getHours now)
        date-day (.getDate date)
        now-day (.getDate now)]
    (and (= date-day now-day)
         (= date-hour now-hour))))

;; ============================================================================
;; Temperature Conversion
;; ============================================================================

(defn fahrenheit-to-celsius [f]
  "Convert Fahrenheit to Celsius"
  (when f (* (- f 32) 0.5556)))

(defn fahrenheit-to-kelvin [f]
  "Convert Fahrenheit to Kelvin"
  (when f (+ (fahrenheit-to-celsius f) 273.15)))

(defn format-temp
  "Format temperature based on unit (F, C, or K)"
  [fahrenheit unit]
  (when fahrenheit
    (case unit
      "F" (str fahrenheit "°F")
      "C" (str (Math/round (fahrenheit-to-celsius fahrenheit)) "°C")
      "K" (str (Math/round (fahrenheit-to-kelvin fahrenheit)) "K")
      (str fahrenheit "°F"))))

(defn mps-to-mph [mps]
  "Convert meters per second to miles per hour"
  (when mps (* mps 2.237)))

;; ============================================================================
;; Styles
;; ============================================================================

(def container-style
  {:max-width "1200px"
   :margin "0 auto"
   :padding "20px"
   :font-family "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"})

(def card-style
  {:background "#ffffff"
   :border "1px solid #e0e0e0"
   :border-radius "12px"
   :padding "24px"
   :margin-bottom "20px"
   :box-shadow "0 2px 8px rgba(0,0,0,0.1)"})

(def header-style
  {:text-align "center"
   :margin-bottom "30px"})

(def title-style
  {:font-size "28px"
   :font-weight "600"
   :color "#1a1a1a"
   :margin "0 0 10px 0"})

(def subtitle-style
  {:font-size "14px"
   :color "#666"
   :margin 0})

(def input-group-style
  {:display "flex"
   :gap "10px"
   :margin-bottom "20px"
   :flex-wrap "wrap"})

(def input-style
  {:padding "10px 15px"
   :border "1px solid #ddd"
   :border-radius "6px"
   :font-size "14px"
   :flex "1"
   :min-width "120px"})

(def button-style
  {:padding "10px 20px"
   :background "#3b82f6"
   :color "white"
   :border "none"
   :border-radius "6px"
   :cursor "pointer"
   :font-size "14px"
   :font-weight "500"
   :transition "background 0.2s"})

(def quick-buttons-style
  {:display "flex"
   :gap "8px"
   :flex-wrap "wrap"
   :margin-bottom "20px"})

(def quick-button-style
  {:padding "8px 16px"
   :background "#f3f4f6"
   :border "1px solid #e5e7eb"
   :border-radius "6px"
   :cursor "pointer"
   :font-size "13px"
   :transition "all 0.2s"})

(def controls-bar-style
  {:display "flex"
   :justify-content "space-between"
   :align-items "center"
   :margin-bottom "20px"
   :flex-wrap "wrap"
   :gap "15px"})

(def slider-group-style
  {:display "flex"
   :gap "8px"
   :align-items "center"})

(def slider-label-style
  {:font-size "14px"
   :font-weight "500"
   :color "#4b5563"})

(def slider-button-style
  {:padding "6px 14px"
   :border "1px solid #ddd"
   :border-radius "6px"
   :background "#fff"
   :cursor "pointer"
   :font-size "13px"
   :transition "all 0.2s"})

(def slider-button-active-style
  (merge slider-button-style
         {:background "#3b82f6"
          :color "white"
          :border-color "#3b82f6"}))

(def unit-toggle-group-style
  {:display "flex"
   :gap "8px"})

(def unit-button-style
  {:padding "6px 14px"
   :border "1px solid #ddd"
   :border-radius "6px"
   :background "#fff"
   :cursor "pointer"
   :font-size "13px"
   :transition "all 0.2s"})

(def unit-button-active-style
  (merge unit-button-style
         {:background "#3b82f6"
          :color "white"
          :border-color "#3b82f6"}))

(def timeline-container-style
  {:overflow-x "auto"
   :overflow-y "hidden"
   :padding "10px 0"
   :margin "20px 0"
   :scroll-behavior "smooth"
   :-webkit-overflow-scrolling "touch"})

(def timeline-track-style
  {:display "flex"
   :gap "12px"
   :padding "5px"})

(def hour-card-style
  {:background "#ffffff"
   :border "1px solid #e5e7eb"
   :border-radius "10px"
   :padding "16px"
   :min-width "140px"
   :text-align "center"
   :flex-shrink "0"
   :transition "all 0.2s"
   :box-shadow "0 1px 3px rgba(0,0,0,0.1)"})

(def hour-card-current-style
  (merge hour-card-style
         {:border "2px solid #3b82f6"
          :background "#eff6ff"
          :box-shadow "0 2px 8px rgba(59,130,246,0.3)"}))

(def hour-card-hover-style
  (merge hour-card-style
         {:box-shadow "0 4px 12px rgba(0,0,0,0.15)"
          :transform "translateY(-2px)"}))

(def time-label-style
  {:font-size "14px"
   :font-weight "600"
   :color "#1a1a1a"
   :margin "0 0 8px 0"})

(def weather-icon-style
  {:font-size "36px"
   :margin "8px 0"})

(def temp-display-style
  {:font-size "24px"
   :font-weight "600"
   :color "#3b82f6"
   :margin "8px 0"})

(def precip-display-style
  {:font-size "12px"
   :color "#3b82f6"
   :margin "4px 0"})

(def wind-display-style
  {:font-size "12px"
   :color "#6b7280"
   :margin "4px 0"})

(def loading-style
  {:text-align "center"
   :padding "40px"
   :color "#6b7280"})

(def error-style
  {:background "#fef2f2"
   :border "1px solid #fecaca"
   :color "#dc2626"
   :padding "12px"
   :border-radius "6px"
   :margin "10px 0"})

(def location-display-style
  {:font-size "18px"
   :font-weight "500"
   :color "#1a1a1a"
   :margin-bottom "15px"})

;; ============================================================================
;; Quick City Locations
;; ============================================================================

(def quick-cities
  [{:name "Charlotte, NC" :lat 35.2271 :lon -80.8431}
   {:name "Miami, FL" :lat 25.7617 :lon -80.1918}
   {:name "Denver, CO" :lat 39.7392 :lon -104.9903}
   {:name "Seattle, WA" :lat 47.6062 :lon -122.3321}
   {:name "New York, NY" :lat 40.7128 :lon -74.0060}
   {:name "Los Angeles, CA" :lat 34.0522 :lon -118.2437}
   {:name "Chicago, IL" :lat 41.8781 :lon -87.6298}])

;; ============================================================================
;; Components
;; ============================================================================

(defn loading-spinner []
  [:div {:style loading-style}
   [:div {:style {:font-size "40px" :margin-bottom "10px"}} "⏳"]
   [:div "Loading hourly forecast..."]])

(defn error-message [{:keys [message]}]
  [:div {:style error-style}
   [:strong "Error: "] message])

(defn location-input [{:keys [lat lon on-lat-change on-lon-change on-fetch]}]
  [:div
   [:div {:style input-group-style}
    [:input {:type "number"
             :placeholder "Latitude"
             :value lat
             :on-change #(on-lat-change (.. % -target -value))
             :step "0.0001"
             :style input-style}]
    [:input {:type "number"
             :placeholder "Longitude"
             :value lon
             :on-change #(on-lon-change (.. % -target -value))
             :step "0.0001"
             :style input-style}]
    [:button {:on-click on-fetch
              :style button-style
              :on-mouse-over #(set! (.. % -target -style -background) "#2563eb")
              :on-mouse-out #(set! (.. % -target -style -background) "#3b82f6")}
     "Get Hourly Forecast"]]])

(defn quick-city-buttons [{:keys [cities on-select]}]
  [:div {:style quick-buttons-style}
   (for [city cities]
     ^{:key (:name city)}
     [:button
      {:style quick-button-style
       :on-click #(on-select city)
       :on-mouse-over #(set! (.. % -target -style -background) "#e5e7eb")
       :on-mouse-out #(set! (.. % -target -style -background) "#f3f4f6")}
      (:name city)])])

(defn controls-bar [{:keys [hours-to-show on-hours-change unit on-unit-change]}]
  [:div {:style controls-bar-style}
   [:div {:style slider-group-style}
    [:span {:style slider-label-style} "Show:"]
    (for [hours [6 12 24 48]]
      ^{:key hours}
      [:button
       {:style (if (= hours hours-to-show)
                 slider-button-active-style
                 slider-button-style)
        :on-click #(on-hours-change hours)}
       (str hours "h")])]

   [:div {:style unit-toggle-group-style}
    (for [u ["F" "C" "K"]]
      ^{:key u}
      [:button
       {:style (if (= u unit) unit-button-active-style unit-button-style)
        :on-click #(on-unit-change u)}
       u])]])

(defn hour-card [{:keys [period unit]}]
  (let [hovering? (r/atom false)]
    (fn [{:keys [period unit]}]
      (let [{:keys [startTime temperature windSpeed shortForecast
                    probabilityOfPrecipitation]} period
            date (parse-iso-time startTime)
            is-current? (is-current-hour? date)
            precip-value (get-in probabilityOfPrecipitation [:value])
            icon (get-weather-icon shortForecast)
            wind-mph (when windSpeed
                       (let [wind-str (str windSpeed)
                             matches (re-find #"(\d+)" wind-str)]
                         (when matches (js/parseInt (second matches)))))]
        [:div
         {:style (cond
                   is-current? hour-card-current-style
                   @hovering? hour-card-hover-style
                   :else hour-card-style)
          :on-mouse-enter #(reset! hovering? true)
          :on-mouse-leave #(reset! hovering? false)}

         [:div {:style time-label-style}
          (if is-current? "🔵 Now" (format-hour-time date))]
         [:div {:style weather-icon-style} icon]
         [:div {:style temp-display-style} (format-temp temperature unit)]

         (when (and precip-value (> precip-value 0))
           [:div {:style precip-display-style}
            "💧 " precip-value "%"])

         (when wind-mph
           [:div {:style wind-display-style}
            "💨 " wind-mph " mph"])]))))

(defn hourly-timeline [{:keys [periods unit hours-to-show timeline-ref]}]
  (let [displayed-periods (take hours-to-show periods)]
    [:div {:style timeline-container-style
           :ref timeline-ref}
     [:div {:style timeline-track-style}
      (for [period displayed-periods]
        ^{:key (:number period)}
        [hour-card {:period period :unit unit}])]]))

(defn forecast-display [{:keys [forecast-data location-name unit hours-to-show
                                on-hours-change on-unit-change timeline-ref]}]
  (let [periods (get-in forecast-data [:properties :periods])]
    [:div
     [:div {:style location-display-style} "📍 " location-name]

     [controls-bar
      {:hours-to-show hours-to-show
       :on-hours-change on-hours-change
       :unit unit
       :on-unit-change on-unit-change}]

     [hourly-timeline
      {:periods periods
       :unit unit
       :hours-to-show hours-to-show
       :timeline-ref timeline-ref}]]))

;; ============================================================================
;; Main Component
;; ============================================================================

(defn main-component []
  (let [lat (r/atom "35.2271")
        lon (r/atom "-80.8431")
        location-name (r/atom "Charlotte, NC")
        forecast-data (r/atom nil)
        loading? (r/atom false)
        error (r/atom nil)
        unit (r/atom "F")
        hours-to-show (r/atom 24)
        timeline-ref (atom nil)

        scroll-to-current
        (fn []
          (when-let [container @timeline-ref]
            (when-let [current-card (.querySelector container "[style*='border: 2px solid']")]
              (.scrollIntoView current-card #js {:behavior "smooth" :inline "center"}))))

        fetch-forecast
        (fn []
          (reset! loading? true)
          (reset! error nil)
          (reset! forecast-data nil)

          (get-hourly-forecast-url
           {:lat @lat
            :lon @lon
            :on-success
            (fn [hourly-url]
              (get-hourly-forecast-data
               {:url hourly-url
                :on-success
                (fn [data]
                  (reset! forecast-data data)
                  (reset! loading? false)
                  ;; Scroll to current hour after a short delay
                  (js/setTimeout scroll-to-current 300))
                :on-error
                (fn [err]
                  (reset! error (str "Failed to fetch hourly forecast: " err))
                  (reset! loading? false))}))
            :on-error
            (fn [err]
              (reset! error (str "Failed to find location: " err))
              (reset! loading? false))}))

        select-city
        (fn [city]
          (reset! lat (str (:lat city)))
          (reset! lon (str (:lon city)))
          (reset! location-name (:name city))
          (fetch-forecast))]

    (fn []
      [:div {:style container-style}
       [:div {:style header-style}
        [:h1 {:style title-style} "⏰ Hourly Weather Forecast"]
        [:p {:style subtitle-style}
         "Hour-by-hour predictions with interactive timeline"]]

       [:div {:style card-style}
        [location-input
         {:lat @lat
          :lon @lon
          :on-lat-change #(reset! lat %)
          :on-lon-change #(reset! lon %)
          :on-fetch fetch-forecast}]

        [quick-city-buttons
         {:cities quick-cities
          :on-select select-city}]]

       (cond
         @loading? [loading-spinner]
         @error [error-message {:message @error}]
         @forecast-data [:div {:style card-style}
                         [forecast-display
                          {:forecast-data @forecast-data
                           :location-name @location-name
                           :unit @unit
                           :hours-to-show @hours-to-show
                           :on-hours-change #(reset! hours-to-show %)
                           :on-unit-change #(reset! unit %)
                           :timeline-ref timeline-ref}]])])))

;; ============================================================================
;; Mount
;; ============================================================================

(defn ^:export init []
  (when-let [el (js/document.getElementById "hourly-forecast-demo")]
    (rdom/render [main-component] el)))

(init)

Try It Live

Select a city, then use the hour controls (6h, 12h, 24h, 48h) to adjust the timeline! The current hour will be highlighted and centered automatically.

(kind/hiccup
 [:div#hourly-forecast-demo {:style {:min-height "700px"}}
  [:script {:type "application/x-scittle"
            :src "hourly_forecast.cljs"}]])

Demo 5: Weather Alerts System

Safety first! This demo displays active weather alerts with professional severity-based styling and expandable detail views.

Alert features:

  • Severity-based color coding - Left border colors indicate alert severity:
    • Extreme: Dark Red (#b91c1c) - Tornado warnings, extreme conditions
    • Severe: Orange (#ea580c) - Severe thunderstorm, flash flood warnings
    • Moderate: Yellow/Gold (#ca8a04) - Heat advisories, winter weather advisories
    • Minor: Green (#65a30d) - Frost advisories, minor weather events
  • Event-specific emoji icons - 🌪️ tornado, 🌀 hurricane, 🌊 flood, 🔥 fire, etc.
  • Expandable alert cards - Click to reveal full description and timing
  • Badge system - Color-coded urgency, severity, and certainty badges
  • No alerts state - Clean “✅ No Active Alerts” display when all is clear

Technical features:

  • Dynamic border-left styling based on severity
  • Expandable/collapsible sections with state management
  • Badge color mapping for urgency (Immediate, Expected, Future)
  • Badge color mapping for certainty (Observed, Likely, Possible)
  • Time formatting for alert validity periods
  • Conditional rendering (only show times when present)

Color coding system:

graph TD A[Alert Severity] --> B[Extreme - Dark Red] A --> C[Severe - Orange] A --> D[Moderate - Yellow] A --> E[Minor - Green] F[Urgency] --> G[Immediate - Red] F --> H[Expected - Orange] F --> I[Future - Blue] J[Certainty] --> K[Observed - Green] J --> L[Likely - Blue] J --> M[Possible - Purple]

What each alert card shows:

  • Event name with emoji icon (Tornado Warning 🌪️)
  • Headline/summary
  • Severity, urgency, certainty badges
  • Expandable full description
  • Effective and expiration times

Pro tip: Try Oklahoma City or Kansas City - they’re in Tornado Alley and often have active weather alerts!

(ns scittle.weather.weather-alerts
  "Display active weather alerts with severity-based styling and expandable details.
   Demonstrates conditional rendering, color coding, and alert management."
  (:require [reagent.core :as r]
            [reagent.dom :as rdom]
            [clojure.string :as str]))

;; ============================================================================
;; API Functions (Inline)
;; ============================================================================

(defn fetch-json
  "Fetch JSON from URL with error handling."
  [{:keys [url on-success on-error]
    :or {on-error #(js/console.error "Fetch error:" %)}}]
  (-> (js/fetch url)
      (.then (fn [response]
               (if (.-ok response)
                 (.json response)
                 (throw (js/Error. (str "HTTP " (.-status response)))))))
      (.then (fn [data]
               (on-success (js->clj data :keywordize-keys true))))
      (.catch on-error)))

(defn get-alerts-for-point
  "Get active weather alerts for coordinates."
  [{:keys [lat lon on-success on-error]}]
  (fetch-json
   {:url (str "https://api.weather.gov/alerts/active?point=" lat "," lon)
    :on-success on-success
    :on-error on-error}))

;; ============================================================================
;; Severity Utilities
;; ============================================================================

(defn get-severity-color
  "Map severity level to color."
  [severity]
  (case (str/lower-case (or severity "unknown"))
    "extreme" "#b91c1c" ; Dark red
    "severe" "#ea580c" ; Orange
    "moderate" "#ca8a04" ; Yellow/Gold
    "minor" "#65a30d" ; Green
    "#6b7280")) ; Gray for unknown

(defn get-urgency-badge-color
  "Map urgency level to badge color."
  [urgency]
  (case (str/lower-case (or urgency "unknown"))
    "immediate" "#dc2626"
    "expected" "#f59e0b"
    "future" "#3b82f6"
    "#6b7280"))

(defn get-certainty-badge-color
  "Map certainty level to badge color."
  [certainty]
  (case (str/lower-case (or certainty "unknown"))
    "observed" "#059669"
    "likely" "#3b82f6"
    "possible" "#8b5cf6"
    "#6b7280"))

(defn get-alert-icon
  "Map alert event to emoji icon."
  [event]
  (let [event-lower (str/lower-case (or event ""))]
    (cond
      (re-find #"tornado" event-lower) "🌪️"
      (re-find #"hurricane" event-lower) "🌀"
      (re-find #"flood" event-lower) "🌊"
      (re-find #"fire" event-lower) "🔥"
      (re-find #"heat" event-lower) "🌡️"
      (re-find #"winter|snow|ice|blizzard" event-lower) "❄️"
      (re-find #"wind" event-lower) "💨"
      (re-find #"thunder|lightning" event-lower) "⚡"
      (re-find #"fog" event-lower) "🌫️"
      :else "⚠️")))

;; ============================================================================
;; Time Utilities
;; ============================================================================

(defn format-alert-time
  "Format ISO time to readable format."
  [iso-string]
  (when iso-string
    (let [date (js/Date. iso-string)]
      (.toLocaleString date "en-US"
                       #js {:month "short"
                            :day "numeric"
                            :hour "numeric"
                            :minute "2-digit"}))))

;; ============================================================================
;; Styles
;; ============================================================================

(def container-style
  {:max-width "1000px"
   :margin "0 auto"
   :padding "20px"
   :font-family "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"})

(def card-style
  {:background "#ffffff"
   :border "1px solid #e0e0e0"
   :border-radius "12px"
   :padding "24px"
   :margin-bottom "20px"
   :box-shadow "0 2px 8px rgba(0,0,0,0.1)"})

(def header-style
  {:text-align "center"
   :margin-bottom "30px"})

(def title-style
  {:font-size "28px"
   :font-weight "600"
   :color "#1a1a1a"
   :margin "0 0 10px 0"})

(def subtitle-style
  {:font-size "14px"
   :color "#666"
   :margin 0})

(def input-group-style
  {:display "flex"
   :gap "10px"
   :margin-bottom "20px"
   :flex-wrap "wrap"})

(def input-style
  {:padding "10px 15px"
   :border "1px solid #ddd"
   :border-radius "6px"
   :font-size "14px"
   :flex "1"
   :min-width "120px"})

(def button-style
  {:padding "10px 20px"
   :background "#3b82f6"
   :color "white"
   :border "none"
   :border-radius "6px"
   :cursor "pointer"
   :font-size "14px"
   :font-weight "500"
   :transition "background 0.2s"})

(def quick-buttons-style
  {:display "flex"
   :gap "8px"
   :flex-wrap "wrap"
   :margin-bottom "20px"})

(def quick-button-style
  {:padding "8px 16px"
   :background "#f3f4f6"
   :border "1px solid #e5e7eb"
   :border-radius "6px"
   :cursor "pointer"
   :font-size "13px"
   :transition "all 0.2s"})

(def alerts-container-style
  {:display "flex"
   :flex-direction "column"
   :gap "15px"})

(defn alert-card-style [severity-color]
  {:background "#ffffff"
   :border-left (str "4px solid " severity-color)
   :border-radius "8px"
   :padding "20px"
   :box-shadow "0 2px 6px rgba(0,0,0,0.1)"
   :transition "all 0.2s"})

(def alert-header-style
  {:display "flex"
   :justify-content "space-between"
   :align-items "flex-start"
   :margin-bottom "12px"
   :gap "15px"})

(def alert-title-section-style
  {:flex "1"})

(def alert-event-style
  {:font-size "20px"
   :font-weight "600"
   :color "#1a1a1a"
   :margin "0 0 8px 0"
   :display "flex"
   :align-items "center"
   :gap "10px"})

(def alert-headline-style
  {:font-size "14px"
   :color "#4b5563"
   :margin "0 0 12px 0"
   :line-height "1.4"})

(def badges-container-style
  {:display "flex"
   :gap "8px"
   :flex-wrap "wrap"
   :margin-bottom "12px"})

(defn badge-style [color]
  {:background color
   :color "white"
   :padding "4px 12px"
   :border-radius "12px"
   :font-size "12px"
   :font-weight "600"
   :text-transform "uppercase"
   :letter-spacing "0.5px"})

(def expand-button-style
  {:background "transparent"
   :border "1px solid #d1d5db"
   :border-radius "6px"
   :padding "8px 16px"
   :cursor "pointer"
   :font-size "13px"
   :color "#4b5563"
   :transition "all 0.2s"
   :display "flex"
   :align-items "center"
   :gap "8px"})

(def expanded-details-style
  {:margin-top "15px"
   :padding-top "15px"
   :border-top "1px solid #e5e7eb"})

(def description-style
  {:font-size "14px"
   :color "#374151"
   :line-height "1.6"
   :margin "0 0 15px 0"
   :white-space "pre-wrap"})

(def time-info-style
  {:display "grid"
   :grid-template-columns "repeat(auto-fit, minmax(200px, 1fr))"
   :gap "10px"
   :margin-top "15px"
   :padding "12px"
   :background "#f9fafb"
   :border-radius "6px"})

(def time-item-style
  {:font-size "13px"
   :color "#6b7280"})

(def time-label-style
  {:font-weight "600"
   :color "#374151"})

(def no-alerts-style
  {:text-align "center"
   :padding "40px"
   :color "#6b7280"})

(def no-alerts-icon-style
  {:font-size "64px"
   :margin-bottom "15px"})

(def no-alerts-text-style
  {:font-size "18px"
   :font-weight "500"
   :color "#1a1a1a"
   :margin "0 0 8px 0"})

(def no-alerts-subtext-style
  {:font-size "14px"
   :color "#6b7280"})

(def loading-style
  {:text-align "center"
   :padding "40px"
   :color "#6b7280"})

(def error-style
  {:background "#fef2f2"
   :border "1px solid #fecaca"
   :color "#dc2626"
   :padding "12px"
   :border-radius "6px"
   :margin "10px 0"})

(def location-display-style
  {:font-size "18px"
   :font-weight "500"
   :color "#1a1a1a"
   :margin-bottom "20px"})

;; ============================================================================
;; Quick City Locations
;; ============================================================================

(def quick-cities
  [{:name "Charlotte, NC" :lat 35.2271 :lon -80.8431}
   {:name "Miami, FL" :lat 25.7617 :lon -80.1918}
   {:name "Denver, CO" :lat 39.7392 :lon -104.9903}
   {:name "Seattle, WA" :lat 47.6062 :lon -122.3321}
   {:name "New York, NY" :lat 40.7128 :lon -74.0060}
   {:name "Los Angeles, CA" :lat 34.0522 :lon -118.2437}
   {:name "Chicago, IL" :lat 41.8781 :lon -87.6298}
   {:name "Oklahoma City, OK" :lat 35.4676 :lon -97.5164} ; Often has alerts
   {:name "Kansas City, MO" :lat 39.0997 :lon -94.5786}]) ; Tornado alley

;; ============================================================================
;; Components
;; ============================================================================

(defn loading-spinner []
  [:div {:style loading-style}
   [:div {:style {:font-size "40px" :margin-bottom "10px"}} "⏳"]
   [:div "Loading weather alerts..."]])

(defn error-message [{:keys [message]}]
  [:div {:style error-style}
   [:strong "Error: "] message])

(defn location-input [{:keys [lat lon on-lat-change on-lon-change on-fetch]}]
  [:div
   [:div {:style input-group-style}
    [:input {:type "number"
             :placeholder "Latitude"
             :value lat
             :on-change #(on-lat-change (.. % -target -value))
             :step "0.0001"
             :style input-style}]
    [:input {:type "number"
             :placeholder "Longitude"
             :value lon
             :on-change #(on-lon-change (.. % -target -value))
             :step "0.0001"
             :style input-style}]
    [:button {:on-click on-fetch
              :style button-style
              :on-mouse-over #(set! (.. % -target -style -background) "#2563eb")
              :on-mouse-out #(set! (.. % -target -style -background) "#3b82f6")}
     "Check Alerts"]]])

(defn quick-city-buttons [{:keys [cities on-select]}]
  [:div {:style quick-buttons-style}
   (for [city cities]
     ^{:key (:name city)}
     [:button
      {:style quick-button-style
       :on-click #(on-select city)
       :on-mouse-over #(set! (.. % -target -style -background) "#e5e7eb")
       :on-mouse-out #(set! (.. % -target -style -background) "#f3f4f6")}
      (:name city)])])

(defn alert-card-component [{:keys [alert]}]
  (let [expanded? (r/atom false)
        props (:properties alert)
        {:keys [event headline description severity urgency certainty
                onset expires effective]} props
        icon (get-alert-icon event)
        severity-color (get-severity-color severity)]

    (fn [{:keys [alert]}]
      [:div {:style (alert-card-style severity-color)}

       ;; Alert Header
       [:div {:style alert-header-style}
        [:div {:style alert-title-section-style}
         [:h3 {:style alert-event-style}
          [:span icon]
          [:span event]]
         [:p {:style alert-headline-style} headline]

         ;; Badges
         [:div {:style badges-container-style}
          [:span {:style (badge-style severity-color)} severity]
          [:span {:style (badge-style (get-urgency-badge-color urgency))} urgency]
          [:span {:style (badge-style (get-certainty-badge-color certainty))} certainty]]]]

       ;; Expand/Collapse Button
       [:button
        {:style expand-button-style
         :on-click #(swap! expanded? not)
         :on-mouse-over #(set! (.. % -target -style -background) "#f3f4f6")
         :on-mouse-out #(set! (.. % -target -style -background) "transparent")}
        [:span (if @expanded? "▼" "▶")]
        [:span (if @expanded? "Hide Details" "View Details")]]

       ;; Expanded Details
       (when @expanded?
         [:div {:style expanded-details-style}
          [:div {:style description-style} description]

          [:div {:style time-info-style}
           (when onset
             [:div {:style time-item-style}
              [:span {:style time-label-style} "Effective: "]
              (format-alert-time onset)])

           (when expires
             [:div {:style time-item-style}
              [:span {:style time-label-style} "Expires: "]
              (format-alert-time expires)])]])])))

(defn no-alerts-display [{:keys [location-name]}]
  [:div {:style no-alerts-style}
   [:div {:style no-alerts-icon-style} "✅"]
   [:div {:style no-alerts-text-style} "No Active Weather Alerts"]
   [:div {:style no-alerts-subtext-style}
    "There are currently no weather alerts for " location-name]])

(defn alerts-display [{:keys [alerts-data location-name]}]
  (let [features (:features alerts-data)
        alert-count (count features)]
    [:div
     [:div {:style location-display-style}
      "⚠️ Weather Alerts for " location-name
      (when (> alert-count 0)
        (str " (" alert-count " active)"))]

     (if (empty? features)
       [no-alerts-display {:location-name location-name}]
       [:div {:style alerts-container-style}
        (for [alert features]
          ^{:key (:id alert)}
          [alert-card-component {:alert alert}])])]))

;; ============================================================================
;; Main Component
;; ============================================================================

(defn main-component []
  (let [lat (r/atom "35.2271")
        lon (r/atom "-80.8431")
        location-name (r/atom "Charlotte, NC")
        alerts-data (r/atom nil)
        loading? (r/atom false)
        error (r/atom nil)

        fetch-alerts
        (fn []
          (reset! loading? true)
          (reset! error nil)
          (reset! alerts-data nil)

          (get-alerts-for-point
           {:lat @lat
            :lon @lon
            :on-success
            (fn [data]
              (reset! alerts-data data)
              (reset! loading? false))
            :on-error
            (fn [err]
              (reset! error (str "Failed to fetch alerts: " err))
              (reset! loading? false))}))

        select-city
        (fn [city]
          (reset! lat (str (:lat city)))
          (reset! lon (str (:lon city)))
          (reset! location-name (:name city))
          (fetch-alerts))]

    (fn []
      [:div {:style container-style}
       [:div {:style header-style}
        [:h1 {:style title-style} "⚠️ Weather Alerts"]
        [:p {:style subtitle-style}
         "Active weather warnings and advisories with severity-based styling"]]

       [:div {:style card-style}
        [location-input
         {:lat @lat
          :lon @lon
          :on-lat-change #(reset! lat %)
          :on-lon-change #(reset! lon %)
          :on-fetch fetch-alerts}]

        [quick-city-buttons
         {:cities quick-cities
          :on-select select-city}]]

       (cond
         @loading? [loading-spinner]
         @error [error-message {:message @error}]
         @alerts-data [:div {:style card-style}
                       [alerts-display
                        {:alerts-data @alerts-data
                         :location-name @location-name}]])])))

;; ============================================================================
;; Mount
;; ============================================================================

(defn ^:export init []
  (when-let [el (js/document.getElementById "weather-alerts-demo")]
    (rdom/render [main-component] el)))

(init)

Try It Live

Try different cities - some locations may show “No Active Alerts” which is good news! Click “View Details” on any alert to expand it.

(kind/hiccup
 [:div#weather-alerts-demo {:style {:min-height "600px"}}
  [:script {:type "application/x-scittle"
            :src "weather_alerts.cljs"}]])

Demo 6: Complete Weather Dashboard

The Grand Finale! This demo brings everything together into a professional, full-featured weather application.

What makes this special:

This is what you build when you understand all the pieces. It’s a complete weather application that could serve as the foundation for a production app!

Integrated features:

  • Beautiful gradient header - Purple gradient with location and last-updated time
  • Tab navigation system - Switch between Current, 7-Day, Hourly, and Alerts views
  • Unified settings bar - Temperature unit control and auto-refresh indicator
  • Grid-based city selector - 8 major cities in a responsive grid layout
  • Parallel data fetching - Loads all weather data simultaneously for speed
  • Comprehensive state management - Tracks location, loading, units, and timestamps

Technical architecture:

  • Parallel API calls - Uses atoms and completion checks to fetch 4 data sources at once
  • Unified API function - get-all-weather-data consolidates the multi-step process
  • Tab-based views - Each tab renders different data using the same source
  • Consistent styling - Reuses patterns from previous demos in a cohesive design
  • Error handling - Gracefully handles missing alerts or failed requests

The data flow:

graph TB A[User clicks city] --> B[Fetch Points] B --> C{Parallel Requests} C --> D[Forecast] C --> E[Hourly] C --> F[Station → Observations] C --> G[Alerts] D --> H[Complete Data] E --> H F --> H G --> H H --> I[Render Dashboard]

What each tab shows:

  • ☀️ Current - Large temp display with emoji icon + key metrics (humidity, wind, pressure)
  • 📅 7-Day - Week forecast cards in responsive grid
  • ⏰ Hourly - Next 12 hours with time labels and temps
  • ⚠️ Alerts - Weather warnings or “All clear” message

Why this matters:

This demo shows how to build a real application by composing smaller pieces. Each previous demo taught a specific pattern, and now we see how they fit together into something useful and polished.

(ns scittle.weather.complete-dashboard
  "Full-featured weather dashboard integrating all previous demos.
   Demonstrates tab navigation, settings management, and comprehensive weather display."
  (:require [reagent.core :as r]
            [reagent.dom :as rdom]
            [clojure.string :as str]))

;; ============================================================================
;; API Functions (Consolidated)
;; ============================================================================

(defn fetch-json
  [{:keys [url on-success on-error]
    :or {on-error #(js/console.error "Fetch error:" %)}}]
  (-> (js/fetch url)
      (.then (fn [response]
               (if (.-ok response)
                 (.json response)
                 (throw (js/Error. (str "HTTP " (.-status response)))))))
      (.then (fn [data]
               (on-success (js->clj data :keywordize-keys true))))
      (.catch on-error)))

(defn get-all-weather-data
  "Fetch all weather data for a location."
  [{:keys [lat lon on-success on-error]}]
  (fetch-json
   {:url (str "https://api.weather.gov/points/" lat "," lon)
    :on-success
    (fn [points-data]
      (let [props (:properties points-data)
            forecast-url (:forecast props)
            hourly-url (:forecastHourly props)
            station-url (:observationStations props)]
        ;; Fetch all data in parallel
        (let [results (atom {:forecast nil :hourly nil :station-id nil :observations nil})
              complete-check (fn []
                               (when (and (:forecast @results)
                                          (:hourly @results)
                                          (:observations @results))
                                 (on-success @results)))]

          ;; Fetch forecast
          (fetch-json
           {:url forecast-url
            :on-success (fn [data]
                          (swap! results assoc :forecast data)
                          (complete-check))
            :on-error on-error})

          ;; Fetch hourly
          (fetch-json
           {:url hourly-url
            :on-success (fn [data]
                          (swap! results assoc :hourly data)
                          (complete-check))
            :on-error on-error})

          ;; Fetch station and observations
          (fetch-json
           {:url station-url
            :on-success (fn [stations]
                          (let [station-id (-> stations :features first :properties :stationIdentifier)]
                            (swap! results assoc :station-id station-id)
                            (fetch-json
                             {:url (str "https://api.weather.gov/stations/" station-id "/observations/latest")
                              :on-success (fn [obs]
                                            (swap! results assoc :observations obs)
                                            (complete-check))
                              :on-error on-error})))
            :on-error on-error}))))
    :on-error on-error}))

(defn get-alerts
  [{:keys [lat lon on-success on-error]}]
  (fetch-json
   {:url (str "https://api.weather.gov/alerts/active?point=" lat "," lon)
    :on-success on-success
    :on-error on-error}))

;; ============================================================================
;; Utilities (from previous demos)
;; ============================================================================

(defn celsius-to-fahrenheit [c]
  (when c (+ (* c 1.8) 32)))

(defn fahrenheit-to-celsius [f]
  (when f (* (- f 32) 0.5556)))

(defn fahrenheit-to-kelvin [f]
  (when f (+ (fahrenheit-to-celsius f) 273.15)))

(defn format-temp [fahrenheit unit]
  (when fahrenheit
    (case unit
      "F" (str fahrenheit "°F")
      "C" (str (Math/round (fahrenheit-to-celsius fahrenheit)) "°C")
      "K" (str (Math/round (fahrenheit-to-kelvin fahrenheit)) "K")
      (str fahrenheit "°F"))))

(defn get-weather-icon [short-forecast]
  (let [forecast-lower (str/lower-case (or short-forecast ""))]
    (cond
      (re-find #"thunder|tstorm" forecast-lower) "⛈️"
      (re-find #"rain|shower" forecast-lower) "🌧️"
      (re-find #"snow|flurr" forecast-lower) "❄️"
      (re-find #"cloud" forecast-lower) "☁️"
      (re-find #"partly|mostly" forecast-lower) "⛅"
      (re-find #"clear|sunny" forecast-lower) "☀️"
      :else "🌤️")))

;; ============================================================================
;; Styles
;; ============================================================================

(def app-container-style
  {:max-width "1400px"
   :margin "0 auto"
   :padding "20px"
   :font-family "-apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif"})

(def header-card-style
  {:background "linear-gradient(135deg, #667eea 0%, #764ba2 100%)"
   :color "white"
   :border-radius "12px"
   :padding "30px"
   :margin-bottom "20px"
   :box-shadow "0 4px 12px rgba(0,0,0,0.15)"})

(def app-title-style
  {:font-size "32px"
   :font-weight "700"
   :margin "0 0 10px 0"
   :display "flex"
   :align-items "center"
   :gap "15px"})

(def location-header-style
  {:font-size "18px"
   :opacity "0.9"
   :margin "10px 0 5px 0"})

(def last-updated-style
  {:font-size "14px"
   :opacity "0.8"})

(def card-style
  {:background "#ffffff"
   :border "1px solid #e0e0e0"
   :border-radius "12px"
   :padding "24px"
   :margin-bottom "20px"
   :box-shadow "0 2px 8px rgba(0,0,0,0.1)"})

(def location-search-style
  {:display "grid"
   :grid-template-columns "1fr 1fr auto"
   :gap "10px"
   :margin-bottom "15px"})

(def input-style
  {:padding "12px 15px"
   :border "1px solid #ddd"
   :border-radius "8px"
   :font-size "14px"})

(def button-primary-style
  {:padding "12px 24px"
   :background "#3b82f6"
   :color "white"
   :border "none"
   :border-radius "8px"
   :cursor "pointer"
   :font-size "14px"
   :font-weight "600"
   :transition "all 0.2s"})

(def quick-cities-grid-style
  {:display "grid"
   :grid-template-columns "repeat(auto-fill, minmax(140px, 1fr))"
   :gap "10px"
   :margin-top "15px"})

(def city-button-style
  {:padding "10px"
   :background "#f3f4f6"
   :border "1px solid #e5e7eb"
   :border-radius "8px"
   :cursor "pointer"
   :font-size "13px"
   :text-align "center"
   :transition "all 0.2s"})

(def tabs-container-style
  {:display "flex"
   :gap "5px"
   :border-bottom "2px solid #e5e7eb"
   :margin-bottom "20px"})

(defn tab-style [active?]
  {:padding "12px 24px"
   :background (if active? "#3b82f6" "transparent")
   :color (if active? "white" "#6b7280")
   :border "none"
   :border-bottom (if active? "2px solid #3b82f6" "2px solid transparent")
   :cursor "pointer"
   :font-size "14px"
   :font-weight (if active? "600" "500")
   :transition "all 0.2s"
   :border-radius "8px 8px 0 0"})

(def settings-bar-style
  {:display "flex"
   :justify-content "space-between"
   :align-items "center"
   :padding "15px"
   :background "#f9fafb"
   :border-radius "8px"
   :margin-bottom "20px"
   :flex-wrap "wrap"
   :gap "15px"})

(def setting-group-style
  {:display "flex"
   :align-items "center"
   :gap "10px"})

(def setting-label-style
  {:font-size "14px"
   :font-weight "500"
   :color "#4b5563"})

(def toggle-buttons-style
  {:display "flex"
   :gap "5px"})

(defn toggle-button-style [active?]
  {:padding "6px 14px"
   :background (if active? "#3b82f6" "white")
   :color (if active? "white" "#6b7280")
   :border "1px solid #d1d5db"
   :border-radius "6px"
   :cursor "pointer"
   :font-size "13px"
   :transition "all 0.2s"})

(def current-summary-style
  {:display "grid"
   :grid-template-columns "auto 1fr"
   :gap "30px"
   :align-items "center"})

(def large-temp-section-style
  {:text-align "center"})

(def temp-display-style
  {:font-size "72px"
   :font-weight "300"
   :color "#1a1a1a"
   :line-height "1"
   :margin "0"})

(def condition-text-style
  {:font-size "18px"
   :color "#6b7280"
   :margin "10px 0"})

(def metrics-quick-grid-style
  {:display "grid"
   :grid-template-columns "repeat(auto-fit, minmax(150px, 1fr))"
   :gap "15px"})

(def metric-item-style
  {:padding "12px"
   :background "#f9fafb"
   :border-radius "8px"})

(def metric-label-style
  {:font-size "12px"
   :color "#6b7280"
   :margin-bottom "5px"
   :text-transform "uppercase"
   :font-weight "600"})

(def metric-value-style
  {:font-size "20px"
   :color "#1a1a1a"
   :font-weight "500"})

(def forecast-mini-grid-style
  {:display "grid"
   :grid-template-columns "repeat(auto-fit, minmax(120px, 1fr))"
   :gap "12px"})

(def forecast-mini-card-style
  {:padding "15px"
   :background "#f9fafb"
   :border-radius "8px"
   :text-align "center"})

(def loading-style
  {:text-align "center"
   :padding "60px"
   :color "#6b7280"})

;; ============================================================================
;; Quick Cities
;; ============================================================================

(def quick-cities
  [{:name "Charlotte, NC" :lat 35.2271 :lon -80.8431}
   {:name "Miami, FL" :lat 25.7617 :lon -80.1918}
   {:name "Denver, CO" :lat 39.7392 :lon -104.9903}
   {:name "Seattle, WA" :lat 47.6062 :lon -122.3321}
   {:name "New York, NY" :lat 40.7128 :lon -74.0060}
   {:name "Los Angeles, CA" :lat 34.0522 :lon -118.2437}
   {:name "Chicago, IL" :lat 41.8781 :lon -87.6298}
   {:name "Phoenix, AZ" :lat 33.4484 :lon -112.0740}
   {:name "Boston, MA" :lat 42.3601 :lon -71.0589}])

;; ============================================================================
;; Components
;; ============================================================================

(defn loading-spinner []
  [:div {:style loading-style}
   [:div {:style {:font-size "60px" :margin-bottom "20px"}} "🌐"]
   [:div {:style {:font-size "18px"}} "Loading weather data..."]])

(defn location-search-panel [{:keys [lat lon on-lat-change on-lon-change on-fetch cities on-city-select]}]
  [:div {:style card-style}
   [:h3 {:style {:margin "0 0 15px 0" :font-size "18px"}} "📍 Location"]

   [:div {:style location-search-style}
    [:input {:type "number"
             :placeholder "Latitude"
             :value lat
             :on-change #(on-lat-change (.. % -target -value))
             :step "0.0001"
             :style input-style}]
    [:input {:type "number"
             :placeholder "Longitude"
             :value lon
             :on-change #(on-lon-change (.. % -target -value))
             :step "0.0001"
             :style input-style}]
    [:button {:on-click on-fetch
              :style button-primary-style}
     "Get Weather"]]

   [:div {:style quick-cities-grid-style}
    (for [city cities]
      ^{:key (:name city)}
      [:button
       {:style city-button-style
        :on-click #(on-city-select city)
        :on-mouse-over #(set! (.. % -target -style -background) "#e5e7eb")
        :on-mouse-out #(set! (.. % -target -style -background) "#f3f4f6")}
       (:name city)])]])

(defn settings-bar [{:keys [unit on-unit-change]}]
  [:div {:style settings-bar-style}
   [:div {:style setting-group-style}
    [:span {:style setting-label-style} "Temperature Unit:"]
    [:div {:style toggle-buttons-style}
     (for [u ["F" "C" "K"]]
       ^{:key u}
       [:button
        {:style (toggle-button-style (= u unit))
         :on-click #(on-unit-change u)}
        u])]]

   [:div {:style {:font-size "13px" :color "#6b7280"}}
    "🔄 Auto-refresh: Off"]])

(defn tabs-navigation [{:keys [active-tab on-tab-change]}]
  [:div {:style tabs-container-style}
   (for [tab [{:id :current :label "☀️ Current"}
              {:id :forecast :label "📅 7-Day"}
              {:id :hourly :label "⏰ Hourly"}
              {:id :alerts :label "⚠️ Alerts"}]]
     ^{:key (:id tab)}
     [:button
      {:style (tab-style (= active-tab (:id tab)))
       :on-click #(on-tab-change (:id tab))}
      (:label tab)])])

(defn current-conditions-view [{:keys [observations unit]}]
  (let [props (get-in observations [:properties])
        temp-c (get-in props [:temperature :value])
        temp-f (when temp-c (Math/round (celsius-to-fahrenheit temp-c)))
        condition (:textDescription props)
        humidity (get-in props [:relativeHumidity :value])
        wind-speed (get-in props [:windSpeed :value])
        pressure (get-in props [:barometricPressure :value])]

    [:div
     [:div {:style current-summary-style}
      [:div {:style large-temp-section-style}
       [:div {:style {:font-size "48px" :margin-bottom "10px"}}
        (get-weather-icon condition)]
       [:div {:style temp-display-style}
        (format-temp temp-f unit)]
       [:div {:style condition-text-style} condition]]

      [:div {:style metrics-quick-grid-style}
       [:div {:style metric-item-style}
        [:div {:style metric-label-style} "Humidity"]
        [:div {:style metric-value-style}
         (when humidity (str (Math/round humidity) "%"))]]

       [:div {:style metric-item-style}
        [:div {:style metric-label-style} "Wind"]
        [:div {:style metric-value-style}
         (when wind-speed (str (Math/round (* wind-speed 2.237)) " mph"))]]

       [:div {:style metric-item-style}
        [:div {:style metric-label-style} "Pressure"]
        [:div {:style metric-value-style}
         (when pressure (str (Math/round (/ pressure 100)) " mb"))]]]]]))

(defn forecast-view [{:keys [forecast unit]}]
  (let [periods (take 7 (get-in forecast [:properties :periods]))]
    [:div {:style forecast-mini-grid-style}
     (for [period periods]
       ^{:key (:number period)}
       [:div {:style forecast-mini-card-style}
        [:div {:style {:font-weight "600" :margin-bottom "8px"}}
         (:name period)]
        [:div {:style {:font-size "36px" :margin "10px 0"}}
         (get-weather-icon (:shortForecast period))]
        [:div {:style {:font-size "24px" :font-weight "600" :color "#3b82f6"}}
         (format-temp (:temperature period) unit)]
        [:div {:style {:font-size "13px" :color "#6b7280" :margin-top "8px"}}
         (:shortForecast period)]])]))

(defn hourly-view [{:keys [hourly unit]}]
  (let [periods (take 12 (get-in hourly [:properties :periods]))]
    [:div {:style forecast-mini-grid-style}
     (for [period periods]
       ^{:key (:number period)}
       (let [date (js/Date. (:startTime period))
             hours (.getHours date)
             display-hour (if (= hours 0) 12 (if (> hours 12) (- hours 12) hours))
             period-label (if (< hours 12) "AM" "PM")]
         [:div {:style forecast-mini-card-style}
          [:div {:style {:font-weight "600" :margin-bottom "8px"}}
           (str display-hour " " period-label)]
          [:div {:style {:font-size "32px" :margin "8px 0"}}
           (get-weather-icon (:shortForecast period))]
          [:div {:style {:font-size "20px" :font-weight "600" :color "#3b82f6"}}
           (format-temp (:temperature period) unit)]]))]))

(defn alerts-view [{:keys [alerts]}]
  (let [features (:features alerts)]
    (if (empty? features)
      [:div {:style {:text-align "center" :padding "40px"}}
       [:div {:style {:font-size "64px"}} "✅"]
       [:div {:style {:font-size "18px" :font-weight "500" :margin-top "15px"}}
        "No Active Weather Alerts"]
       [:div {:style {:font-size "14px" :color "#6b7280" :margin-top "8px"}}
        "All clear! No weather warnings or advisories."]]

      [:div
       (for [alert features]
         (let [props (:properties alert)]
           ^{:key (:id alert)}
           [:div {:style {:padding "20px"
                          :background "#fff3cd"
                          :border-left "4px solid #f59e0b"
                          :border-radius "8px"
                          :margin-bottom "15px"}}
            [:h4 {:style {:margin "0 0 10px 0" :color "#92400e"}}
             (:event props)]
            [:p {:style {:margin "0" :color "#78350f"}}
             (:headline props)]]))])))

(defn weather-dashboard [{:keys [weather-data location-name unit on-unit-change]}]
  (let [active-tab (r/atom :current)
        {:keys [observations forecast hourly alerts]} weather-data]

    (fn [{:keys [weather-data location-name unit on-unit-change]}]
      [:div
       [settings-bar
        {:unit unit
         :on-unit-change on-unit-change}]

       [tabs-navigation
        {:active-tab @active-tab
         :on-tab-change #(reset! active-tab %)}]

       [:div {:style card-style}
        (case @active-tab
          :current [current-conditions-view
                    {:observations (:observations weather-data)
                     :unit unit}]
          :forecast [forecast-view
                     {:forecast (:forecast weather-data)
                      :unit unit}]
          :hourly [hourly-view
                   {:hourly (:hourly weather-data)
                    :unit unit}]
          :alerts [alerts-view
                   {:alerts (or (:alerts weather-data) {:features []})}])]])))

;; ============================================================================
;; Main Component
;; ============================================================================

(defn main-component []
  (let [lat (r/atom "35.2271")
        lon (r/atom "-80.8431")
        location-name (r/atom "Charlotte, NC")
        weather-data (r/atom nil)
        loading? (r/atom false)
        unit (r/atom "F")
        last-updated (r/atom nil)

        fetch-all-weather
        (fn []
          (reset! loading? true)
          (reset! weather-data nil)

          (get-all-weather-data
           {:lat @lat
            :lon @lon
            :on-success
            (fn [data]
              ;; Also fetch alerts
              (get-alerts
               {:lat @lat
                :lon @lon
                :on-success (fn [alerts]
                              (reset! weather-data (assoc data :alerts alerts))
                              (reset! last-updated (js/Date.))
                              (reset! loading? false))
                :on-error (fn [_]
                            (reset! weather-data (assoc data :alerts {:features []}))
                            (reset! last-updated (js/Date.))
                            (reset! loading? false))}))
            :on-error
            (fn [err]
              (js/alert (str "Failed to fetch weather: " err))
              (reset! loading? false))}))

        select-city
        (fn [city]
          (reset! lat (str (:lat city)))
          (reset! lon (str (:lon city)))
          (reset! location-name (:name city))
          (fetch-all-weather))]

    (fn []
      [:div {:style app-container-style}
       [:div {:style header-card-style}
        [:h1 {:style app-title-style}
         [:span "☀️"] [:span "Weather Dashboard"]]
        (when @location-name
          [:div {:style location-header-style}
           "📍 " @location-name])
        (when @last-updated
          [:div {:style last-updated-style}
           "Last updated: " (.toLocaleTimeString @last-updated)])]

       [location-search-panel
        {:lat @lat
         :lon @lon
         :on-lat-change #(reset! lat %)
         :on-lon-change #(reset! lon %)
         :on-fetch fetch-all-weather
         :cities quick-cities
         :on-city-select select-city}]

       (cond
         @loading? [loading-spinner]
         @weather-data [weather-dashboard
                        {:weather-data @weather-data
                         :location-name @location-name
                         :unit @unit
                         :on-unit-change #(reset! unit %)}])])))

;; ============================================================================
;; Mount
;; ============================================================================

(defn ^:export init []
  (when-let [el (js/document.getElementById "complete-dashboard-demo")]
    (rdom/render [main-component] el)))

(init)

Try It Live

Click any city and watch all the data load! Switch between tabs to see different views of the same weather data. Try changing the temperature unit - it updates across all tabs!

(kind/hiccup
 [:div#complete-dashboard-demo {:style {:min-height "900px"}}
  [:script {:type "application/x-scittle"
            :src "complete_dashboard.cljs"}]])

Wrapping Up

What We’ve Built

Over these six demos, we’ve created a complete weather application suite:

  1. Simple Lookup - Foundation for API calls and state management
  2. Current Conditions - Unit conversion and comprehensive data display
  3. Forecast Viewer - Visual cards and responsive grids
  4. Hourly Timeline - Interactive controls and horizontal scrolling
  5. Weather Alerts - Severity-based styling and expandable content
  6. Complete Dashboard - Integration and professional UI

Key Takeaways

  • No API key required - The NWS API is free, reliable, and comprehensive
  • Keyword arguments everywhere - Self-documenting, flexible code
  • Scittle = Zero friction - No build tools, instant feedback, pure browser development
  • Composable patterns - Each demo builds on previous concepts
  • Production-ready patterns - Error handling, loading states, responsive design

What You Can Do Next

These demos are starting points. Here are some ideas to extend them:

  • Add geolocation to auto-detect user’s location
  • Implement search history with localStorage
  • Add weather data visualization (charts, graphs)
  • Create weather comparison tools (multiple locations)
  • Build severe weather notification system
  • Add unit preferences persistence
  • Implement auto-refresh functionality
  • Create mobile-optimized views
  • Add weather data export features

The Power of Browser-Native Development

What makes this approach special:

  • Immediate feedback - Changes appear instantly at localhost:1971
  • No dependencies - Just ClojureScript, Reagent, and a browser
  • Educational - See exactly how everything works
  • Shareable - Send a single HTML file, no setup required
  • Extensible - Add features without rebuild cycles

Resources

Thank You!

I hope these demos inspire you to build your own weather applications - or any other browser-based tools. The combination of Scittle, ClojureScript, and free APIs like the NWS opens up endless possibilities for creative, educational, and practical projects.

Weather data belongs to everyone. Let’s build great things with it! ☀️🌧️❄️


All source code is available in the article above. Copy, modify, and share freely!

source: src/scittle/weather/weather_nws_integration.clj