merge: geo headers on city/region hubs (opportunity map pre-select, city highlight, color fix)
This commit is contained in:
@@ -6,6 +6,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
### 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).
|
- **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).
|
- **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).
|
||||||
|
|||||||
@@ -246,6 +246,8 @@ def create_app() -> Quart:
|
|||||||
"csrf_token": get_csrf_token,
|
"csrf_token": get_csrf_token,
|
||||||
"ab_variant": getattr(g, "ab_variant", None),
|
"ab_variant": getattr(g, "ab_variant", None),
|
||||||
"ab_tag": getattr(g, "ab_tag", None),
|
"ab_tag": getattr(g, "ab_tag", None),
|
||||||
|
"user_country": g.get("user_country", ""),
|
||||||
|
"user_city": g.get("user_city", ""),
|
||||||
"lang": effective_lang,
|
"lang": effective_lang,
|
||||||
"t": get_translations(effective_lang),
|
"t": get_translations(effective_lang),
|
||||||
"v": _ASSET_VERSION,
|
"v": _ASSET_VERSION,
|
||||||
|
|||||||
@@ -79,12 +79,23 @@ async def opportunity_map():
|
|||||||
if not await is_flag_enabled("maps", default=True):
|
if not await is_flag_enabled("maps", default=True):
|
||||||
abort(404)
|
abort(404)
|
||||||
countries = await fetch_analytics("""
|
countries = await fetch_analytics("""
|
||||||
SELECT DISTINCT country_slug, country_name_en
|
SELECT DISTINCT country_slug, country_name_en, country_code
|
||||||
FROM serving.location_profiles
|
FROM serving.location_profiles
|
||||||
WHERE city_slug IS NOT NULL
|
WHERE city_slug IS NOT NULL
|
||||||
ORDER BY country_name_en
|
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")
|
@bp.route("/opportunity-map/data")
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
hx-trigger="change">
|
hx-trigger="change">
|
||||||
<option value="">— choose country —</option>
|
<option value="">— choose country —</option>
|
||||||
{% for c in countries %}
|
{% 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 %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -41,9 +41,9 @@
|
|||||||
<div class="mt-4 text-sm text-slate">
|
<div class="mt-4 text-sm text-slate">
|
||||||
<strong>Circle size:</strong> population |
|
<strong>Circle size:</strong> population |
|
||||||
<strong>Color:</strong>
|
<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)
|
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#16A34A;vertical-align:middle;margin:0 4px"></span>High (≥60)
|
||||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#D97706;vertical-align:middle;margin:0 4px"></span>Mid (40–70)
|
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#D97706;vertical-align:middle;margin:0 4px"></span>Mid (30–60)
|
||||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#3B82F6;vertical-align:middle;margin:0 4px"></span>Low (<40)
|
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#DC2626;vertical-align:middle;margin:0 4px"></span>Low (<30)
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -62,9 +62,9 @@
|
|||||||
var refLayer = L.layerGroup().addTo(map);
|
var refLayer = L.layerGroup().addTo(map);
|
||||||
|
|
||||||
function oppColor(score) {
|
function oppColor(score) {
|
||||||
if (score >= 70) return '#16A34A';
|
if (score >= 60) return '#16A34A';
|
||||||
if (score >= 40) return '#D97706';
|
if (score >= 30) return '#D97706';
|
||||||
return '#3B82F6';
|
return '#DC2626';
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeIcon(size, color) {
|
function makeIcon(size, color) {
|
||||||
@@ -133,6 +133,10 @@
|
|||||||
document.body.addEventListener('htmx:afterSwap', function(e) {
|
document.body.addEventListener('htmx:afterSwap', function(e) {
|
||||||
if (e.detail.target.id === 'map-data') renderMap();
|
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>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -892,6 +892,12 @@
|
|||||||
transform: scale(1.1);
|
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 */
|
/* Non-article city markers: faded + dashed border, no click affordance */
|
||||||
.pn-marker--muted {
|
.pn-marker--muted {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
|
|||||||
@@ -49,7 +49,7 @@
|
|||||||
var pop = c.population >= 1000000
|
var pop = c.population >= 1000000
|
||||||
? (c.population / 1000000).toFixed(1) + 'M'
|
? (c.population / 1000000).toFixed(1) + 'M'
|
||||||
: (c.population >= 1000 ? Math.round(c.population / 1000) + 'K' : (c.population || ''));
|
: (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>'
|
var tip = '<strong>' + c.city_name + '</strong><br>'
|
||||||
+ (c.padel_venue_count || 0) + ' venues'
|
+ (c.padel_venue_count || 0) + ' venues'
|
||||||
+ (pop ? ' · ' + pop : '')
|
+ (pop ? ' · ' + pop : '')
|
||||||
@@ -69,6 +69,26 @@
|
|||||||
bounds.push([c.lat, c.lon]);
|
bounds.push([c.lat, c.lon]);
|
||||||
});
|
});
|
||||||
if (bounds.length) map.fitBounds(bounds, { padding: [24, 24] });
|
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); });
|
.catch(function(err) { console.error('Country map fetch failed:', err); });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
<meta property="og:image" content="{{ url_for('static', filename='images/logo.png', _external=True) }}">
|
<meta property="og:image" content="{{ url_for('static', filename='images/logo.png', _external=True) }}">
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
|
||||||
|
<script>window.__GEO = {country: "{{ user_country }}", city: "{{ user_city }}"};</script>
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
Reference in New Issue
Block a user