bluest-hour / app.py
profplate's picture
Improve hero title legibility with text-shadow and brighter color
219da3b verified
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, <em>Blue Nights</em>"
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"""
<section class="bh-shell">
<div class="bh-card bh-error-card">
<div class="bh-kicker">Twilight data unavailable</div>
<h2 class="bh-error-title">Could not calculate tonight's walk.</h2>
<p class="bh-error-copy">{escape(message)}</p>
</div>
</section>
"""
def render_result(context):
"""Render the atmospheric HTML result card for the selected date."""
return f"""
<section class="bh-shell">
<div class="bh-card bh-quote-card">
<p class="bh-quote">{DIDION_QUOTE}</p>
<p class="bh-attribution">— {DIDION_ATTR}</p>
</div>
<div class="bh-card bh-walk-card">
<div class="bh-meta-row">
<span class="bh-kicker">Evening walk prediction</span>
<span class="bh-location">Godfrey, Illinois</span>
</div>
<h2 class="bh-date">{context["display_date"]}</h2>
<div class="bh-time-block">
<div class="bh-time-label">Start your walk at</div>
<div class="bh-time">{fmt_time(context["walk_start"])}</div>
<div class="bh-time-sub">
A centered {WALK_MINUTES}-minute walk from {fmt_time(context["walk_start"])}
to {fmt_time(context["walk_end"])}
</div>
</div>
<div class="bh-callout-row">
<div class="bh-pill">
<span class="bh-pill-label">Predicted bluest moment</span>
<span class="bh-pill-value">{fmt_time(context["bluest_moment"])}</span>
</div>
<div class="bh-pill">
<span class="bh-pill-label">Local offset</span>
<span class="bh-pill-value">{LOCAL_OFFSET_MINUTES} minutes earlier</span>
</div>
</div>
</div>
<div class="bh-card">
<div class="bh-section-head">
<span class="bh-kicker">Twilight timeline</span>
<span class="bh-section-note">Sunset to nautical twilight end</span>
</div>
<div class="bh-timeline">
<div
class="bh-walk-window"
style="left: {context["walk_left"]:.2f}%; width: {context["walk_width"]:.2f}%"
></div>
<div class="bh-marker" style="left: {context["sunset_pct"]:.2f}%">
<span class="bh-marker-dot sun"></span>
<span class="bh-marker-label">Sunset</span>
<span class="bh-marker-time">{fmt_time(context["sunset"])}</span>
</div>
<div class="bh-marker" style="left: {context["dusk_pct"]:.2f}%">
<span class="bh-marker-dot civil"></span>
<span class="bh-marker-label">Dusk</span>
<span class="bh-marker-time">{fmt_time(context["dusk"])}</span>
</div>
<div class="bh-marker bh-marker-featured" style="left: {context["bluest_pct"]:.2f}%">
<span class="bh-marker-dot blue"></span>
<span class="bh-marker-label">Bluest moment</span>
<span class="bh-marker-time">{fmt_time(context["bluest_moment"])}</span>
</div>
<div class="bh-marker" style="left: {context["nautical_pct"]:.2f}%">
<span class="bh-marker-dot nautical"></span>
<span class="bh-marker-label">Nautical end</span>
<span class="bh-marker-time">{fmt_time(context["nautical_end"])}</span>
</div>
</div>
<div class="bh-timeline-caption">
The highlighted band is the recommended walk window, centered on the locally adjusted bluest point.
</div>
</div>
<div class="bh-stats">
<div class="bh-card bh-stat-card">
<div class="bh-stat-label">Sunset (API)</div>
<div class="bh-stat-value">{fmt_time(context["sunset"])}</div>
</div>
<div class="bh-card bh-stat-card">
<div class="bh-stat-label">Dusk (API)</div>
<div class="bh-stat-value">{fmt_time(context["dusk"])}</div>
</div>
<div class="bh-card bh-stat-card">
<div class="bh-stat-label">Blue hour span</div>
<div class="bh-stat-value">{fmt_duration_minutes(context["blue_hour_minutes"])}</div>
</div>
<div class="bh-card bh-stat-card">
<div class="bh-stat-label">Daylight</div>
<div class="bh-stat-value">{context["day_length"]}</div>
</div>
</div>
<div class="bh-card bh-note-card">
<div class="bh-note-title">How this stays grounded in the local landscape</div>
<p class="bh-note-copy">
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.
</p>
</div>
</section>
"""
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"""
<section class="bh-hero">
<div class="bh-hero-copy">
<div class="bh-kicker">The Bluest Hour</div>
<h1 class="bh-title">A calmer way to catch the evening blue.</h1>
<p class="bh-intro">
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.
</p>
</div>
<div class="bh-hero-aside">
<div class="bh-hero-chip">Godfrey, IL</div>
<div class="bh-hero-chip">35-minute local offset</div>
<div class="bh-hero-chip">Twilight data from the API</div>
</div>
</section>
"""
FOOTER_HTML = """
<div class="bh-footer">
Twilight data from <a href="https://sunrisesunset.io/api/" target="_blank" rel="noopener noreferrer">sunrisesunset.io</a>
for Godfrey, Illinois. The walk recommendation keeps the original local offset and centered 20-minute window.
</div>
"""
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()