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:
@@ -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(
|
||||
"""
|
||||
|
||||
@@ -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 = '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <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'})
|
||||
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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <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,15 +139,15 @@
|
||||
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'})
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,14 +100,13 @@
|
||||
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'})
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <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,43 +99,31 @@
|
||||
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'}
|
||||
)
|
||||
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] });
|
||||
});
|
||||
|
||||
// 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'})
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user