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:
@@ -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")
|
||||
|
||||
127
web/src/padelnomics/public/templates/opportunity_map.html
Normal file
127
web/src/padelnomics/public/templates/opportunity_map.html
Normal 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 |
|
||||
<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)
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#D97706;vertical-align:middle;margin:0 4px"></span>Mid (40–70)
|
||||
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#3B82F6;vertical-align:middle;margin:0 4px"></span>Low (<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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <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 %}
|
||||
Reference in New Issue
Block a user