from datetime import datetime, timedelta from html import escape import gradio as gr import pytz import requests # Godfrey, IL coordinates LAT = 38.9556 LNG = -90.1868 TZID = "America/Chicago" WALK_MINUTES = 20 # Local horizon offset (minutes). The bluest moment arrives earlier than the # astronomical midpoint because terrain, trees, and buildings on the western # horizon block the sun before it reaches the geometric horizon. This offset # is calibrated by nightly observation and recorded in the tracking spreadsheet. # Positive value = shift the predicted bluest moment earlier. LOCAL_OFFSET_MINUTES = 35 DIDION_QUOTE = ( '"In certain latitudes there comes a span of time approaching and following ' 'the summer solstice, some weeks in all, when the twilights turn long and blue."' ) DIDION_ATTR = "Joan Didion, Blue Nights" def get_twilight_data(date_str): """Fetch twilight times from sunrisesunset.io API.""" url = "https://api.sunrisesunset.io/json" params = { "lat": LAT, "lng": LNG, "date": date_str, "timezone": TZID, } response = requests.get(url, params=params, timeout=10) response.raise_for_status() data = response.json() if data.get("status") != "OK": raise ValueError(f"API returned status: {data.get('status')}") return data["results"] def parse_local_time(time_str, date_str): """Parse a time string like '7:36:00 PM' with a date into a datetime.""" dt_str = f"{date_str} {time_str}" return datetime.strptime(dt_str, "%Y-%m-%d %I:%M:%S %p") def fmt_time(dt): """Format a datetime as a friendly 12-hour time string.""" return dt.strftime("%-I:%M %p").lower() def fmt_duration_minutes(total_minutes): """Format minutes as 'Xh Ym' or 'Ym'.""" if total_minutes >= 60: return f"{total_minutes // 60}h {total_minutes % 60}m" return f"{total_minutes}m" def fmt_day_length(day_length): """Format an API day-length value into a friendlier string.""" if not day_length: return "Unavailable" try: hours, minutes, seconds = [int(part) for part in day_length.split(":")] except ValueError: return day_length total_minutes = round((hours * 3600 + minutes * 60 + seconds) / 60) return fmt_duration_minutes(total_minutes) def minutes_between(start, end): """Return the rounded whole-minute difference between two datetimes.""" return round((end - start).total_seconds() / 60) def clamp_pct(value): """Clamp a percentage so the timeline remains well-formed.""" return max(0.0, min(100.0, value)) def position_pct(moment, window_start, window_seconds): """Position a timestamp within the displayed timeline window.""" delta_seconds = (moment - window_start).total_seconds() return clamp_pct((delta_seconds / window_seconds) * 100) def build_walk_context(date_str): """Calculate all twilight and walk details for the selected date.""" datetime.strptime(date_str, "%Y-%m-%d") results = get_twilight_data(date_str) sunset = parse_local_time(results["sunset"], date_str) dusk = parse_local_time(results["dusk"], date_str) nautical_end = parse_local_time(results["nautical_twilight_end"], date_str) astro_midpoint = dusk + (nautical_end - dusk) / 2 bluest_moment = astro_midpoint - timedelta(minutes=LOCAL_OFFSET_MINUTES) walk_half = timedelta(minutes=WALK_MINUTES / 2) walk_start = bluest_moment - walk_half walk_end = bluest_moment + walk_half display_date = datetime.strptime(date_str, "%Y-%m-%d").strftime("%A, %B %-d, %Y") blue_hour_minutes = max(0, minutes_between(dusk, nautical_end)) # Match the Pages version's visual framing by showing sunset through the end # of nautical twilight with a little padding on both sides. window_start = sunset - timedelta(minutes=5) window_end = nautical_end + timedelta(minutes=5) window_seconds = max((window_end - window_start).total_seconds(), 1) walk_left = position_pct(walk_start, window_start, window_seconds) walk_right = position_pct(walk_end, window_start, window_seconds) walk_width = max(walk_right - walk_left, 6.0) return { "date_str": date_str, "display_date": display_date, "day_length": fmt_day_length(results.get("day_length", "")), "sunset": sunset, "dusk": dusk, "nautical_end": nautical_end, "astro_midpoint": astro_midpoint, "bluest_moment": bluest_moment, "walk_start": walk_start, "walk_end": walk_end, "blue_hour_minutes": blue_hour_minutes, "walk_left": walk_left, "walk_width": min(walk_width, 100.0 - walk_left), "sunset_pct": position_pct(sunset, window_start, window_seconds), "dusk_pct": position_pct(dusk, window_start, window_seconds), "bluest_pct": position_pct(bluest_moment, window_start, window_seconds), "nautical_pct": position_pct(nautical_end, window_start, window_seconds), } def render_error(message): """Render a styled error state that matches the rest of the interface.""" return f"""
Twilight data unavailable

Could not calculate tonight's walk.

{escape(message)}

""" def render_result(context): """Render the atmospheric HTML result card for the selected date.""" return f"""

{DIDION_QUOTE}

— {DIDION_ATTR}

Evening walk prediction Godfrey, Illinois

{context["display_date"]}

Start your walk at
{fmt_time(context["walk_start"])}
A centered {WALK_MINUTES}-minute walk from {fmt_time(context["walk_start"])} to {fmt_time(context["walk_end"])}
Predicted bluest moment {fmt_time(context["bluest_moment"])}
Local offset {LOCAL_OFFSET_MINUTES} minutes earlier
Twilight timeline Sunset to nautical twilight end
Sunset {fmt_time(context["sunset"])}
Dusk {fmt_time(context["dusk"])}
Nautical end {fmt_time(context["nautical_end"])}
The highlighted band is the recommended walk window, centered on the locally adjusted bluest point.
Sunset (API)
{fmt_time(context["sunset"])}
Dusk (API)
{fmt_time(context["dusk"])}
Blue hour span
{fmt_duration_minutes(context["blue_hour_minutes"])}
Daylight
{context["day_length"]}
How this stays grounded in the local landscape

Twilight times come directly from the API for Godfrey, IL ({LAT:.4f}, {LNG:.4f}). The astronomical midpoint is then shifted {LOCAL_OFFSET_MINUTES} minutes earlier to reflect the observed western horizon, and the walk is centered on that adjusted moment.

""" def calculate_blue_hour(date_str): """Calculate the predicted bluest moment and render the HTML panel.""" try: context = build_walk_context(date_str) except Exception as exc: return render_error(f"Could not fetch twilight data: {exc}") return render_result(context) def on_load(): """Calculate for today on first load.""" tz = pytz.timezone(TZID) today = datetime.now(tz).strftime("%Y-%m-%d") return today, calculate_blue_hour(today) def on_date_change(date_str): """Recalculate when the date changes.""" if not date_str: return render_error("Choose a date to calculate the evening walk window.") return calculate_blue_hour(date_str) HEADER_HTML = f"""
The Bluest Hour

A calmer way to catch the evening blue.

A small Gradio Space that tells you when to start a twilight walk in Godfrey, Illinois, so that twenty quiet minutes are centered on the deepest local blue.

Godfrey, IL
35-minute local offset
Twilight data from the API
""" FOOTER_HTML = """ """ CSS = """ @import url('https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@400;500;600&family=Manrope:wght@400;500;600;700&display=swap'); :root { --bh-night: #091321; --bh-indigo: #10233c; --bh-twilight: #1d466d; --bh-sky: #4075a8; --bh-glow: #d7b267; --bh-amber: #c9833e; --bh-mist: #f2ece2; --bh-soft: rgba(242, 236, 226, 0.74); --bh-card: rgba(10, 24, 40, 0.46); --bh-card-strong: rgba(8, 18, 31, 0.68); --bh-border: rgba(215, 178, 103, 0.22); --bh-line: rgba(255, 255, 255, 0.16); --bh-shadow: 0 24px 70px rgba(4, 10, 20, 0.42); } html, body { min-height: 100%; background: radial-gradient(circle at top, rgba(215, 178, 103, 0.18), transparent 30%), linear-gradient(180deg, #07111d 0%, #0d2034 20%, #173658 46%, #316491 70%, #7f91a9 88%, #cf9347 100%); color: var(--bh-mist); } body { font-family: 'Manrope', 'Avenir Next', 'Segoe UI', sans-serif; } .gradio-container { max-width: 980px !important; margin: 0 auto; padding: 32px 18px 56px !important; background: transparent !important; } .gradio-container .prose, .gradio-container .prose * { color: inherit; } #bh-controls, #bh-results { border: none !important; background: transparent !important; box-shadow: none !important; padding: 0 !important; } .bh-hero { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(220px, 0.8fr); gap: 18px; align-items: end; margin-bottom: 22px; } .bh-hero-copy { text-shadow: 0 1px 8px rgba(4, 10, 20, 0.5); } .bh-kicker { font-size: 0.75rem; letter-spacing: 0.24em; text-transform: uppercase; color: rgba(242, 236, 226, 0.76); margin-bottom: 10px; } .bh-title { margin: 0; font-family: 'Cormorant Garamond', Georgia, serif; font-size: clamp(3.2rem, 6vw, 4.9rem); line-height: 0.9; font-weight: 600; letter-spacing: 0.01em; color: #ffffff; text-shadow: 0 2px 16px rgba(4, 10, 20, 0.7), 0 1px 3px rgba(4, 10, 20, 0.5); } .bh-intro { margin: 18px 0 0; max-width: 620px; font-size: 1rem; line-height: 1.75; color: rgba(242, 236, 226, 0.88); } .bh-hero-aside { display: flex; flex-direction: column; gap: 10px; align-self: center; } .bh-hero-chip { padding: 12px 14px; border: 1px solid var(--bh-border); border-radius: 999px; background: rgba(9, 19, 33, 0.34); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); font-size: 0.82rem; color: rgba(242, 236, 226, 0.84); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.05); } #date-input { margin-bottom: 10px; } #date-input label { font-size: 0.78rem !important; letter-spacing: 0.18em; text-transform: uppercase; color: rgba(242, 236, 226, 0.72) !important; } #date-input .wrap, #date-input .form, #date-input .container { background: transparent !important; } #date-input input { min-height: 58px; border-radius: 18px !important; border: 1px solid var(--bh-border) !important; background: rgba(8, 18, 31, 0.42) !important; color: var(--bh-mist) !important; box-shadow: var(--bh-shadow); padding: 0 18px !important; font-size: 1rem !important; } #date-input input:focus { border-color: rgba(215, 178, 103, 0.52) !important; box-shadow: 0 0 0 1px rgba(215, 178, 103, 0.34), var(--bh-shadow) !important; } .bh-shell { display: flex; flex-direction: column; gap: 16px; } .bh-card { position: relative; overflow: hidden; border-radius: 26px; border: 1px solid var(--bh-border); background: linear-gradient(180deg, rgba(10, 24, 40, 0.72) 0%, rgba(9, 21, 35, 0.48) 100%); box-shadow: var(--bh-shadow); backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px); padding: 22px; } .bh-card::before { content: ""; position: absolute; inset: 0; background: linear-gradient(140deg, rgba(255, 255, 255, 0.08), transparent 38%); pointer-events: none; } .bh-quote-card { padding: 22px 24px 20px; } .bh-quote { margin: 0; font-family: 'Cormorant Garamond', Georgia, serif; font-size: clamp(1.32rem, 2vw, 1.62rem); line-height: 1.45; color: rgba(242, 236, 226, 0.92); } .bh-attribution { margin: 10px 0 0; color: rgba(242, 236, 226, 0.7); font-size: 0.88rem; letter-spacing: 0.03em; } .bh-walk-card { padding: 26px; background: radial-gradient(circle at top right, rgba(215, 178, 103, 0.16), transparent 32%), linear-gradient(180deg, rgba(8, 20, 34, 0.82) 0%, rgba(22, 57, 90, 0.72) 58%, rgba(58, 95, 132, 0.56) 100%); } .bh-meta-row, .bh-section-head { display: flex; justify-content: space-between; gap: 14px; align-items: center; flex-wrap: wrap; } .bh-location, .bh-section-note { font-size: 0.84rem; color: rgba(242, 236, 226, 0.72); } .bh-date { margin: 12px 0 0; font-family: 'Cormorant Garamond', Georgia, serif; font-size: clamp(2rem, 3.3vw, 2.9rem); font-weight: 500; line-height: 1; } .bh-time-block { margin-top: 20px; text-align: center; padding: 20px 18px; border-radius: 22px; background: rgba(255, 255, 255, 0.05); border: 1px solid rgba(255, 255, 255, 0.12); } .bh-time-label, .bh-pill-label, .bh-stat-label, .bh-marker-label { font-size: 0.74rem; letter-spacing: 0.18em; text-transform: uppercase; } .bh-time-label { color: rgba(242, 236, 226, 0.72); } .bh-time { margin-top: 8px; font-family: 'Cormorant Garamond', Georgia, serif; font-size: clamp(3rem, 7vw, 4.4rem); line-height: 0.92; color: #f3dc9a; text-shadow: 0 0 28px rgba(215, 178, 103, 0.22); } .bh-time-sub { margin-top: 10px; font-size: 0.95rem; color: rgba(242, 236, 226, 0.82); } .bh-callout-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 16px; } .bh-pill { display: flex; flex-direction: column; gap: 6px; padding: 16px 18px; border-radius: 18px; background: rgba(9, 19, 33, 0.34); border: 1px solid rgba(255, 255, 255, 0.1); } .bh-pill-value { font-family: 'Cormorant Garamond', Georgia, serif; font-size: 1.45rem; color: var(--bh-mist); } .bh-timeline { position: relative; height: 118px; margin-top: 18px; border-radius: 24px; overflow: hidden; border: 1px solid rgba(255, 255, 255, 0.1); background: linear-gradient(90deg, rgba(210, 134, 62, 0.9) 0%, rgba(105, 152, 197, 0.95) 22%, rgba(47, 95, 145, 0.98) 52%, rgba(16, 35, 60, 1) 82%, rgba(8, 17, 29, 1) 100%); } .bh-timeline::after { content: ""; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), transparent 40%, rgba(4, 10, 20, 0.28) 100%); pointer-events: none; } .bh-walk-window { position: absolute; top: 14px; bottom: 14px; min-width: 18px; border-radius: 18px; background: rgba(243, 220, 154, 0.18); border-left: 2px solid rgba(243, 220, 154, 0.95); border-right: 2px solid rgba(243, 220, 154, 0.95); box-shadow: 0 0 32px rgba(243, 220, 154, 0.16); } .bh-marker { position: absolute; top: 14px; transform: translateX(-50%); display: flex; flex-direction: column; align-items: center; gap: 4px; text-align: center; min-width: 96px; z-index: 2; } .bh-marker-dot { width: 10px; height: 10px; border-radius: 999px; box-shadow: 0 0 0 3px rgba(9, 19, 33, 0.35); } .bh-marker-dot.sun { background: #f1b35a; } .bh-marker-dot.civil { background: #84acd3; } .bh-marker-dot.blue { background: #d9dff9; } .bh-marker-dot.nautical { background: #112742; } .bh-marker-label { color: rgba(242, 236, 226, 0.82); } .bh-marker-time { font-family: 'Cormorant Garamond', Georgia, serif; font-size: 1.08rem; color: var(--bh-mist); } .bh-marker-featured .bh-marker-time { color: #f3dc9a; } .bh-timeline-caption { margin-top: 12px; color: rgba(242, 236, 226, 0.72); font-size: 0.92rem; line-height: 1.65; } .bh-stats { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } .bh-stat-card { min-height: 126px; display: flex; flex-direction: column; justify-content: space-between; background: linear-gradient(180deg, rgba(9, 19, 33, 0.58), rgba(10, 24, 40, 0.42)); } .bh-stat-label { color: rgba(242, 236, 226, 0.68); } .bh-stat-value { margin-top: 18px; font-family: 'Cormorant Garamond', Georgia, serif; font-size: clamp(1.8rem, 3vw, 2.4rem); line-height: 1; color: var(--bh-mist); } .bh-note-title, .bh-error-title { font-family: 'Cormorant Garamond', Georgia, serif; font-size: 1.9rem; line-height: 1.05; margin: 0; } .bh-note-copy, .bh-error-copy { margin: 12px 0 0; color: rgba(242, 236, 226, 0.8); line-height: 1.75; font-size: 0.96rem; } .bh-error-card { background: linear-gradient(180deg, rgba(44, 18, 21, 0.76), rgba(20, 11, 14, 0.66)); border-color: rgba(224, 133, 110, 0.28); } .bh-footer { margin-top: 18px; color: rgba(242, 236, 226, 0.62); font-size: 0.84rem; line-height: 1.7; text-align: center; } .bh-footer a { color: #f1cd84; text-decoration: none; } .bh-footer a:hover { text-decoration: underline; } @media (max-width: 820px) { .bh-hero { grid-template-columns: 1fr; } .bh-hero-aside { flex-direction: row; flex-wrap: wrap; } } @media (max-width: 680px) { .gradio-container { padding: 20px 12px 42px !important; } .bh-card, .bh-walk-card { padding: 18px; border-radius: 22px; } .bh-callout-row, .bh-stats { grid-template-columns: 1fr; } .bh-timeline { height: 168px; padding-top: 6px; } .bh-marker { min-width: 70px; } } @media (max-width: 520px) { .bh-title { font-size: 2.9rem; } .bh-date { font-size: 2rem; } .bh-time { font-size: 3rem; } .bh-quote { font-size: 1.2rem; } .bh-marker { top: 12px; min-width: 58px; } .bh-marker-label { font-size: 0.6rem; letter-spacing: 0.12em; } .bh-marker-time { font-size: 0.92rem; } } """ with gr.Blocks(title="The Bluest Hour", css=CSS) as demo: gr.HTML(HEADER_HTML) with gr.Group(elem_id="bh-controls"): date_input = gr.Textbox( label="Choose a date", placeholder="YYYY-MM-DD", max_lines=1, elem_id="date-input", ) result = gr.HTML(elem_id="bh-results") gr.HTML(FOOTER_HTML) date_input.change(fn=on_date_change, inputs=date_input, outputs=result) date_input.submit(fn=on_date_change, inputs=date_input, outputs=result) demo.load(fn=on_load, outputs=[date_input, result]) demo.launch()