feat(maps): Phase 3 — country overview city bubble map + article_detail Leaflet loader
Add #country-map div to country-overview.md.jinja (both DE/EN).
article_detail.html: always include Leaflet CSS, conditionally load
Leaflet JS only when #country-map or #city-map divs are present.
Initializes country city-bubble map and city venue-dot map from
/api/markets/{slug}/cities.json and /api/markets/{country}/{city}/venues.json.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,7 @@
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.min.css') }}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
@@ -57,3 +58,91 @@
|
||||
</article>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
(function() {
|
||||
var countryMapEl = document.getElementById('country-map');
|
||||
var cityMapEl = document.getElementById('city-map');
|
||||
if (!countryMapEl && !cityMapEl) return;
|
||||
|
||||
function scoreColor(score) {
|
||||
if (score >= 60) return '#16A34A';
|
||||
if (score >= 30) return '#D97706';
|
||||
return '#DC2626';
|
||||
}
|
||||
|
||||
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);
|
||||
var lang = document.documentElement.lang || 'en';
|
||||
fetch('/api/markets/' + slug + '/cities.json')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
if (!data.length) return;
|
||||
var maxV = Math.max.apply(null, data.map(function(d) { return d.padel_venue_count || 1; }));
|
||||
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 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);
|
||||
bounds.push([c.lat, c.lon]);
|
||||
});
|
||||
if (bounds.length) map.fitBounds(bounds, {padding: [20, 20]});
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
fetch('/api/markets/' + countrySlug + '/' + citySlug + '/venues.json')
|
||||
.then(function(r) { return r.json(); })
|
||||
.then(function(data) {
|
||||
data.forEach(function(v) {
|
||||
if (!v.lat || !v.lon) return;
|
||||
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 script = document.createElement('script');
|
||||
script.src = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';
|
||||
script.onload = function() {
|
||||
if (countryMapEl) initCountryMap(countryMapEl);
|
||||
if (cityMapEl) initCityMap(cityMapEl);
|
||||
};
|
||||
document.head.appendChild(script);
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -40,6 +40,8 @@ priority_column: total_venues
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="country-map" data-country-slug="{{ country_slug }}" style="height:360px; border-radius:12px; margin-bottom:1.5rem;"></div>
|
||||
|
||||
In {{ country_name_en }} erfassen wir aktuell **{{ total_venues }} Padelanlagen** in **{{ city_count }} Städten**. Der durchschnittliche <a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> liegt bei **{{ avg_market_score }}/100**{% if avg_market_score >= 55 %} — ein starker Markt mit breiter Infrastruktur und belastbaren Preisdaten{% elif avg_market_score >= 35 %} — ein wachsender Markt mit guter Abdeckung{% else %} — ein aufstrebender Markt, in dem Früheinsteiger noch Premiumstandorte sichern können{% endif %}.
|
||||
|
||||
## Marktlandschaft
|
||||
@@ -172,6 +174,8 @@ Der **Market Score (Ø {{ avg_market_score }}/100)** bewertet die Marktreife: Be
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="country-map" data-country-slug="{{ country_slug }}" style="height:360px; border-radius:12px; margin-bottom:1.5rem;"></div>
|
||||
|
||||
{{ country_name_en }} has **{{ total_venues }} padel venues** tracked across **{{ city_count }} cities**. The average <a href="/{{ language }}/market-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</a> across tracked cities is **{{ avg_market_score }}/100**{% if avg_market_score >= 55 %} — a strong market with widespread venue penetration and solid pricing data{% elif avg_market_score >= 35 %} — a growing market with healthy city coverage{% else %} — an emerging market where early entrants can still capture prime locations{% endif %}.
|
||||
|
||||
## Market Landscape
|
||||
|
||||
Reference in New Issue
Block a user