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 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-03-08 20:43:52 +01:00
parent 40d8c75b81
commit 291fb2abd9
7 changed files with 60 additions and 10 deletions

View File

@@ -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).

View File

@@ -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,

View File

@@ -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")

View File

@@ -30,7 +30,7 @@
hx-trigger="change">
<option value="">— choose country —</option>
{% for c in countries %}
<option value="{{ c.country_slug }}">{{ c.country_name_en }}</option>
<option value="{{ c.country_slug }}" {% if c.country_slug == selected_slug %}selected{% endif %}>{{ c.country_name_en }}</option>
{% endfor %}
</select>
</div>
@@ -41,9 +41,9 @@
<div class="mt-4 text-sm text-slate">
<strong>Circle size:</strong> population &nbsp;|&nbsp;
<strong>Color:</strong>
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#16A34A;vertical-align:middle;margin:0 4px"></span>High (≥70) &nbsp;
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#D97706;vertical-align:middle;margin:0 4px"></span>Mid (4070) &nbsp;
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#3B82F6;vertical-align:middle;margin:0 4px"></span>Low (&lt;40)
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#16A34A;vertical-align:middle;margin:0 4px"></span>High (≥60) &nbsp;
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#D97706;vertical-align:middle;margin:0 4px"></span>Mid (3060) &nbsp;
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#DC2626;vertical-align:middle;margin:0 4px"></span>Low (&lt;30)
</div>
</main>
{% 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');
})();
</script>
{% endblock %}

View File

@@ -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;

View File

@@ -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 = '<strong>' + c.city_name + '</strong><br>'
+ (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: '<div class="pn-marker pn-marker--highlight" style="width:' + hs + 'px;height:' + hs + 'px;background:' + hColor + ';"></div>',
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); });
}

View File

@@ -36,6 +36,7 @@
<meta property="og:image" content="{{ url_for('static', filename='images/logo.png', _external=True) }}">
<meta name="twitter:card" content="summary_large_image">
<script>window.__GEO = {country: "{{ user_country }}", city: "{{ user_city }}"};</script>
{% block head %}{% endblock %}
</head>
<body>