feat(maps): Phase 5 — standalone opportunity map page

New route GET /<lang>/opportunity-map renders a full-width Leaflet map
with a country selector. On country change, fetches
/api/opportunity/{slug}.json and renders opportunity circles
(color-coded by score, sized by population) plus existing-venue gray
reference dots from /api/markets/{country}/cities.json.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Deeman
2026-03-04 15:32:56 +01:00
parent edf678ac4e
commit 6e936dbb95
2 changed files with 139 additions and 0 deletions

View File

@@ -5,6 +5,7 @@ from pathlib import Path
from quart import Blueprint, 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
from ..i18n import get_translations
@@ -71,6 +72,17 @@ async def market_score():
return await render_template("market_score.html")
@bp.route("/opportunity-map")
async def opportunity_map():
"""Interactive padel opportunity map — country selector + location dots."""
countries = await fetch_analytics("""
SELECT DISTINCT country_slug, country_name_en
FROM serving.city_market_profile
ORDER BY country_name_en
""")
return await render_template("opportunity_map.html", countries=countries)
@bp.route("/imprint")
async def imprint():
lang = g.get("lang", "en")

View File

@@ -0,0 +1,127 @@
{% extends "base.html" %}
{% block title %}Padel Opportunity Map — {{ config.APP_NAME }}{% endblock %}
{% block head %}
<meta name="description" content="Explore padel investment opportunities by location. Find underserved markets with high population, strong sports culture, and no existing padel courts.">
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.min.css') }}">
<style>
#opportunity-map { width: 100%; height: 600px; border-radius: 12px; }
.opp-legend {
background: white; padding: 10px 14px; border-radius: 8px;
box-shadow: 0 1px 4px rgba(0,0,0,.15); font-size: 0.8125rem; line-height: 1.8;
}
.opp-legend span { display: inline-block; width: 12px; height: 12px; border-radius: 50%; margin-right: 6px; vertical-align: middle; }
</style>
{% endblock %}
{% block content %}
<main class="container-page py-12">
<header class="mb-6">
<h1 class="text-3xl mb-2">Padel Opportunity Map</h1>
<p class="text-slate">Locations ranked by investment potential — population, supply gap, sports culture, and catchment reach.</p>
</header>
<div class="card mb-4" style="padding: 1rem 1.25rem;">
<label class="form-label" for="opp-country-select" style="margin-bottom: 0.5rem; display:block;">Select a country</label>
<select id="opp-country-select" class="form-input" style="max-width: 280px;">
<option value="">— choose country —</option>
{% for c in countries %}
<option value="{{ c.country_slug }}">{{ c.country_name_en }}</option>
{% endfor %}
</select>
</div>
<div id="opportunity-map"></div>
<div class="mt-4 text-sm text-slate">
<strong>Circle size:</strong> population &nbsp;|&nbsp;
<strong>Color:</strong>
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#16A34A;vertical-align:middle;margin:0 4px"></span>High (≥70) &nbsp;
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#D97706;vertical-align:middle;margin:0 4px"></span>Mid (4070) &nbsp;
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#3B82F6;vertical-align:middle;margin:0 4px"></span>Low (&lt;40)
</div>
</main>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.min.js') }}"></script>
<script>
(function() {
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);
var oppLayer = L.layerGroup().addTo(map);
var refLayer = L.layerGroup().addTo(map);
function oppColor(score) {
if (score >= 70) return '#16A34A';
if (score >= 40) return '#D97706';
return '#3B82F6';
}
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) {
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 radius = 4 + 20 * 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);
bounds.push([loc.lat, loc.lon]);
});
if (bounds.length) map.fitBounds(bounds, {padding: [30, 30]});
});
// Existing venues as small gray reference 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);
});
});
}
document.getElementById('opp-country-select').addEventListener('change', function() {
loadCountry(this.value);
});
})();
</script>
{% endblock %}