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:
@@ -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()
|
||||
|
||||
@@ -367,8 +367,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,
|
||||
)
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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,28 +24,34 @@
|
||||
}
|
||||
|
||||
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 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 = 10 + 36 * Math.sqrt((c.padel_venue_count || 1) / maxV);
|
||||
var hasArticle = c.has_article !== false;
|
||||
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;">'
|
||||
+ (c.padel_venue_count || 0) + ' ' + (T.venues || 'venues')
|
||||
+ 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>';
|
||||
@@ -74,7 +80,7 @@
|
||||
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 hSize = 8 + 36 * Math.sqrt((match.population || 1) / maxPop);
|
||||
var hIcon = PNMarkers.makeIcon({
|
||||
size: hSize,
|
||||
color: sc(match.opportunity_score || 0),
|
||||
@@ -83,8 +89,6 @@
|
||||
L.marker([match.lat, match.lon], { icon: hIcon }).addTo(map);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch(function(err) { console.error('Country map fetch failed:', err); });
|
||||
}
|
||||
|
||||
function initCityMap(el, venueIcon) {
|
||||
|
||||
Reference in New Issue
Block a user