feat(map): inline country map data + show zero-court locations

Country-overview maps now read data from an inline JSON island instead
of fetching from /api/markets/<slug>/cities.json. Query broadened to
include all locations with opportunity_score > 0 (not just those with
courts), exposing high-opportunity zero-court cities like Göttingen.

- Population-based marker sizing (was venue-count)
- Zero-court tooltip shows distance to nearest court
- /api/.../cities.json now requires login (admin only)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-03-11 18:10:58 +01:00
parent fb03602653
commit 5df369ef89
4 changed files with 101 additions and 59 deletions

View File

@@ -48,6 +48,7 @@ async def countries():
@bp.route("/markets/<country_slug>/cities.json") @bp.route("/markets/<country_slug>/cities.json")
@login_required
async def country_cities(country_slug: str): async def country_cities(country_slug: str):
"""City-level data for a country overview bubble map.""" """City-level data for a country overview bubble map."""
await _require_maps_flag() await _require_maps_flag()

View File

@@ -367,8 +367,42 @@ async def article_page(url_path: str):
body_html = build_path.read_text() body_html = build_path.read_text()
# Detect country-overview pages and inline map data (no JSON API fetch)
map_locations: list[dict] = []
parts = clean_path.strip("/").split("/")
if len(parts) == 2 and parts[0] == "markets":
country_slug = parts[1]
map_locations = await fetch_analytics(
"""
SELECT location_name AS city_name, city_slug, lat, lon,
COALESCE(city_padel_venue_count, 0) AS padel_venue_count,
market_score, opportunity_score, population,
nearest_padel_court_km
FROM serving.location_profiles
WHERE country_slug = ? AND opportunity_score > 0
ORDER BY opportunity_score DESC
LIMIT 300
""",
[country_slug],
)
article_rows = await fetch_all(
"""SELECT url_path FROM articles
WHERE url_path LIKE ? AND status = 'published'
AND published_at <= datetime('now')""",
(f"/markets/{country_slug}/%",),
)
article_slugs: set[str] = set()
for a in article_rows:
a_parts = a["url_path"].rstrip("/").split("/")
if len(a_parts) >= 4:
article_slugs.add(a_parts[3])
for row in map_locations:
slug = row.get("city_slug")
row["has_article"] = slug in article_slugs if slug else False
return await render_template( return await render_template(
"article_detail.html", "article_detail.html",
article=article, article=article,
body_html=Markup(body_html), body_html=Markup(body_html),
map_locations=map_locations,
) )

View File

@@ -62,8 +62,11 @@
{% block scripts %} {% block scripts %}
<script> <script>
window.LEAFLET_JS_URL = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}'; window.LEAFLET_JS_URL = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';
window.__MAP_T = {score_label:"{{ t.map_score_label }}",venues:"{{ t.map_venues }}",pop:"{{ t.map_pop }}",click_explore:"{{ t.map_click_explore }}",coming_soon:"{{ t.map_coming_soon }}",courts:"{{ t.map_courts }}",indoor:"{{ t.map_indoor }}",outdoor:"{{ t.map_outdoor }}"}; window.__MAP_T = {score_label:"{{ t.map_score_label }}",venues:"{{ t.map_venues }}",pop:"{{ t.map_pop }}",click_explore:"{{ t.map_click_explore }}",coming_soon:"{{ t.map_coming_soon }}",courts:"{{ t.map_courts }}",indoor:"{{ t.map_indoor }}",outdoor:"{{ t.map_outdoor }}",km_nearest:"{{ t.map_km_nearest }}",no_nearby:"{{ t.map_no_nearby }}"};
</script> </script>
{% if map_locations %}
<script id="country-map-data" type="application/json">{{ map_locations | tojson }}</script>
{% endif %}
<script src="{{ url_for('static', filename='js/map-markers.js') }}"></script> <script src="{{ url_for('static', filename='js/map-markers.js') }}"></script>
<script src="{{ url_for('static', filename='js/article-maps.js') }}"></script> <script src="{{ url_for('static', filename='js/article-maps.js') }}"></script>
{% endblock %} {% endblock %}

View File

@@ -2,8 +2,8 @@
* Leaflet map initialisation for article pages (country + city maps). * Leaflet map initialisation for article pages (country + city maps).
* *
* Looks for #country-map and #city-map elements. If neither exists, does nothing. * Looks for #country-map and #city-map elements. If neither exists, does nothing.
* Expects data-* attributes on the map elements and a global LEAFLET_JS_URL * Country maps read inline JSON from #country-map-data (no network fetch).
* variable pointing to the Leaflet JS bundle. * City maps still fetch venue data from the JSON API.
* *
* Depends on map-markers.js (window.PNMarkers) being loaded first. * Depends on map-markers.js (window.PNMarkers) being loaded first.
*/ */
@@ -24,28 +24,34 @@
} }
function initCountryMap(el) { function initCountryMap(el) {
var dataEl = document.getElementById('country-map-data');
if (!dataEl) return;
var data = JSON.parse(dataEl.textContent);
var slug = el.dataset.countrySlug; var slug = el.dataset.countrySlug;
var map = L.map(el, {scrollWheelZoom: false}); var map = L.map(el, {scrollWheelZoom: false});
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map); L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
var lang = document.documentElement.lang || 'en'; var lang = document.documentElement.lang || 'en';
fetch('/api/markets/' + slug + '/cities.json')
.then(function(r) { return r.json(); })
.then(function(data) {
if (!data.length) return; if (!data.length) return;
var maxV = Math.max.apply(null, data.map(function(d) { return d.padel_venue_count || 1; })); var maxPop = Math.max.apply(null, data.map(function(d) { return d.population || 1; }));
var bounds = []; var bounds = [];
data.forEach(function(c) { data.forEach(function(c) {
if (!c.lat || !c.lon) return; if (!c.lat || !c.lon) return;
var size = 10 + 36 * Math.sqrt((c.padel_venue_count || 1) / maxV); var size = 8 + 36 * Math.sqrt((c.population || 1) / maxPop);
var hasArticle = c.has_article !== false; var hasArticle = c.has_article === true;
var score = c.opportunity_score || 0; var score = c.opportunity_score || 0;
var hex = sc(score); var hex = sc(score);
var pop = fmtPop(c.population); var pop = fmtPop(c.population);
var venueInfo = (c.padel_venue_count || 0) > 0
? (c.padel_venue_count + ' ' + (T.venues || 'venues'))
: (c.nearest_padel_court_km
? Math.round(c.nearest_padel_court_km) + ' ' + (T.km_nearest || 'km to nearest court')
: (T.no_nearby || 'No courts nearby'));
var tip = '<strong>' + c.city_name + '</strong><br>' var tip = '<strong>' + c.city_name + '</strong><br>'
+ '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + hex + ';vertical-align:middle;margin-right:4px;"></span>' + '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + hex + ';vertical-align:middle;margin-right:4px;"></span>'
+ '<span style="color:' + hex + ';font-weight:600;">' + (T.score_label || 'Padelnomics Score') + ': ' + Math.round(score) + '/100</span><br>' + '<span style="color:' + hex + ';font-weight:600;">' + (T.score_label || 'Padelnomics Score') + ': ' + Math.round(score) + '/100</span><br>'
+ '<span style="color:#94A3B8;font-size:0.75rem;">' + '<span style="color:#94A3B8;font-size:0.75rem;">'
+ (c.padel_venue_count || 0) + ' ' + (T.venues || 'venues') + venueInfo
+ (pop ? ' · ' + pop + ' ' + (T.pop || 'pop') : '') + '</span>'; + (pop ? ' · ' + pop + ' ' + (T.pop || 'pop') : '') + '</span>';
if (hasArticle) { if (hasArticle) {
tip += '<br><span style="color:#94A3B8;font-size:0.75rem;">' + (T.click_explore || 'Click to explore →') + '</span>'; tip += '<br><span style="color:#94A3B8;font-size:0.75rem;">' + (T.click_explore || 'Click to explore →') + '</span>';
@@ -74,7 +80,7 @@
return c.city_name && c.city_name.toLowerCase() === uc.toLowerCase(); return c.city_name && c.city_name.toLowerCase() === uc.toLowerCase();
}); });
if (match && match.lat && match.lon) { if (match && match.lat && match.lon) {
var hSize = 10 + 36 * Math.sqrt((match.padel_venue_count || 1) / maxV); var hSize = 8 + 36 * Math.sqrt((match.population || 1) / maxPop);
var hIcon = PNMarkers.makeIcon({ var hIcon = PNMarkers.makeIcon({
size: hSize, size: hSize,
color: sc(match.opportunity_score || 0), color: sc(match.opportunity_score || 0),
@@ -83,8 +89,6 @@
L.marker([match.lat, match.lon], { icon: hIcon }).addTo(map); L.marker([match.lat, match.lon], { icon: hIcon }).addTo(map);
} }
} }
})
.catch(function(err) { console.error('Country map fetch failed:', err); });
} }
function initCityMap(el, venueIcon) { function initCityMap(el, venueIcon) {