diff --git a/web/src/padelnomics/api.py b/web/src/padelnomics/api.py index 34cabd9..edf356e 100644 --- a/web/src/padelnomics/api.py +++ b/web/src/padelnomics/api.py @@ -48,6 +48,7 @@ async def countries(): @bp.route("/markets//cities.json") +@login_required async def country_cities(country_slug: str): """City-level data for a country overview bubble map.""" await _require_maps_flag() diff --git a/web/src/padelnomics/content/routes.py b/web/src/padelnomics/content/routes.py index ba48035..412fece 100644 --- a/web/src/padelnomics/content/routes.py +++ b/web/src/padelnomics/content/routes.py @@ -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, ) diff --git a/web/src/padelnomics/content/templates/article_detail.html b/web/src/padelnomics/content/templates/article_detail.html index 88420a5..19f3eba 100644 --- a/web/src/padelnomics/content/templates/article_detail.html +++ b/web/src/padelnomics/content/templates/article_detail.html @@ -62,8 +62,11 @@ {% block scripts %} +{% if map_locations %} + +{% endif %} {% endblock %} diff --git a/web/src/padelnomics/static/js/article-maps.js b/web/src/padelnomics/static/js/article-maps.js index 01200c8..4a10c9f 100644 --- a/web/src/padelnomics/static/js/article-maps.js +++ b/web/src/padelnomics/static/js/article-maps.js @@ -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 = '' + c.city_name + '
' - + '' - + '' + (T.score_label || 'Padelnomics Score') + ': ' + Math.round(score) + '/100
' - + '' - + (c.padel_venue_count || 0) + ' ' + (T.venues || 'venues') - + (pop ? ' · ' + pop + ' ' + (T.pop || 'pop') : '') + ''; - if (hasArticle) { - tip += '
' + (T.click_explore || 'Click to explore →') + ''; - } else { - tip += '
' + (T.coming_soon || 'Coming soon') + ''; - } - 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 = '' + c.city_name + '
' + + '' + + '' + (T.score_label || 'Padelnomics Score') + ': ' + Math.round(score) + '/100
' + + '' + + venueInfo + + (pop ? ' · ' + pop + ' ' + (T.pop || 'pop') : '') + ''; + if (hasArticle) { + tip += '
' + (T.click_explore || 'Click to explore →') + ''; + } else { + tip += '
' + (T.coming_soon || 'Coming soon') + ''; + } + 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) {