merge: dual-ring map markers with 5-tier color scale
All checks were successful
CI / test (push) Successful in 54s
CI / tag (push) Successful in 3s

This commit is contained in:
Deeman
2026-03-08 22:16:54 +01:00
9 changed files with 198 additions and 94 deletions

View File

@@ -6,6 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] ## [Unreleased]
### Changed
- **Dual-ring map markers** — map markers now encode two scores visually: inner core = primary score, outer ring = secondary score. Markets hub and country overview show Market Score (core) + Opportunity Score (ring). Opportunity map shows Opportunity Score (core) + Market Score (ring). City venue maps unchanged (navy dots). Color scale upgraded from 3-tier (green/amber/red) to 5-tier (deep green ≥80, teal ≥60, amber ≥40, orange-red ≥20, red <20) with distinct luminance at each tier for colorblind safety. Markers < 18px fall back to single-layer (no ring). Muted markers (cities without articles) show dashed ring outline. Highlighted markers (user's geo city) get blue outer glow. Opportunity map markers with score ≥75 pulse gently to highlight top investment targets. Tooltip lines now have inline color dots matching marker layers. All map scripts share a single `map-markers.js` module (`PNMarkers.scoreColor` + `PNMarkers.makeIcon`), replacing 3 duplicated implementations.
### Added ### Added
- **Geo headers on city/region hubs** — Cloudflare geo headers (`CF-IPCountry`, `CF-IPCity`) now used across location-based pages. Opportunity map pre-selects and auto-loads the user's country. Country overview maps highlight the user's city with a blue ring (best-effort CF-IPCity name match). `window.__GEO` JS global injected via `base.html` for client-side map code. - **Geo headers on city/region hubs** — Cloudflare geo headers (`CF-IPCountry`, `CF-IPCity`) now used across location-based pages. Opportunity map pre-selects and auto-loads the user's country. Country overview maps highlight the user's city with a blue ring (best-effort CF-IPCity name match). `window.__GEO` JS global injected via `base.html` for client-side map code.

View File

@@ -10,6 +10,7 @@
<body> <body>
<div class="article-body">{{ body_html | safe }}</div> <div class="article-body">{{ body_html | safe }}</div>
<script>window.LEAFLET_JS_URL = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';</script> <script>window.LEAFLET_JS_URL = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.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>
</body> </body>
</html> </html>

View File

@@ -34,5 +34,6 @@
</div> </div>
<script>window.LEAFLET_JS_URL = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';</script> <script>window.LEAFLET_JS_URL = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.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 %}

View File

@@ -61,5 +61,6 @@
{% block scripts %} {% block scripts %}
<script>window.LEAFLET_JS_URL = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.js") }}';</script> <script>window.LEAFLET_JS_URL = '{{ url_for("static", filename="vendor/leaflet/leaflet.min.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 %}

View File

@@ -19,17 +19,22 @@
<div id="markets-map" style="height:420px; border-radius:12px;" class="mb-4"></div> <div id="markets-map" style="height:420px; border-radius:12px;" class="mb-4"></div>
<!-- Map legend --> <!-- Map legend -->
<div class="mb-6" style="display:flex; gap:1.5rem; align-items:center; font-size:0.82rem; color:#64748B;"> <div class="mb-6" style="display:flex; flex-wrap:wrap; gap:1rem 1.5rem; align-items:center; font-size:0.82rem; color:#64748B;">
<span style="display:flex; align-items:center; gap:0.35rem;"> <span style="display:flex; align-items:center; gap:0.35rem;">
<span style="display:inline-block; width:12px; height:12px; border-radius:50%; background:#16A34A; border:2px solid white; box-shadow:0 1px 3px rgba(0,0,0,0.2);"></span> <span style="font-weight:600;"></span> inner = Market Score
<span style="display:inline-block; width:18px; height:18px; border-radius:50%; background:#16A34A; border:2px solid white; box-shadow:0 1px 3px rgba(0,0,0,0.2);"></span> &nbsp;<span style="font-weight:600;"></span> ring = Opportunity Score
{{ t.mkt_legend_size }} </span>
<span style="display:flex; align-items:center; gap:0.3rem;">
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#15803D;"></span>≥80
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#0D9488;margin-left:4px;"></span>≥60
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#D97706;margin-left:4px;"></span>≥40
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#EA580C;margin-left:4px;"></span>≥20
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#DC2626;margin-left:4px;"></span>&lt;20
</span> </span>
<span style="display:flex; align-items:center; gap:0.35rem;"> <span style="display:flex; align-items:center; gap:0.35rem;">
<span style="display:inline-block; width:14px; height:14px; border-radius:50%; background:#16A34A; border:2px solid white; box-shadow:0 1px 3px rgba(0,0,0,0.2);"></span> <span style="display:inline-block; width:12px; height:12px; border-radius:50%; background:#64748B; border:2px solid white; box-shadow:0 1px 3px rgba(0,0,0,0.2);"></span>
<span style="display:inline-block; width:14px; height:14px; border-radius:50%; background:#D97706; border:2px solid white; box-shadow:0 1px 3px rgba(0,0,0,0.2);"></span> <span style="display:inline-block; width:18px; height:18px; border-radius:50%; background:#64748B; border:2px solid white; box-shadow:0 1px 3px rgba(0,0,0,0.2);"></span>
<span style="display:inline-block; width:14px; height:14px; border-radius:50%; background:#DC2626; border:2px solid white; box-shadow:0 1px 3px rgba(0,0,0,0.2);"></span> {{ t.mkt_legend_size }}
{{ t.mkt_legend_color }}
</span> </span>
</div> </div>
@@ -83,28 +88,20 @@
{% block scripts %} {% block scripts %}
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.min.js') }}"></script> <script src="{{ url_for('static', filename='vendor/leaflet/leaflet.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/map-markers.js') }}"></script>
<script> <script>
(function() { (function() {
var sc = PNMarkers.scoreColor;
var map = L.map('markets-map', {scrollWheelZoom: false}).setView([48.5, 10], 4); 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', { 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>', attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>',
maxZoom: 18 maxZoom: 18
}).addTo(map); }).addTo(map);
function scoreColor(score) { function dot(hex, filled) {
if (score >= 60) return '#16A34A'; return filled
if (score >= 30) return '#D97706'; ? '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + hex + ';vertical-align:middle;margin-right:4px;"></span>'
return '#DC2626'; : '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;border:2px solid ' + hex + ';vertical-align:middle;margin-right:4px;"></span>';
}
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.82;"></div>',
iconSize: [s, s],
iconAnchor: [s / 2, s / 2],
});
} }
var data = {{ map_countries | tojson }}; var data = {{ map_countries | tojson }};
@@ -114,13 +111,13 @@
data.forEach(function(c) { data.forEach(function(c) {
if (!c.lat || !c.lon) return; if (!c.lat || !c.lon) return;
var size = 12 + 44 * Math.sqrt(c.total_venues / maxV); var size = 12 + 44 * Math.sqrt(c.total_venues / maxV);
var color = scoreColor(c.avg_market_score); var coreHex = sc(c.avg_market_score);
var oppColor = scoreColor(c.avg_opportunity_score || 0); var ringHex = sc(c.avg_opportunity_score || 0);
var tip = '<strong>' + c.country_name_en + '</strong><br>' var tip = '<strong>' + c.country_name_en + '</strong><br>'
+ c.total_venues + ' venues · ' + c.city_count + ' cities<br>' + dot(coreHex, true) + '<span style="color:' + coreHex + ';font-weight:600;">Market Score: ' + c.avg_market_score + '/100</span><br>'
+ '<span style="color:' + color + ';font-weight:600;">Padelnomics Market Score: ' + c.avg_market_score + '/100</span><br>' + dot(ringHex, false) + '<span style="color:' + ringHex + ';font-weight:600;">Opportunity Score: ' + (c.avg_opportunity_score || 0) + '/100</span><br>'
+ '<span style="color:' + oppColor + ';font-weight:600;">Padelnomics Opportunity Score: ' + (c.avg_opportunity_score || 0) + '/100</span>'; + '<span style="color:#94A3B8;font-size:0.75rem;">' + c.total_venues + ' venues · ' + c.city_count + ' cities</span>';
L.marker([c.lat, c.lon], { icon: makeIcon(size, color) }) L.marker([c.lat, c.lon], { icon: PNMarkers.makeIcon({ size: size, coreColor: coreHex, ringColor: ringHex }) })
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] }) .bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
.on('click', function() { window.location = '/' + lang + '/markets/' + c.country_slug; }) .on('click', function() { window.location = '/' + lang + '/markets/' + c.country_slug; })
.addTo(map); .addTo(map);

View File

@@ -38,20 +38,26 @@
<div id="opportunity-map"></div> <div id="opportunity-map"></div>
<div id="map-data" style="display:none;"></div> <div id="map-data" style="display:none;"></div>
<div class="mt-4 text-sm text-slate"> <div class="mt-4 text-sm text-slate" style="display:flex; flex-wrap:wrap; gap:0.5rem 1.5rem; align-items:center;">
<strong>Circle size:</strong> population &nbsp;|&nbsp; <span><strong></strong> inner = Opportunity Score &nbsp;<strong></strong> ring = Market Score</span>
<strong>Color:</strong> <span style="display:flex; align-items:center; gap:0.3rem;">
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#16A34A;vertical-align:middle;margin:0 4px"></span>High (≥60) &nbsp; <span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#15803D;"></span>≥80
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#D97706;vertical-align:middle;margin:0 4px"></span>Mid (3060) &nbsp; <span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#0D9488;margin-left:4px;"></span>≥60
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#DC2626;vertical-align:middle;margin:0 4px"></span>Low (&lt;30) <span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#D97706;margin-left:4px;"></span>≥40
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#EA580C;margin-left:4px;"></span>≥20
<span style="display:inline-block;width:10px;height:10px;border-radius:50%;background:#DC2626;margin-left:4px;"></span>&lt;20
</span>
<span><strong>Size:</strong> population</span>
</div> </div>
</main> </main>
{% endblock %} {% endblock %}
{% block scripts %} {% block scripts %}
<script src="{{ url_for('static', filename='vendor/leaflet/leaflet.min.js') }}"></script> <script src="{{ url_for('static', filename='vendor/leaflet/leaflet.min.js') }}"></script>
<script src="{{ url_for('static', filename='js/map-markers.js') }}"></script>
<script> <script>
(function() { (function() {
var sc = PNMarkers.scoreColor;
var TILES = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'; var TILES = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
var TILES_ATTR = '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>'; var TILES_ATTR = '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>';
@@ -61,22 +67,6 @@
var oppLayer = L.layerGroup().addTo(map); var oppLayer = L.layerGroup().addTo(map);
var refLayer = L.layerGroup().addTo(map); var refLayer = L.layerGroup().addTo(map);
function oppColor(score) {
if (score >= 60) return '#16A34A';
if (score >= 30) return '#D97706';
return '#DC2626';
}
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({ var REF_ICON = L.divIcon({
className: '', className: '',
html: '<div class="pn-venue" style="background:#94A3B8;border-color:white;opacity:0.7;"></div>', html: '<div class="pn-venue" style="background:#94A3B8;border-color:white;opacity:0.7;"></div>',
@@ -90,6 +80,12 @@
: (p || ''); : (p || '');
} }
function dot(hex, filled) {
return filled
? '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + hex + ';vertical-align:middle;margin-right:4px;"></span>'
: '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;border:2px solid ' + hex + ';vertical-align:middle;margin-right:4px;"></span>';
}
function renderMap() { function renderMap() {
oppLayer.clearLayers(); oppLayer.clearLayers();
refLayer.clearLayers(); refLayer.clearLayers();
@@ -113,16 +109,22 @@
oppData.forEach(function(loc) { oppData.forEach(function(loc) {
if (!loc.lat || !loc.lon) return; if (!loc.lat || !loc.lon) return;
var size = 8 + 40 * Math.sqrt((loc.population || 1) / maxPop); var size = 8 + 40 * Math.sqrt((loc.population || 1) / maxPop);
var color = oppColor(loc.opportunity_score); var coreHex = sc(loc.opportunity_score);
var ringHex = sc(loc.market_score || 0);
var dist = loc.nearest_padel_court_km != null var dist = loc.nearest_padel_court_km != null
? loc.nearest_padel_court_km.toFixed(1) + ' km to nearest court' ? loc.nearest_padel_court_km.toFixed(1) + ' km to nearest court'
: 'No nearby courts'; : 'No nearby courts';
var mktColor = loc.market_score >= 60 ? '#16A34A' : (loc.market_score >= 30 ? '#D97706' : '#DC2626');
var tip = '<strong>' + loc.location_name + '</strong><br>' var tip = '<strong>' + loc.location_name + '</strong><br>'
+ '<span style="color:' + color + ';font-weight:600;">Padelnomics Opportunity Score: ' + loc.opportunity_score + '/100</span><br>' + dot(coreHex, true) + '<span style="color:' + coreHex + ';font-weight:600;">Opportunity Score: ' + loc.opportunity_score + '/100</span><br>'
+ '<span style="color:' + mktColor + ';font-weight:600;">Padelnomics Market Score: ' + (loc.market_score || 0) + '/100</span><br>' + dot(ringHex, false) + '<span style="color:' + ringHex + ';font-weight:600;">Market Score: ' + (loc.market_score || 0) + '/100</span><br>'
+ dist + ' · Pop. ' + fmtPop(loc.population); + '<span style="color:#94A3B8;font-size:0.75rem;">' + dist + ' · Pop. ' + fmtPop(loc.population) + '</span>';
L.marker([loc.lat, loc.lon], { icon: makeIcon(size, color) }) var icon = PNMarkers.makeIcon({
size: size,
coreColor: coreHex,
ringColor: ringHex,
pulse: loc.opportunity_score >= 75,
});
L.marker([loc.lat, loc.lon], { icon: icon })
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] }) .bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
.addTo(oppLayer); .addTo(oppLayer);
bounds.push([loc.lat, loc.lon]); bounds.push([loc.lat, loc.lon]);

View File

@@ -879,8 +879,10 @@
.leaflet-tooltip.map-tooltip::before { display: none; } .leaflet-tooltip.map-tooltip::before { display: none; }
.leaflet-tooltip.map-tooltip strong { color: white; } .leaflet-tooltip.map-tooltip strong { color: white; }
/* Polished variable-size circle — white border + drop shadow */ /* ── Dual-ring map markers ── */
/* Container: sets size, white border, shadow, hover */
.pn-marker { .pn-marker {
position: relative;
border-radius: 50%; border-radius: 50%;
border: 2.5px solid white; border: 2.5px solid white;
box-shadow: 0 2px 8px rgba(0,0,0,0.28); box-shadow: 0 2px 8px rgba(0,0,0,0.28);
@@ -891,25 +893,57 @@
box-shadow: 0 3px 12px rgba(0,0,0,0.38); box-shadow: 0 3px 12px rgba(0,0,0,0.38);
transform: scale(1.1); transform: scale(1.1);
} }
/* Outer ring: secondary score color */
/* User's city highlight — blue ring on top of score-colored bubble */ .pn-marker__ring {
.pn-marker--highlight { position: absolute;
border: 3px solid #3B82F6; inset: 0;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.3), 0 2px 8px rgba(0,0,0,0.28); border-radius: 50%;
opacity: 0.65;
}
/* Inner core: primary score color, inset 5px from container edges */
.pn-marker__core {
position: absolute;
inset: 5px;
border-radius: 50%;
border: 1.5px solid rgba(255,255,255,0.5);
}
/* Compact fallback: marker < 18px, no ring — core fills container */
.pn-marker--compact .pn-marker__core {
inset: 0;
border: none;
} }
/* Non-article city markers: faded + dashed border, no click affordance */ /* User's city highlight — blue outer glow */
.pn-marker--highlight {
border: 2.5px solid #3B82F6;
box-shadow: 0 0 0 3px rgba(59,130,246,0.25), 0 2px 8px rgba(0,0,0,0.28);
}
/* Non-article city markers: faded, dashed ring outline, no click */
.pn-marker--muted { .pn-marker--muted {
opacity: 0.45; opacity: 0.4;
border: 2px dashed rgba(255,255,255,0.6);
cursor: default; cursor: default;
filter: saturate(0.7); filter: saturate(0.6) brightness(1.1);
}
.pn-marker--muted .pn-marker__ring {
background: transparent !important;
border: 1.5px dashed rgba(255,255,255,0.6);
opacity: 1;
} }
.pn-marker--muted:hover { .pn-marker--muted:hover {
transform: none; transform: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.28); box-shadow: 0 2px 8px rgba(0,0,0,0.28);
} }
/* Pulse animation — opportunity map only, score >= 75 */
.pn-marker--pulse .pn-marker__ring {
animation: marker-pulse 2.5s ease-in-out infinite;
}
@keyframes marker-pulse {
0%, 100% { transform: scale(1); opacity: 0.65; }
50% { transform: scale(1.35); opacity: 0.25; }
}
/* Small fixed venue dot */ /* Small fixed venue dot */
.pn-venue { .pn-venue {
width: 10px; width: 10px;

View File

@@ -4,6 +4,8 @@
* 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 * Expects data-* attributes on the map elements and a global LEAFLET_JS_URL
* variable pointing to the Leaflet JS bundle. * variable pointing to the Leaflet JS bundle.
*
* Depends on map-markers.js (window.PNMarkers) being loaded first.
*/ */
(function() { (function() {
var countryMapEl = document.getElementById('country-map'); var countryMapEl = document.getElementById('country-map');
@@ -12,22 +14,13 @@
var TILES = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png'; var TILES = 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png';
var TILES_ATTR = '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>'; var TILES_ATTR = '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> &copy; <a href="https://carto.com/">CARTO</a>';
var sc = PNMarkers.scoreColor;
function scoreColor(score) { function tooltipDot(hex, filled) {
if (score >= 60) return '#16A34A'; var style = filled
if (score >= 30) return '#D97706'; ? 'display:inline-block;width:8px;height:8px;border-radius:50%;background:' + hex + ';vertical-align:middle;margin-right:4px;'
return '#DC2626'; : 'display:inline-block;width:8px;height:8px;border-radius:50%;border:2px solid ' + hex + ';vertical-align:middle;margin-right:4px;';
} return '<span style="' + style + '"></span>';
function makeIcon(size, color, muted) {
var s = Math.round(size);
var cls = 'pn-marker' + (muted ? ' pn-marker--muted' : '');
return L.divIcon({
className: '',
html: '<div class="' + cls + '" style="width:' + s + 'px;height:' + s + 'px;background:' + color + ';"></div>',
iconSize: [s, s],
iconAnchor: [s / 2, s / 2],
});
} }
function initCountryMap(el) { function initCountryMap(el) {
@@ -45,22 +38,29 @@
if (!c.lat || !c.lon) return; if (!c.lat || !c.lon) return;
var size = 10 + 36 * Math.sqrt((c.padel_venue_count || 1) / maxV); var size = 10 + 36 * Math.sqrt((c.padel_venue_count || 1) / maxV);
var hasArticle = c.has_article !== false; var hasArticle = c.has_article !== false;
var color = scoreColor(c.market_score); var coreHex = sc(c.market_score);
var ringHex = sc(c.opportunity_score || 0);
var pop = c.population >= 1000000 var pop = c.population >= 1000000
? (c.population / 1000000).toFixed(1) + 'M' ? (c.population / 1000000).toFixed(1) + 'M'
: (c.population >= 1000 ? Math.round(c.population / 1000) + 'K' : (c.population || '')); : (c.population >= 1000 ? Math.round(c.population / 1000) + 'K' : (c.population || ''));
var oppColor = c.opportunity_score >= 60 ? '#16A34A' : (c.opportunity_score >= 30 ? '#D97706' : '#DC2626');
var tip = '<strong>' + c.city_name + '</strong><br>' var tip = '<strong>' + c.city_name + '</strong><br>'
+ tooltipDot(coreHex, true) + '<span style="color:' + coreHex + ';font-weight:600;">Market Score: ' + Math.round(c.market_score) + '/100</span><br>'
+ tooltipDot(ringHex, false) + '<span style="color:' + ringHex + ';font-weight:600;">Opportunity Score: ' + Math.round(c.opportunity_score || 0) + '/100</span><br>'
+ '<span style="color:#94A3B8;font-size:0.75rem;">'
+ (c.padel_venue_count || 0) + ' venues' + (c.padel_venue_count || 0) + ' venues'
+ (pop ? ' · ' + pop : '') + (pop ? ' · ' + pop : '') + '</span>';
+ '<br><span style="color:' + color + ';font-weight:600;">Padelnomics Market Score: ' + Math.round(c.market_score) + '/100</span>'
+ '<br><span style="color:' + oppColor + ';font-weight:600;">Padelnomics Opportunity Score: ' + Math.round(c.opportunity_score || 0) + '/100</span>';
if (hasArticle) { if (hasArticle) {
tip += '<br><span style="color:#94A3B8;font-size:0.75rem;">Click to explore →</span>'; tip += '<br><span style="color:#94A3B8;font-size:0.75rem;">Click to explore →</span>';
} else { } else {
tip += '<br><span style="color:#94A3B8;font-size:0.75rem;">Coming soon</span>'; tip += '<br><span style="color:#94A3B8;font-size:0.75rem;">Coming soon</span>';
} }
var marker = L.marker([c.lat, c.lon], { icon: makeIcon(size, color, !hasArticle) }) var icon = PNMarkers.makeIcon({
size: size,
coreColor: coreHex,
ringColor: ringHex,
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)] }) .bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
.addTo(map); .addTo(map);
if (hasArticle) { if (hasArticle) {
@@ -78,13 +78,11 @@
}); });
if (match && match.lat && match.lon) { if (match && match.lat && match.lon) {
var hSize = 10 + 36 * Math.sqrt((match.padel_venue_count || 1) / maxV); var hSize = 10 + 36 * Math.sqrt((match.padel_venue_count || 1) / maxV);
var hs = Math.round(hSize); var hIcon = PNMarkers.makeIcon({
var hColor = scoreColor(match.market_score); size: hSize,
var hIcon = L.divIcon({ coreColor: sc(match.market_score),
className: '', ringColor: sc(match.opportunity_score || 0),
html: '<div class="pn-marker pn-marker--highlight" style="width:' + hs + 'px;height:' + hs + 'px;background:' + hColor + ';"></div>', highlight: true,
iconSize: [hs, hs],
iconAnchor: [hs / 2, hs / 2],
}); });
L.marker([match.lat, match.lon], { icon: hIcon }).addTo(map); L.marker([match.lat, match.lon], { icon: hIcon }).addTo(map);
} }

View File

@@ -0,0 +1,67 @@
/**
* Shared map marker utilities — dual-ring design with 5-tier color scale.
*
* Exposes window.PNMarkers = { scoreColor, makeIcon }
*
* scoreColor(score) → hex color string (5 tiers, colorblind-safe luminance steps)
* makeIcon(opts) → L.divIcon with dual-ring HTML
*
* opts = {
* size: number, // marker diameter in px
* coreColor: string, // hex for inner core (primary score)
* ringColor: string, // hex for outer ring (secondary score)
* muted: boolean, // dashed ring, no click affordance
* highlight: boolean, // blue outer glow (user's geo city)
* pulse: boolean, // gentle ring pulse (high opportunity)
* }
*/
(function() {
'use strict';
// 5-tier color scale — distinct luminance at each tier for deuteranopia/protanopia
function scoreColor(score) {
if (score >= 80) return '#15803D'; // deep green — excellent
if (score >= 60) return '#0D9488'; // teal — good
if (score >= 40) return '#D97706'; // amber — moderate
if (score >= 20) return '#EA580C'; // orange-red — below avg
return '#DC2626'; // red — poor
}
var COMPACT_THRESHOLD_PX = 18;
function makeIcon(opts) {
var s = Math.round(opts.size);
var compact = s < COMPACT_THRESHOLD_PX;
var cls = 'pn-marker';
if (compact) cls += ' pn-marker--compact';
if (opts.muted) cls += ' pn-marker--muted';
if (opts.highlight) cls += ' pn-marker--highlight';
if (opts.pulse && !opts.muted) cls += ' pn-marker--pulse';
var html;
if (compact || !opts.ringColor) {
// Single-layer fallback: core fills entire marker
html = '<div class="' + cls + '" style="width:' + s + 'px;height:' + s + 'px;">'
+ '<div class="pn-marker__core" style="background:' + opts.coreColor + ';"></div>'
+ '</div>';
} else {
html = '<div class="' + cls + '" style="width:' + s + 'px;height:' + s + 'px;">'
+ '<div class="pn-marker__ring" style="background:' + opts.ringColor + ';"></div>'
+ '<div class="pn-marker__core" style="background:' + opts.coreColor + ';"></div>'
+ '</div>';
}
return L.divIcon({
className: '',
html: html,
iconSize: [s, s],
iconAnchor: [s / 2, s / 2],
});
}
window.PNMarkers = {
scoreColor: scoreColor,
makeIcon: makeIcon,
};
})();