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
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/<country_slug>/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/<country_slug>/<city_slug>/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/<country_slug>.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(
"""

View File

@@ -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 = '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>';
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: '<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) {
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: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
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 + '<br>' + (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 = '<strong>' + c.city_name + '</strong><br>'
+ (c.padel_venue_count || 0) + ' venues'
+ (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; })
.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: '<div class="pn-venue"></div>',
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: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
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 ? '<br>' + 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 = '<strong>' + v.name + '</strong>' + (courtLine ? '<br>' + courtLine : '');
L.marker([v.lat, v.lon], { icon: VENUE_ICON })
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -7] })
.addTo(map);
});
});
}

View File

@@ -82,6 +82,16 @@
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')
.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 + '<br>' + 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 = '<strong>' + c.country_name_en + '</strong><br>'
+ c.total_venues + ' venues · ' + c.city_count + ' cities<br>'
+ '<span style="color:' + color + ';font-weight:600;">Score ' + c.avg_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/' + c.country_slug; })
.addTo(map);
});
});
})();

View File

@@ -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

View File

@@ -48,11 +48,11 @@
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.min.js') }}"></script>
<script>
(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);
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 18
}).addTo(map);
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
var oppLayer = L.layerGroup().addTo(map);
var refLayer = L.layerGroup().addTo(map);
@@ -63,12 +63,34 @@
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) {
oppLayer.clearLayers();
refLayer.clearLayers();
if (!slug) return;
// Opportunity dots
fetch('/api/opportunity/' + slug + '.json')
.then(function(r) { return r.json(); })
.then(function(data) {
@@ -77,44 +99,32 @@
var bounds = [];
data.forEach(function(loc) {
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 dist = loc.nearest_padel_court_km != null
? loc.nearest_padel_court_km.toFixed(1) + ' km to nearest court'
: 'No nearby courts';
var pop = loc.population >= 1000000
? (loc.population / 1000000).toFixed(1) + 'M'
: (loc.population >= 1000 ? Math.round(loc.population / 1000) + 'K' : loc.population);
L.circleMarker([loc.lat, loc.lon], {
radius: radius,
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);
var tip = '<strong>' + loc.location_name + '</strong><br>'
+ '<span style="color:' + color + ';font-weight:600;">Score ' + loc.opportunity_score + '/100</span><br>'
+ 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]});
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')
.then(function(r) { return r.json(); })
.then(function(data) {
data.forEach(function(c) {
if (!c.lat || !c.lon || !c.padel_venue_count) return;
L.circleMarker([c.lat, c.lon], {
radius: 4,
fillColor: '#94A3B8', color: '#94A3B8',
fillOpacity: 0.5, opacity: 0.7, weight: 1
})
.bindTooltip(c.city_name + ' — ' + c.padel_venue_count + ' venues (existing)', {className: 'map-tooltip'})
.addTo(refLayer);
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);
});
});
}

View File

@@ -860,3 +860,45 @@
from { transform: rotate(0deg); }
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;
}