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>
138 lines
5.6 KiB
HTML
138 lines
5.6 KiB
HTML
{% 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 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(TILES, { attribution: TILES_ATTR, 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 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;
|
||
|
||
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 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 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 (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.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);
|
||
});
|
||
});
|
||
}
|
||
|
||
document.getElementById('opp-country-select').addEventListener('change', function() {
|
||
loadCountry(this.value);
|
||
});
|
||
})();
|
||
</script>
|
||
{% endblock %}
|