From 77772b7ea43a79692c95037487447ef35d079658 Mon Sep 17 00:00:00 2001 From: Deeman Date: Wed, 4 Mar 2026 20:51:00 +0100 Subject: [PATCH] feat(maps): beanflows-style divIcon bubbles + feature flag gate Replace L.circleMarker with L.divIcon + .pn-marker CSS class (white border, box-shadow, hover scale) matching the beanflows growing conditions map pattern. Dark .map-tooltip CSS override (no arrow, dark navy background). Small venue dots use .pn-venue class. Add _require_maps_flag() to all 4 API endpoints (default=True so dev works without seeding the flag row). Gate /opportunity-map route the same way. Co-Authored-By: Claude Sonnet 4.6 --- web/src/padelnomics/api.py | 17 ++++- .../content/templates/article_detail.html | 73 ++++++++++++------- .../content/templates/markets.html | 27 ++++--- web/src/padelnomics/public/routes.py | 5 +- .../public/templates/opportunity_map.html | 70 ++++++++++-------- web/src/padelnomics/static/css/input.css | 42 +++++++++++ 6 files changed, 165 insertions(+), 69 deletions(-) diff --git a/web/src/padelnomics/api.py b/web/src/padelnomics/api.py index a15caad..37c620d 100644 --- a/web/src/padelnomics/api.py +++ b/web/src/padelnomics/api.py @@ -5,18 +5,30 @@ Serves pre-aggregated geographic data from analytics.duckdb for Leaflet maps. All responses are JSON with 1-hour public cache headers (data changes at most daily when the pipeline runs). """ -from quart import Blueprint, jsonify +from quart import Blueprint, abort, jsonify from .analytics import fetch_analytics +from .core import is_flag_enabled bp = Blueprint("api", __name__) _CACHE_HEADERS = {"Cache-Control": "public, max-age=3600"} +async def _require_maps_flag() -> None: + """Abort with 404 if the maps feature flag is explicitly disabled. + + Defaults to enabled (True) so that dev environments without the flag row + in the DB still work. An admin can disable by setting the flag to False. + """ + if not await is_flag_enabled("maps", default=True): + abort(404) + + @bp.route("/markets/countries.json") async def countries(): """Country-level aggregates for the markets hub map.""" + await _require_maps_flag() rows = await fetch_analytics(""" SELECT country_code, country_name_en, country_slug, COUNT(*) AS city_count, @@ -34,6 +46,7 @@ async def countries(): @bp.route("/markets//cities.json") async def country_cities(country_slug: str): """City-level data for a country overview bubble map.""" + await _require_maps_flag() assert country_slug, "country_slug required" rows = await fetch_analytics( """ @@ -52,6 +65,7 @@ async def country_cities(country_slug: str): @bp.route("/markets///venues.json") async def city_venues(country_slug: str, city_slug: str): """Venue-level dots for the city detail map.""" + await _require_maps_flag() assert country_slug and city_slug, "country_slug and city_slug required" rows = await fetch_analytics( """ @@ -69,6 +83,7 @@ async def city_venues(country_slug: str, city_slug: str): @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( """ diff --git a/web/src/padelnomics/content/templates/article_detail.html b/web/src/padelnomics/content/templates/article_detail.html index 426650b..3a6e1fd 100644 --- a/web/src/padelnomics/content/templates/article_detail.html +++ b/web/src/padelnomics/content/templates/article_detail.html @@ -66,19 +66,29 @@ var cityMapEl = document.getElementById('city-map'); if (!countryMapEl && !cityMapEl) return; + var TILES = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'; + var TILES_ATTR = '© OSM © CARTO'; + function scoreColor(score) { if (score >= 60) return '#16A34A'; if (score >= 30) return '#D97706'; return '#DC2626'; } + function makeIcon(size, color) { + var s = Math.round(size); + return L.divIcon({ + className: '', + html: '
', + iconSize: [s, s], + iconAnchor: [s / 2, s / 2], + }); + } + function initCountryMap(el) { var slug = el.dataset.countrySlug; var map = L.map(el, {scrollWheelZoom: false}); - L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { - attribution: '© OSM © CARTO', - maxZoom: 18 - }).addTo(map); + 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(); }) @@ -88,32 +98,39 @@ var bounds = []; data.forEach(function(c) { if (!c.lat || !c.lon) return; - var radius = 5 + 18 * Math.sqrt((c.padel_venue_count || 1) / maxV); + var size = 10 + 36 * Math.sqrt((c.padel_venue_count || 1) / maxV); var color = scoreColor(c.market_score); - var marker = L.circleMarker([c.lat, c.lon], { - radius: radius, - fillColor: color, color: color, - fillOpacity: 0.6, opacity: 0.9, weight: 1 - }) - .bindTooltip(c.city_name + '
' + (c.padel_venue_count || 0) + ' venues', {className: 'map-tooltip'}) - .on('click', function() { window.location = '/' + lang + '/markets/' + slug + '/' + c.city_slug; }) - .addTo(map); + var pop = c.population >= 1000000 + ? (c.population / 1000000).toFixed(1) + 'M' + : (c.population >= 1000 ? Math.round(c.population / 1000) + 'K' : (c.population || '')); + var tip = '' + c.city_name + '
' + + (c.padel_venue_count || 0) + ' venues' + + (pop ? ' · ' + pop : '') + '
' + + 'Score ' + Math.round(c.market_score) + '/100'; + L.marker([c.lat, c.lon], { icon: makeIcon(size, color) }) + .bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] }) + .on('click', function() { window.location = '/' + lang + '/markets/' + slug + '/' + c.city_slug; }) + .addTo(map); bounds.push([c.lat, c.lon]); }); - if (bounds.length) map.fitBounds(bounds, {padding: [20, 20]}); + if (bounds.length) map.fitBounds(bounds, { padding: [24, 24] }); }); } + var VENUE_ICON = L.divIcon({ + className: '', + html: '
', + iconSize: [10, 10], + iconAnchor: [5, 5], + }); + function initCityMap(el) { var countrySlug = el.dataset.countrySlug; var citySlug = el.dataset.citySlug; var lat = parseFloat(el.dataset.lat); var lon = parseFloat(el.dataset.lon); var map = L.map(el, {scrollWheelZoom: false}).setView([lat, lon], 13); - L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { - attribution: '© OSM © CARTO', - maxZoom: 18 - }).addTo(map); + L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map); fetch('/api/markets/' + countrySlug + '/' + citySlug + '/venues.json') .then(function(r) { return r.json(); }) .then(function(data) { @@ -122,16 +139,16 @@ var indoor = v.indoor_court_count || 0; var outdoor = v.outdoor_court_count || 0; var total = v.court_count || (indoor + outdoor); - var tip = v.name + (total ? '
' + total + ' courts' + - (indoor ? ' (' + indoor + ' indoor' : '') + - (outdoor ? (indoor ? ', ' : ' (') + outdoor + ' outdoor)' : (indoor ? ')' : '')) : ''); - L.circleMarker([v.lat, v.lon], { - radius: 6, - fillColor: '#1D4ED8', color: '#1D4ED8', - fillOpacity: 0.6, opacity: 0.9, weight: 1 - }) - .bindTooltip(tip, {className: 'map-tooltip'}) - .addTo(map); + var courtLine = total + ? total + ' court' + (total > 1 ? 's' : '') + + (indoor || outdoor + ? ' (' + [indoor ? indoor + ' indoor' : '', outdoor ? outdoor + ' outdoor' : ''].filter(Boolean).join(', ') + ')' + : '') + : ''; + var tip = '' + v.name + '' + (courtLine ? '
' + courtLine : ''); + L.marker([v.lat, v.lon], { icon: VENUE_ICON }) + .bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -7] }) + .addTo(map); }); }); } diff --git a/web/src/padelnomics/content/templates/markets.html b/web/src/padelnomics/content/templates/markets.html index 492f020..a807cfe 100644 --- a/web/src/padelnomics/content/templates/markets.html +++ b/web/src/padelnomics/content/templates/markets.html @@ -82,6 +82,16 @@ return '#DC2626'; } + function makeIcon(size, color) { + var s = Math.round(size); + return L.divIcon({ + className: '', + html: '
', + iconSize: [s, s], + iconAnchor: [s / 2, s / 2], + }); + } + fetch('/api/markets/countries.json') .then(function(r) { return r.json(); }) .then(function(data) { @@ -90,16 +100,15 @@ var lang = document.documentElement.lang || 'en'; data.forEach(function(c) { if (!c.lat || !c.lon) return; - var radius = 6 + 22 * Math.sqrt(c.total_venues / maxV); + var size = 12 + 44 * Math.sqrt(c.total_venues / maxV); var color = scoreColor(c.avg_market_score); - L.circleMarker([c.lat, c.lon], { - radius: radius, - fillColor: color, color: color, - fillOpacity: 0.6, opacity: 0.9, weight: 1 - }) - .bindTooltip(c.country_name_en + '
' + c.total_venues + ' venues in ' + c.city_count + ' cities', {className: 'map-tooltip'}) - .on('click', function() { window.location = '/' + lang + '/markets/' + c.country_slug; }) - .addTo(map); + var tip = '' + c.country_name_en + '
' + + c.total_venues + ' venues · ' + c.city_count + ' cities
' + + 'Score ' + c.avg_market_score + '/100'; + L.marker([c.lat, c.lon], { icon: makeIcon(size, color) }) + .bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] }) + .on('click', function() { window.location = '/' + lang + '/markets/' + c.country_slug; }) + .addTo(map); }); }); })(); diff --git a/web/src/padelnomics/public/routes.py b/web/src/padelnomics/public/routes.py index 9bf0248..018bf74 100644 --- a/web/src/padelnomics/public/routes.py +++ b/web/src/padelnomics/public/routes.py @@ -3,7 +3,7 @@ Public domain: landing page, marketing pages, legal pages, feedback. """ from pathlib import Path -from quart import Blueprint, g, render_template, request, session +from quart import Blueprint, abort, g, render_template, request, session from ..analytics import fetch_analytics from ..core import check_rate_limit, count_where, csrf_protect, execute, fetch_all, fetch_one @@ -75,6 +75,9 @@ async def market_score(): @bp.route("/opportunity-map") async def opportunity_map(): """Interactive padel opportunity map — country selector + location dots.""" + from ..core import is_flag_enabled + if not await is_flag_enabled("maps", default=True): + abort(404) countries = await fetch_analytics(""" SELECT DISTINCT country_slug, country_name_en FROM serving.city_market_profile diff --git a/web/src/padelnomics/public/templates/opportunity_map.html b/web/src/padelnomics/public/templates/opportunity_map.html index 7eea4a3..c98efad 100644 --- a/web/src/padelnomics/public/templates/opportunity_map.html +++ b/web/src/padelnomics/public/templates/opportunity_map.html @@ -48,11 +48,11 @@