Compare commits

...

2 Commits

Author SHA1 Message Date
Deeman
e71be6da5a merge: inline country map data + show zero-court locations
All checks were successful
CI / test (push) Successful in 59s
CI / tag (push) Successful in 3s
2026-03-11 19:10:18 +01:00
Deeman
5df369ef89 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>
2026-03-11 18:10:58 +01:00
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")
@login_required
async def country_cities(country_slug: str):
"""City-level data for a country overview bubble map."""
await _require_maps_flag()

View File

@@ -368,8 +368,42 @@ async def article_page(url_path: str):
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(
"article_detail.html",
article=article,
body_html=Markup(body_html),
map_locations=map_locations,
)

View File

@@ -62,8 +62,11 @@
{% block scripts %}
<script>
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>
{% 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/article-maps.js') }}"></script>
{% endblock %}

View File

@@ -2,8 +2,8 @@
* Leaflet map initialisation for article pages (country + city maps).
*
* 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
* variable pointing to the Leaflet JS bundle.
* Country maps read inline JSON from #country-map-data (no network fetch).
* City maps still fetch venue data from the JSON API.
*
* Depends on map-markers.js (window.PNMarkers) being loaded first.
*/
@@ -24,67 +24,71 @@
}
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 map = L.map(el, {scrollWheelZoom: false});
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
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;
var maxV = Math.max.apply(null, data.map(function(d) { return d.padel_venue_count || 1; }));
var bounds = [];
data.forEach(function(c) {
if (!c.lat || !c.lon) return;
var size = 10 + 36 * Math.sqrt((c.padel_venue_count || 1) / maxV);
var hasArticle = c.has_article !== false;
var score = c.opportunity_score || 0;
var hex = sc(score);
var pop = fmtPop(c.population);
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="color:' + hex + ';font-weight:600;">' + (T.score_label || 'Padelnomics Score') + ': ' + Math.round(score) + '/100</span><br>'
+ '<span style="color:#94A3B8;font-size:0.75rem;">'
+ (c.padel_venue_count || 0) + ' ' + (T.venues || 'venues')
+ (pop ? ' · ' + pop + ' ' + (T.pop || 'pop') : '') + '</span>';
if (hasArticle) {
tip += '<br><span style="color:#94A3B8;font-size:0.75rem;">' + (T.click_explore || 'Click to explore →') + '</span>';
} else {
tip += '<br><span style="color:#94A3B8;font-size:0.75rem;">' + (T.coming_soon || 'Coming soon') + '</span>';
}
var icon = PNMarkers.makeIcon({
size: size,
color: hex,
muted: !hasArticle,
});
var marker = L.marker([c.lat, c.lon], { icon: icon })
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
.addTo(map);
if (hasArticle) {
marker.on('click', function() { window.location = '/' + lang + '/markets/' + slug + '/' + c.city_slug; });
}
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 hIcon = PNMarkers.makeIcon({
size: hSize,
color: sc(match.opportunity_score || 0),
highlight: true,
});
L.marker([match.lat, match.lon], { icon: hIcon }).addTo(map);
}
}
})
.catch(function(err) { console.error('Country map fetch failed:', err); });
if (!data.length) return;
var maxPop = Math.max.apply(null, data.map(function(d) { return d.population || 1; }));
var bounds = [];
data.forEach(function(c) {
if (!c.lat || !c.lon) return;
var size = 8 + 36 * Math.sqrt((c.population || 1) / maxPop);
var hasArticle = c.has_article === true;
var score = c.opportunity_score || 0;
var hex = sc(score);
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>'
+ '<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:#94A3B8;font-size:0.75rem;">'
+ venueInfo
+ (pop ? ' · ' + pop + ' ' + (T.pop || 'pop') : '') + '</span>';
if (hasArticle) {
tip += '<br><span style="color:#94A3B8;font-size:0.75rem;">' + (T.click_explore || 'Click to explore →') + '</span>';
} else {
tip += '<br><span style="color:#94A3B8;font-size:0.75rem;">' + (T.coming_soon || 'Coming soon') + '</span>';
}
var icon = PNMarkers.makeIcon({
size: size,
color: hex,
muted: !hasArticle,
});
var marker = L.marker([c.lat, c.lon], { icon: icon })
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
.addTo(map);
if (hasArticle) {
marker.on('click', function() { window.location = '/' + lang + '/markets/' + slug + '/' + c.city_slug; });
}
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 = 8 + 36 * Math.sqrt((match.population || 1) / maxPop);
var hIcon = PNMarkers.makeIcon({
size: hSize,
color: sc(match.opportunity_score || 0),
highlight: true,
});
L.marker([match.lat, match.lon], { icon: hIcon }).addTo(map);
}
}
}
function initCityMap(el, venueIcon) {