Compare commits
2 Commits
v202603111
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e71be6da5a | ||
|
|
5df369ef89 |
@@ -48,6 +48,7 @@ async def countries():
|
|||||||
|
|
||||||
|
|
||||||
@bp.route("/markets/<country_slug>/cities.json")
|
@bp.route("/markets/<country_slug>/cities.json")
|
||||||
|
@login_required
|
||||||
async def country_cities(country_slug: str):
|
async def country_cities(country_slug: str):
|
||||||
"""City-level data for a country overview bubble map."""
|
"""City-level data for a country overview bubble map."""
|
||||||
await _require_maps_flag()
|
await _require_maps_flag()
|
||||||
|
|||||||
@@ -368,8 +368,42 @@ async def article_page(url_path: str):
|
|||||||
|
|
||||||
body_html = build_path.read_text()
|
body_html = build_path.read_text()
|
||||||
|
|
||||||
|
# Detect country-overview pages and inline map data (no JSON API fetch)
|
||||||
|
map_locations: list[dict] = []
|
||||||
|
parts = clean_path.strip("/").split("/")
|
||||||
|
if len(parts) == 2 and parts[0] == "markets":
|
||||||
|
country_slug = parts[1]
|
||||||
|
map_locations = await fetch_analytics(
|
||||||
|
"""
|
||||||
|
SELECT location_name AS city_name, city_slug, lat, lon,
|
||||||
|
COALESCE(city_padel_venue_count, 0) AS padel_venue_count,
|
||||||
|
market_score, opportunity_score, population,
|
||||||
|
nearest_padel_court_km
|
||||||
|
FROM serving.location_profiles
|
||||||
|
WHERE country_slug = ? AND opportunity_score > 0
|
||||||
|
ORDER BY opportunity_score DESC
|
||||||
|
LIMIT 300
|
||||||
|
""",
|
||||||
|
[country_slug],
|
||||||
|
)
|
||||||
|
article_rows = await fetch_all(
|
||||||
|
"""SELECT url_path FROM articles
|
||||||
|
WHERE url_path LIKE ? AND status = 'published'
|
||||||
|
AND published_at <= datetime('now')""",
|
||||||
|
(f"/markets/{country_slug}/%",),
|
||||||
|
)
|
||||||
|
article_slugs: set[str] = set()
|
||||||
|
for a in article_rows:
|
||||||
|
a_parts = a["url_path"].rstrip("/").split("/")
|
||||||
|
if len(a_parts) >= 4:
|
||||||
|
article_slugs.add(a_parts[3])
|
||||||
|
for row in map_locations:
|
||||||
|
slug = row.get("city_slug")
|
||||||
|
row["has_article"] = slug in article_slugs if slug else False
|
||||||
|
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"article_detail.html",
|
"article_detail.html",
|
||||||
article=article,
|
article=article,
|
||||||
body_html=Markup(body_html),
|
body_html=Markup(body_html),
|
||||||
|
map_locations=map_locations,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -62,8 +62,11 @@
|
|||||||
{% block scripts %}
|
{% block scripts %}
|
||||||
<script>
|
<script>
|
||||||
window.LEAFLET_JS_URL = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';
|
window.LEAFLET_JS_URL = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';
|
||||||
window.__MAP_T = {score_label:"{{ t.map_score_label }}",venues:"{{ t.map_venues }}",pop:"{{ t.map_pop }}",click_explore:"{{ t.map_click_explore }}",coming_soon:"{{ t.map_coming_soon }}",courts:"{{ t.map_courts }}",indoor:"{{ t.map_indoor }}",outdoor:"{{ t.map_outdoor }}"};
|
window.__MAP_T = {score_label:"{{ t.map_score_label }}",venues:"{{ t.map_venues }}",pop:"{{ t.map_pop }}",click_explore:"{{ t.map_click_explore }}",coming_soon:"{{ t.map_coming_soon }}",courts:"{{ t.map_courts }}",indoor:"{{ t.map_indoor }}",outdoor:"{{ t.map_outdoor }}",km_nearest:"{{ t.map_km_nearest }}",no_nearby:"{{ t.map_no_nearby }}"};
|
||||||
</script>
|
</script>
|
||||||
|
{% if map_locations %}
|
||||||
|
<script id="country-map-data" type="application/json">{{ map_locations | tojson }}</script>
|
||||||
|
{% endif %}
|
||||||
<script src="{{ url_for('static', filename='js/map-markers.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/map-markers.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/article-maps.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/article-maps.js') }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -2,8 +2,8 @@
|
|||||||
* Leaflet map initialisation for article pages (country + city maps).
|
* Leaflet map initialisation for article pages (country + city maps).
|
||||||
*
|
*
|
||||||
* Looks for #country-map and #city-map elements. If neither exists, does nothing.
|
* Looks for #country-map and #city-map elements. If neither exists, does nothing.
|
||||||
* Expects data-* attributes on the map elements and a global LEAFLET_JS_URL
|
* Country maps read inline JSON from #country-map-data (no network fetch).
|
||||||
* variable pointing to the Leaflet JS bundle.
|
* City maps still fetch venue data from the JSON API.
|
||||||
*
|
*
|
||||||
* Depends on map-markers.js (window.PNMarkers) being loaded first.
|
* Depends on map-markers.js (window.PNMarkers) being loaded first.
|
||||||
*/
|
*/
|
||||||
@@ -24,67 +24,71 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function initCountryMap(el) {
|
function initCountryMap(el) {
|
||||||
|
var dataEl = document.getElementById('country-map-data');
|
||||||
|
if (!dataEl) return;
|
||||||
|
var data = JSON.parse(dataEl.textContent);
|
||||||
var slug = el.dataset.countrySlug;
|
var slug = el.dataset.countrySlug;
|
||||||
var map = L.map(el, {scrollWheelZoom: false});
|
var map = L.map(el, {scrollWheelZoom: false});
|
||||||
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
|
L.tileLayer(TILES, { attribution: TILES_ATTR, maxZoom: 18 }).addTo(map);
|
||||||
var lang = document.documentElement.lang || 'en';
|
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 size = 10 + 36 * Math.sqrt((c.padel_venue_count || 1) / maxV);
|
|
||||||
var hasArticle = c.has_article !== false;
|
|
||||||
var score = c.opportunity_score || 0;
|
|
||||||
var hex = sc(score);
|
|
||||||
var pop = fmtPop(c.population);
|
|
||||||
var tip = '<strong>' + c.city_name + '</strong><br>'
|
|
||||||
+ '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + hex + ';vertical-align:middle;margin-right:4px;"></span>'
|
|
||||||
+ '<span style="color:' + hex + ';font-weight:600;">' + (T.score_label || 'Padelnomics Score') + ': ' + Math.round(score) + '/100</span><br>'
|
|
||||||
+ '<span style="color:#94A3B8;font-size:0.75rem;">'
|
|
||||||
+ (c.padel_venue_count || 0) + ' ' + (T.venues || 'venues')
|
|
||||||
+ (pop ? ' · ' + pop + ' ' + (T.pop || 'pop') : '') + '</span>';
|
|
||||||
if (hasArticle) {
|
|
||||||
tip += '<br><span style="color:#94A3B8;font-size:0.75rem;">' + (T.click_explore || 'Click to explore →') + '</span>';
|
|
||||||
} else {
|
|
||||||
tip += '<br><span style="color:#94A3B8;font-size:0.75rem;">' + (T.coming_soon || 'Coming soon') + '</span>';
|
|
||||||
}
|
|
||||||
var icon = PNMarkers.makeIcon({
|
|
||||||
size: size,
|
|
||||||
color: hex,
|
|
||||||
muted: !hasArticle,
|
|
||||||
});
|
|
||||||
var marker = L.marker([c.lat, c.lon], { icon: icon })
|
|
||||||
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
|
|
||||||
.addTo(map);
|
|
||||||
if (hasArticle) {
|
|
||||||
marker.on('click', function() { window.location = '/' + lang + '/markets/' + slug + '/' + c.city_slug; });
|
|
||||||
}
|
|
||||||
bounds.push([c.lat, c.lon]);
|
|
||||||
});
|
|
||||||
if (bounds.length) map.fitBounds(bounds, { padding: [24, 24] });
|
|
||||||
|
|
||||||
// Highlight user's city (best-effort name match via CF-IPCity)
|
if (!data.length) return;
|
||||||
var uc = (window.__GEO || {}).city || '';
|
var maxPop = Math.max.apply(null, data.map(function(d) { return d.population || 1; }));
|
||||||
if (uc) {
|
var bounds = [];
|
||||||
var match = data.find(function(c) {
|
data.forEach(function(c) {
|
||||||
return c.city_name && c.city_name.toLowerCase() === uc.toLowerCase();
|
if (!c.lat || !c.lon) return;
|
||||||
});
|
var size = 8 + 36 * Math.sqrt((c.population || 1) / maxPop);
|
||||||
if (match && match.lat && match.lon) {
|
var hasArticle = c.has_article === true;
|
||||||
var hSize = 10 + 36 * Math.sqrt((match.padel_venue_count || 1) / maxV);
|
var score = c.opportunity_score || 0;
|
||||||
var hIcon = PNMarkers.makeIcon({
|
var hex = sc(score);
|
||||||
size: hSize,
|
var pop = fmtPop(c.population);
|
||||||
color: sc(match.opportunity_score || 0),
|
var venueInfo = (c.padel_venue_count || 0) > 0
|
||||||
highlight: true,
|
? (c.padel_venue_count + ' ' + (T.venues || 'venues'))
|
||||||
});
|
: (c.nearest_padel_court_km
|
||||||
L.marker([match.lat, match.lon], { icon: hIcon }).addTo(map);
|
? Math.round(c.nearest_padel_court_km) + ' ' + (T.km_nearest || 'km to nearest court')
|
||||||
}
|
: (T.no_nearby || 'No courts nearby'));
|
||||||
}
|
var tip = '<strong>' + c.city_name + '</strong><br>'
|
||||||
})
|
+ '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + hex + ';vertical-align:middle;margin-right:4px;"></span>'
|
||||||
.catch(function(err) { console.error('Country map fetch failed:', err); });
|
+ '<span style="color:' + hex + ';font-weight:600;">' + (T.score_label || 'Padelnomics Score') + ': ' + Math.round(score) + '/100</span><br>'
|
||||||
|
+ '<span style="color:#94A3B8;font-size:0.75rem;">'
|
||||||
|
+ venueInfo
|
||||||
|
+ (pop ? ' · ' + pop + ' ' + (T.pop || 'pop') : '') + '</span>';
|
||||||
|
if (hasArticle) {
|
||||||
|
tip += '<br><span style="color:#94A3B8;font-size:0.75rem;">' + (T.click_explore || 'Click to explore →') + '</span>';
|
||||||
|
} else {
|
||||||
|
tip += '<br><span style="color:#94A3B8;font-size:0.75rem;">' + (T.coming_soon || 'Coming soon') + '</span>';
|
||||||
|
}
|
||||||
|
var icon = PNMarkers.makeIcon({
|
||||||
|
size: size,
|
||||||
|
color: hex,
|
||||||
|
muted: !hasArticle,
|
||||||
|
});
|
||||||
|
var marker = L.marker([c.lat, c.lon], { icon: icon })
|
||||||
|
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
|
||||||
|
.addTo(map);
|
||||||
|
if (hasArticle) {
|
||||||
|
marker.on('click', function() { window.location = '/' + lang + '/markets/' + slug + '/' + c.city_slug; });
|
||||||
|
}
|
||||||
|
bounds.push([c.lat, c.lon]);
|
||||||
|
});
|
||||||
|
if (bounds.length) map.fitBounds(bounds, { padding: [24, 24] });
|
||||||
|
|
||||||
|
// Highlight user's city (best-effort name match via CF-IPCity)
|
||||||
|
var uc = (window.__GEO || {}).city || '';
|
||||||
|
if (uc) {
|
||||||
|
var match = data.find(function(c) {
|
||||||
|
return c.city_name && c.city_name.toLowerCase() === uc.toLowerCase();
|
||||||
|
});
|
||||||
|
if (match && match.lat && match.lon) {
|
||||||
|
var hSize = 8 + 36 * Math.sqrt((match.population || 1) / maxPop);
|
||||||
|
var hIcon = PNMarkers.makeIcon({
|
||||||
|
size: hSize,
|
||||||
|
color: sc(match.opportunity_score || 0),
|
||||||
|
highlight: true,
|
||||||
|
});
|
||||||
|
L.marker([match.lat, match.lon], { icon: hIcon }).addTo(map);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initCityMap(el, venueIcon) {
|
function initCityMap(el, venueIcon) {
|
||||||
|
|||||||
Reference in New Issue
Block a user