diff --git a/web/src/padelnomics/api.py b/web/src/padelnomics/api.py index 3bd6f0b..34cabd9 100644 --- a/web/src/padelnomics/api.py +++ b/web/src/padelnomics/api.py @@ -82,7 +82,6 @@ async def country_cities(country_slug: str): @bp.route("/markets///venues.json") -@login_required async def city_venues(country_slug: str, city_slug: str): """Venue-level dots for the city detail map.""" await _require_maps_flag() @@ -99,23 +98,3 @@ async def city_venues(country_slug: str, city_slug: str): ) return jsonify(rows), 200, _CACHE_HEADERS - -@bp.route("/opportunity/.json") -async def opportunity(country_slug: str): - """Location-level opportunity scores for the opportunity map.""" - await _require_maps_flag() - assert country_slug, "country_slug required" - rows = await fetch_analytics( - """ - SELECT location_name, location_slug, lat, lon, - opportunity_score, market_score, - nearest_padel_court_km, - padel_venue_count, population - FROM serving.location_profiles - WHERE country_slug = ? AND opportunity_score > 0 - ORDER BY opportunity_score DESC - LIMIT 500 - """, - [country_slug], - ) - return jsonify(rows), 200, _CACHE_HEADERS diff --git a/web/src/padelnomics/public/routes.py b/web/src/padelnomics/public/routes.py index 1a8ba03..476415c 100644 --- a/web/src/padelnomics/public/routes.py +++ b/web/src/padelnomics/public/routes.py @@ -87,6 +87,46 @@ async def opportunity_map(): return await render_template("opportunity_map.html", countries=countries) +@bp.route("/opportunity-map/data") +async def opportunity_map_data(): + """HTMX partial: opportunity + reference data islands for Leaflet map.""" + from ..core import is_flag_enabled + if not await is_flag_enabled("maps", default=True): + abort(404) + country_slug = request.args.get("country", "") + if not country_slug: + return "" + opp_points = await fetch_analytics( + """ + SELECT location_name, location_slug, lat, lon, + opportunity_score, market_score, + nearest_padel_court_km, padel_venue_count, population + FROM serving.location_profiles + WHERE country_slug = ? AND opportunity_score > 0 + ORDER BY opportunity_score DESC + LIMIT 500 + """, + [country_slug], + ) + ref_points = await fetch_analytics( + """ + SELECT city_name, city_slug, lat, lon, + city_padel_venue_count AS padel_venue_count, + market_score, population + FROM serving.location_profiles + WHERE country_slug = ? AND city_slug IS NOT NULL + ORDER BY city_padel_venue_count DESC + LIMIT 200 + """, + [country_slug], + ) + return await render_template( + "partials/opportunity_map_data.html", + opp_points=opp_points, + ref_points=ref_points, + ) + + @bp.route("/imprint") async def imprint(): lang = g.get("lang", "en") diff --git a/web/src/padelnomics/public/templates/opportunity_map.html b/web/src/padelnomics/public/templates/opportunity_map.html index 0ddf190..a255a36 100644 --- a/web/src/padelnomics/public/templates/opportunity_map.html +++ b/web/src/padelnomics/public/templates/opportunity_map.html @@ -24,7 +24,10 @@
- {% for c in countries %} @@ -33,6 +36,7 @@
+
Circle size: population  |  @@ -86,53 +90,48 @@ : (p || ''); } - function loadCountry(slug) { + function renderMap() { oppLayer.clearLayers(); refLayer.clearLayers(); - if (!slug) return; + var oppEl = document.getElementById('opp-data'); + var refEl = document.getElementById('ref-data'); + if (!oppEl) return; + var oppData = JSON.parse(oppEl.textContent); + var refData = JSON.parse(refEl.textContent); - fetch('/api/opportunity/' + slug + '.json') - .then(function(r) { return r.json(); }) - .then(function(data) { - if (!data.length) return; - var maxPop = Math.max.apply(null, data.map(function(d) { return d.population || 1; })); - var bounds = []; - data.forEach(function(loc) { - if (!loc.lat || !loc.lon) return; - var size = 8 + 40 * Math.sqrt((loc.population || 1) / maxPop); - var color = oppColor(loc.opportunity_score); - var dist = loc.nearest_padel_court_km != null - ? loc.nearest_padel_court_km.toFixed(1) + ' km to nearest court' - : 'No nearby courts'; - var mktColor = loc.market_score >= 60 ? '#16A34A' : (loc.market_score >= 30 ? '#D97706' : '#DC2626'); - var tip = '' + loc.location_name + '
' - + 'Padelnomics Opportunity Score: ' + loc.opportunity_score + '/100
' - + 'Padelnomics Market Score: ' + (loc.market_score || 0) + '/100
' - + dist + ' · Pop. ' + fmtPop(loc.population); - L.marker([loc.lat, loc.lon], { icon: makeIcon(size, color) }) - .bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] }) - .addTo(oppLayer); - bounds.push([loc.lat, loc.lon]); - }); - if (bounds.length) map.fitBounds(bounds, { padding: [30, 30] }); - }); + refData.forEach(function(c) { + if (!c.lat || !c.lon || !c.padel_venue_count) return; + L.marker([c.lat, c.lon], { icon: REF_ICON }) + .bindTooltip(c.city_name + ' — ' + c.padel_venue_count + ' existing venues', + { className: 'map-tooltip', direction: 'top', offset: [0, -7] }) + .addTo(refLayer); + }); - // Existing venues as small gray reference dots (drawn first = behind opp dots) - fetch('/api/markets/' + slug + '/cities.json') - .then(function(r) { return r.json(); }) - .then(function(data) { - data.forEach(function(c) { - if (!c.lat || !c.lon || !c.padel_venue_count) return; - L.marker([c.lat, c.lon], { icon: REF_ICON }) - .bindTooltip(c.city_name + ' — ' + c.padel_venue_count + ' existing venues', - { className: 'map-tooltip', direction: 'top', offset: [0, -7] }) - .addTo(refLayer); - }); - }); + if (!oppData.length) return; + var maxPop = Math.max.apply(null, oppData.map(function(d) { return d.population || 1; })); + var bounds = []; + oppData.forEach(function(loc) { + if (!loc.lat || !loc.lon) return; + var size = 8 + 40 * Math.sqrt((loc.population || 1) / maxPop); + var color = oppColor(loc.opportunity_score); + var dist = loc.nearest_padel_court_km != null + ? loc.nearest_padel_court_km.toFixed(1) + ' km to nearest court' + : 'No nearby courts'; + var mktColor = loc.market_score >= 60 ? '#16A34A' : (loc.market_score >= 30 ? '#D97706' : '#DC2626'); + var tip = '' + loc.location_name + '
' + + 'Padelnomics Opportunity Score: ' + loc.opportunity_score + '/100
' + + 'Padelnomics Market Score: ' + (loc.market_score || 0) + '/100
' + + dist + ' · Pop. ' + fmtPop(loc.population); + L.marker([loc.lat, loc.lon], { icon: makeIcon(size, color) }) + .bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] }) + .addTo(oppLayer); + bounds.push([loc.lat, loc.lon]); + }); + if (bounds.length) map.fitBounds(bounds, { padding: [30, 30] }); } - document.getElementById('opp-country-select').addEventListener('change', function() { - loadCountry(this.value); + document.body.addEventListener('htmx:afterSwap', function(e) { + if (e.detail.target.id === 'map-data') renderMap(); }); })(); diff --git a/web/src/padelnomics/public/templates/partials/opportunity_map_data.html b/web/src/padelnomics/public/templates/partials/opportunity_map_data.html new file mode 100644 index 0000000..d684587 --- /dev/null +++ b/web/src/padelnomics/public/templates/partials/opportunity_map_data.html @@ -0,0 +1,2 @@ + +