From 291fb2abd902c074ecdc18adb00c6b5e2b13d3e8 Mon Sep 17 00:00:00 2001 From: Deeman Date: Sun, 8 Mar 2026 20:43:52 +0100 Subject: [PATCH] feat(geo): use CF headers on opportunity map + country overview maps Pre-select user's country on opportunity map dropdown (CF-IPCountry), auto-load the map on page load. Highlight user's city on country overview maps with a blue ring (CF-IPCity best-effort match). Unify opportunity score color scale to red/amber/green (was using blue for low scores). Inject window.__GEO global for client-side geo access. Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 6 +++++ web/src/padelnomics/app.py | 2 ++ web/src/padelnomics/public/routes.py | 15 +++++++++++-- .../public/templates/opportunity_map.html | 18 +++++++++------ web/src/padelnomics/static/css/input.css | 6 +++++ web/src/padelnomics/static/js/article-maps.js | 22 ++++++++++++++++++- web/src/padelnomics/templates/base.html | 1 + 7 files changed, 60 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 20339b6..55a7452 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- **Geo headers on city/region hubs** — Cloudflare geo headers (`CF-IPCountry`, `CF-IPCity`) now used across location-based pages. Opportunity map pre-selects and auto-loads the user's country. Country overview maps highlight the user's city with a blue ring (best-effort CF-IPCity name match). `window.__GEO` JS global injected via `base.html` for client-side map code. + +### Fixed +- **Opportunity map color scale** — low-score bubbles used blue (`#3B82F6`) instead of red (`#DC2626`), inconsistent with the unified `scoreColor()` scale used everywhere else. Fixed in `oppColor()`, legend, and `article-maps.js` tooltip colors. Thresholds aligned to ≥60/30/\<30. + ### Changed - **Opportunity Score v5 → v6** — calibrates for saturated markets (Spain avg dropped from ~78 to ~50-60 range). Density ceiling lowered from 8 → 5/100k (Spain at 6-16/100k now hits zero-gap). Supply deficit weight increased from 35 → 40 pts. Addressable market reduced from 25 → 20 pts. Market validation inverted → "market headroom": high country avg maturity now reduces opportunity (saturated market = less room for new entrants). - **Markets page map legend** — bubble map now has a visual legend explaining size = venue count, color = Market Score. Opportunity score tooltip color unified to same green/amber/red scale (was using blue for low scores, inconsistent). diff --git a/web/src/padelnomics/app.py b/web/src/padelnomics/app.py index d2e64c1..9bbd640 100644 --- a/web/src/padelnomics/app.py +++ b/web/src/padelnomics/app.py @@ -246,6 +246,8 @@ def create_app() -> Quart: "csrf_token": get_csrf_token, "ab_variant": getattr(g, "ab_variant", None), "ab_tag": getattr(g, "ab_tag", None), + "user_country": g.get("user_country", ""), + "user_city": g.get("user_city", ""), "lang": effective_lang, "t": get_translations(effective_lang), "v": _ASSET_VERSION, diff --git a/web/src/padelnomics/public/routes.py b/web/src/padelnomics/public/routes.py index 476415c..7497305 100644 --- a/web/src/padelnomics/public/routes.py +++ b/web/src/padelnomics/public/routes.py @@ -79,12 +79,23 @@ async def opportunity_map(): if not await is_flag_enabled("maps", default=True): abort(404) countries = await fetch_analytics(""" - SELECT DISTINCT country_slug, country_name_en + SELECT DISTINCT country_slug, country_name_en, country_code FROM serving.location_profiles WHERE city_slug IS NOT NULL ORDER BY country_name_en """) - return await render_template("opportunity_map.html", countries=countries) + user_cc = g.get("user_country", "") + selected_slug = "" + if user_cc: + for c in countries: + if c["country_code"] == user_cc: + selected_slug = c["country_slug"] + break + countries = sorted( + countries, + key=lambda c: (0 if c["country_code"] == user_cc else 1, c["country_name_en"]), + ) + return await render_template("opportunity_map.html", countries=countries, selected_slug=selected_slug) @bp.route("/opportunity-map/data") diff --git a/web/src/padelnomics/public/templates/opportunity_map.html b/web/src/padelnomics/public/templates/opportunity_map.html index a255a36..1105528 100644 --- a/web/src/padelnomics/public/templates/opportunity_map.html +++ b/web/src/padelnomics/public/templates/opportunity_map.html @@ -30,7 +30,7 @@ hx-trigger="change"> {% for c in countries %} - + {% endfor %} @@ -41,9 +41,9 @@
Circle size: population  |  Color: - High (≥70)   - Mid (40–70)   - Low (<40) + High (≥60)   + Mid (30–60)   + Low (<30)
{% endblock %} @@ -62,9 +62,9 @@ var refLayer = L.layerGroup().addTo(map); function oppColor(score) { - if (score >= 70) return '#16A34A'; - if (score >= 40) return '#D97706'; - return '#3B82F6'; + if (score >= 60) return '#16A34A'; + if (score >= 30) return '#D97706'; + return '#DC2626'; } function makeIcon(size, color) { @@ -133,6 +133,10 @@ document.body.addEventListener('htmx:afterSwap', function(e) { if (e.detail.target.id === 'map-data') renderMap(); }); + + // Auto-load if country pre-selected via geo header + var sel = document.getElementById('opp-country-select'); + if (sel.value) htmx.trigger(sel, 'change'); })(); {% endblock %} diff --git a/web/src/padelnomics/static/css/input.css b/web/src/padelnomics/static/css/input.css index f32fa83..4183c15 100644 --- a/web/src/padelnomics/static/css/input.css +++ b/web/src/padelnomics/static/css/input.css @@ -892,6 +892,12 @@ transform: scale(1.1); } +/* User's city highlight — blue ring on top of score-colored bubble */ +.pn-marker--highlight { + border: 3px solid #3B82F6; + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3), 0 2px 8px rgba(0,0,0,0.28); +} + /* Non-article city markers: faded + dashed border, no click affordance */ .pn-marker--muted { opacity: 0.45; diff --git a/web/src/padelnomics/static/js/article-maps.js b/web/src/padelnomics/static/js/article-maps.js index d90cf8f..6e2b2b6 100644 --- a/web/src/padelnomics/static/js/article-maps.js +++ b/web/src/padelnomics/static/js/article-maps.js @@ -49,7 +49,7 @@ var pop = c.population >= 1000000 ? (c.population / 1000000).toFixed(1) + 'M' : (c.population >= 1000 ? Math.round(c.population / 1000) + 'K' : (c.population || '')); - var oppColor = c.opportunity_score >= 60 ? '#16A34A' : (c.opportunity_score >= 30 ? '#D97706' : '#3B82F6'); + var oppColor = c.opportunity_score >= 60 ? '#16A34A' : (c.opportunity_score >= 30 ? '#D97706' : '#DC2626'); var tip = '' + c.city_name + '
' + (c.padel_venue_count || 0) + ' venues' + (pop ? ' · ' + pop : '') @@ -69,6 +69,26 @@ bounds.push([c.lat, c.lon]); }); if (bounds.length) map.fitBounds(bounds, { padding: [24, 24] }); + + // Highlight user's city (best-effort name match via CF-IPCity) + var uc = (window.__GEO || {}).city || ''; + if (uc) { + var match = data.find(function(c) { + return c.city_name && c.city_name.toLowerCase() === uc.toLowerCase(); + }); + if (match && match.lat && match.lon) { + var hSize = 10 + 36 * Math.sqrt((match.padel_venue_count || 1) / maxV); + var hs = Math.round(hSize); + var hColor = scoreColor(match.market_score); + var hIcon = L.divIcon({ + className: '', + html: '
', + iconSize: [hs, hs], + iconAnchor: [hs / 2, hs / 2], + }); + L.marker([match.lat, match.lon], { icon: hIcon }).addTo(map); + } + } }) .catch(function(err) { console.error('Country map fetch failed:', err); }); } diff --git a/web/src/padelnomics/templates/base.html b/web/src/padelnomics/templates/base.html index f2a066e..144f455 100644 --- a/web/src/padelnomics/templates/base.html +++ b/web/src/padelnomics/templates/base.html @@ -36,6 +36,7 @@ + {% block head %}{% endblock %}