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 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-03-04 20:51:00 +01:00
parent 59f1f0d699
commit 77772b7ea4
6 changed files with 165 additions and 69 deletions

View File

@@ -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 All responses are JSON with 1-hour public cache headers (data changes at most
daily when the pipeline runs). daily when the pipeline runs).
""" """
from quart import Blueprint, jsonify from quart import Blueprint, abort, jsonify
from .analytics import fetch_analytics from .analytics import fetch_analytics
from .core import is_flag_enabled
bp = Blueprint("api", __name__) bp = Blueprint("api", __name__)
_CACHE_HEADERS = {"Cache-Control": "public, max-age=3600"} _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") @bp.route("/markets/countries.json")
async def countries(): async def countries():
"""Country-level aggregates for the markets hub map.""" """Country-level aggregates for the markets hub map."""
await _require_maps_flag()
rows = await fetch_analytics(""" rows = await fetch_analytics("""
SELECT country_code, country_name_en, country_slug, SELECT country_code, country_name_en, country_slug,
COUNT(*) AS city_count, COUNT(*) AS city_count,
@@ -34,6 +46,7 @@ async def countries():
@bp.route("/markets/<country_slug>/cities.json") @bp.route("/markets/<country_slug>/cities.json")
async def country_cities(country_slug: str): async def country_cities(country_slug: str):
"""City-level data for a country overview bubble map.""" """City-level data for a country overview bubble map."""
await _require_maps_flag()
assert country_slug, "country_slug required" assert country_slug, "country_slug required"
rows = await fetch_analytics( rows = await fetch_analytics(
""" """
@@ -52,6 +65,7 @@ async def country_cities(country_slug: str):
@bp.route("/markets/<country_slug>/<city_slug>/venues.json") @bp.route("/markets/<country_slug>/<city_slug>/venues.json")
async def city_venues(country_slug: str, city_slug: str): async def city_venues(country_slug: str, city_slug: str):
"""Venue-level dots for the city detail map.""" """Venue-level dots for the city detail map."""
await _require_maps_flag()
assert country_slug and city_slug, "country_slug and city_slug required" assert country_slug and city_slug, "country_slug and city_slug required"
rows = await fetch_analytics( rows = await fetch_analytics(
""" """
@@ -69,6 +83,7 @@ async def city_venues(country_slug: str, city_slug: str):
@bp.route("/opportunity/<country_slug>.json") @bp.route("/opportunity/<country_slug>.json")
async def opportunity(country_slug: str): async def opportunity(country_slug: str):
"""Location-level opportunity scores for the opportunity map.""" """Location-level opportunity scores for the opportunity map."""
await _require_maps_flag()
assert country_slug, "country_slug required" assert country_slug, "country_slug required"
rows = await fetch_analytics( rows = await fetch_analytics(
""" """

View File

@@ -66,19 +66,29 @@
var cityMapEl = document.getElementById('city-map'); var cityMapEl = document.getElementById('city-map');
if (!countryMapEl && !cityMapEl) return; if (!countryMapEl && !cityMapEl) return;
var TILES = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
var TILES_ATTR = '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>';
function scoreColor(score) { function scoreColor(score) {
if (score >= 60) return '#16A34A'; if (score >= 60) return '#16A34A';
if (score >= 30) return '#D97706'; if (score >= 30) return '#D97706';
return '#DC2626'; return '#DC2626';
} }
function makeIcon(size, color) {
var s = Math.round(size);
return L.divIcon({
className: '',
html: '<div class="pn-marker" style="width:' + s + 'px;height:' + s + 'px;background:' + color + ';opacity:0.82;"></div>',
iconSize: [s, s],
iconAnchor: [s / 2, s / 2],
});
}
function initCountryMap(el) { function initCountryMap(el) {
var slug = el.dataset.countrySlug; var slug = el.dataset.countrySlug;
var map = L.map(el, {scrollWheelZoom: false}); var map = L.map(el, {scrollWheelZoom: false});
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 18
}).addTo(map);
var lang = document.documentElement.lang || 'en'; var lang = document.documentElement.lang || 'en';
fetch('/api/markets/' + slug + '/cities.json') fetch('/api/markets/' + slug + '/cities.json')
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
@@ -88,32 +98,39 @@
var bounds = []; var bounds = [];
data.forEach(function(c) { data.forEach(function(c) {
if (!c.lat || !c.lon) return; 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 color = scoreColor(c.market_score);
var marker = L.circleMarker([c.lat, c.lon], { var pop = c.population >= 1000000
radius: radius, ? (c.population / 1000000).toFixed(1) + 'M'
fillColor: color, color: color, : (c.population >= 1000 ? Math.round(c.population / 1000) + 'K' : (c.population || ''));
fillOpacity: 0.6, opacity: 0.9, weight: 1 var tip = '<strong>' + c.city_name + '</strong><br>'
}) + (c.padel_venue_count || 0) + ' venues'
.bindTooltip(c.city_name + '<br>' + (c.padel_venue_count || 0) + ' venues', {className: 'map-tooltip'}) + (pop ? ' · ' + pop : '') + '<br>'
+ '<span style="color:' + color + ';font-weight:600;">Score ' + Math.round(c.market_score) + '/100</span>';
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; }) .on('click', function() { window.location = '/' + lang + '/markets/' + slug + '/' + c.city_slug; })
.addTo(map); .addTo(map);
bounds.push([c.lat, c.lon]); 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: '<div class="pn-venue"></div>',
iconSize: [10, 10],
iconAnchor: [5, 5],
});
function initCityMap(el) { function initCityMap(el) {
var countrySlug = el.dataset.countrySlug; var countrySlug = el.dataset.countrySlug;
var citySlug = el.dataset.citySlug; var citySlug = el.dataset.citySlug;
var lat = parseFloat(el.dataset.lat); var lat = parseFloat(el.dataset.lat);
var lon = parseFloat(el.dataset.lon); var lon = parseFloat(el.dataset.lon);
var map = L.map(el, {scrollWheelZoom: false}).setView([lat, lon], 13); 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', { L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 18
}).addTo(map);
fetch('/api/markets/' + countrySlug + '/' + citySlug + '/venues.json') fetch('/api/markets/' + countrySlug + '/' + citySlug + '/venues.json')
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
@@ -122,15 +139,15 @@
var indoor = v.indoor_court_count || 0; var indoor = v.indoor_court_count || 0;
var outdoor = v.outdoor_court_count || 0; var outdoor = v.outdoor_court_count || 0;
var total = v.court_count || (indoor + outdoor); var total = v.court_count || (indoor + outdoor);
var tip = v.name + (total ? '<br>' + total + ' courts' + var courtLine = total
(indoor ? ' (' + indoor + ' indoor' : '') + ? total + ' court' + (total > 1 ? 's' : '')
(outdoor ? (indoor ? ', ' : ' (') + outdoor + ' outdoor)' : (indoor ? ')' : '')) : ''); + (indoor || outdoor
L.circleMarker([v.lat, v.lon], { ? ' (' + [indoor ? indoor + ' indoor' : '', outdoor ? outdoor + ' outdoor' : ''].filter(Boolean).join(', ') + ')'
radius: 6, : '')
fillColor: '#1D4ED8', color: '#1D4ED8', : '';
fillOpacity: 0.6, opacity: 0.9, weight: 1 var tip = '<strong>' + v.name + '</strong>' + (courtLine ? '<br>' + courtLine : '');
}) L.marker([v.lat, v.lon], { icon: VENUE_ICON })
.bindTooltip(tip, {className: 'map-tooltip'}) .bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -7] })
.addTo(map); .addTo(map);
}); });
}); });

View File

@@ -82,6 +82,16 @@
return '#DC2626'; return '#DC2626';
} }
function makeIcon(size, color) {
var s = Math.round(size);
return L.divIcon({
className: '',
html: '<div class="pn-marker" style="width:' + s + 'px;height:' + s + 'px;background:' + color + ';opacity:0.82;"></div>',
iconSize: [s, s],
iconAnchor: [s / 2, s / 2],
});
}
fetch('/api/markets/countries.json') fetch('/api/markets/countries.json')
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
@@ -90,14 +100,13 @@
var lang = document.documentElement.lang || 'en'; var lang = document.documentElement.lang || 'en';
data.forEach(function(c) { data.forEach(function(c) {
if (!c.lat || !c.lon) return; 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); var color = scoreColor(c.avg_market_score);
L.circleMarker([c.lat, c.lon], { var tip = '<strong>' + c.country_name_en + '</strong><br>'
radius: radius, + c.total_venues + ' venues · ' + c.city_count + ' cities<br>'
fillColor: color, color: color, + '<span style="color:' + color + ';font-weight:600;">Score ' + c.avg_market_score + '/100</span>';
fillOpacity: 0.6, opacity: 0.9, weight: 1 L.marker([c.lat, c.lon], { icon: makeIcon(size, color) })
}) .bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
.bindTooltip(c.country_name_en + '<br>' + c.total_venues + ' venues in ' + c.city_count + ' cities', {className: 'map-tooltip'})
.on('click', function() { window.location = '/' + lang + '/markets/' + c.country_slug; }) .on('click', function() { window.location = '/' + lang + '/markets/' + c.country_slug; })
.addTo(map); .addTo(map);
}); });

View File

@@ -3,7 +3,7 @@ Public domain: landing page, marketing pages, legal pages, feedback.
""" """
from pathlib import Path 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 ..analytics import fetch_analytics
from ..core import check_rate_limit, count_where, csrf_protect, execute, fetch_all, fetch_one 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") @bp.route("/opportunity-map")
async def opportunity_map(): async def opportunity_map():
"""Interactive padel opportunity map — country selector + location dots.""" """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(""" countries = await fetch_analytics("""
SELECT DISTINCT country_slug, country_name_en SELECT DISTINCT country_slug, country_name_en
FROM serving.city_market_profile FROM serving.city_market_profile

View File

@@ -48,11 +48,11 @@
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.min.js') }}"></script> <script src="{{ url_for('static', filename='vendor/leaflet/leaflet.min.js') }}"></script>
<script> <script>
(function() { (function() {
var TILES = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
var TILES_ATTR = '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>';
var map = L.map('opportunity-map', {scrollWheelZoom: false}).setView([48.5, 10], 4); var map = L.map('opportunity-map', {scrollWheelZoom: false}).setView([48.5, 10], 4);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', { L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 18
}).addTo(map);
var oppLayer = L.layerGroup().addTo(map); var oppLayer = L.layerGroup().addTo(map);
var refLayer = L.layerGroup().addTo(map); var refLayer = L.layerGroup().addTo(map);
@@ -63,12 +63,34 @@
return '#3B82F6'; return '#3B82F6';
} }
function makeIcon(size, color) {
var s = Math.round(size);
return L.divIcon({
className: '',
html: '<div class="pn-marker" style="width:' + s + 'px;height:' + s + 'px;background:' + color + ';opacity:0.8;"></div>',
iconSize: [s, s],
iconAnchor: [s / 2, s / 2],
});
}
var REF_ICON = L.divIcon({
className: '',
html: '<div class="pn-venue" style="background:#94A3B8;border-color:white;opacity:0.7;"></div>',
iconSize: [10, 10],
iconAnchor: [5, 5],
});
function fmtPop(p) {
return p >= 1000000 ? (p / 1000000).toFixed(1) + 'M'
: p >= 1000 ? Math.round(p / 1000) + 'K'
: (p || '');
}
function loadCountry(slug) { function loadCountry(slug) {
oppLayer.clearLayers(); oppLayer.clearLayers();
refLayer.clearLayers(); refLayer.clearLayers();
if (!slug) return; if (!slug) return;
// Opportunity dots
fetch('/api/opportunity/' + slug + '.json') fetch('/api/opportunity/' + slug + '.json')
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
@@ -77,43 +99,31 @@
var bounds = []; var bounds = [];
data.forEach(function(loc) { data.forEach(function(loc) {
if (!loc.lat || !loc.lon) return; if (!loc.lat || !loc.lon) return;
var radius = 4 + 20 * Math.sqrt((loc.population || 1) / maxPop); var size = 8 + 40 * Math.sqrt((loc.population || 1) / maxPop);
var color = oppColor(loc.opportunity_score); var color = oppColor(loc.opportunity_score);
var dist = loc.nearest_padel_court_km != null var dist = loc.nearest_padel_court_km != null
? loc.nearest_padel_court_km.toFixed(1) + ' km to nearest court' ? loc.nearest_padel_court_km.toFixed(1) + ' km to nearest court'
: 'No nearby courts'; : 'No nearby courts';
var pop = loc.population >= 1000000 var tip = '<strong>' + loc.location_name + '</strong><br>'
? (loc.population / 1000000).toFixed(1) + 'M' + '<span style="color:' + color + ';font-weight:600;">Score ' + loc.opportunity_score + '/100</span><br>'
: (loc.population >= 1000 ? Math.round(loc.population / 1000) + 'K' : loc.population); + dist + ' · Pop. ' + fmtPop(loc.population);
L.circleMarker([loc.lat, loc.lon], { L.marker([loc.lat, loc.lon], { icon: makeIcon(size, color) })
radius: radius, .bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
fillColor: color, color: color,
fillOpacity: 0.6, opacity: 0.9, weight: 1
})
.bindTooltip(
'<strong>' + loc.location_name + '</strong><br>' +
'Opportunity Score: ' + loc.opportunity_score + '/100<br>' +
dist + '<br>Population: ' + pop,
{className: 'map-tooltip'}
)
.addTo(oppLayer); .addTo(oppLayer);
bounds.push([loc.lat, loc.lon]); bounds.push([loc.lat, loc.lon]);
}); });
if (bounds.length) map.fitBounds(bounds, {padding: [30, 30]}); if (bounds.length) map.fitBounds(bounds, { padding: [30, 30] });
}); });
// Existing venues as small gray reference dots // Existing venues as small gray reference dots (drawn first = behind opp dots)
fetch('/api/markets/' + slug + '/cities.json') fetch('/api/markets/' + slug + '/cities.json')
.then(function(r) { return r.json(); }) .then(function(r) { return r.json(); })
.then(function(data) { .then(function(data) {
data.forEach(function(c) { data.forEach(function(c) {
if (!c.lat || !c.lon || !c.padel_venue_count) return; if (!c.lat || !c.lon || !c.padel_venue_count) return;
L.circleMarker([c.lat, c.lon], { L.marker([c.lat, c.lon], { icon: REF_ICON })
radius: 4, .bindTooltip(c.city_name + ' — ' + c.padel_venue_count + ' existing venues',
fillColor: '#94A3B8', color: '#94A3B8', { className: 'map-tooltip', direction: 'top', offset: [0, -7] })
fillOpacity: 0.5, opacity: 0.7, weight: 1
})
.bindTooltip(c.city_name + ' — ' + c.padel_venue_count + ' venues (existing)', {className: 'map-tooltip'})
.addTo(refLayer); .addTo(refLayer);
}); });
}); });

View File

@@ -860,3 +860,45 @@
from { transform: rotate(0deg); } from { transform: rotate(0deg); }
to { transform: rotate(360deg); } to { transform: rotate(360deg); }
} }
/* ── Leaflet map overrides (beanflows-style) ── */
/* Dark tooltip — no arrow */
.leaflet-tooltip.map-tooltip {
background: #1E293B;
color: rgba(255,255,255,0.9);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 8px;
padding: 8px 12px;
box-shadow: 0 4px 16px rgba(0,0,0,0.3);
font-size: 0.8125rem;
line-height: 1.6;
white-space: nowrap;
pointer-events: none;
}
.leaflet-tooltip.map-tooltip::before { display: none; }
.leaflet-tooltip.map-tooltip strong { color: white; }
/* Polished variable-size circle — white border + drop shadow */
.pn-marker {
border-radius: 50%;
border: 2.5px solid white;
box-shadow: 0 2px 8px rgba(0,0,0,0.28);
cursor: pointer;
transition: box-shadow 0.15s, transform 0.1s;
}
.pn-marker:hover {
box-shadow: 0 3px 12px rgba(0,0,0,0.38);
transform: scale(1.1);
}
/* Small fixed venue dot */
.pn-venue {
width: 10px;
height: 10px;
border-radius: 50%;
background: #1D4ED8;
border: 2px solid white;
box-shadow: 0 1px 4px rgba(0,0,0,0.25);
cursor: pointer;
}