merge(worktree): interactive maps for market pages

Self-hosted Leaflet 1.9.4 maps across 4 placements: markets hub
country bubbles, country overview city bubbles, city venue dots, and
a standalone opportunity map. New /api blueprint with 4 JSON endpoints.
New city_venue_locations SQLMesh serving model. No CDN — GDPR-safe.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

# Conflicts:
#	CHANGELOG.md
This commit is contained in:
Deeman
2026-03-04 15:36:41 +01:00
16 changed files with 1073 additions and 0 deletions

View File

@@ -7,6 +7,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased]
### Added
- **Interactive Leaflet maps** — geographic visualization across 4 key placements using self-hosted Leaflet 1.9.4 (GDPR-safe, no CDN):
- **Markets hub** (`/markets`): country bubble map with circles sized by total venues, colored by avg market score (green ≥ 60, amber 30-60, red < 30). Click navigates to country overview.
- **Country overview articles**: city bubble map loads after article render, auto-fits bounds, click navigates to city page. Bubbles colored by market score.
- **City cost articles**: venue dot map centered on city lat/lon (zoom 13), navy dots per venue with tooltip showing name + court breakdown (indoor/outdoor).
- **Opportunity map** (`/<lang>/opportunity-map`): standalone full-width page with country selector. Circles sized by population, colored by opportunity score (green ≥ 70, amber 40-70, blue < 40). Existing venues shown as gray reference dots.
- New `/api` blueprint with 4 JSON endpoints (`/api/markets/countries.json`, `/api/markets/<country>/cities.json`, `/api/markets/<country>/<city>/venues.json`, `/api/opportunity/<country>.json`) — 1-hour public cache headers, all queries against `analytics.duckdb` via `fetch_analytics`.
- New SQLMesh serving model `city_venue_locations` exposing venue lat/lon + court counts per city.
- `pseo_city_costs_de` serving model: added `lat`/`lon` columns for city map data attributes in baked articles.
- Leaflet CSS included on all article pages (5KB, cached). JS loaded dynamically only when a map container is present.
- **Individualised article financial calculations with real per-country cost data** — ~30 CAPEX/OPEX calculator fields now scale to each country's actual cost level via Eurostat data, eliminating the identical DE-hardcoded numbers shown for every city globally.
- **New Eurostat datasets extracted** (8 new landing files): electricity prices (`nrg_pc_205`), gas prices (`nrg_pc_203`), labour costs (`lc_lci_lev`), and 5 price level index categories from `prc_ppp_ind` (construction, housing, services, misc, government).
- `extract/padelnomics_extract/src/padelnomics_extract/eurostat.py`: added 8 dataset entries; added `dataset_code` field support so multiple dict entries can share one Eurostat API endpoint (needed for 5 prc_ppp_ind variants).

View File

@@ -0,0 +1,26 @@
-- Per-venue lat/lon for the city detail dot map.
-- Joins dim_venues to dim_cities to attach country_slug and city_slug
-- (needed by the /api/markets/<country>/<city>/venues.json endpoint).
-- Only rows with valid coordinates are included.
MODEL (
name serving.city_venue_locations,
kind FULL,
cron '@daily',
grain venue_id
);
SELECT
v.venue_id,
v.name,
v.lat,
v.lon,
v.court_count,
v.indoor_court_count,
v.outdoor_court_count,
v.city_slug,
c.country_slug
FROM foundation.dim_venues v
JOIN foundation.dim_cities c
ON v.country_code = c.country_code AND v.city_slug = c.city_slug
WHERE v.lat IS NOT NULL AND v.lon IS NOT NULL

View File

@@ -26,6 +26,9 @@ SELECT
c.country_code,
c.country_name_en,
c.country_slug,
-- City coordinates (for the city venue dot map)
c.lat,
c.lon,
-- Market metrics
c.population,
c.padel_venue_count,

View File

@@ -0,0 +1,85 @@
"""
JSON API endpoints for interactive maps.
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 .analytics import fetch_analytics
bp = Blueprint("api", __name__)
_CACHE_HEADERS = {"Cache-Control": "public, max-age=3600"}
@bp.route("/markets/countries.json")
async def countries():
"""Country-level aggregates for the markets hub map."""
rows = await fetch_analytics("""
SELECT country_code, country_name_en, country_slug,
COUNT(*) AS city_count,
SUM(padel_venue_count) AS total_venues,
ROUND(AVG(market_score), 1) AS avg_market_score,
AVG(lat) AS lat, AVG(lon) AS lon
FROM serving.city_market_profile
GROUP BY country_code, country_name_en, country_slug
HAVING SUM(padel_venue_count) > 0
ORDER BY total_venues DESC
""")
return jsonify(rows), 200, _CACHE_HEADERS
@bp.route("/markets/<country_slug>/cities.json")
async def country_cities(country_slug: str):
"""City-level data for a country overview bubble map."""
assert country_slug, "country_slug required"
rows = await fetch_analytics(
"""
SELECT city_name, city_slug, lat, lon,
padel_venue_count, market_score, population
FROM serving.city_market_profile
WHERE country_slug = ?
ORDER BY padel_venue_count DESC
LIMIT 200
""",
[country_slug],
)
return jsonify(rows), 200, _CACHE_HEADERS
@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."""
assert country_slug and city_slug, "country_slug and city_slug required"
rows = await fetch_analytics(
"""
SELECT name, lat, lon, court_count,
indoor_court_count, outdoor_court_count
FROM serving.city_venue_locations
WHERE country_slug = ? AND city_slug = ?
LIMIT 500
""",
[country_slug, city_slug],
)
return jsonify(rows), 200, _CACHE_HEADERS
@bp.route("/opportunity/<country_slug>.json")
async def opportunity(country_slug: str):
"""Location-level opportunity scores for the opportunity map."""
assert country_slug, "country_slug required"
rows = await fetch_analytics(
"""
SELECT location_name, location_slug, lat, lon,
opportunity_score, nearest_padel_court_km,
padel_venue_count, population
FROM serving.location_opportunity_profile
WHERE country_slug = ? AND opportunity_score > 0
ORDER BY opportunity_score DESC
LIMIT 500
""",
[country_slug],
)
return jsonify(rows), 200, _CACHE_HEADERS

View File

@@ -362,6 +362,7 @@ def create_app() -> Quart:
from .admin.pipeline_routes import bp as pipeline_bp
from .admin.pseo_routes import bp as pseo_bp
from .admin.routes import bp as admin_bp
from .api import bp as api_bp
from .auth.routes import bp as auth_bp
from .billing.routes import bp as billing_bp
from .content.routes import bp as content_bp
@@ -391,6 +392,9 @@ def create_app() -> Quart:
app.register_blueprint(pipeline_bp)
app.register_blueprint(webhooks_bp)
# JSON API for interactive maps (no lang prefix)
app.register_blueprint(api_bp, url_prefix="/api")
# Content catch-all LAST — lives under /<lang> too
app.register_blueprint(content_bp, url_prefix="/<lang>")

View File

@@ -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: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <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: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <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 %}

View File

@@ -39,6 +39,8 @@ priority_column: population
</div>
</div>
<div id="city-map" data-country-slug="{{ country_slug }}" data-city-slug="{{ city_slug }}" data-lat="{{ lat }}" data-lon="{{ lon }}" style="height:300px; border-radius:12px; margin-bottom:1.5rem;"></div>
{{ city_name }} erreicht einen **<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> von {{ market_score | round(1) }}/100** — damit liegt die Stadt{% if market_score >= 55 %} unter den stärksten Padel-Märkten in {{ country_name_en }}{% elif market_score >= 35 %} im soliden Mittelfeld der Padel-Märkte in {{ country_name_en }}{% else %} in einem frühen Padel-Markt mit Wachstumspotenzial{% endif %}. Aktuell gibt es **{{ padel_venue_count }} Padelanlagen** für {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} Einwohner — das entspricht {{ venues_per_100k | round(1) }} Anlagen pro 100.000 Einwohner.{% if opportunity_score %} Der **<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> Opportunity Score</a> von {{ opportunity_score | round(1) }}/100** bewertet das Investitionspotenzial — Versorgungslücken, Einzugsgebiet und Sportaffinität der Region:{% if opportunity_score >= 65 and market_score < 40 %} überschaubare Konkurrenz trifft auf starkes Standortpotenzial{% elif opportunity_score >= 65 %} hohes Potenzial trotz bereits aktivem Marktumfeld{% elif opportunity_score >= 40 %} solides Potenzial, der Markt beginnt sich zu verdichten{% else %} der Standort ist vergleichsweise gut versorgt, Differenzierung wird zum Schlüssel{% endif %}.{% endif %}
Die entscheidende Frage für Investoren: Was bringt ein Padel-Investment bei den aktuellen Preisen, Auslastungsraten und Baukosten tatsächlich? Das Finanzmodell unten rechnet mit echten Marktdaten aus {{ city_name }}.
@@ -179,6 +181,8 @@ Der **Market Score ({{ market_score | round(1) }}/100)** misst die *Marktreife*:
</div>
</div>
<div id="city-map" data-country-slug="{{ country_slug }}" data-city-slug="{{ city_slug }}" data-lat="{{ lat }}" data-lon="{{ lon }}" style="height:300px; border-radius:12px; margin-bottom:1.5rem;"></div>
{{ city_name }} has a **<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> of {{ market_score | round(1) }}/100** — placing it{% if market_score >= 55 %} among the strongest padel markets in {{ country_name_en }}{% elif market_score >= 35 %} in the mid-tier of {{ country_name_en }}'s padel markets{% else %} in an early-stage padel market with room for growth{% endif %}. The city currently has **{{ padel_venue_count }} padel venues** serving a population of {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} residents — a density of {{ venues_per_100k | round(1) }} venues per 100,000 people.{% if opportunity_score %} The **<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> Opportunity Score</a> of {{ opportunity_score | round(1) }}/100** scores investment potential — supply gaps, catchment reach, and sports culture as a demand proxy:{% if opportunity_score >= 65 and market_score < 40 %} limited competition meets strong location fundamentals{% elif opportunity_score >= 65 %} strong potential despite an already active market{% elif opportunity_score >= 40 %} solid potential as the market starts to fill in{% else %} the area is comparatively well-served; differentiation is the key lever{% endif %}.{% endif %}
The question that matters: given current pricing, occupancy, and build costs, what does a padel investment in {{ city_name }} actually return? The financial model below works with real local market data.

View File

@@ -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

View File

@@ -6,6 +6,7 @@
<meta name="description" content="{{ t.markets_page_description }}">
<meta property="og:title" content="{{ t.markets_page_og_title }} - {{ config.APP_NAME }}">
<meta property="og:description" content="{{ t.markets_page_og_description }}">
<link rel="stylesheet" href="{{ url_for('static', filename='vendor/leaflet/leaflet.min.css') }}">
{% endblock %}
{% block content %}
@@ -15,6 +16,8 @@
<p class="text-slate">{{ t.mkt_subheading }}</p>
</header>
<div id="markets-map" style="height:420px; border-radius:12px;" class="mb-6"></div>
<!-- Filters -->
<div class="card mb-8">
<div style="display: grid; grid-template-columns: 1fr auto auto; gap: 1rem; align-items: end;">
@@ -62,3 +65,43 @@
</div>
</main>
{% endblock %}
{% block scripts %}
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.min.js') }}"></script>
<script>
(function() {
var map = L.map('markets-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);
function scoreColor(score) {
if (score >= 60) return '#16A34A';
if (score >= 30) return '#D97706';
return '#DC2626';
}
fetch('/api/markets/countries.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.total_venues; }));
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 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'})
.on('click', function() { window.location = '/' + lang + '/markets/' + c.country_slug; })
.addTo(map);
});
});
})();
</script>
{% endblock %}

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 %}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 618 B

View File

@@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}

File diff suppressed because one or more lines are too long