Compare commits
39 Commits
v202603071
...
v202603092
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9dc705970e | ||
|
|
9c5bed01f5 | ||
|
|
3ce97cd41b | ||
|
|
ff6401254a | ||
|
|
487722c2f3 | ||
|
|
23c7570736 | ||
|
|
e39dd4ec0b | ||
|
|
cce3c466ba | ||
|
|
00d2e37934 | ||
|
|
8e0dd6af63 | ||
|
|
eff50aef7d | ||
|
|
5d0e52ade7 | ||
|
|
cd95ff7b6c | ||
|
|
6d44c116aa | ||
|
|
bda2f85fd6 | ||
|
|
a47dfd5535 | ||
|
|
116a4272f1 | ||
|
|
8ced3a986e | ||
|
|
291fb2abd9 | ||
|
|
bfb0178615 | ||
|
|
40d8c75b81 | ||
|
|
d7bd053dc6 | ||
|
|
d379dc7551 | ||
|
|
814e8290a2 | ||
|
|
67fbfde53d | ||
|
|
bf811444ba | ||
|
|
3c135051fd | ||
|
|
c3847bb617 | ||
|
|
fcef47cb22 | ||
|
|
118c2c0fc7 | ||
|
|
cd6d950233 | ||
|
|
28e44384ef | ||
|
|
b1e008a2a4 | ||
|
|
d556ceecee | ||
|
|
f215ea8e3a | ||
|
|
b2ffad055b | ||
|
|
544891611f | ||
|
|
c30a7943aa | ||
|
|
b071199895 |
27
CHANGELOG.md
27
CHANGELOG.md
@@ -7,6 +7,33 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- **Opportunity Score v7 → v8** — better spread and discrimination across the full 0-100 range. Addressable market weight reduced (20→15 pts) with steeper sqrt curve (ceiling 1M, was LN/500K). Economic power reduced (15→10 pts). Supply deficit increased (40→50 pts) with market existence dampener: countries with zero padel venues get max 5 pts supply deficit (factor 0.1), scaling linearly to full credit at 50+ venues. NULL nearest-court distance now treated as 0 (assume nearby) instead of 0.5. Added `country_percentile` output column (PERCENT_RANK within country). Target: P5-P95 spread ≥40 pts (was 22), zero-venue countries avg <30.
|
||||||
|
- **Opportunity Score v6 → v7 (calibration fix)** — two fixes for inflated scores in saturated markets. (1) `dim_locations` now sources venue coordinates from `dim_venues` (deduplicated OSM + Playtomic) instead of `stg_padel_courts` (OSM only), making Playtomic-only venues visible to spatial lookups. (2) Country-level supply saturation dampener on the 40-pt supply deficit component: saturated countries (Spain ~4.5/100k) get dampened supply deficit (×0.55 → 22 pts max), emerging markets (Germany ~0.7/100k) are nearly unaffected (×0.93 → ~37 pts).
|
||||||
|
- **Single-score simplification** — consolidated two public-facing scores (Market Score + Opportunity Score) into one **Padelnomics Score** (internally: `opportunity_score`). All maps, tooltips, article templates, and the methodology page now show a single score. Dual-ring markers reverted to single-color markers. `/market-score` route renamed to `/padelnomics-score` (old URL 301-redirects). All `mscore_*` i18n keys replaced with `pnscore_*`. Business plan queries `opportunity_score` from `location_profiles` (replaces legacy `city_market_overview` view). Map tooltip strings now i18n'd via `window.__MAP_T` (12 keys, EN + DE).
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Non-Latin city names on map** — GeoNames entries with CJK/Cyrillic/Arabic characters (e.g. "Seelow" showing Japanese) now filtered in `stg_population_geonames` via Latin-only regex.
|
||||||
|
- **GeoNames regex DuckDB compatibility** — replaced Python-style `\u00C0` Unicode escapes in `stg_population_geonames` regex with literal Unicode characters (`À-ɏḀ-ỿ`) for DuckDB compatibility.
|
||||||
|
- **Score range safety** — `location_profiles` clamps both scores to 0-100 via `LEAST/GREATEST`.
|
||||||
|
- **Pipeline cast fix** — `venue_pricing_benchmarks.sql` defensively casts `snapshot_date` VARCHAR to DATE.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
- **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.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Opportunity map color scale** — low-score bubbles used blue (`#3B82F6`) instead of red (`#DC2626`), inconsistent with the unified `scoreColor()` scale used everywhere else. Fixed in `oppColor()`, legend, and `article-maps.js` tooltip colors. Thresholds aligned to ≥60/30/\<30.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- **Opportunity Score v5 → v6** — calibrates for saturated markets (Spain avg dropped from ~78 to ~50-60 range). Density ceiling lowered from 8 → 5/100k (Spain at 6-16/100k now hits zero-gap). Supply deficit weight increased from 35 → 40 pts. Addressable market reduced from 25 → 20 pts. Market validation inverted → "market headroom": high country avg maturity now reduces opportunity (saturated market = less room for new entrants).
|
||||||
|
- **Markets page map legend** — bubble map now has a visual legend explaining size = venue count, color = Market Score. Opportunity score tooltip color unified to same green/amber/red scale (was using blue for low scores, inconsistent).
|
||||||
|
- **Geo-localized article sorting** — `/markets` page sorts articles by user proximity using Cloudflare CF-IPCountry header. User's country first, nearby countries second (DACH, Iberia, Nordics, etc.), rest by published_at. Map bubbles re-ordered so user's country renders on top. Falls back to chronological order when header is absent (local dev).
|
||||||
|
- **Score v6: Global economic data** — `dim_countries.median_income_pps` and `pli_construction` now cover all target markets, not just EU. World Bank WDI indicators (GNI per capita PPP + price level ratio) fill gaps for non-EU countries (AR, MX, AE, AU, etc.) with values calibrated to the Eurostat scale using Germany as anchor. EU countries keep exact Eurostat values. New extractor (`worldbank.py`), staging model (`stg_worldbank_income`), and `dim_countries` fallback CTEs. No changes to scoring formulas — the fix is upstream in the data layer.
|
||||||
|
- **Market Score v3 → v4** — fixes Spain averaging 54 (should be 65-80). Four calibration changes: count gate threshold lowered from 5 → 3 venues (3 establishes a market pattern), density ceiling lowered from LN(21) → LN(11) (10/100k is reachable for mature markets), demand evidence fallback raised from 0.4 → 0.65 multiplier with 0.3 floor (existence of venues IS evidence of demand), economic context ceiling changed from income/200 → income/25000 (actual discrimination instead of free 10 pts for everyone).
|
||||||
|
- **Opportunity Score v4 → v5** — fixes structural flaws: supply gap (30pts) + catchment gap (15pts) merged into single supply deficit (35pts, GREATEST of density gap and distance gap) eliminating ~80% correlated double-count. New sports culture signal (10pts) using tennis court density as racquet-sport adoption proxy. New construction affordability signal (5pts) using income relative to PLI construction costs from `dim_countries`. Economic power reduced from 20 → 15pts. New dependency on `foundation.dim_countries` for `pli_construction`.
|
||||||
|
|
||||||
- **Unified `location_profiles` serving model** — merged `city_market_profile` and `location_opportunity_profile` into a single `serving.location_profiles` table at `(country_code, geoname_id)` grain. Both Marktreife-Score (Market Score) and Marktpotenzial-Score (Opportunity Score) are now computed per location. City data enriched via LEFT JOIN `dim_cities` on `geoname_id`. Downstream models (`planner_defaults`, `pseo_city_costs_de`, `pseo_city_pricing`) updated to query `location_profiles` directly. `city_padel_venue_count` (exact from dim_cities) distinguished from `padel_venue_count` (spatial 5km from dim_locations).
|
- **Unified `location_profiles` serving model** — merged `city_market_profile` and `location_opportunity_profile` into a single `serving.location_profiles` table at `(country_code, geoname_id)` grain. Both Marktreife-Score (Market Score) and Marktpotenzial-Score (Opportunity Score) are now computed per location. City data enriched via LEFT JOIN `dim_cities` on `geoname_id`. Downstream models (`planner_defaults`, `pseo_city_costs_de`, `pseo_city_pricing`) updated to query `location_profiles` directly. `city_padel_venue_count` (exact from dim_cities) distinguished from `padel_venue_count` (spatial 5km from dim_locations).
|
||||||
- **Both scores on all map tooltips** — country map shows avg Market Score + avg Opportunity Score; city map shows Market Score + Opportunity Score per city; opportunity map shows Opportunity Score + Market Score per location. All score labels use the trademarked "Padelnomics Market Score" / "Padelnomics Opportunity Score" names.
|
- **Both scores on all map tooltips** — country map shows avg Market Score + avg Opportunity Score; city map shows Market Score + Opportunity Score per city; opportunity map shows Opportunity Score + Market Score per location. All score labels use the trademarked "Padelnomics Market Score" / "Padelnomics Opportunity Score" names.
|
||||||
- **API endpoints** — `/api/markets/countries.json` adds `avg_opportunity_score`; `/api/markets/<country>/cities.json` adds `opportunity_score`; `/api/opportunity/<country>.json` adds `market_score`.
|
- **API endpoints** — `/api/markets/countries.json` adds `avg_opportunity_score`; `/api/markets/<country>/cities.json` adds `opportunity_score`; `/api/opportunity/<country>.json` adds `market_score`.
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ RUN mkdir -p /app/data && chown -R appuser:appuser /app
|
|||||||
COPY --from=build --chown=appuser:appuser /app .
|
COPY --from=build --chown=appuser:appuser /app .
|
||||||
COPY --from=css-build /app/web/src/padelnomics/static/css/output.css ./web/src/padelnomics/static/css/output.css
|
COPY --from=css-build /app/web/src/padelnomics/static/css/output.css ./web/src/padelnomics/static/css/output.css
|
||||||
COPY --chown=appuser:appuser infra/supervisor/workflows.toml ./infra/supervisor/workflows.toml
|
COPY --chown=appuser:appuser infra/supervisor/workflows.toml ./infra/supervisor/workflows.toml
|
||||||
|
COPY --chown=appuser:appuser content/ ./content/
|
||||||
USER appuser
|
USER appuser
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
ENV DATABASE_PATH=/app/data/app.db
|
ENV DATABASE_PATH=/app/data/app.db
|
||||||
|
|||||||
@@ -159,6 +159,7 @@
|
|||||||
- [x] Feedback widget (HTMX POST, rate-limited)
|
- [x] Feedback widget (HTMX POST, rate-limited)
|
||||||
- [x] Interactive ROI calculator widget on landing page (JS sliders, no server call)
|
- [x] Interactive ROI calculator widget on landing page (JS sliders, no server call)
|
||||||
- [x] **CRO overhaul — homepage + supplier landing pages** — JTBD-driven copy rewrite (feature → outcome framing), proof strip, struggling-moments sections, "Why Padelnomics" comparison, rewritten FAQ, conditional supplier stats, data-backed proof points, tier-specific CTAs (EN + DE)
|
- [x] **CRO overhaul — homepage + supplier landing pages** — JTBD-driven copy rewrite (feature → outcome framing), proof strip, struggling-moments sections, "Why Padelnomics" comparison, rewritten FAQ, conditional supplier stats, data-backed proof points, tier-specific CTAs (EN + DE)
|
||||||
|
- [x] **Single-score simplification** — consolidated Market Score + Opportunity Score into one public "Padelnomics Score" (`opportunity_score`). Single-color map markers, unified methodology page at `/padelnomics-score`, i18n'd map tooltips, updated pSEO templates + business plan. Non-Latin city name filter in pipeline.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -180,12 +181,12 @@
|
|||||||
| Submit sitemap to Google Search Console | Set up Google Search Console + Bing Webmaster Tools (SEO hub ready — just add env vars) |
|
| Submit sitemap to Google Search Console | Set up Google Search Console + Bing Webmaster Tools (SEO hub ready — just add env vars) |
|
||||||
| Verify Litestream R2 backup running on prod | |
|
| Verify Litestream R2 backup running on prod | |
|
||||||
|
|
||||||
### Gemeinde-level pSEO (follow-up from dual score work)
|
### Gemeinde-level pSEO (follow-up from single-score simplification)
|
||||||
|
|
||||||
| 🛠 Tech |
|
| 🛠 Tech |
|
||||||
|--------|
|
|--------|
|
||||||
| Gemeinde-level pSEO article template — consumes `location_opportunity_profile` data, targets "Padel in [Ort]" + "Padel bauen in [Ort]" queries (zero SERP competition confirmed) |
|
| Gemeinde-level pSEO article template — consumes `location_profiles` data, targets "Padel in [Ort]" + "Padel bauen in [Ort]" queries (zero SERP competition confirmed) |
|
||||||
| "Top 50 underserved locations" ranking page — high-value SEO content, fully programmatic from `location_opportunity_profile` ORDER BY opportunity_score DESC |
|
| "Top 50 underserved locations" ranking page — high-value SEO content, fully programmatic from `location_profiles` ORDER BY opportunity_score DESC |
|
||||||
|
|
||||||
### Week 1–2 — First Revenue
|
### Week 1–2 — First Revenue
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ extract-census-usa-income = "padelnomics_extract.census_usa_income:main"
|
|||||||
extract-ons-uk = "padelnomics_extract.ons_uk:main"
|
extract-ons-uk = "padelnomics_extract.ons_uk:main"
|
||||||
extract-geonames = "padelnomics_extract.geonames:main"
|
extract-geonames = "padelnomics_extract.geonames:main"
|
||||||
extract-gisco = "padelnomics_extract.gisco:main"
|
extract-gisco = "padelnomics_extract.gisco:main"
|
||||||
|
extract-worldbank = "padelnomics_extract.worldbank:main"
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
requires = ["hatchling"]
|
requires = ["hatchling"]
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ A graphlib.TopologicalSorter schedules them: tasks with no unmet dependencies
|
|||||||
run immediately in parallel; each completion may unlock new tasks.
|
run immediately in parallel; each completion may unlock new tasks.
|
||||||
|
|
||||||
Current dependency graph:
|
Current dependency graph:
|
||||||
- All 9 non-availability extractors have no dependencies (run in parallel)
|
- All 10 non-availability extractors have no dependencies (run in parallel)
|
||||||
- playtomic_availability depends on playtomic_tenants (starts as soon as
|
- playtomic_availability depends on playtomic_tenants (starts as soon as
|
||||||
tenants finishes, even if other extractors are still running)
|
tenants finishes, even if other extractors are still running)
|
||||||
"""
|
"""
|
||||||
@@ -38,6 +38,8 @@ from .playtomic_availability import EXTRACTOR_NAME as AVAILABILITY_NAME
|
|||||||
from .playtomic_availability import extract as extract_availability
|
from .playtomic_availability import extract as extract_availability
|
||||||
from .playtomic_tenants import EXTRACTOR_NAME as TENANTS_NAME
|
from .playtomic_tenants import EXTRACTOR_NAME as TENANTS_NAME
|
||||||
from .playtomic_tenants import extract as extract_tenants
|
from .playtomic_tenants import extract as extract_tenants
|
||||||
|
from .worldbank import EXTRACTOR_NAME as WORLDBANK_NAME
|
||||||
|
from .worldbank import extract as extract_worldbank
|
||||||
|
|
||||||
logger = setup_logging("padelnomics.extract")
|
logger = setup_logging("padelnomics.extract")
|
||||||
|
|
||||||
@@ -54,6 +56,7 @@ EXTRACTORS: dict[str, tuple] = {
|
|||||||
GEONAMES_NAME: (extract_geonames, []),
|
GEONAMES_NAME: (extract_geonames, []),
|
||||||
GISCO_NAME: (extract_gisco, []),
|
GISCO_NAME: (extract_gisco, []),
|
||||||
TENANTS_NAME: (extract_tenants, []),
|
TENANTS_NAME: (extract_tenants, []),
|
||||||
|
WORLDBANK_NAME: (extract_worldbank, []),
|
||||||
AVAILABILITY_NAME: (extract_availability, [TENANTS_NAME]),
|
AVAILABILITY_NAME: (extract_availability, [TENANTS_NAME]),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
153
extract/padelnomics_extract/src/padelnomics_extract/worldbank.py
Normal file
153
extract/padelnomics_extract/src/padelnomics_extract/worldbank.py
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
"""World Bank WDI extractor — GNI per capita PPP and price level ratio.
|
||||||
|
|
||||||
|
Fetches two indicators (one API call each, no key required):
|
||||||
|
- NY.GNP.PCAP.PP.CD — GNI per capita, PPP (international $)
|
||||||
|
- PA.NUS.PPPC.RF — Price level ratio (PPP conversion factor / exchange rate)
|
||||||
|
|
||||||
|
These provide global fallbacks behind Eurostat for dim_countries.median_income_pps
|
||||||
|
and dim_countries.pli_construction (see dim_countries.sql for calibration logic).
|
||||||
|
|
||||||
|
API: World Bank API v2 — https://api.worldbank.org/v2/
|
||||||
|
No API key required. No env vars.
|
||||||
|
|
||||||
|
Landing: {LANDING_DIR}/worldbank/{year}/{month}/wdi_indicators.json.gz
|
||||||
|
Output: {"rows": [{"country_code": "DE", "indicator": "NY.GNP.PCAP.PP.CD",
|
||||||
|
"ref_year": 2023, "value": 74200.0}, ...], "count": N}
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sqlite3
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import niquests
|
||||||
|
|
||||||
|
from ._shared import HTTP_TIMEOUT_SECONDS, run_extractor, setup_logging
|
||||||
|
from .utils import get_last_cursor, landing_path, write_gzip_atomic
|
||||||
|
|
||||||
|
logger = setup_logging("padelnomics.extract.worldbank")
|
||||||
|
|
||||||
|
EXTRACTOR_NAME = "worldbank"
|
||||||
|
|
||||||
|
INDICATORS = ["NY.GNP.PCAP.PP.CD", "PA.NUS.PPPC.RF"]
|
||||||
|
# 6 years of data — we take the latest non-null per country in staging
|
||||||
|
DATE_RANGE = "2019:2025"
|
||||||
|
MAX_PER_PAGE = 5000
|
||||||
|
MAX_PAGES = 3
|
||||||
|
|
||||||
|
WDI_BASE_URL = "https://api.worldbank.org/v2/country/all/indicator"
|
||||||
|
|
||||||
|
# WB aggregate codes that look like real 2-letter country codes.
|
||||||
|
# These are regional/income-group aggregates, not actual countries.
|
||||||
|
_WB_AGGREGATE_CODES = frozenset({
|
||||||
|
"EU", "OE",
|
||||||
|
"XC", "XD", "XE", "XF", "XG", "XH", "XI", "XJ", "XL", "XM",
|
||||||
|
"XN", "XO", "XP", "XQ", "XR", "XS", "XT", "XU", "XV", "XY",
|
||||||
|
"ZF", "ZG", "ZH", "ZI", "ZJ", "ZQ", "ZT",
|
||||||
|
"V1", "V2", "V3", "V4",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_country_code(wb_code: str) -> str | None:
|
||||||
|
"""Normalize WB country code to ISO alpha-2. Returns None for aggregates."""
|
||||||
|
code = wb_code.strip().upper()
|
||||||
|
if len(code) != 2:
|
||||||
|
return None
|
||||||
|
# Reject codes starting with a digit (e.g. "1W" for World)
|
||||||
|
if code[0].isdigit():
|
||||||
|
return None
|
||||||
|
if code in _WB_AGGREGATE_CODES:
|
||||||
|
return None
|
||||||
|
return code
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_indicator(
|
||||||
|
session: niquests.Session,
|
||||||
|
indicator: str,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""Fetch all records for one indicator. Returns list of row dicts."""
|
||||||
|
rows: list[dict] = []
|
||||||
|
page = 1
|
||||||
|
|
||||||
|
while page <= MAX_PAGES:
|
||||||
|
url = (
|
||||||
|
f"{WDI_BASE_URL}/{indicator}"
|
||||||
|
f"?format=json&date={DATE_RANGE}&per_page={MAX_PER_PAGE}&page={page}"
|
||||||
|
)
|
||||||
|
logger.info("GET %s page %d", indicator, page)
|
||||||
|
resp = session.get(url, timeout=HTTP_TIMEOUT_SECONDS * 2)
|
||||||
|
resp.raise_for_status()
|
||||||
|
|
||||||
|
data = resp.json()
|
||||||
|
assert isinstance(data, list) and len(data) == 2, (
|
||||||
|
f"unexpected WB response shape for {indicator}: {type(data)}, len={len(data)}"
|
||||||
|
)
|
||||||
|
meta, records = data
|
||||||
|
total_pages = meta.get("pages", 1)
|
||||||
|
|
||||||
|
if records is None:
|
||||||
|
logger.warning("WB returned null data for %s page %d", indicator, page)
|
||||||
|
break
|
||||||
|
|
||||||
|
for record in records:
|
||||||
|
value = record.get("value")
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
country_code = _normalize_country_code(record["country"]["id"])
|
||||||
|
if country_code is None:
|
||||||
|
continue
|
||||||
|
rows.append({
|
||||||
|
"country_code": country_code,
|
||||||
|
"indicator": indicator,
|
||||||
|
"ref_year": int(record["date"]),
|
||||||
|
"value": float(value),
|
||||||
|
})
|
||||||
|
|
||||||
|
if page >= total_pages:
|
||||||
|
break
|
||||||
|
page += 1
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def extract(
|
||||||
|
landing_dir: Path,
|
||||||
|
year_month: str,
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
session: niquests.Session,
|
||||||
|
) -> dict:
|
||||||
|
"""Fetch WDI indicators. Skips if already run this month."""
|
||||||
|
last_cursor = get_last_cursor(conn, EXTRACTOR_NAME)
|
||||||
|
if last_cursor == year_month:
|
||||||
|
logger.info("already have data for %s — skipping", year_month)
|
||||||
|
return {"files_written": 0, "files_skipped": 1, "bytes_written": 0}
|
||||||
|
|
||||||
|
rows: list[dict] = []
|
||||||
|
for indicator in INDICATORS:
|
||||||
|
indicator_rows = _fetch_indicator(session, indicator)
|
||||||
|
logger.info("%s: %d records", indicator, len(indicator_rows))
|
||||||
|
rows.extend(indicator_rows)
|
||||||
|
|
||||||
|
assert len(rows) >= 200, f"expected ≥200 WB records, got {len(rows)} — API may have changed"
|
||||||
|
logger.info("total: %d WDI records", len(rows))
|
||||||
|
|
||||||
|
year, month = year_month.split("/")
|
||||||
|
dest_dir = landing_path(landing_dir, "worldbank", year, month)
|
||||||
|
dest = dest_dir / "wdi_indicators.json.gz"
|
||||||
|
payload = json.dumps({"rows": rows, "count": len(rows)}).encode()
|
||||||
|
bytes_written = write_gzip_atomic(dest, payload)
|
||||||
|
logger.info("written %s bytes compressed", f"{bytes_written:,}")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"files_written": 1,
|
||||||
|
"files_skipped": 0,
|
||||||
|
"bytes_written": bytes_written,
|
||||||
|
"cursor_value": year_month,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
run_extractor(EXTRACTOR_NAME, extract)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -42,7 +42,7 @@ do
|
|||||||
# The web app detects the inode change on next query — no restart needed.
|
# The web app detects the inode change on next query — no restart needed.
|
||||||
DUCKDB_PATH="${DUCKDB_PATH:-/data/padelnomics/lakehouse.duckdb}" \
|
DUCKDB_PATH="${DUCKDB_PATH:-/data/padelnomics/lakehouse.duckdb}" \
|
||||||
SERVING_DUCKDB_PATH="${SERVING_DUCKDB_PATH:-/data/padelnomics/analytics.duckdb}" \
|
SERVING_DUCKDB_PATH="${SERVING_DUCKDB_PATH:-/data/padelnomics/analytics.duckdb}" \
|
||||||
uv run python -m padelnomics.export_serving
|
uv run python src/padelnomics/export_serving.py
|
||||||
|
|
||||||
) || {
|
) || {
|
||||||
if [ -n "${ALERT_WEBHOOK_URL:-}" ]; then
|
if [ -n "${ALERT_WEBHOOK_URL:-}" ]; then
|
||||||
|
|||||||
@@ -72,3 +72,8 @@ description = "UK local authority population estimates from ONS"
|
|||||||
module = "padelnomics_extract.gisco"
|
module = "padelnomics_extract.gisco"
|
||||||
schedule = "0 0 1 1 *"
|
schedule = "0 0 1 1 *"
|
||||||
description = "EU geographic boundaries (NUTS2 polygons) from Eurostat GISCO"
|
description = "EU geographic boundaries (NUTS2 polygons) from Eurostat GISCO"
|
||||||
|
|
||||||
|
[worldbank]
|
||||||
|
module = "padelnomics_extract.worldbank"
|
||||||
|
schedule = "monthly"
|
||||||
|
description = "GNI per capita PPP + price level ratio from World Bank WDI"
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
--
|
--
|
||||||
-- Consolidates data previously duplicated across dim_cities and dim_locations:
|
-- Consolidates data previously duplicated across dim_cities and dim_locations:
|
||||||
-- - country_name_en / country_slug (was: ~50-line CASE blocks in both models)
|
-- - country_name_en / country_slug (was: ~50-line CASE blocks in both models)
|
||||||
-- - median_income_pps (was: country_income CTE in both models)
|
-- - median_income_pps (Eurostat PPS preferred, World Bank GNI PPP fallback)
|
||||||
-- - energy prices, labour costs, PLI indices (new — from Eurostat datasets)
|
-- - energy prices, labour costs, PLI indices (Eurostat, WB price level ratio fallback)
|
||||||
-- - cost override columns for the financial calculator
|
-- - cost override columns for the financial calculator
|
||||||
--
|
--
|
||||||
|
-- World Bank fallback: for non-EU countries (AR, MX, AE, AU, etc.), income and PLI
|
||||||
|
-- are derived from WB WDI indicators calibrated to the Eurostat scale using Germany
|
||||||
|
-- as anchor. See de_calibration CTE. EU countries keep exact Eurostat values.
|
||||||
|
--
|
||||||
-- Used by: dim_cities, dim_locations, pseo_city_costs_de, planner_defaults.
|
-- Used by: dim_cities, dim_locations, pseo_city_costs_de, planner_defaults.
|
||||||
-- Grain: country_code (one row per ISO 3166-1 alpha-2 country code).
|
-- Grain: country_code (one row per ISO 3166-1 alpha-2 country code).
|
||||||
-- Kind: FULL — small table (~40 rows), full refresh daily.
|
-- Kind: FULL — small table (~40 rows), full refresh daily.
|
||||||
@@ -82,6 +86,26 @@ de_elec AS (
|
|||||||
de_gas AS (
|
de_gas AS (
|
||||||
SELECT gas_eur_gj FROM latest_gas WHERE country_code = 'DE'
|
SELECT gas_eur_gj FROM latest_gas WHERE country_code = 'DE'
|
||||||
),
|
),
|
||||||
|
-- Latest World Bank WDI per country (GNI PPP + price level ratio)
|
||||||
|
latest_wb AS (
|
||||||
|
SELECT country_code, gni_ppp, price_level_ratio, ref_year AS wb_year
|
||||||
|
FROM staging.stg_worldbank_income
|
||||||
|
WHERE gni_ppp IS NOT NULL OR price_level_ratio IS NOT NULL
|
||||||
|
QUALIFY ROW_NUMBER() OVER (PARTITION BY country_code ORDER BY ref_year DESC) = 1
|
||||||
|
),
|
||||||
|
-- Germany calibration anchor: Eurostat PPS + WB GNI PPP + WB price ratio + Eurostat PLI construction.
|
||||||
|
-- Used to scale World Bank values into Eurostat-comparable ranges.
|
||||||
|
-- Single row; if DE is missing from any source, that ratio produces NULL (safe fallthrough).
|
||||||
|
de_calibration AS (
|
||||||
|
SELECT
|
||||||
|
i.median_income_pps AS de_eurostat_pps,
|
||||||
|
wb.gni_ppp AS de_gni_ppp,
|
||||||
|
wb.price_level_ratio AS de_price_level_ratio,
|
||||||
|
p.construction AS de_pli_construction
|
||||||
|
FROM (SELECT median_income_pps FROM latest_income WHERE country_code = 'DE') i
|
||||||
|
CROSS JOIN (SELECT gni_ppp, price_level_ratio FROM latest_wb WHERE country_code = 'DE') wb
|
||||||
|
CROSS JOIN (SELECT construction FROM pli_pivoted WHERE country_code = 'DE') p
|
||||||
|
),
|
||||||
-- All distinct country codes from any source
|
-- All distinct country codes from any source
|
||||||
all_countries AS (
|
all_countries AS (
|
||||||
SELECT country_code FROM latest_income
|
SELECT country_code FROM latest_income
|
||||||
@@ -93,6 +117,8 @@ all_countries AS (
|
|||||||
SELECT country_code FROM latest_labour
|
SELECT country_code FROM latest_labour
|
||||||
UNION
|
UNION
|
||||||
SELECT country_code FROM pli_pivoted
|
SELECT country_code FROM pli_pivoted
|
||||||
|
UNION
|
||||||
|
SELECT country_code FROM latest_wb
|
||||||
-- Ensure known padel markets appear even if Eurostat doesn't cover them yet
|
-- Ensure known padel markets appear even if Eurostat doesn't cover them yet
|
||||||
UNION ALL
|
UNION ALL
|
||||||
SELECT unnest(['DE','ES','GB','FR','IT','PT','AT','CH','NL','BE','SE','NO','DK','FI',
|
SELECT unnest(['DE','ES','GB','FR','IT','PT','AT','CH','NL','BE','SE','NO','DK','FI',
|
||||||
@@ -149,15 +175,21 @@ SELECT
|
|||||||
ELSE ac.country_code
|
ELSE ac.country_code
|
||||||
END, '[^a-zA-Z0-9]+', '-'
|
END, '[^a-zA-Z0-9]+', '-'
|
||||||
)) AS country_slug,
|
)) AS country_slug,
|
||||||
-- Income data
|
-- Income: Eurostat PPS preferred, World Bank GNI PPP scaled to PPS as fallback
|
||||||
|
COALESCE(
|
||||||
i.median_income_pps,
|
i.median_income_pps,
|
||||||
i.income_year,
|
ROUND(wb.gni_ppp * (de_cal.de_eurostat_pps / NULLIF(de_cal.de_gni_ppp, 0)), 0)
|
||||||
|
) AS median_income_pps,
|
||||||
|
COALESCE(i.income_year, wb.wb_year) AS income_year,
|
||||||
-- Raw energy and labour data (for reference / future staffed-scenario use)
|
-- Raw energy and labour data (for reference / future staffed-scenario use)
|
||||||
e.electricity_eur_kwh,
|
e.electricity_eur_kwh,
|
||||||
g.gas_eur_gj,
|
g.gas_eur_gj,
|
||||||
la.labour_cost_eur_hour,
|
la.labour_cost_eur_hour,
|
||||||
-- PLI indices per category (EU27=100)
|
-- PLI construction: Eurostat preferred, World Bank price level ratio scaled to PLI as fallback
|
||||||
p.construction AS pli_construction,
|
COALESCE(
|
||||||
|
p.construction,
|
||||||
|
ROUND(wb.price_level_ratio / NULLIF(de_cal.de_price_level_ratio, 0) * de_cal.de_pli_construction, 1)
|
||||||
|
) AS pli_construction,
|
||||||
p.housing AS pli_housing,
|
p.housing AS pli_housing,
|
||||||
p.services AS pli_services,
|
p.services AS pli_services,
|
||||||
p.misc AS pli_misc,
|
p.misc AS pli_misc,
|
||||||
@@ -278,8 +310,10 @@ LEFT JOIN latest_electricity e ON ac.country_code = e.country_code
|
|||||||
LEFT JOIN latest_gas g ON ac.country_code = g.country_code
|
LEFT JOIN latest_gas g ON ac.country_code = g.country_code
|
||||||
LEFT JOIN latest_labour la ON ac.country_code = la.country_code
|
LEFT JOIN latest_labour la ON ac.country_code = la.country_code
|
||||||
LEFT JOIN pli_pivoted p ON ac.country_code = p.country_code
|
LEFT JOIN pli_pivoted p ON ac.country_code = p.country_code
|
||||||
|
LEFT JOIN latest_wb wb ON ac.country_code = wb.country_code
|
||||||
CROSS JOIN de_pli de_p
|
CROSS JOIN de_pli de_p
|
||||||
CROSS JOIN de_elec de_e
|
CROSS JOIN de_elec de_e
|
||||||
CROSS JOIN de_gas de_g
|
CROSS JOIN de_gas de_g
|
||||||
|
CROSS JOIN de_calibration de_cal
|
||||||
-- Enforce grain
|
-- Enforce grain
|
||||||
QUALIFY ROW_NUMBER() OVER (PARTITION BY ac.country_code ORDER BY ac.country_code) = 1
|
QUALIFY ROW_NUMBER() OVER (PARTITION BY ac.country_code ORDER BY ac.country_code) = 1
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
-- foundation.dim_countries → country_name_en, country_slug, median_income_pps
|
-- foundation.dim_countries → country_name_en, country_slug, median_income_pps
|
||||||
-- stg_nuts2_boundaries + stg_regional_income → EU NUTS-2/NUTS-1 income (spatial join)
|
-- stg_nuts2_boundaries + stg_regional_income → EU NUTS-2/NUTS-1 income (spatial join)
|
||||||
-- stg_income_usa → US state-level income (PPS-normalised)
|
-- stg_income_usa → US state-level income (PPS-normalised)
|
||||||
-- stg_padel_courts → padel venue count + nearest court distance (km)
|
-- foundation.dim_venues → padel venue count + nearest court distance (km)
|
||||||
-- stg_tennis_courts → tennis court count within 25km radius
|
-- stg_tennis_courts → tennis court count within 25km radius
|
||||||
--
|
--
|
||||||
-- Income resolution cascade:
|
-- Income resolution cascade:
|
||||||
@@ -48,6 +48,7 @@ locations AS (
|
|||||||
ref_year
|
ref_year
|
||||||
FROM staging.stg_population_geonames
|
FROM staging.stg_population_geonames
|
||||||
WHERE lat IS NOT NULL AND lon IS NOT NULL
|
WHERE lat IS NOT NULL AND lon IS NOT NULL
|
||||||
|
AND LENGTH(TRIM(city_name)) > 0
|
||||||
),
|
),
|
||||||
-- ── EU NUTS-2 income via spatial join ──────────────────────────────────────
|
-- ── EU NUTS-2 income via spatial join ──────────────────────────────────────
|
||||||
-- Each EU location's (lon, lat) is matched against NUTS-2 boundary polygons.
|
-- Each EU location's (lon, lat) is matched against NUTS-2 boundary polygons.
|
||||||
@@ -136,10 +137,12 @@ us_income AS (
|
|||||||
PARTITION BY m.admin1_code ORDER BY s.ref_year DESC
|
PARTITION BY m.admin1_code ORDER BY s.ref_year DESC
|
||||||
) = 1
|
) = 1
|
||||||
),
|
),
|
||||||
-- Padel court lat/lon for distance and density calculations
|
-- Padel venue lat/lon for distance and density calculations.
|
||||||
|
-- Uses dim_venues (deduplicated OSM + Playtomic) instead of stg_padel_courts (OSM only)
|
||||||
|
-- so Playtomic-only venues are visible to spatial lookups.
|
||||||
padel_courts AS (
|
padel_courts AS (
|
||||||
SELECT lat, lon, country_code
|
SELECT lat, lon, country_code
|
||||||
FROM staging.stg_padel_courts
|
FROM foundation.dim_venues
|
||||||
WHERE lat IS NOT NULL AND lon IS NOT NULL
|
WHERE lat IS NOT NULL AND lon IS NOT NULL
|
||||||
),
|
),
|
||||||
-- Nearest padel court distance per location (bbox pre-filter → exact sphere distance)
|
-- Nearest padel court distance per location (bbox pre-filter → exact sphere distance)
|
||||||
|
|||||||
@@ -5,28 +5,40 @@
|
|||||||
--
|
--
|
||||||
-- Two scores per location:
|
-- Two scores per location:
|
||||||
--
|
--
|
||||||
-- Padelnomics Market Score (Marktreife-Score v3, 0–100):
|
-- Padelnomics Market Score (Marktreife-Score v4, 0–100):
|
||||||
-- "How mature/established is this padel market?"
|
-- "How mature/established is this padel market?"
|
||||||
-- Only meaningful for locations matched to a dim_cities row (city_slug IS NOT NULL)
|
-- Only meaningful for locations matched to a dim_cities row (city_slug IS NOT NULL)
|
||||||
-- with padel venues. 0 for all other locations.
|
-- with padel venues. 0 for all other locations.
|
||||||
--
|
--
|
||||||
-- 40 pts supply development — log-scaled density (LN ceiling 20/100k) × count gate
|
-- v4 changes: lower count gate (5→3), lower density ceiling (LN(21)→LN(11)),
|
||||||
-- 25 pts demand evidence — occupancy when available; 40% density proxy otherwise
|
-- better demand fallback (0.4→0.65 with 0.3 floor), economic context discrimination (200→25K).
|
||||||
|
--
|
||||||
|
-- 40 pts supply development — log-scaled density (LN ceiling 10/100k) × count gate (3)
|
||||||
|
-- 25 pts demand evidence — occupancy when available; 65% density proxy + 0.3 floor otherwise
|
||||||
-- 15 pts addressable market — log-scaled population, ceiling 1M
|
-- 15 pts addressable market — log-scaled population, ceiling 1M
|
||||||
-- 10 pts economic context — income PPS normalised to 200 ceiling
|
-- 10 pts economic context — income PPS normalised to 25,000 ceiling
|
||||||
-- 10 pts data quality — completeness discount
|
-- 10 pts data quality — completeness discount
|
||||||
--
|
--
|
||||||
-- Padelnomics Opportunity Score (Marktpotenzial-Score v3, 0–100):
|
-- Padelnomics Opportunity Score (Marktpotenzial-Score v8, 0–100):
|
||||||
-- "Where should I build a padel court?"
|
-- "Where should I build a padel court?"
|
||||||
-- Computed for ALL locations — zero-court locations score highest on supply gap.
|
-- Computed for ALL locations — zero-court locations score highest on supply deficit.
|
||||||
-- H3 catchment methodology: addressable market and supply gap use a regional
|
-- H3 catchment methodology: addressable market and supply deficit use a regional
|
||||||
-- H3 catchment (res-5 cell + 6 neighbours, ~24km radius).
|
-- H3 catchment (res-5 cell + 6 neighbours, ~24km radius).
|
||||||
--
|
--
|
||||||
-- 25 pts addressable market — log-scaled catchment population, ceiling 500K
|
-- v8 changes: better spread/discrimination.
|
||||||
-- 20 pts economic power — income PPS, normalised to 35,000
|
-- - Reweight: addressable market 20→15, economic power 15→10, supply deficit 40→50.
|
||||||
-- 30 pts supply gap — inverted catchment venue density; 0 courts = full marks
|
-- - Supply deficit existence dampener: country_venues/50 factor (0.1–1.0).
|
||||||
-- 15 pts catchment gap — distance to nearest padel court
|
-- Zero-venue countries get max 5 pts supply deficit (was 50).
|
||||||
-- 10 pts sports culture — tennis courts within 25km
|
-- - Steeper addressable market curve: LN/500K → SQRT/1M.
|
||||||
|
-- - NULL distance gap → 0.0 (was 0.5). Unknown = assume nearby.
|
||||||
|
-- - Added country_percentile output column (PERCENT_RANK within country).
|
||||||
|
--
|
||||||
|
-- 15 pts addressable market — sqrt-scaled catchment population, ceiling 1M
|
||||||
|
-- 10 pts economic power — income PPS, normalised to 35,000
|
||||||
|
-- 50 pts supply deficit — max(density gap, distance gap) × existence dampener
|
||||||
|
-- 10 pts sports culture — tennis court density as racquet-sport adoption proxy
|
||||||
|
-- 5 pts construction affordability — income relative to construction costs (PLI)
|
||||||
|
-- 10 pts market headroom — inverse country-level avg market maturity
|
||||||
--
|
--
|
||||||
-- Consumers query directly with WHERE filters:
|
-- Consumers query directly with WHERE filters:
|
||||||
-- cities API: WHERE country_slug = ? AND city_slug IS NOT NULL
|
-- cities API: WHERE country_slug = ? AND city_slug IS NOT NULL
|
||||||
@@ -105,7 +117,7 @@ city_match AS (
|
|||||||
ORDER BY c.padel_venue_count DESC
|
ORDER BY c.padel_venue_count DESC
|
||||||
) = 1
|
) = 1
|
||||||
),
|
),
|
||||||
-- Pricing / occupancy from Playtomic (via city_slug) + H3 catchment
|
-- Pricing / occupancy from Playtomic (via city_slug) + H3 catchment + country PLI
|
||||||
with_pricing AS (
|
with_pricing AS (
|
||||||
SELECT
|
SELECT
|
||||||
b.*,
|
b.*,
|
||||||
@@ -118,6 +130,7 @@ with_pricing AS (
|
|||||||
vpb.median_occupancy_rate,
|
vpb.median_occupancy_rate,
|
||||||
vpb.median_daily_revenue_per_venue,
|
vpb.median_daily_revenue_per_venue,
|
||||||
vpb.price_currency,
|
vpb.price_currency,
|
||||||
|
dc.pli_construction,
|
||||||
COALESCE(ct.catchment_population, b.population)::BIGINT AS catchment_population,
|
COALESCE(ct.catchment_population, b.population)::BIGINT AS catchment_population,
|
||||||
COALESCE(ct.catchment_padel_courts, b.padel_venue_count)::INTEGER AS catchment_padel_courts
|
COALESCE(ct.catchment_padel_courts, b.padel_venue_count)::INTEGER AS catchment_padel_courts
|
||||||
FROM base b
|
FROM base b
|
||||||
@@ -129,9 +142,11 @@ with_pricing AS (
|
|||||||
AND cm.city_slug = vpb.city_slug
|
AND cm.city_slug = vpb.city_slug
|
||||||
LEFT JOIN catchment ct
|
LEFT JOIN catchment ct
|
||||||
ON b.geoname_id = ct.geoname_id
|
ON b.geoname_id = ct.geoname_id
|
||||||
|
LEFT JOIN foundation.dim_countries dc
|
||||||
|
ON b.country_code = dc.country_code
|
||||||
),
|
),
|
||||||
-- Both scores computed from the enriched base
|
-- Step 1: market score only — needed first so we can aggregate country averages.
|
||||||
scored AS (
|
market_scored AS (
|
||||||
SELECT *,
|
SELECT *,
|
||||||
-- City-level venue density (from dim_cities exact count, not dim_locations spatial 5km)
|
-- City-level venue density (from dim_cities exact count, not dim_locations spatial 5km)
|
||||||
CASE WHEN population > 0
|
CASE WHEN population > 0
|
||||||
@@ -144,34 +159,38 @@ scored AS (
|
|||||||
WHEN population > 0 OR COALESCE(city_padel_venue_count, 0) > 0 THEN 0.5
|
WHEN population > 0 OR COALESCE(city_padel_venue_count, 0) > 0 THEN 0.5
|
||||||
ELSE 0.0
|
ELSE 0.0
|
||||||
END AS data_confidence,
|
END AS data_confidence,
|
||||||
-- ── Market Score (Marktreife-Score v3) ──────────────────────────────────
|
-- ── Market Score (Marktreife-Score v4) ──────────────────────────────────
|
||||||
-- 0 when no city match or no venues (city_padel_venue_count NULL or 0)
|
-- 0 when no city match or no venues (city_padel_venue_count NULL or 0)
|
||||||
CASE WHEN COALESCE(city_padel_venue_count, 0) > 0 THEN
|
CASE WHEN COALESCE(city_padel_venue_count, 0) > 0 THEN
|
||||||
ROUND(
|
ROUND(
|
||||||
-- Supply development (40 pts)
|
-- Supply development (40 pts)
|
||||||
|
-- density ceiling 10/100k (LN(11)), count gate 3 venues
|
||||||
40.0 * LEAST(1.0, LN(
|
40.0 * LEAST(1.0, LN(
|
||||||
COALESCE(
|
COALESCE(
|
||||||
CASE WHEN population > 0
|
CASE WHEN population > 0
|
||||||
THEN COALESCE(city_padel_venue_count, 0)::DOUBLE / population * 100000
|
THEN COALESCE(city_padel_venue_count, 0)::DOUBLE / population * 100000
|
||||||
ELSE 0 END
|
ELSE 0 END
|
||||||
, 0) + 1) / LN(21))
|
, 0) + 1) / LN(11))
|
||||||
* LEAST(1.0, COALESCE(city_padel_venue_count, 0) / 5.0)
|
* LEAST(1.0, COALESCE(city_padel_venue_count, 0) / 3.0)
|
||||||
-- Demand evidence (25 pts)
|
-- Demand evidence (25 pts)
|
||||||
|
-- with occupancy: scale to 65% target. Without: 65% of supply proxy + 0.3 floor
|
||||||
|
-- (existence of venues IS evidence of demand)
|
||||||
+ 25.0 * CASE
|
+ 25.0 * CASE
|
||||||
WHEN median_occupancy_rate IS NOT NULL
|
WHEN median_occupancy_rate IS NOT NULL
|
||||||
THEN LEAST(1.0, median_occupancy_rate / 0.65)
|
THEN LEAST(1.0, median_occupancy_rate / 0.65)
|
||||||
ELSE 0.4 * LEAST(1.0, LN(
|
ELSE GREATEST(0.3, 0.65 * LEAST(1.0, LN(
|
||||||
COALESCE(
|
COALESCE(
|
||||||
CASE WHEN population > 0
|
CASE WHEN population > 0
|
||||||
THEN COALESCE(city_padel_venue_count, 0)::DOUBLE / population * 100000
|
THEN COALESCE(city_padel_venue_count, 0)::DOUBLE / population * 100000
|
||||||
ELSE 0 END
|
ELSE 0 END
|
||||||
, 0) + 1) / LN(21))
|
, 0) + 1) / LN(11))
|
||||||
* LEAST(1.0, COALESCE(city_padel_venue_count, 0) / 5.0)
|
* LEAST(1.0, COALESCE(city_padel_venue_count, 0) / 3.0))
|
||||||
END
|
END
|
||||||
-- Addressable market (15 pts)
|
-- Addressable market (15 pts)
|
||||||
+ 15.0 * LEAST(1.0, LN(GREATEST(population, 1)) / LN(1000000))
|
+ 15.0 * LEAST(1.0, LN(GREATEST(population, 1)) / LN(1000000))
|
||||||
-- Economic context (10 pts)
|
-- Economic context (10 pts)
|
||||||
+ 10.0 * LEAST(1.0, COALESCE(median_income_pps, 100) / 200.0)
|
-- ceiling 25,000 PPS discriminates between wealthy and poorer markets
|
||||||
|
+ 10.0 * LEAST(1.0, COALESCE(median_income_pps, 15000) / 25000.0)
|
||||||
-- Data quality (10 pts)
|
-- Data quality (10 pts)
|
||||||
+ 10.0 * CASE
|
+ 10.0 * CASE
|
||||||
WHEN population > 0 AND COALESCE(city_padel_venue_count, 0) > 0 THEN 1.0
|
WHEN population > 0 AND COALESCE(city_padel_venue_count, 0) > 0 THEN 1.0
|
||||||
@@ -180,25 +199,79 @@ scored AS (
|
|||||||
END
|
END
|
||||||
, 1)
|
, 1)
|
||||||
ELSE 0
|
ELSE 0
|
||||||
END AS market_score,
|
END AS market_score
|
||||||
-- ── Opportunity Score (Marktpotenzial-Score v3, H3 catchment) ──────────
|
|
||||||
ROUND(
|
|
||||||
-- Addressable market (25 pts): log-scaled catchment population, ceiling 500K
|
|
||||||
25.0 * LEAST(1.0, LN(GREATEST(catchment_population, 1)) / LN(500000))
|
|
||||||
-- Economic power (20 pts): income PPS normalised to 35,000
|
|
||||||
+ 20.0 * LEAST(1.0, COALESCE(median_income_pps, 15000) / 35000.0)
|
|
||||||
-- Supply gap (30 pts): inverted catchment venue density
|
|
||||||
+ 30.0 * GREATEST(0.0, 1.0 - COALESCE(
|
|
||||||
CASE WHEN catchment_population > 0
|
|
||||||
THEN catchment_padel_courts::DOUBLE / catchment_population * 100000
|
|
||||||
ELSE 0.0
|
|
||||||
END, 0.0) / 8.0)
|
|
||||||
-- Catchment gap (15 pts): distance to nearest court
|
|
||||||
+ 15.0 * COALESCE(LEAST(1.0, nearest_padel_court_km / 30.0), 0.5)
|
|
||||||
-- Sports culture (10 pts): tennis courts within 25km
|
|
||||||
+ 10.0 * LEAST(1.0, tennis_courts_within_25km / 10.0)
|
|
||||||
, 1) AS opportunity_score
|
|
||||||
FROM with_pricing
|
FROM with_pricing
|
||||||
|
),
|
||||||
|
-- Step 2: country-level avg market maturity — used as market headroom signal (10 pts).
|
||||||
|
-- Filter to market_score > 0 (cities with padel courts only) so zero-court locations
|
||||||
|
-- don't dilute the country signal. Higher avg = more saturated = less headroom.
|
||||||
|
country_market AS (
|
||||||
|
SELECT
|
||||||
|
country_code,
|
||||||
|
ROUND(AVG(market_score), 1) AS country_avg_market_score
|
||||||
|
FROM market_scored
|
||||||
|
WHERE market_score > 0
|
||||||
|
GROUP BY country_code
|
||||||
|
),
|
||||||
|
-- Step 3: country-level supply saturation — venues per 100K at the country level.
|
||||||
|
-- Used to dampen supply deficit in saturated markets (Spain, Sweden).
|
||||||
|
country_supply AS (
|
||||||
|
SELECT
|
||||||
|
country_code,
|
||||||
|
SUM(city_padel_venue_count) AS country_venues,
|
||||||
|
SUM(population) AS country_pop,
|
||||||
|
CASE WHEN SUM(population) > 0
|
||||||
|
THEN SUM(city_padel_venue_count) * 100000.0 / SUM(population)
|
||||||
|
ELSE 0
|
||||||
|
END AS venues_per_100k
|
||||||
|
FROM foundation.dim_cities
|
||||||
|
WHERE population > 0
|
||||||
|
GROUP BY country_code
|
||||||
|
),
|
||||||
|
-- Step 4: add opportunity_score using country market validation + supply saturation.
|
||||||
|
scored AS (
|
||||||
|
SELECT ms.*,
|
||||||
|
-- ── Opportunity Score (Marktpotenzial-Score v8, H3 catchment) ──────────
|
||||||
|
ROUND(
|
||||||
|
-- Addressable market (15 pts): sqrt-scaled catchment population, ceiling 1M
|
||||||
|
15.0 * LEAST(1.0, SQRT(GREATEST(catchment_population, 1) / 1000000.0))
|
||||||
|
-- Economic power (10 pts): income PPS normalised to 35,000
|
||||||
|
+ 10.0 * LEAST(1.0, COALESCE(median_income_pps, 15000) / 35000.0)
|
||||||
|
-- Supply deficit (50 pts): max of density gap and distance gap.
|
||||||
|
-- Dampened by market existence: country_venues/50 (0.1–1.0).
|
||||||
|
-- 0 venues in country → factor 0.1 → max 5 pts supply deficit
|
||||||
|
-- 10 venues → 0.2 → max 10 pts
|
||||||
|
-- 50+ venues → 1.0 → full credit
|
||||||
|
+ 50.0 * GREATEST(
|
||||||
|
-- density-based gap (H3 catchment): 0 courts = 1.0, 5/100k = 0.0
|
||||||
|
GREATEST(0.0, 1.0 - COALESCE(
|
||||||
|
CASE WHEN catchment_population > 0
|
||||||
|
THEN GREATEST(catchment_padel_courts, COALESCE(city_padel_venue_count, 0))::DOUBLE / catchment_population * 100000
|
||||||
|
ELSE 0.0
|
||||||
|
END, 0.0) / 5.0),
|
||||||
|
-- distance-based gap: 30km+ = 1.0, 0km = 0.0; NULL = 0.0 (assume nearby)
|
||||||
|
COALESCE(LEAST(1.0, nearest_padel_court_km / 30.0), 0.0)
|
||||||
|
)
|
||||||
|
-- Market existence dampener: zero-venue countries get 0.1, 50+ venues = 1.0
|
||||||
|
* GREATEST(0.1, LEAST(1.0, COALESCE(cs.country_venues, 0) / 50.0))
|
||||||
|
-- Sports culture (10 pts): tennis density as racquet-sport adoption proxy.
|
||||||
|
-- Ceiling 50 courts within 25km. Harmless when tennis data is zero (contributes 0).
|
||||||
|
+ 10.0 * LEAST(1.0, COALESCE(tennis_courts_within_25km, 0) / 50.0)
|
||||||
|
-- Construction affordability (5 pts): income purchasing power relative to build costs.
|
||||||
|
-- PLI construction is EU27=100 index. High income + low construction cost = high score.
|
||||||
|
+ 5.0 * LEAST(1.0,
|
||||||
|
COALESCE(median_income_pps, 15000) / 35000.0
|
||||||
|
/ GREATEST(0.5, COALESCE(pli_construction, 100.0) / 100.0)
|
||||||
|
)
|
||||||
|
-- Market headroom (10 pts): INVERSE country-level avg market maturity.
|
||||||
|
-- High avg market score = saturated market = LESS opportunity for new entrants.
|
||||||
|
-- ES (~46/100): proven demand, less headroom → ~5.4 pts.
|
||||||
|
-- SE (~40/100): emerging → ~6 pts. NULL: 0.5 neutral → 5 pts.
|
||||||
|
+ 10.0 * (1.0 - COALESCE(cm.country_avg_market_score / 100.0, 0.5))
|
||||||
|
, 1) AS opportunity_score
|
||||||
|
FROM market_scored ms
|
||||||
|
LEFT JOIN country_market cm ON ms.country_code = cm.country_code
|
||||||
|
LEFT JOIN country_supply cs ON ms.country_code = cs.country_code
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT
|
||||||
s.geoname_id,
|
s.geoname_id,
|
||||||
@@ -230,8 +303,11 @@ SELECT
|
|||||||
THEN ROUND(s.catchment_padel_courts::DOUBLE / s.catchment_population * 100000, 2)
|
THEN ROUND(s.catchment_padel_courts::DOUBLE / s.catchment_population * 100000, 2)
|
||||||
ELSE NULL
|
ELSE NULL
|
||||||
END AS catchment_venues_per_100k,
|
END AS catchment_venues_per_100k,
|
||||||
s.market_score,
|
LEAST(GREATEST(s.market_score, 0), 100) AS market_score,
|
||||||
s.opportunity_score,
|
LEAST(GREATEST(s.opportunity_score, 0), 100) AS opportunity_score,
|
||||||
|
ROUND(PERCENT_RANK() OVER (
|
||||||
|
PARTITION BY s.country_code ORDER BY s.opportunity_score
|
||||||
|
) * 100, 0) AS country_percentile,
|
||||||
s.median_hourly_rate,
|
s.median_hourly_rate,
|
||||||
s.median_peak_rate,
|
s.median_peak_rate,
|
||||||
s.median_offpeak_rate,
|
s.median_offpeak_rate,
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ SELECT
|
|||||||
country_slug,
|
country_slug,
|
||||||
COUNT(*) AS city_count,
|
COUNT(*) AS city_count,
|
||||||
SUM(padel_venue_count) AS total_venues,
|
SUM(padel_venue_count) AS total_venues,
|
||||||
ROUND(AVG(market_score), 1) AS avg_market_score,
|
-- Population-weighted: large cities (Madrid, Barcelona) dominate, not hundreds of small towns
|
||||||
|
ROUND(SUM(market_score * population) / NULLIF(SUM(population), 0), 1) AS avg_market_score,
|
||||||
MAX(market_score) AS top_city_market_score,
|
MAX(market_score) AS top_city_market_score,
|
||||||
-- Top 5 cities by venue count (prominence), then score for internal linking
|
-- Top 5 cities by venue count (prominence), then score for internal linking
|
||||||
LIST(city_slug ORDER BY padel_venue_count DESC, market_score DESC NULLS LAST)[1:5] AS top_city_slugs,
|
LIST(city_slug ORDER BY padel_venue_count DESC, market_score DESC NULLS LAST)[1:5] AS top_city_slugs,
|
||||||
LIST(city_name ORDER BY padel_venue_count DESC, market_score DESC NULLS LAST)[1:5] AS top_city_names,
|
LIST(city_name ORDER BY padel_venue_count DESC, market_score DESC NULLS LAST)[1:5] AS top_city_names,
|
||||||
-- Opportunity score aggregates (NULL-safe: cities without geoname_id match excluded from AVG)
|
-- Opportunity score aggregates (population-weighted: saturated megacities dominate, not hundreds of small towns)
|
||||||
ROUND(AVG(opportunity_score), 1) AS avg_opportunity_score,
|
ROUND(SUM(opportunity_score * population) / NULLIF(SUM(population), 0), 1) AS avg_opportunity_score,
|
||||||
MAX(opportunity_score) AS top_opportunity_score,
|
MAX(opportunity_score) AS top_opportunity_score,
|
||||||
-- Top 5 opportunity cities by population (prominence), then opportunity score
|
-- Top 5 opportunity cities by population (prominence), then opportunity score
|
||||||
LIST(city_slug ORDER BY population DESC, opportunity_score DESC NULLS LAST)[1:5] AS top_opportunity_slugs,
|
LIST(city_slug ORDER BY population DESC, opportunity_score DESC NULLS LAST)[1:5] AS top_opportunity_slugs,
|
||||||
@@ -36,6 +37,8 @@ SELECT
|
|||||||
-- Use the most common currency in the country (MIN is deterministic for single-currency countries)
|
-- Use the most common currency in the country (MIN is deterministic for single-currency countries)
|
||||||
MIN(price_currency) AS price_currency,
|
MIN(price_currency) AS price_currency,
|
||||||
SUM(population) AS total_population,
|
SUM(population) AS total_population,
|
||||||
|
ROUND(SUM(lat * population) / NULLIF(SUM(population), 0), 4) AS lat,
|
||||||
|
ROUND(SUM(lon * population) / NULLIF(SUM(population), 0), 4) AS lon,
|
||||||
CURRENT_DATE AS refreshed_date
|
CURRENT_DATE AS refreshed_date
|
||||||
FROM serving.pseo_city_costs_de
|
FROM serving.pseo_city_costs_de
|
||||||
GROUP BY country_code, country_name_en, country_slug
|
GROUP BY country_code, country_name_en, country_slug
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ WITH venue_stats AS (
|
|||||||
MAX(da.active_court_count) AS court_count,
|
MAX(da.active_court_count) AS court_count,
|
||||||
COUNT(DISTINCT da.snapshot_date) AS days_observed
|
COUNT(DISTINCT da.snapshot_date) AS days_observed
|
||||||
FROM foundation.fct_daily_availability da
|
FROM foundation.fct_daily_availability da
|
||||||
WHERE da.snapshot_date >= CURRENT_DATE - INTERVAL '30 days'
|
WHERE CAST(da.snapshot_date AS DATE) >= CURRENT_DATE - INTERVAL '30 days'
|
||||||
AND da.occupancy_rate IS NOT NULL
|
AND da.occupancy_rate IS NOT NULL
|
||||||
AND da.occupancy_rate BETWEEN 0 AND 1.5
|
AND da.occupancy_rate BETWEEN 0 AND 1.5
|
||||||
GROUP BY da.tenant_id, da.country_code, da.city, da.city_slug, da.price_currency
|
GROUP BY da.tenant_id, da.country_code, da.city, da.city_slug, da.price_currency
|
||||||
|
|||||||
@@ -38,3 +38,6 @@ WHERE geoname_id IS NOT NULL
|
|||||||
AND city_name IS NOT NULL
|
AND city_name IS NOT NULL
|
||||||
AND lat IS NOT NULL
|
AND lat IS NOT NULL
|
||||||
AND lon IS NOT NULL
|
AND lon IS NOT NULL
|
||||||
|
-- Reject names with non-Latin characters (CJK, Cyrillic, Arabic, Thai, etc.)
|
||||||
|
-- Allows ASCII + Latin Extended (diacritics: ÄÖÜ, àéî, ñ, ø, etc.)
|
||||||
|
AND regexp_matches(city_name, '^[\x20-\x7EÀ-ɏḀ-ỿ]+$')
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
-- World Bank WDI indicators: GNI per capita PPP and price level ratio.
|
||||||
|
-- Pivoted to one row per (country_code, ref_year) with both indicators as columns.
|
||||||
|
--
|
||||||
|
-- Source: data/landing/worldbank/{year}/{month}/wdi_indicators.json.gz
|
||||||
|
-- Extracted by: worldbank.py
|
||||||
|
-- Used by: dim_countries (fallback behind Eurostat for non-EU countries)
|
||||||
|
|
||||||
|
MODEL (
|
||||||
|
name staging.stg_worldbank_income,
|
||||||
|
kind FULL,
|
||||||
|
cron '@daily',
|
||||||
|
grain (country_code, ref_year)
|
||||||
|
);
|
||||||
|
|
||||||
|
WITH parsed AS (
|
||||||
|
SELECT
|
||||||
|
row ->> 'country_code' AS country_code,
|
||||||
|
TRY_CAST(row ->> 'ref_year' AS INTEGER) AS ref_year,
|
||||||
|
row ->> 'indicator' AS indicator,
|
||||||
|
TRY_CAST(row ->> 'value' AS DOUBLE) AS value,
|
||||||
|
CURRENT_DATE AS extracted_date
|
||||||
|
FROM (
|
||||||
|
SELECT UNNEST(rows) AS row
|
||||||
|
FROM read_json(
|
||||||
|
@LANDING_DIR || '/worldbank/*/*/wdi_indicators.json.gz',
|
||||||
|
auto_detect = true
|
||||||
|
)
|
||||||
|
)
|
||||||
|
WHERE (row ->> 'country_code') IS NOT NULL
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
country_code,
|
||||||
|
ref_year,
|
||||||
|
MAX(value) FILTER (WHERE indicator = 'NY.GNP.PCAP.PP.CD') AS gni_ppp,
|
||||||
|
MAX(value) FILTER (WHERE indicator = 'PA.NUS.PPPC.RF') AS price_level_ratio,
|
||||||
|
MAX(extracted_date) AS extracted_date
|
||||||
|
FROM parsed
|
||||||
|
WHERE value IS NOT NULL
|
||||||
|
AND value > 0
|
||||||
|
AND LENGTH(country_code) = 2
|
||||||
|
GROUP BY country_code, ref_year
|
||||||
@@ -111,7 +111,7 @@ _DAG: dict[str, list[str]] = {
|
|||||||
"fct_daily_availability": ["fct_availability_slot", "dim_venue_capacity"],
|
"fct_daily_availability": ["fct_availability_slot", "dim_venue_capacity"],
|
||||||
# Serving
|
# Serving
|
||||||
"venue_pricing_benchmarks": ["fct_daily_availability"],
|
"venue_pricing_benchmarks": ["fct_daily_availability"],
|
||||||
"location_profiles": ["dim_locations", "dim_cities", "venue_pricing_benchmarks"],
|
"location_profiles": ["dim_locations", "dim_cities", "dim_countries", "venue_pricing_benchmarks"],
|
||||||
"planner_defaults": ["venue_pricing_benchmarks", "location_profiles"],
|
"planner_defaults": ["venue_pricing_benchmarks", "location_profiles"],
|
||||||
"pseo_city_costs_de": [
|
"pseo_city_costs_de": [
|
||||||
"location_profiles", "planner_defaults",
|
"location_profiles", "planner_defaults",
|
||||||
|
|||||||
@@ -9,7 +9,11 @@
|
|||||||
</head>
|
</head>
|
||||||
<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") }}';
|
||||||
|
window.__MAP_T = {score_label:"Padelnomics Score",venues:"venues",pop:"pop",click_explore:"Click to explore →",coming_soon:"Coming soon",courts:"courts",indoor:"indoor",outdoor:"outdoor"};
|
||||||
|
</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>
|
||||||
|
|||||||
@@ -33,6 +33,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</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") }}';
|
||||||
|
window.__MAP_T = {score_label:"Padelnomics Score",venues:"venues",pop:"pop",click_explore:"Click to explore →",coming_soon:"Coming soon",courts:"courts",indoor:"indoor",outdoor:"outdoor"};
|
||||||
|
</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 %}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ daily when the pipeline runs).
|
|||||||
from quart import Blueprint, abort, jsonify
|
from quart import Blueprint, abort, jsonify
|
||||||
|
|
||||||
from .analytics import fetch_analytics
|
from .analytics import fetch_analytics
|
||||||
|
from .auth.routes import login_required
|
||||||
from .core import fetch_all, is_flag_enabled
|
from .core import fetch_all, is_flag_enabled
|
||||||
|
|
||||||
bp = Blueprint("api", __name__)
|
bp = Blueprint("api", __name__)
|
||||||
@@ -26,6 +27,7 @@ async def _require_maps_flag() -> None:
|
|||||||
|
|
||||||
|
|
||||||
@bp.route("/markets/countries.json")
|
@bp.route("/markets/countries.json")
|
||||||
|
@login_required
|
||||||
async def countries():
|
async def countries():
|
||||||
"""Country-level aggregates for the markets hub map."""
|
"""Country-level aggregates for the markets hub map."""
|
||||||
await _require_maps_flag()
|
await _require_maps_flag()
|
||||||
@@ -96,23 +98,3 @@ async def city_venues(country_slug: str, city_slug: str):
|
|||||||
)
|
)
|
||||||
return jsonify(rows), 200, _CACHE_HEADERS
|
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."""
|
|
||||||
await _require_maps_flag()
|
|
||||||
assert country_slug, "country_slug required"
|
|
||||||
rows = await fetch_analytics(
|
|
||||||
"""
|
|
||||||
SELECT location_name, location_slug, lat, lon,
|
|
||||||
opportunity_score, market_score,
|
|
||||||
nearest_padel_court_km,
|
|
||||||
padel_venue_count, population
|
|
||||||
FROM serving.location_profiles
|
|
||||||
WHERE country_slug = ? AND opportunity_score > 0
|
|
||||||
ORDER BY opportunity_score DESC
|
|
||||||
LIMIT 500
|
|
||||||
""",
|
|
||||||
[country_slug],
|
|
||||||
)
|
|
||||||
return jsonify(rows), 200, _CACHE_HEADERS
|
|
||||||
|
|||||||
@@ -148,6 +148,18 @@ def create_app() -> Quart:
|
|||||||
# Per-request hooks
|
# Per-request hooks
|
||||||
# -------------------------------------------------------------------------
|
# -------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
async def set_user_geo():
|
||||||
|
"""Stash Cloudflare geo headers in g for proximity sorting.
|
||||||
|
|
||||||
|
Requires nginx: proxy_set_header CF-IPCountry $http_cf_ipcountry;
|
||||||
|
proxy_set_header CF-RegionCode $http_cf_regioncode;
|
||||||
|
proxy_set_header CF-IPCity $http_cf_ipcity;
|
||||||
|
"""
|
||||||
|
g.user_country = request.headers.get("CF-IPCountry", "").upper() or ""
|
||||||
|
g.user_region = request.headers.get("CF-RegionCode", "") or ""
|
||||||
|
g.user_city = request.headers.get("CF-IPCity", "") or ""
|
||||||
|
|
||||||
@app.before_request
|
@app.before_request
|
||||||
async def validate_lang():
|
async def validate_lang():
|
||||||
"""404 unsupported language prefixes (e.g. /fr/terms)."""
|
"""404 unsupported language prefixes (e.g. /fr/terms)."""
|
||||||
@@ -234,6 +246,8 @@ def create_app() -> Quart:
|
|||||||
"csrf_token": get_csrf_token,
|
"csrf_token": get_csrf_token,
|
||||||
"ab_variant": getattr(g, "ab_variant", None),
|
"ab_variant": getattr(g, "ab_variant", None),
|
||||||
"ab_tag": getattr(g, "ab_tag", None),
|
"ab_tag": getattr(g, "ab_tag", None),
|
||||||
|
"user_country": g.get("user_country", ""),
|
||||||
|
"user_city": g.get("user_city", ""),
|
||||||
"lang": effective_lang,
|
"lang": effective_lang,
|
||||||
"t": get_translations(effective_lang),
|
"t": get_translations(effective_lang),
|
||||||
"v": _ASSET_VERSION,
|
"v": _ASSET_VERSION,
|
||||||
|
|||||||
@@ -194,12 +194,16 @@ async def _fetch_market_data(location: str, language: str) -> dict | None:
|
|||||||
|
|
||||||
rows = await fetch_analytics(
|
rows = await fetch_analytics(
|
||||||
"""
|
"""
|
||||||
SELECT city_slug, city_name, country,
|
SELECT city_slug, city_name, country_name_en AS country,
|
||||||
padel_venue_count, venues_per_100k, market_score,
|
city_padel_venue_count AS padel_venue_count,
|
||||||
|
city_venues_per_100k AS venues_per_100k,
|
||||||
|
opportunity_score,
|
||||||
median_peak_rate, median_offpeak_rate, median_occupancy_rate,
|
median_peak_rate, median_offpeak_rate, median_occupancy_rate,
|
||||||
population
|
population
|
||||||
FROM serving.city_market_overview
|
FROM serving.location_profiles
|
||||||
WHERE city_slug = ? OR lower(city_name) = lower(?)
|
WHERE city_slug IS NOT NULL
|
||||||
|
AND (city_slug = ? OR lower(city_name) = lower(?))
|
||||||
|
ORDER BY opportunity_score DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
""",
|
""",
|
||||||
[city_slug, location.strip()],
|
[city_slug, location.strip()],
|
||||||
@@ -485,7 +489,7 @@ def get_plan_sections(state: dict, d: dict, language: str = "en", bp_details: di
|
|||||||
"funded_by_equity": t["bp_lbl_funded_by_equity"],
|
"funded_by_equity": t["bp_lbl_funded_by_equity"],
|
||||||
"total": t["bp_lbl_total"],
|
"total": t["bp_lbl_total"],
|
||||||
"venues_per_100k": t["bp_lbl_venues_per_100k"],
|
"venues_per_100k": t["bp_lbl_venues_per_100k"],
|
||||||
"market_score": t["bp_lbl_market_score"],
|
"padelnomics_score": t["bp_lbl_padelnomics_score"],
|
||||||
"median_peak_rate": t["bp_lbl_median_peak_rate"],
|
"median_peak_rate": t["bp_lbl_median_peak_rate"],
|
||||||
"median_offpeak_rate": t["bp_lbl_median_offpeak_rate"],
|
"median_offpeak_rate": t["bp_lbl_median_offpeak_rate"],
|
||||||
"median_occupancy": t["bp_lbl_median_occupancy"],
|
"median_occupancy": t["bp_lbl_median_occupancy"],
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from jinja2 import Environment, FileSystemLoader
|
|||||||
from markupsafe import Markup
|
from markupsafe import Markup
|
||||||
from quart import Blueprint, abort, g, redirect, render_template, request
|
from quart import Blueprint, abort, g, redirect, render_template, request
|
||||||
|
|
||||||
|
from ..analytics import fetch_analytics
|
||||||
from ..core import (
|
from ..core import (
|
||||||
REPO_ROOT,
|
REPO_ROOT,
|
||||||
capture_waitlist_email,
|
capture_waitlist_email,
|
||||||
@@ -203,6 +204,21 @@ async def markets():
|
|||||||
)
|
)
|
||||||
|
|
||||||
articles = await _filter_articles(q, country, region)
|
articles = await _filter_articles(q, country, region)
|
||||||
|
map_countries = await fetch_analytics("""
|
||||||
|
SELECT country_code, country_name_en, country_slug,
|
||||||
|
city_count, total_venues,
|
||||||
|
avg_market_score, avg_opportunity_score,
|
||||||
|
lat, lon
|
||||||
|
FROM serving.pseo_country_overview
|
||||||
|
ORDER BY total_venues DESC
|
||||||
|
""")
|
||||||
|
# Sort so user's country renders last (on top in Leaflet z-order)
|
||||||
|
user_country = g.get("user_country", "")
|
||||||
|
if user_country and map_countries:
|
||||||
|
map_countries = sorted(
|
||||||
|
map_countries,
|
||||||
|
key=lambda c: 1 if c["country_code"] == user_country else 0,
|
||||||
|
)
|
||||||
|
|
||||||
return await render_template(
|
return await render_template(
|
||||||
"markets.html",
|
"markets.html",
|
||||||
@@ -212,6 +228,7 @@ async def markets():
|
|||||||
current_q=q,
|
current_q=q,
|
||||||
current_country=country,
|
current_country=country,
|
||||||
current_region=region,
|
current_region=region,
|
||||||
|
map_countries=map_countries,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -227,9 +244,46 @@ async def market_results():
|
|||||||
return await render_template("partials/market_results.html", articles=articles)
|
return await render_template("partials/market_results.html", articles=articles)
|
||||||
|
|
||||||
|
|
||||||
|
_NEARBY_COUNTRIES: dict[str, tuple[str, ...]] = {
|
||||||
|
"DE": ("AT", "CH"), "AT": ("DE", "CH"), "CH": ("DE", "AT"),
|
||||||
|
"ES": ("PT",), "PT": ("ES",),
|
||||||
|
"GB": ("IE",), "IE": ("GB",),
|
||||||
|
"US": ("CA",), "CA": ("US",),
|
||||||
|
"IT": ("CH",), "FR": ("BE", "CH"), "BE": ("FR", "NL", "DE"),
|
||||||
|
"NL": ("BE", "DE"), "SE": ("NO", "DK", "FI"), "NO": ("SE", "DK"),
|
||||||
|
"DK": ("SE", "NO", "DE"), "FI": ("SE",),
|
||||||
|
"MX": ("US",), "BR": ("AR",), "AR": ("BR",),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _geo_order_clause(user_country: str) -> tuple[str, list]:
|
||||||
|
"""Build ORDER BY clause that sorts user's country first, nearby second.
|
||||||
|
|
||||||
|
Returns (order_sql, params) where order_sql starts with the geo CASE
|
||||||
|
followed by published_at DESC. Caller prepends 'ORDER BY'.
|
||||||
|
"""
|
||||||
|
if not user_country:
|
||||||
|
return "published_at DESC", []
|
||||||
|
|
||||||
|
nearby = _NEARBY_COUNTRIES.get(user_country, ())
|
||||||
|
if nearby:
|
||||||
|
placeholders = ",".join("?" * len(nearby))
|
||||||
|
geo_case = f"""CASE WHEN country = ? THEN 0
|
||||||
|
WHEN country IN ({placeholders}) THEN 1
|
||||||
|
ELSE 2 END,
|
||||||
|
published_at DESC"""
|
||||||
|
return geo_case, [user_country, *nearby]
|
||||||
|
|
||||||
|
return """CASE WHEN country = ? THEN 0 ELSE 1 END,
|
||||||
|
published_at DESC""", [user_country]
|
||||||
|
|
||||||
|
|
||||||
async def _filter_articles(q: str, country: str, region: str) -> list[dict]:
|
async def _filter_articles(q: str, country: str, region: str) -> list[dict]:
|
||||||
"""Query published articles for the current language."""
|
"""Query published articles for the current language, geo-sorted."""
|
||||||
lang = g.get("lang", "en")
|
lang = g.get("lang", "en")
|
||||||
|
user_country = g.get("user_country", "")
|
||||||
|
geo_order, geo_params = _geo_order_clause(user_country)
|
||||||
|
|
||||||
if q:
|
if q:
|
||||||
# FTS query
|
# FTS query
|
||||||
wheres = ["articles_fts MATCH ?"]
|
wheres = ["articles_fts MATCH ?"]
|
||||||
@@ -243,14 +297,16 @@ async def _filter_articles(q: str, country: str, region: str) -> list[dict]:
|
|||||||
wheres.append("a.region = ?")
|
wheres.append("a.region = ?")
|
||||||
params.append(region)
|
params.append(region)
|
||||||
where = " AND ".join(wheres)
|
where = " AND ".join(wheres)
|
||||||
|
# Geo-sort references a.country
|
||||||
|
order = geo_order.replace("country", "a.country")
|
||||||
return await fetch_all(
|
return await fetch_all(
|
||||||
f"""SELECT a.* FROM articles a
|
f"""SELECT a.* FROM articles a
|
||||||
JOIN articles_fts ON articles_fts.rowid = a.id
|
JOIN articles_fts ON articles_fts.rowid = a.id
|
||||||
WHERE {where}
|
WHERE {where}
|
||||||
AND a.status = 'published' AND a.published_at <= datetime('now')
|
AND a.status = 'published' AND a.published_at <= datetime('now')
|
||||||
ORDER BY a.published_at DESC
|
ORDER BY {order}
|
||||||
LIMIT 100""",
|
LIMIT 100""",
|
||||||
tuple(params),
|
tuple(params + geo_params),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
wheres = ["status = 'published'", "published_at <= datetime('now')", "language = ?"]
|
wheres = ["status = 'published'", "published_at <= datetime('now')", "language = ?"]
|
||||||
@@ -264,8 +320,8 @@ async def _filter_articles(q: str, country: str, region: str) -> list[dict]:
|
|||||||
where = " AND ".join(wheres)
|
where = " AND ".join(wheres)
|
||||||
return await fetch_all(
|
return await fetch_all(
|
||||||
f"""SELECT * FROM articles WHERE {where}
|
f"""SELECT * FROM articles WHERE {where}
|
||||||
ORDER BY published_at DESC LIMIT 100""",
|
ORDER BY {geo_order} LIMIT 100""",
|
||||||
tuple(params),
|
tuple(params + geo_params),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,10 @@
|
|||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% 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") }}';
|
||||||
|
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 }}"};
|
||||||
|
</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 %}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ natural_key: city_key
|
|||||||
languages: [de, en]
|
languages: [de, en]
|
||||||
url_pattern: "/markets/{{ country_slug }}/{{ city_slug }}"
|
url_pattern: "/markets/{{ country_slug }}/{{ city_slug }}"
|
||||||
title_pattern: "{% if language == 'de' %}Padel in {{ city_name }} — Investitionskosten & Marktanalyse {{ 'now' | datetimeformat('%Y') }}{% else %}Padel in {{ city_name }} — Investment Costs & Market Analysis {{ 'now' | datetimeformat('%Y') }}{% endif %}"
|
title_pattern: "{% if language == 'de' %}Padel in {{ city_name }} — Investitionskosten & Marktanalyse {{ 'now' | datetimeformat('%Y') }}{% else %}Padel in {{ city_name }} — Investment Costs & Market Analysis {{ 'now' | datetimeformat('%Y') }}{% endif %}"
|
||||||
meta_description_pattern: "{% if language == 'de' %}Lohnt sich eine Padelhalle in {{ city_name }}? {{ padel_venue_count }} Anlagen, padelnomics Market Score {{ market_score | round(1) }}/100 und ein vollständiges Finanzmodell. Stand {{ 'now' | datetimeformat('%B %Y') }}.{% else %}Is {{ city_name }} worth building a padel center in? {{ padel_venue_count }} venues, padelnomics Market Score {{ market_score | round(1) }}/100, and a full financial model. Updated {{ 'now' | datetimeformat('%B %Y') }}.{% endif %}"
|
meta_description_pattern: "{% if language == 'de' %}Lohnt sich eine Padelhalle in {{ city_name }}? {{ padel_venue_count }} Anlagen, Padelnomics Score {{ opportunity_score | round(1) }}/100 und ein vollständiges Finanzmodell. Stand {{ 'now' | datetimeformat('%B %Y') }}.{% else %}Is {{ city_name }} worth building a padel center in? {{ padel_venue_count }} venues, Padelnomics Score {{ opportunity_score | round(1) }}/100, and a full financial model. Updated {{ 'now' | datetimeformat('%B %Y') }}.{% endif %}"
|
||||||
schema_type: [Article, FAQPage]
|
schema_type: [Article, FAQPage]
|
||||||
priority_column: population
|
priority_column: population
|
||||||
---
|
---
|
||||||
@@ -20,15 +20,9 @@ priority_column: population
|
|||||||
<div class="stats-strip__value">{{ padel_venue_count }}</div>
|
<div class="stats-strip__value">{{ padel_venue_count }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-strip__item">
|
<div class="stats-strip__item">
|
||||||
<div class="stats-strip__label"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</div>
|
<div class="stats-strip__label"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score</div>
|
||||||
<div class="stats-strip__value" style="color:{% if market_score >= 55 %}#16A34A{% elif market_score >= 35 %}#D97706{% else %}#DC2626{% endif %}">{{ market_score | round(1) }}<span class="stats-strip__unit">/100</span></div>
|
|
||||||
</div>
|
|
||||||
{% if opportunity_score %}
|
|
||||||
<div class="stats-strip__item">
|
|
||||||
<div class="stats-strip__label"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Opportunity Score</div>
|
|
||||||
<div class="stats-strip__value" style="color:{% if opportunity_score >= 65 %}#16A34A{% elif opportunity_score >= 40 %}#D97706{% else %}#DC2626{% endif %}">{{ opportunity_score | round(1) }}<span class="stats-strip__unit">/100</span></div>
|
<div class="stats-strip__value" style="color:{% if opportunity_score >= 65 %}#16A34A{% elif opportunity_score >= 40 %}#D97706{% else %}#DC2626{% endif %}">{{ opportunity_score | round(1) }}<span class="stats-strip__unit">/100</span></div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
<div class="stats-strip__item">
|
<div class="stats-strip__item">
|
||||||
<div class="stats-strip__label">Spitzenpreis</div>
|
<div class="stats-strip__label">Spitzenpreis</div>
|
||||||
<div class="stats-strip__value">{% if median_peak_rate %}{{ median_peak_rate | round(0) | int }}{% else %}—{% endif %}<span class="stats-strip__unit">{% if median_peak_rate %}{{ price_currency }}/Std{% endif %}</span></div>
|
<div class="stats-strip__value">{% if median_peak_rate %}{{ median_peak_rate | round(0) | int }}{% else %}—{% endif %}<span class="stats-strip__unit">{% if median_peak_rate %}{{ price_currency }}/Std{% endif %}</span></div>
|
||||||
@@ -41,7 +35,7 @@ priority_column: population
|
|||||||
|
|
||||||
<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>
|
<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 %}
|
{{ city_name }} erreicht einen **<a href="/{{ language }}/padelnomics-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score</a> von {{ opportunity_score | round(1) }}/100** — der Score bewertet Investitionspotenzial anhand von Versorgungslücken, Einzugsgebiet, Marktreife und Sportaffinität der Region. {% if opportunity_score >= 65 %}Damit zählt {{ city_name }} zu den vielversprechendsten Standorten in {{ country_name_en }}{% elif opportunity_score >= 40 %}Solides Potenzial — der Markt bietet noch Raum für neue Anlagen{% else %}Der Standort ist vergleichsweise gut versorgt; Differenzierung wird zum Schlüssel{% 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.
|
||||||
|
|
||||||
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 }}.
|
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 }}.
|
||||||
|
|
||||||
@@ -95,16 +89,15 @@ Eine detaillierte Preisanalyse mit Preisspannen und Vergleichsdaten findest Du a
|
|||||||
|----------|------|
|
|----------|------|
|
||||||
| Anlagen | {{ padel_venue_count }} |
|
| Anlagen | {{ padel_venue_count }} |
|
||||||
| Anlagen pro 100K Einwohner | {{ venues_per_100k | round(1) }} |
|
| Anlagen pro 100K Einwohner | {{ venues_per_100k | round(1) }} |
|
||||||
| <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score | {{ market_score | round(1) }}/100 |
|
| <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score | {{ opportunity_score | round(1) }}/100 |
|
||||||
{% if opportunity_score %}| <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Opportunity Score | {{ opportunity_score | round(1) }}/100 |
|
| Datenqualität | {{ (data_confidence * 100) | round(0) | int }}% |
|
||||||
{% endif %}| Datenqualität | {{ (data_confidence * 100) | round(0) | int }}% |
|
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Ist {{ city_name }} ein guter Standort für eine Padelhalle?</summary>
|
<summary>Ist {{ city_name }} ein guter Standort für eine Padelhalle?</summary>
|
||||||
|
|
||||||
{{ city_name }} erreicht **{{ market_score | round(1) }}/100** auf dem <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score, der Anlagendichte, Bevölkerungsgröße und Datenqualität berücksichtigt. {% if market_score >= 55 %}Ein Score über 55 signalisiert einen starken Markt: etablierte Anlagendichte, wachsende Spielerbasis und belastbare Preisdaten. {% elif market_score >= 35 %}Ein mittlerer Score bedeutet solide Grundlagen, aber einen teils stärker umkämpften oder datenlimitierten Markt. {% else %}Ein niedrigerer Score spricht für einen Markt im frühen Aufbau — was gleichzeitig weniger Wettbewerb und First-Mover-Vorteile bedeuten kann. {% endif %}Mit dem [Finanzplaner](/{{ language }}/planner) kannst Du Deine eigenen Annahmen durchrechnen.
|
{{ city_name }} erreicht **{{ opportunity_score | round(1) }}/100** auf dem <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score, der Versorgungslücken, Einzugsgebiet, Marktreife und Sportaffinität der Region bewertet. {% if opportunity_score >= 65 %}Ein Score über 65 signalisiert hohes Investitionspotenzial: relevantes Einzugsgebiet, Versorgungslücken und eine sportaffine Bevölkerung. {% elif opportunity_score >= 40 %}Ein mittlerer Score steht für solides Potenzial — der Markt bietet Raum, erfordert aber sorgfältige Standortwahl. {% else %}Ein niedrigerer Score deutet auf einen bereits gut versorgten Markt — Differenzierung über Lage, Qualität oder Preisgestaltung wird hier entscheidend. {% endif %}Mit dem [Finanzplaner](/{{ language }}/planner) kannst Du Deine eigenen Annahmen durchrechnen.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -134,17 +127,9 @@ Das Gesamtinvestment hängt vom Hallentyp (Indoor vs. Outdoor), Grundstückskost
|
|||||||
<details>
|
<details>
|
||||||
<summary>Wie schneidet {{ city_name }} im Vergleich zu anderen Städten in {{ country_name_en }} ab?</summary>
|
<summary>Wie schneidet {{ city_name }} im Vergleich zu anderen Städten in {{ country_name_en }} ab?</summary>
|
||||||
|
|
||||||
Der <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score von {{ market_score | round(1) }}/100 zeigt {{ city_name }}s Position unter den erfassten Städten in {{ country_name_en }}. In der [Marktübersicht für {{ country_name_en }}](/{{ language }}/markets/{{ country_slug }}) findest Du den Vergleich aller Städte.
|
Der <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score von {{ opportunity_score | round(1) }}/100 zeigt {{ city_name }}s Investitionspotenzial im Vergleich zu anderen Städten in {{ country_name_en }}. In der [Marktübersicht für {{ country_name_en }}](/{{ language }}/markets/{{ country_slug }}) findest Du den Vergleich aller Städte.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
{% if opportunity_score %}
|
|
||||||
<details>
|
|
||||||
<summary>Was ist der Unterschied zwischen Market Score und Opportunity Score?</summary>
|
|
||||||
|
|
||||||
Der **Market Score ({{ market_score | round(1) }}/100)** misst die *Marktreife*: Bevölkerungsgröße, bestehende Anlagendichte und Datenqualität. Ein hoher Wert steht für einen etablierten Markt mit belastbaren Preisdaten — und oft auch für mehr Wettbewerb. Der **Opportunity Score ({{ opportunity_score | round(1) }}/100)** dreht die Logik um: Er bewertet *Investitionspotenzial* anhand von Versorgungslücken, Entfernung zur nächsten Anlage und der Tennisinfrastruktur als Proxy für Racket-Sport-Affinität. Hoher Opportunity Score bei niedrigem Market Score — das ist das klassische White-Space-Signal. Hoher Wert in beiden — bewiesene Nachfrage mit noch offenen Standorten.
|
|
||||||
</details>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
|
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
|
||||||
Bereit für Deine eigene Kalkulation? →
|
Bereit für Deine eigene Kalkulation? →
|
||||||
<a href="/{{ language }}/planner" style="font-weight:600;color:#1D4ED8;margin-left:0.25rem;">Businessplan erstellen</a>
|
<a href="/{{ language }}/planner" style="font-weight:600;color:#1D4ED8;margin-left:0.25rem;">Businessplan erstellen</a>
|
||||||
@@ -162,15 +147,9 @@ Der **Market Score ({{ market_score | round(1) }}/100)** misst die *Marktreife*:
|
|||||||
<div class="stats-strip__value">{{ padel_venue_count }}</div>
|
<div class="stats-strip__value">{{ padel_venue_count }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-strip__item">
|
<div class="stats-strip__item">
|
||||||
<div class="stats-strip__label"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</div>
|
<div class="stats-strip__label"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score</div>
|
||||||
<div class="stats-strip__value" style="color:{% if market_score >= 55 %}#16A34A{% elif market_score >= 35 %}#D97706{% else %}#DC2626{% endif %}">{{ market_score | round(1) }}<span class="stats-strip__unit">/100</span></div>
|
|
||||||
</div>
|
|
||||||
{% if opportunity_score %}
|
|
||||||
<div class="stats-strip__item">
|
|
||||||
<div class="stats-strip__label"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Opportunity Score</div>
|
|
||||||
<div class="stats-strip__value" style="color:{% if opportunity_score >= 65 %}#16A34A{% elif opportunity_score >= 40 %}#D97706{% else %}#DC2626{% endif %}">{{ opportunity_score | round(1) }}<span class="stats-strip__unit">/100</span></div>
|
<div class="stats-strip__value" style="color:{% if opportunity_score >= 65 %}#16A34A{% elif opportunity_score >= 40 %}#D97706{% else %}#DC2626{% endif %}">{{ opportunity_score | round(1) }}<span class="stats-strip__unit">/100</span></div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
<div class="stats-strip__item">
|
<div class="stats-strip__item">
|
||||||
<div class="stats-strip__label">Peak Rate</div>
|
<div class="stats-strip__label">Peak Rate</div>
|
||||||
<div class="stats-strip__value">{% if median_peak_rate %}{{ median_peak_rate | round(0) | int }}{% else %}—{% endif %}<span class="stats-strip__unit">{% if median_peak_rate %}{{ price_currency }}/hr{% endif %}</span></div>
|
<div class="stats-strip__value">{% if median_peak_rate %}{{ median_peak_rate | round(0) | int }}{% else %}—{% endif %}<span class="stats-strip__unit">{% if median_peak_rate %}{{ price_currency }}/hr{% endif %}</span></div>
|
||||||
@@ -183,7 +162,7 @@ Der **Market Score ({{ market_score | round(1) }}/100)** misst die *Marktreife*:
|
|||||||
|
|
||||||
<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>
|
<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 %}
|
{{ city_name }} has a **<a href="/{{ language }}/padelnomics-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score</a> of {{ opportunity_score | round(1) }}/100** — the score evaluates investment potential based on supply gaps, catchment reach, market maturity, and sports culture. {% if opportunity_score >= 65 %}This places {{ city_name }} among the most promising locations in {{ country_name_en }}{% elif opportunity_score >= 40 %}Solid potential — the market still has room for new facilities{% else %}The area is comparatively well-served; differentiation becomes the key lever{% 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.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
@@ -237,16 +216,15 @@ For a detailed pricing breakdown with price ranges and venue comparisons, see th
|
|||||||
|--------|-------|
|
|--------|-------|
|
||||||
| Venues | {{ padel_venue_count }} |
|
| Venues | {{ padel_venue_count }} |
|
||||||
| Venues per 100K residents | {{ venues_per_100k | round(1) }} |
|
| Venues per 100K residents | {{ venues_per_100k | round(1) }} |
|
||||||
| <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score | {{ market_score | round(1) }}/100 |
|
| <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score | {{ opportunity_score | round(1) }}/100 |
|
||||||
{% if opportunity_score %}| <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Opportunity Score | {{ opportunity_score | round(1) }}/100 |
|
| Data Confidence | {{ (data_confidence * 100) | round(0) | int }}% |
|
||||||
{% endif %}| Data Confidence | {{ (data_confidence * 100) | round(0) | int }}% |
|
|
||||||
|
|
||||||
## FAQ
|
## FAQ
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Is {{ city_name }} a good location for a padel center?</summary>
|
<summary>Is {{ city_name }} a good location for a padel center?</summary>
|
||||||
|
|
||||||
{{ city_name }} scores **{{ market_score | round(1) }}/100** on the <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score, which primarily reflects venue density alongside population size and data completeness. {% if market_score >= 55 %}A score above 55 indicates a strong market: established venue density, a growing player base, and solid pricing data. {% elif market_score >= 35 %}A mid-range score means decent fundamentals but a more competitive or data-limited market. {% else %}A lower score reflects an early-stage market — which can also mean lower competition and first-mover advantage. {% endif %}Use the [Padelnomics planner](/{{ language }}/planner) to model your specific assumptions.
|
{{ city_name }} scores **{{ opportunity_score | round(1) }}/100** on the <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score, which evaluates investment potential based on supply gaps, catchment reach, market maturity, and sports culture. {% if opportunity_score >= 65 %}A score above 65 signals strong investment potential: meaningful catchment area, supply gaps, and a sports-oriented population. {% elif opportunity_score >= 40 %}A mid-range score means solid potential — the market has room, but requires careful site selection. {% else %}A lower score indicates a well-served market — differentiation through location, quality, or pricing becomes critical. {% endif %}Use the [Padelnomics planner](/{{ language }}/planner) to model your specific assumptions.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -276,17 +254,9 @@ Total investment depends on venue type (indoor vs outdoor), land costs, and loca
|
|||||||
<details>
|
<details>
|
||||||
<summary>How does {{ city_name }} compare to other {{ country_name_en }} cities?</summary>
|
<summary>How does {{ city_name }} compare to other {{ country_name_en }} cities?</summary>
|
||||||
|
|
||||||
{{ city_name }}'s <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score of {{ market_score | round(1) }}/100 reflects its ranking among tracked {{ country_name_en }} cities. See the [{{ country_name_en }} market overview](/{{ language }}/markets/{{ country_slug }}) for a full comparison across cities.
|
{{ city_name }}'s <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score of {{ opportunity_score | round(1) }}/100 reflects its investment potential among tracked {{ country_name_en }} cities. See the [{{ country_name_en }} market overview](/{{ language }}/markets/{{ country_slug }}) for a full comparison across cities.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
{% if opportunity_score %}
|
|
||||||
<details>
|
|
||||||
<summary>What is the difference between Market Score and Opportunity Score?</summary>
|
|
||||||
|
|
||||||
The **Market Score ({{ market_score | round(1) }}/100)** measures *market maturity*: population size, existing venue density, and data quality. A high score signals an established market with reliable pricing data — and typically more competition. The **Opportunity Score ({{ opportunity_score | round(1) }}/100)** inverts that logic: it scores *investment potential* based on supply gaps, distance to the nearest facility, and tennis infrastructure as a proxy for racket sport demand. High Opportunity Score with a low Market Score is the classic white-space signal. High on both means proven demand with open locations still available.
|
|
||||||
</details>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
|
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
|
||||||
Ready to run the numbers for {{ city_name }}? →
|
Ready to run the numbers for {{ city_name }}? →
|
||||||
<a href="/{{ language }}/planner" style="font-weight:600;color:#1D4ED8;margin-left:0.25rem;">Build your business plan</a>
|
<a href="/{{ language }}/planner" style="font-weight:600;color:#1D4ED8;margin-left:0.25rem;">Build your business plan</a>
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ Die Preisspanne von {{ hourly_rate_p25 | round(0) | int }} bis {{ hourly_rate_p7
|
|||||||
|
|
||||||
## Wie steht {{ city_name }} im Vergleich da?
|
## Wie steht {{ city_name }} im Vergleich da?
|
||||||
|
|
||||||
{{ city_name }} hat {{ padel_venue_count }} Padelanlagen für {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} Einwohner ({{ venues_per_100k | round(1) }} Anlagen pro 100K Einwohner). {% if market_score >= 55 %}Mit einem <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 gehört {{ city_name }} zu den stärksten Padel-Märkten in {{ country_name_en }} — höhere Auslastung und Preise sind typisch für dichte, etablierte Märkte. {% elif market_score >= 35 %}Ein Market Score von {{ market_score | round(1) }}/100 steht für einen Markt im Aufbau: genug Angebot für marktgerechte Preise, aber Raum für neue Anlagen. {% else %}Ein Market Score von {{ market_score | round(1) }}/100 deutet auf einen Markt in der Frühphase hin, in dem sich Preise und Auslastung mit dem Wachstum des Sports noch deutlich entwickeln können. {% endif %}
|
{{ city_name }} hat {{ padel_venue_count }} Padelanlagen für {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} Einwohner ({{ venues_per_100k | round(1) }} Anlagen pro 100K Einwohner). {% if opportunity_score >= 65 %}Mit einem <a href="/{{ language }}/padelnomics-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score</a> von {{ opportunity_score | round(1) }}/100 zählt {{ city_name }} zu den vielversprechendsten Standorten in {{ country_name_en }}. {% elif opportunity_score >= 40 %}Ein <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score von {{ opportunity_score | round(1) }}/100 steht für solides Investitionspotenzial: genug Markt für faire Preise, aber Raum für neue Anlagen. {% else %}Ein <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score von {{ opportunity_score | round(1) }}/100 spricht für einen bereits gut versorgten Markt — Differenzierung über Qualität und Lage wird entscheidend. {% endif %}
|
||||||
|
|
||||||
Die Anlagendichte von {{ venues_per_100k | round(1) }} pro 100K Einwohner beeinflusst die Preisgestaltung direkt: {% if venues_per_100k >= 3.0 %}Höhere Dichte bedeutet mehr Wettbewerb, was die Preise eher stabilisiert oder senkt.{% elif venues_per_100k >= 1.0 %}Moderate Dichte ermöglicht marktgerechte Preise bei gleichzeitigem Wachstumsspielraum.{% else %}Niedrige Dichte gibt Betreibern mehr Preissetzungsmacht — vorausgesetzt, die Nachfrage ist da.{% endif %}
|
Die Anlagendichte von {{ venues_per_100k | round(1) }} pro 100K Einwohner beeinflusst die Preisgestaltung direkt: {% if venues_per_100k >= 3.0 %}Höhere Dichte bedeutet mehr Wettbewerb, was die Preise eher stabilisiert oder senkt.{% elif venues_per_100k >= 1.0 %}Moderate Dichte ermöglicht marktgerechte Preise bei gleichzeitigem Wachstumsspielraum.{% else %}Niedrige Dichte gibt Betreibern mehr Preissetzungsmacht — vorausgesetzt, die Nachfrage ist da.{% endif %}
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ The P25–P75 price range of {{ hourly_rate_p25 | round(0) | int }} to {{ hourly
|
|||||||
|
|
||||||
## How Does {{ city_name }} Compare?
|
## How Does {{ city_name }} Compare?
|
||||||
|
|
||||||
{{ city_name }} has {{ padel_venue_count }} padel venues for a population of {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} ({{ venues_per_100k | round(1) }} venues per 100K residents). {% if market_score >= 55 %}With 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, {{ city_name }} is one of the stronger padel markets in {{ country_name_en }} — higher occupancy and pricing typically follow dense, competitive markets. {% elif market_score >= 35 %}A market score of {{ market_score | round(1) }}/100 reflects a mid-tier market: enough supply to have competitive pricing, but room for new venues to grow. {% else %}A market score of {{ market_score | round(1) }}/100 indicates an early-stage market where pricing and occupancy benchmarks may shift as the sport grows. {% endif %}
|
{{ city_name }} has {{ padel_venue_count }} padel venues for a population of {% if population >= 1000000 %}{{ (population / 1000000) | round(1) }}M{% else %}{{ (population / 1000) | round(0) | int }}K{% endif %} ({{ venues_per_100k | round(1) }} venues per 100K residents). {% if opportunity_score >= 65 %}With a <a href="/{{ language }}/padelnomics-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score</a> of {{ opportunity_score | round(1) }}/100, {{ city_name }} is among the most promising investment locations in {{ country_name_en }}. {% elif opportunity_score >= 40 %}A <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score of {{ opportunity_score | round(1) }}/100 reflects solid investment potential: enough market for competitive pricing, but room for new venues. {% else %}A <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score of {{ opportunity_score | round(1) }}/100 indicates a well-served market — differentiation through quality and location becomes critical. {% endif %}
|
||||||
|
|
||||||
Venue density of {{ venues_per_100k | round(1) }} per 100K residents directly influences pricing: {% if venues_per_100k >= 3.0 %}higher density means more competition, which tends to stabilize or compress prices.{% elif venues_per_100k >= 1.0 %}moderate density supports market-rate pricing with room for growth.{% else %}low density gives operators more pricing power — provided demand exists.{% endif %}
|
Venue density of {{ venues_per_100k | round(1) }} per 100K residents directly influences pricing: {% if venues_per_100k >= 3.0 %}higher density means more competition, which tends to stabilize or compress prices.{% elif venues_per_100k >= 1.0 %}moderate density supports market-rate pricing with room for growth.{% else %}low density gives operators more pricing power — provided demand exists.{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ natural_key: country_slug
|
|||||||
languages: [en, de]
|
languages: [en, de]
|
||||||
url_pattern: "/markets/{{ country_slug }}"
|
url_pattern: "/markets/{{ country_slug }}"
|
||||||
title_pattern: "{% if language == 'de' %}Padel in {{ country_name_en }} — Marktüberblick {{ 'now' | datetimeformat('%Y') }}{% else %}Padel in {{ country_name_en }} — Market Overview {{ 'now' | datetimeformat('%Y') }}{% endif %}"
|
title_pattern: "{% if language == 'de' %}Padel in {{ country_name_en }} — Marktüberblick {{ 'now' | datetimeformat('%Y') }}{% else %}Padel in {{ country_name_en }} — Market Overview {{ 'now' | datetimeformat('%Y') }}{% endif %}"
|
||||||
meta_description_pattern: "{% if language == 'de' %}{{ total_venues }} Padelanlagen in {{ city_count }} Städten in {{ country_name_en }}. padelnomics Market Score, Preisdaten und Investmentanalysen für jede Stadt.{% else %}{{ total_venues }} padel venues across {{ city_count }} cities in {{ country_name_en }}. padelnomics Market Score, pricing data, and investment analysis for each city.{% endif %}"
|
meta_description_pattern: "{% if language == 'de' %}{{ total_venues }} Padelanlagen in {{ city_count }} Städten in {{ country_name_en }}. Padelnomics Score, Preisdaten und Investmentanalysen für jede Stadt.{% else %}{{ total_venues }} padel venues across {{ city_count }} cities in {{ country_name_en }}. Padelnomics Score, pricing data, and investment analysis for each city.{% endif %}"
|
||||||
schema_type: [Article, FAQPage]
|
schema_type: [Article, FAQPage]
|
||||||
priority_column: total_venues
|
priority_column: total_venues
|
||||||
---
|
---
|
||||||
@@ -25,15 +25,9 @@ priority_column: total_venues
|
|||||||
<div class="stats-strip__value">{{ city_count }}</div>
|
<div class="stats-strip__value">{{ city_count }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-strip__item">
|
<div class="stats-strip__item">
|
||||||
<div class="stats-strip__label"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</div>
|
<div class="stats-strip__label"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score</div>
|
||||||
<div class="stats-strip__value" style="color:{% if avg_market_score >= 55 %}#16A34A{% elif avg_market_score >= 35 %}#D97706{% else %}#DC2626{% endif %}">{{ avg_market_score }}<span class="stats-strip__unit">/100</span></div>
|
|
||||||
</div>
|
|
||||||
{% if avg_opportunity_score %}
|
|
||||||
<div class="stats-strip__item">
|
|
||||||
<div class="stats-strip__label"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Opportunity Score</div>
|
|
||||||
<div class="stats-strip__value" style="color:{% if avg_opportunity_score >= 65 %}#16A34A{% elif avg_opportunity_score >= 40 %}#D97706{% else %}#DC2626{% endif %}">{{ avg_opportunity_score }}<span class="stats-strip__unit">/100</span></div>
|
<div class="stats-strip__value" style="color:{% if avg_opportunity_score >= 65 %}#16A34A{% elif avg_opportunity_score >= 40 %}#D97706{% else %}#DC2626{% endif %}">{{ avg_opportunity_score }}<span class="stats-strip__unit">/100</span></div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
<div class="stats-strip__item">
|
<div class="stats-strip__item">
|
||||||
<div class="stats-strip__label">Median Spitzenpreis</div>
|
<div class="stats-strip__label">Median Spitzenpreis</div>
|
||||||
<div class="stats-strip__value">{% if median_peak_rate %}{{ median_peak_rate | int }}{% else %}—{% endif %}<span class="stats-strip__unit">{% if median_peak_rate and price_currency %}{{ price_currency }}/Std{% endif %}</span></div>
|
<div class="stats-strip__value">{% if median_peak_rate %}{{ median_peak_rate | int }}{% else %}—{% endif %}<span class="stats-strip__unit">{% if median_peak_rate and price_currency %}{{ price_currency }}/Std{% endif %}</span></div>
|
||||||
@@ -42,19 +36,17 @@ priority_column: total_venues
|
|||||||
|
|
||||||
<div id="country-map" data-country-slug="{{ country_slug }}" style="height:360px; border-radius:12px; margin-bottom:1.5rem;"></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 %}.
|
In {{ country_name_en }} erfassen wir aktuell **{{ total_venues }} Padelanlagen** in **{{ city_count }} Städten**. Der durchschnittliche <a href="/{{ language }}/padelnomics-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score</a> liegt bei **{{ avg_opportunity_score }}/100** — {% if avg_opportunity_score >= 65 %}hohes Investitionspotenzial mit relevanten Versorgungslücken{% elif avg_opportunity_score >= 40 %}solides Potenzial, der Markt bietet noch Raum für neue Standorte{% else %}ein bereits gut versorgter Markt, der sorgfältige Standortwahl erfordert{% endif %}.
|
||||||
|
|
||||||
## Marktlandschaft
|
## Marktlandschaft
|
||||||
|
|
||||||
Padel wächst in {{ country_name_en }} mit bemerkenswertem Tempo. Unsere Daten zeigen {{ total_venues }} erfasste Anlagen — eine Zahl, die angesichts nicht auf Buchungsplattformen gelisteter Clubs vermutlich noch höher liegt. Der durchschnittliche <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score von {{ avg_market_score }}/100 über {{ city_count }} Städte spiegelt sowohl die Marktreife als auch die Datenverfügbarkeit wider.
|
Padel wächst in {{ country_name_en }} mit bemerkenswertem Tempo. Unsere Daten zeigen {{ total_venues }} erfasste Anlagen — eine Zahl, die angesichts nicht auf Buchungsplattformen gelisteter Clubs vermutlich noch höher liegt. Der durchschnittliche <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score von {{ avg_opportunity_score }}/100 über {{ city_count }} Städte bewertet das Investitionspotenzial anhand von Versorgungslücken, Einzugsgebiet, Marktreife und Sportaffinität.
|
||||||
|
|
||||||
{% if avg_market_score >= 55 %}Märkte mit Scores über 55 weisen in der Regel eine etablierte Spielerbasis, belastbare Preisdaten und berechenbare Nachfragemuster auf — entscheidend für eine solide Finanzplanung. Dennoch bleiben viele Städte unterversorgt: Selbst in reifen Märkten variiert die Anlagendichte pro 100.000 Einwohner erheblich.{% elif avg_market_score >= 35 %}Ein Score im mittleren Bereich deutet auf eine Wachstumsphase hin: Die Nachfrage ist nachweisbar, die Anlageninfrastruktur baut sich auf, und Preise haben sich noch nicht vollständig auf Wettbewerbsniveau eingependelt. Das eröffnet Chancen für gut positionierte Neueintritte.{% else %}Aufstrebende Märkte bieten First-Mover-Vorteile — weniger direkte Konkurrenz, potenziell attraktivere Mietkonditionen und die Möglichkeit, eine loyale Spielerbasis aufzubauen, bevor sich der Markt verdichtet.{% endif %}
|
{% if avg_opportunity_score >= 65 %}Ein Durchschnittsscore über 65 signalisiert relevante Versorgungslücken bei gleichzeitig vorhandener Nachfrage. Selbst in Regionen mit etablierter Padel-Infrastruktur variiert die Anlagendichte pro 100.000 Einwohner erheblich — lokale Analyse lohnt sich.{% elif avg_opportunity_score >= 40 %}Ein Score im mittleren Bereich deutet auf eine Wachstumsphase hin: Die Nachfrage ist nachweisbar, die Anlageninfrastruktur baut sich auf, und gut positionierte Standorte bieten noch Chancen für Neueintritte.{% else %}Viele Standorte in {{ country_name_en }} sind bereits gut versorgt. Neue Projekte brauchen eine sorgfältige Standortanalyse und ein klares Differenzierungsprofil.{% endif %}
|
||||||
|
|
||||||
{% if avg_opportunity_score %}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> Opportunity Score</a> von {{ avg_opportunity_score }}/100** zeigt, wie viel Investitionspotenzial in {{ country_name_en }} noch unerschlossen ist. {% if avg_opportunity_score >= 60 and avg_market_score < 40 %}Die Kombination aus hohem Opportunity Score und moderatem Market Score macht {{ country_name_en }} besonders interessant: Nachfragepotenzial und Sportaffinität sind vorhanden, die Infrastruktur noch im Aufbau — First-Mover-Konditionen für gut gewählte Standorte.{% elif avg_opportunity_score >= 60 %}Trotz eines bereits aktiven Markts gibt es noch Standorte mit erheblichem Potenzial — vor allem in mittelgroßen Städten und an der Peripherie großer Ballungsräume.{% else %}Viele Standorte in {{ country_name_en }} sind bereits gut versorgt. Neue Projekte brauchen eine sorgfältige Standortanalyse und ein klares Differenzierungsprofil.{% endif %}{% endif %}
|
|
||||||
|
|
||||||
## Top-Städte in {{ country_name_en }}
|
## Top-Städte in {{ country_name_en }}
|
||||||
|
|
||||||
Die Rangfolge basiert auf dem <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score — einem Komposit aus Bevölkerungsgröße, Anlagendichte und Datenqualität. Städte mit höherem Score bieten in der Regel größere adressierbare Märkte und belastbarere Benchmarks für die Finanzplanung.
|
Die Rangfolge basiert auf dem <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score — einer Bewertung des Investitionspotenzials anhand von Versorgungslücken, Einzugsgebiet, Marktreife und Sportaffinität. Städte mit höherem Score bieten in der Regel die besten Standortbedingungen für neue Anlagen.
|
||||||
|
|
||||||
| Stadt | Marktanalyse |
|
| Stadt | Marktanalyse |
|
||||||
|-------|-------------|
|
|-------|-------------|
|
||||||
@@ -63,17 +55,6 @@ Die Rangfolge basiert auf dem <span style="font-family:'Bricolage Grotesque',san
|
|||||||
|
|
||||||
Jede Stadtseite enthält detaillierte Preisdaten, eine Kosten-Nutzen-Analyse und einen vorkonfigurierten Finanzplaner mit lokalen Marktdaten als Ausgangswerte.
|
Jede Stadtseite enthält detaillierte Preisdaten, eine Kosten-Nutzen-Analyse und einen vorkonfigurierten Finanzplaner mit lokalen Marktdaten als Ausgangswerte.
|
||||||
|
|
||||||
{% if top_opportunity_names and top_opportunity_names | length > 0 %}
|
|
||||||
## Top-Standorte nach Investitionspotenzial
|
|
||||||
|
|
||||||
Diese Städte erzielen die höchsten Opportunity Scores in {{ country_name_en }} — dort treffen adressierbare Nachfrage auf Versorgungslücken und starke Standortfaktoren. Sie können sich von den Top-Städten nach Market Score unterscheiden: Der Market Score belohnt bestehende Märkte, der Opportunity Score unerschlossenes Potenzial.
|
|
||||||
|
|
||||||
| Stadt | Investmentanalyse |
|
|
||||||
|-------|-------------------|
|
|
||||||
{% for i in range(top_opportunity_names | length) %}| [{{ top_opportunity_names[i] }}](/{{ language }}/markets/{{ country_slug }}/{{ top_opportunity_slugs[i] }}) | [Marktanalyse ansehen →](/{{ language }}/markets/{{ country_slug }}/{{ top_opportunity_slugs[i] }}) |
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
## Preisüberblick
|
## Preisüberblick
|
||||||
|
|
||||||
{% if median_peak_rate %}
|
{% if median_peak_rate %}
|
||||||
@@ -112,7 +93,7 @@ Wir erfassen aktuell **{{ total_venues }} Padelanlagen** in **{{ city_count }} S
|
|||||||
<details>
|
<details>
|
||||||
<summary>Welche Stadt in {{ country_name_en }} eignet sich am besten für eine Padelhalle?</summary>
|
<summary>Welche Stadt in {{ country_name_en }} eignet sich am besten für eine Padelhalle?</summary>
|
||||||
|
|
||||||
Unsere Spitzenstadt nach <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score ist **{{ top_city_names[0] }}** (Score: {{ top_city_market_score }}/100). Der Score kombiniert Bevölkerungsgröße, Anlagendichte und Datenqualität. Eine hohe Punktzahl deutet auf einen großen adressierbaren Markt mit validierten Preisdaten hin. Die beste Stadt für *Dein* Vorhaben hängt aber von Faktoren wie Flächenverfügbarkeit, lokalem Wettbewerb und Deiner Zielgruppe ab. Nutze den <a href="/{{ language }}/planner">Finanzplaner</a>, um verschiedene Standorte durchzurechnen.
|
Unsere Spitzenstadt nach <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score ist **{{ top_city_names[0] }}**. Der Score bewertet Investitionspotenzial anhand von Versorgungslücken, Einzugsgebiet, Marktreife und Sportaffinität. Die beste Stadt für *Dein* Vorhaben hängt aber von Faktoren wie Flächenverfügbarkeit, lokalem Wettbewerb und Deiner Zielgruppe ab. Nutze den <a href="/{{ language }}/planner">Finanzplaner</a>, um verschiedene Standorte durchzurechnen.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -124,23 +105,15 @@ Unsere Spitzenstadt nach <span style="font-family:'Bricolage Grotesque',sans-ser
|
|||||||
<details>
|
<details>
|
||||||
<summary>Wie schnell wächst Padel in {{ country_name_en }}?</summary>
|
<summary>Wie schnell wächst Padel in {{ country_name_en }}?</summary>
|
||||||
|
|
||||||
Padel gehört zu den am schnellsten wachsenden Racketsportarten in Europa. Mit {{ total_venues }} erfassten Anlagen in {{ city_count }} Städten zeigt {{ country_name_en }} {% if avg_market_score >= 55 %}bereits eine reife Infrastruktur — Wachstum kommt hier vor allem aus steigender Spielfrequenz und Premiumangeboten{% elif avg_market_score >= 35 %}eine klare Wachstumsdynamik mit steigender Nachfrage und neuen Anlagen{% else %}ein frühes Wachstumsstadium mit großem Potenzial für Neueintritte{% endif %}. Die Sportart profitiert von niedriger Einstiegshürde, hohem Spaßfaktor und starker Mund-zu-Mund-Verbreitung.
|
Padel gehört zu den am schnellsten wachsenden Racketsportarten in Europa. Mit {{ total_venues }} erfassten Anlagen in {{ city_count }} Städten zeigt {{ country_name_en }} {% if avg_opportunity_score >= 65 %}noch erhebliches Wachstumspotenzial — viele Standorte sind unterversorgt{% elif avg_opportunity_score >= 40 %}eine klare Wachstumsdynamik mit steigender Nachfrage und neuen Anlagen{% else %}bereits eine gut ausgebaute Infrastruktur — Wachstum kommt hier vor allem aus steigender Spielfrequenz und Premiumangeboten{% endif %}. Die Sportart profitiert von niedriger Einstiegshürde, hohem Spaßfaktor und starker Mund-zu-Mund-Verbreitung.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Welche Städte haben die besten Preisdaten?</summary>
|
<summary>Welche Städte haben die besten Preisdaten?</summary>
|
||||||
|
|
||||||
Städte mit höherem <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score (wie {{ top_city_names[0] }}) haben in der Regel die umfassendsten Preisdaten, weil dort mehr Anlagen auf Playtomic gelistet sind. In unserem <a href="/{{ language }}/markets/{{ country_slug }}">{{ country_name_en }}-Marktüberblick</a> findest Du alle Städte nach Market Score sortiert.
|
Städte mit höherem <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score (wie {{ top_city_names[0] }}) haben in der Regel die umfassendsten Preisdaten, weil dort mehr Anlagen auf Playtomic gelistet sind. In unserem <a href="/{{ language }}/markets/{{ country_slug }}">{{ country_name_en }}-Marktüberblick</a> findest Du alle Städte nach Score sortiert.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
{% if avg_opportunity_score %}
|
|
||||||
<details>
|
|
||||||
<summary>Was ist der Unterschied zwischen Market Score und Opportunity Score?</summary>
|
|
||||||
|
|
||||||
Der **Market Score (Ø {{ avg_market_score }}/100)** bewertet die Marktreife: Bevölkerungsgröße, bestehende Anlagendichte und Datenqualität. Der **Opportunity Score (Ø {{ avg_opportunity_score }}/100)** dreht die Logik um: Er misst Investitionspotenzial — Versorgungslücken, Einzugsgebiet und Tennisinfrastruktur als Proxy für Racket-Sport-Affinität. Für Standortentscheidungen ist die Kombination beider Scores am aussagekräftigsten: Hoher Opportunity Score bei niedrigem Market Score signalisiert White-Space-Chancen. Hoher Wert in beiden zeigt Märkte, in denen Nachfrage belegt ist und trotzdem noch offene Standorte existieren.
|
|
||||||
</details>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
|
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
|
||||||
Du überlegst, eine Padelhalle in {{ country_name_en }} zu eröffnen? Rechne Dein Vorhaben mit echten Marktdaten durch →
|
Du überlegst, eine Padelhalle in {{ country_name_en }} zu eröffnen? Rechne Dein Vorhaben mit echten Marktdaten durch →
|
||||||
<a href="/{{ language }}/planner" style="font-weight:600;color:#1D4ED8;margin-left:0.25rem;">Zum Finanzplaner</a>
|
<a href="/{{ language }}/planner" style="font-weight:600;color:#1D4ED8;margin-left:0.25rem;">Zum Finanzplaner</a>
|
||||||
@@ -159,15 +132,9 @@ Der **Market Score (Ø {{ avg_market_score }}/100)** bewertet die Marktreife: Be
|
|||||||
<div class="stats-strip__value">{{ city_count }}</div>
|
<div class="stats-strip__value">{{ city_count }}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stats-strip__item">
|
<div class="stats-strip__item">
|
||||||
<div class="stats-strip__label"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score</div>
|
<div class="stats-strip__label"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score</div>
|
||||||
<div class="stats-strip__value" style="color:{% if avg_market_score >= 55 %}#16A34A{% elif avg_market_score >= 35 %}#D97706{% else %}#DC2626{% endif %}">{{ avg_market_score }}<span class="stats-strip__unit">/100</span></div>
|
|
||||||
</div>
|
|
||||||
{% if avg_opportunity_score %}
|
|
||||||
<div class="stats-strip__item">
|
|
||||||
<div class="stats-strip__label"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Opportunity Score</div>
|
|
||||||
<div class="stats-strip__value" style="color:{% if avg_opportunity_score >= 65 %}#16A34A{% elif avg_opportunity_score >= 40 %}#D97706{% else %}#DC2626{% endif %}">{{ avg_opportunity_score }}<span class="stats-strip__unit">/100</span></div>
|
<div class="stats-strip__value" style="color:{% if avg_opportunity_score >= 65 %}#16A34A{% elif avg_opportunity_score >= 40 %}#D97706{% else %}#DC2626{% endif %}">{{ avg_opportunity_score }}<span class="stats-strip__unit">/100</span></div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
<div class="stats-strip__item">
|
<div class="stats-strip__item">
|
||||||
<div class="stats-strip__label">Median Peak Rate</div>
|
<div class="stats-strip__label">Median Peak Rate</div>
|
||||||
<div class="stats-strip__value">{% if median_peak_rate %}{{ median_peak_rate | int }}{% else %}—{% endif %}<span class="stats-strip__unit">{% if median_peak_rate and price_currency %}{{ price_currency }}/hr{% endif %}</span></div>
|
<div class="stats-strip__value">{% if median_peak_rate %}{{ median_peak_rate | int }}{% else %}—{% endif %}<span class="stats-strip__unit">{% if median_peak_rate and price_currency %}{{ price_currency }}/hr{% endif %}</span></div>
|
||||||
@@ -176,19 +143,17 @@ Der **Market Score (Ø {{ avg_market_score }}/100)** bewertet die Marktreife: Be
|
|||||||
|
|
||||||
<div id="country-map" data-country-slug="{{ country_slug }}" style="height:360px; border-radius:12px; margin-bottom:1.5rem;"></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 %}.
|
{{ country_name_en }} has **{{ total_venues }} padel venues** tracked across **{{ city_count }} cities**. The average <a href="/{{ language }}/padelnomics-score" style="text-decoration:none"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score</a> is **{{ avg_opportunity_score }}/100** — {% if avg_opportunity_score >= 65 %}strong investment potential with meaningful supply gaps{% elif avg_opportunity_score >= 40 %}solid potential with room for new locations{% else %}a well-served market requiring careful site selection{% endif %}.
|
||||||
|
|
||||||
## Market Landscape
|
## Market Landscape
|
||||||
|
|
||||||
Padel is growing rapidly across {{ country_name_en }}. Our data tracks {{ total_venues }} venues — a figure that likely understates the true count given independent clubs not listed on booking platforms. The average <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score of {{ avg_market_score }}/100 across {{ city_count }} cities reflects both market maturity and data availability.
|
Padel is growing rapidly across {{ country_name_en }}. Our data tracks {{ total_venues }} venues — a figure that likely understates the true count given independent clubs not listed on booking platforms. The average <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score of {{ avg_opportunity_score }}/100 across {{ city_count }} cities evaluates investment potential based on supply gaps, catchment reach, market maturity, and sports culture.
|
||||||
|
|
||||||
{% if avg_market_score >= 55 %}Markets scoring above 55 typically show an established player base, reliable pricing data, and predictable demand patterns — all critical for sound financial planning. Yet even in mature markets, venue density per 100,000 residents varies significantly between cities, leaving genuine supply gaps even in established markets.{% elif avg_market_score >= 35 %}A mid-range score signals a growth phase: demand is proven, venue infrastructure is building, and pricing hasn't fully settled to competitive levels. This creates opportunities for well-positioned new entrants who can secure good locations before the market matures.{% else %}Emerging markets offer first-mover advantages — less direct competition, potentially better lease terms, and the opportunity to build a loyal player base before the market fills out. The trade-off is less pricing data and more uncertainty in demand projections.{% endif %}
|
{% if avg_opportunity_score >= 65 %}A score above 65 signals meaningful supply gaps alongside existing demand. Even in regions with established padel infrastructure, venue density per 100,000 residents varies significantly between cities — local analysis pays off.{% elif avg_opportunity_score >= 40 %}A mid-range score signals a growth phase: demand is proven, venue infrastructure is building, and well-positioned locations still offer opportunities for new entrants.{% else %}Many locations in {{ country_name_en }} are already well-served. New projects need careful site selection and a clear differentiation strategy to compete.{% endif %}
|
||||||
|
|
||||||
{% if avg_opportunity_score %}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> Opportunity Score</a> of {{ avg_opportunity_score }}/100** shows how much investment potential remains untapped in {{ country_name_en }}. {% if avg_opportunity_score >= 60 and avg_market_score < 40 %}The combination of a high Opportunity Score and a moderate Market Score makes {{ country_name_en }} particularly attractive for new entrants: demand potential and sports culture are there, infrastructure is still building — first-mover conditions for well-chosen locations.{% elif avg_opportunity_score >= 60 %}Despite an already active market, locations with significant potential remain — particularly in mid-size cities and at the periphery of major metro areas.{% else %}Many locations in {{ country_name_en }} are already well-served. New projects need careful site selection and a clear differentiation strategy to compete.{% endif %}{% endif %}
|
|
||||||
|
|
||||||
## Top Cities in {{ country_name_en }}
|
## Top Cities in {{ country_name_en }}
|
||||||
|
|
||||||
Cities are ranked by <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score — a composite of population size, venue density, and data quality. Higher-scoring cities generally offer larger addressable markets and more reliable benchmarks for financial planning.
|
Cities are ranked by <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score — evaluating investment potential based on supply gaps, catchment reach, market maturity, and sports culture. Higher-scoring cities generally offer the strongest conditions for new facilities.
|
||||||
|
|
||||||
| City | Market Analysis |
|
| City | Market Analysis |
|
||||||
|------|----------------|
|
|------|----------------|
|
||||||
@@ -197,17 +162,6 @@ Cities are ranked by <span style="font-family:'Bricolage Grotesque',sans-serif;f
|
|||||||
|
|
||||||
Each city page includes detailed pricing data, a cost-benefit analysis, and a pre-configured financial planner seeded with local market data.
|
Each city page includes detailed pricing data, a cost-benefit analysis, and a pre-configured financial planner seeded with local market data.
|
||||||
|
|
||||||
{% if top_opportunity_names and top_opportunity_names | length > 0 %}
|
|
||||||
## Top Locations by Investment Potential
|
|
||||||
|
|
||||||
These cities score highest on the Opportunity Score in {{ country_name_en }} — where addressable demand meets supply gaps and strong location fundamentals. They may differ from the top Market Score cities: the Market Score rewards established markets, the Opportunity Score rewards untapped potential.
|
|
||||||
|
|
||||||
| City | Market Analysis |
|
|
||||||
|------|----------------|
|
|
||||||
{% for i in range(top_opportunity_names | length) %}| [{{ top_opportunity_names[i] }}](/{{ language }}/markets/{{ country_slug }}/{{ top_opportunity_slugs[i] }}) | [View investment analysis →](/{{ language }}/markets/{{ country_slug }}/{{ top_opportunity_slugs[i] }}) |
|
|
||||||
{% endfor %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
## Pricing Overview
|
## Pricing Overview
|
||||||
|
|
||||||
{% if median_peak_rate %}
|
{% if median_peak_rate %}
|
||||||
@@ -246,7 +200,7 @@ We currently track **{{ total_venues }} padel venues** across **{{ city_count }}
|
|||||||
<details>
|
<details>
|
||||||
<summary>Which city in {{ country_name_en }} is best for a padel center?</summary>
|
<summary>Which city in {{ country_name_en }} is best for a padel center?</summary>
|
||||||
|
|
||||||
Our top-ranked city by <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score is **{{ top_city_names[0] }}** (score: {{ top_city_market_score }}/100). The <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score combines population size, existing venue density, and data quality — a high score indicates a large addressable market with validated pricing data. However, the best city for *you* depends on land availability, local competition, and your target customer profile. Use the <a href="/{{ language }}/planner">financial planner</a> to model different locations side by side.
|
Our top-ranked city by <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Score is **{{ top_city_names[0] }}**. The score evaluates investment potential based on supply gaps, catchment reach, market maturity, and sports culture. However, the best city for *you* depends on land availability, local competition, and your target customer profile. Use the <a href="/{{ language }}/planner">financial planner</a> to model different locations side by side.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
@@ -258,23 +212,15 @@ Our top-ranked city by <span style="font-family:'Bricolage Grotesque',sans-serif
|
|||||||
<details>
|
<details>
|
||||||
<summary>How fast is padel growing in {{ country_name_en }}?</summary>
|
<summary>How fast is padel growing in {{ country_name_en }}?</summary>
|
||||||
|
|
||||||
Padel is one of the fastest-growing racquet sports in Europe. With {{ total_venues }} venues tracked across {{ city_count }} cities, {{ country_name_en }} shows {% if avg_market_score >= 55 %}a mature infrastructure — growth here comes mainly from increasing play frequency and premium offerings{% elif avg_market_score >= 35 %}clear growth momentum with rising demand and new venues opening{% else %}early-stage growth with significant potential for new entrants{% endif %}. The sport benefits from a low barrier to entry, high enjoyment factor, and strong word-of-mouth growth among players.
|
Padel is one of the fastest-growing racquet sports in Europe. With {{ total_venues }} venues tracked across {{ city_count }} cities, {{ country_name_en }} shows {% if avg_opportunity_score >= 65 %}significant untapped potential — many locations remain underserved{% elif avg_opportunity_score >= 40 %}clear growth momentum with rising demand and new venues opening{% else %}a well-developed infrastructure — growth comes mainly from increasing play frequency and premium offerings{% endif %}. The sport benefits from a low barrier to entry, high enjoyment factor, and strong word-of-mouth growth among players.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Which cities have the best pricing data?</summary>
|
<summary>Which cities have the best pricing data?</summary>
|
||||||
|
|
||||||
Cities with higher <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Scores (like {{ top_city_names[0] }}) typically have the most comprehensive pricing data, because more venues are listed on Playtomic. Browse our <a href="/{{ language }}/markets/{{ country_slug }}">{{ country_name_en }} market overview</a> to see all cities ranked by <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Market Score.
|
Cities with higher <span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> Scores (like {{ top_city_names[0] }}) typically have the most comprehensive pricing data, because more venues are listed on Playtomic. Browse our <a href="/{{ language }}/markets/{{ country_slug }}">{{ country_name_en }} market overview</a> to see all cities ranked by score.
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
{% if avg_opportunity_score %}
|
|
||||||
<details>
|
|
||||||
<summary>What is the difference between Market Score and Opportunity Score?</summary>
|
|
||||||
|
|
||||||
The **Market Score (avg. {{ avg_market_score }}/100)** measures market maturity: population size, existing venue density, and data quality. The **Opportunity Score (avg. {{ avg_opportunity_score }}/100)** inverts that logic: it scores investment potential based on supply gaps, catchment reach, and tennis infrastructure as a proxy for racket sport demand. For site selection, the combination of both scores is the most informative signal. A high Opportunity Score with a low Market Score points to white-space locations. High on both means proven demand with open sites still available.
|
|
||||||
</details>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
|
<div style="background:#EFF6FF;border:1px solid #BFDBFE;border-radius:12px;padding:1rem 1.25rem;margin:1.5rem 0;">
|
||||||
Considering a padel center in {{ country_name_en }}? Model your investment with real market data →
|
Considering a padel center in {{ country_name_en }}? Model your investment with real market data →
|
||||||
<a href="/{{ language }}/planner" style="font-weight:600;color:#1D4ED8;margin-left:0.25rem;">Open the Planner</a>
|
<a href="/{{ language }}/planner" style="font-weight:600;color:#1D4ED8;margin-left:0.25rem;">Open the Planner</a>
|
||||||
|
|||||||
@@ -16,7 +16,23 @@
|
|||||||
<p class="text-slate">{{ t.mkt_subheading }}</p>
|
<p class="text-slate">{{ t.mkt_subheading }}</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div id="markets-map" style="height:420px; border-radius:12px;" class="mb-6"></div>
|
<div id="markets-map" style="height:420px; border-radius:12px;" class="mb-4"></div>
|
||||||
|
|
||||||
|
<!-- Map legend -->
|
||||||
|
<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.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><20
|
||||||
|
</span>
|
||||||
|
<span style="display:flex; align-items:center; gap:0.35rem;">
|
||||||
|
<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:18px; height:18px; border-radius:50%; background:#64748B; border:2px solid white; box-shadow:0 1px 3px rgba(0,0,0,0.2);"></span>
|
||||||
|
{{ t.mkt_legend_size }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="card mb-8">
|
<div class="card mb-8">
|
||||||
@@ -68,51 +84,37 @@
|
|||||||
|
|
||||||
{% 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>
|
||||||
|
window.__MAP_T = {score_label:"{{ t.map_score_label }}",venues:"{{ t.map_venues }}",cities:"{{ t.map_cities }}"};
|
||||||
(function() {
|
(function() {
|
||||||
|
var sc = PNMarkers.scoreColor;
|
||||||
|
var T = window.__MAP_T;
|
||||||
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: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
||||||
maxZoom: 18
|
maxZoom: 18
|
||||||
}).addTo(map);
|
}).addTo(map);
|
||||||
|
|
||||||
function scoreColor(score) {
|
var data = {{ map_countries | tojson }};
|
||||||
if (score >= 60) return '#16A34A';
|
if (data.length) {
|
||||||
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.82;"></div>',
|
|
||||||
iconSize: [s, s],
|
|
||||||
iconAnchor: [s / 2, s / 2],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
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 maxV = Math.max.apply(null, data.map(function(d) { return d.total_venues; }));
|
||||||
var lang = document.documentElement.lang || 'en';
|
var lang = document.documentElement.lang || 'en';
|
||||||
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 score = c.avg_opportunity_score || 0;
|
||||||
var oppColor = c.avg_opportunity_score >= 60 ? '#16A34A' : (c.avg_opportunity_score >= 30 ? '#D97706' : '#3B82F6');
|
var hex = sc(score);
|
||||||
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>'
|
+ '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + hex + ';vertical-align:middle;margin-right:4px;"></span>'
|
||||||
+ '<span style="color:' + color + ';font-weight:600;">Padelnomics Market Score: ' + c.avg_market_score + '/100</span><br>'
|
+ '<span style="color:' + hex + ';font-weight:600;">' + T.score_label + ': ' + score + '/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 + ' ' + T.venues + ' · ' + c.city_count + ' ' + T.cities + '</span>';
|
||||||
L.marker([c.lat, c.lon], { icon: makeIcon(size, color) })
|
L.marker([c.lat, c.lon], { icon: PNMarkers.makeIcon({ size: size, color: hex }) })
|
||||||
.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);
|
||||||
});
|
});
|
||||||
});
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -606,6 +606,20 @@
|
|||||||
"mkt_all_countries": "Alle Länder",
|
"mkt_all_countries": "Alle Länder",
|
||||||
"mkt_all_regions": "Alle Regionen",
|
"mkt_all_regions": "Alle Regionen",
|
||||||
"mkt_no_results": "Keine Märkte gefunden. Passe Deine Filter an.",
|
"mkt_no_results": "Keine Märkte gefunden. Passe Deine Filter an.",
|
||||||
|
"mkt_legend_size": "Kreisgröße = Anzahl Anlagen",
|
||||||
|
"mkt_legend_color": "Farbe = Padelnomics Score",
|
||||||
|
"map_score_label": "Padelnomics Score",
|
||||||
|
"map_venues": "Anlagen",
|
||||||
|
"map_pop": "Einw.",
|
||||||
|
"map_click_explore": "Klicken für Details →",
|
||||||
|
"map_coming_soon": "Demnächst",
|
||||||
|
"map_courts": "Plätze",
|
||||||
|
"map_indoor": "Indoor",
|
||||||
|
"map_outdoor": "Outdoor",
|
||||||
|
"map_cities": "Städte",
|
||||||
|
"map_existing_venues": "bestehende Anlagen",
|
||||||
|
"map_km_nearest": "km zur nächsten Anlage",
|
||||||
|
"map_no_nearby": "Keine Anlagen in der Nähe",
|
||||||
"waitlist_markets_title": "Marktdaten — Demnächst verfügbar",
|
"waitlist_markets_title": "Marktdaten — Demnächst verfügbar",
|
||||||
"waitlist_markets_sub": "Detaillierte Marktberichte für Padel-Investoren: Baukosten, Umsatz-Benchmarks, Auslastungsdaten und ROI-Analysen nach Stadt und Region.",
|
"waitlist_markets_sub": "Detaillierte Marktberichte für Padel-Investoren: Baukosten, Umsatz-Benchmarks, Auslastungsdaten und ROI-Analysen nach Stadt und Region.",
|
||||||
"waitlist_markets_feature1": "Echte Kostendaten aus laufenden Anlagen in über 30 Ländern",
|
"waitlist_markets_feature1": "Echte Kostendaten aus laufenden Anlagen in über 30 Ländern",
|
||||||
@@ -1587,7 +1601,7 @@
|
|||||||
"bp_lbl_funded_by_equity": "Eigenfinanziert",
|
"bp_lbl_funded_by_equity": "Eigenfinanziert",
|
||||||
"bp_lbl_total": "Gesamt",
|
"bp_lbl_total": "Gesamt",
|
||||||
"bp_lbl_venues_per_100k": "Anlagen je 100.000 Einwohner",
|
"bp_lbl_venues_per_100k": "Anlagen je 100.000 Einwohner",
|
||||||
"bp_lbl_market_score": "Markt-Score",
|
"bp_lbl_padelnomics_score": "Padelnomics Score",
|
||||||
"bp_lbl_median_peak_rate": "Median Hauptzeit-Preis",
|
"bp_lbl_median_peak_rate": "Median Hauptzeit-Preis",
|
||||||
"bp_lbl_median_offpeak_rate": "Median Nebenzeit-Preis",
|
"bp_lbl_median_offpeak_rate": "Median Nebenzeit-Preis",
|
||||||
"bp_lbl_median_occupancy": "Median-Auslastung",
|
"bp_lbl_median_occupancy": "Median-Auslastung",
|
||||||
@@ -1688,80 +1702,54 @@
|
|||||||
"email_business_plan_preheader": "Professioneller Padel-Finanzplan — jetzt herunterladen",
|
"email_business_plan_preheader": "Professioneller Padel-Finanzplan — jetzt herunterladen",
|
||||||
"email_footer_tagline": "Die Planungsplattform für Padel-Unternehmer",
|
"email_footer_tagline": "Die Planungsplattform für Padel-Unternehmer",
|
||||||
"email_footer_copyright": "© {year} {app_name}. Du erhältst diese E-Mail, weil du ein Konto hast oder eine Anfrage gestellt hast.",
|
"email_footer_copyright": "© {year} {app_name}. Du erhältst diese E-Mail, weil du ein Konto hast oder eine Anfrage gestellt hast.",
|
||||||
"footer_market_score": "Market Score",
|
"footer_padelnomics_score": "Padelnomics Score",
|
||||||
"mscore_page_title": "Der padelnomics Market Score — So messen wir Marktpotenzial",
|
"pnscore_page_title": "Padelnomics Score — So bewerten wir Padel-Investitionsstandorte",
|
||||||
"mscore_meta_desc": "Der padelnomics Market Score bewertet Städte von 0 bis 100 nach ihrem Potenzial für Padel-Investitionen. Erfahre, wie Demografie, Wirtschaftskraft, Nachfragesignale und Datenabdeckung einfließen.",
|
"pnscore_meta_desc": "Der Padelnomics Score bewertet das Investitionspotenzial von Padel-Standorten in Europa. Versorgungslücken, Einzugsgebiet, Marktreife und Sportaffinität in einem Score von 0-100.",
|
||||||
"mscore_og_desc": "Ein datengestützter Komposit-Score (0–100), der die Attraktivität einer Stadt für Padelanlagen-Investitionen misst. Was steckt dahinter — und was bedeutet er für Deine Planung?",
|
"pnscore_og_desc": "Ein Score, der zeigt, wo sich eine Padelhalle lohnt. Methodik, Komponenten und Datenquellen erklärt.",
|
||||||
"mscore_h1": "Der padelnomics Market Score",
|
"pnscore_subtitle": "Ein Score für Padel-Investitionspotenzial — Versorgungslücken, Einzugsgebiet, Marktreife und Sportaffinität auf einer Skala von 0 bis 100.",
|
||||||
"mscore_subtitle": "Ein datengestütztes Maß für die Attraktivität einer Stadt als Padel-Investitionsstandort.",
|
"pnscore_what_h2": "Was ist der Padelnomics Score?",
|
||||||
"mscore_dual_h2": "Zwei Scores, zwei Fragen",
|
"pnscore_what_intro": "Der Padelnomics Score ist ein Komposit-Index von 0 bis 100, der bewertet, wie attraktiv ein Standort für eine neue Padelanlage ist. Er kombiniert angebotsseitige Lücken (gibt es genug Courts?) mit nachfrageseitigen Signalen (Bevölkerung, Einkommen, Sportaffinität) und berücksichtigt die Marktreife. Ein hoher Score bedeutet: Es gibt adressierbare Nachfrage, das Gebiet ist unterversorgt und die Rahmenbedingungen begünstigen ein Investment.",
|
||||||
"mscore_dual_intro": "Padelnomics veröffentlicht zwei eigenständige Scores für jeden Markt. Sie beantworten unterschiedliche Fragen und basieren auf unterschiedlichen Methoden — beide zu kennen ist entscheidend für eine fundierte Investitionsentscheidung.",
|
"pnscore_components_h2": "Was der Score misst",
|
||||||
"mscore_reife_chip": "padelnomics Marktreife-Score™",
|
"pnscore_components_intro": "Sechs gewichtete Komponenten fließen in den Gesamtscore ein. Jede erfasst einen anderen Aspekt des Investitionspotenzials.",
|
||||||
"mscore_reife_question": "Wie etabliert ist dieser Padel-Markt?",
|
"pnscore_cat_market_h3": "Adressierbarer Markt (15 Pkt)",
|
||||||
"mscore_reife_desc": "Berechnet für Städte mit mindestens einer Padelanlage. Kombiniert Bevölkerungsgröße, Wirtschaftskraft, Nachfragesignale aus Buchungsplattformen und Datenvollständigkeit.",
|
"pnscore_cat_market_p": "Einzugsgebiet-Bevölkerung im Umkreis von ~24 km (H3 Res-5-Zelle + Nachbarn). Wurzelskaliert — ein Einzugsgebiet von 1 Mio. erreicht das Maximum. Größeres Einzugsgebiet bedeutet mehr potenzielle Spieler.",
|
||||||
"mscore_potenzial_chip": "padelnomics Marktpotenzial-Score™",
|
"pnscore_cat_econ_h3": "Wirtschaftskraft (10 Pkt)",
|
||||||
"mscore_potenzial_question": "Wo sollte ich eine Padelanlage bauen?",
|
"pnscore_cat_econ_p": "Regionales Einkommen in Kaufkraftstandards (KKS). Höheres verfügbares Einkommen stützt Premium-Preise und häufigeres Spielen. Daten von Eurostat (EU), Census (USA), ONS (UK).",
|
||||||
"mscore_potenzial_desc": "Berechnet für alle Standorte weltweit, auch dort, wo es noch keine Anlagen gibt. Angebotslücken, unterversorgte Einzugsgebiete und Schlägersportkultur schlagen positiv zu Buche — die entscheidenden Signale für Erstinvestitionen.",
|
"pnscore_cat_gap_h3": "Versorgungslücke (50 Pkt)",
|
||||||
"mscore_what_h2": "Marktreife-Score: Was er misst",
|
"pnscore_cat_gap_p": "Die gewichtigste Komponente. Misst zwei Signale: Anlagendichte-Lücke (wie weit unter 5 Courts pro 100K?) und Entfernungslücke (wie weit zur nächsten Anlage?). Gedämpft nach Marktreife — Länder mit wenigen oder keinen Padel-Anlagen erhalten reduzierten Punktwert, da eine Versorgungslücke ohne nachgewiesene Nachfrage spekulativ ist. Voller Punktwert erst ab 50+ Anlagen im Land.",
|
||||||
"mscore_what_intro": "Der padelnomics Marktreife-Score ist ein Komposit-Index von 0 bis 100, der bewertet, wie etabliert und attraktiv ein bestehender Padel-Markt ist. Er gilt ausschließlich für Städte mit mindestens einer Padelanlage — vier Datenkategorien fließen in eine einzige Kennzahl ein, damit Du schnell einschätzen kannst, welche Märkte sich genauer anzuschauen lohnen.",
|
"pnscore_cat_sports_h3": "Sportaffinität (10 Pkt)",
|
||||||
"mscore_cat_demo_h3": "Demografie",
|
"pnscore_cat_sports_p": "Tennisplatz-Dichte im Umkreis von 25 km als Proxy für Racketsport-Affinität. Regionen mit starker Tennis-Infrastruktur haben ein bereites Publikum für Padel — einen eng verwandten Sport mit niedrigerer Einstiegshürde.",
|
||||||
"mscore_cat_demo_p": "Bevölkerungsgröße als Indikator für den adressierbaren Markt. Größere Städte tragen in der Regel mehr Anlagen und höhere Auslastung.",
|
"pnscore_cat_catchment_h3": "Baukosten-Erschwinglichkeit (5 Pkt)",
|
||||||
"mscore_cat_econ_h3": "Wirtschaftskraft",
|
"pnscore_cat_catchment_p": "Einkommen relativ zu lokalen Baukosten (Eurostat-Preisniveau-Index). Höhere Erschwinglichkeit bedeutet bessere Margen beim Bau — das Umsatzpotenzial wird nicht von den Baukosten aufgefressen.",
|
||||||
"mscore_cat_econ_p": "Regionale Kaufkraft und Einkommensindikatoren. In Märkten mit höherem verfügbarem Einkommen ist die Nachfrage nach Freizeitsportarten wie Padel tendenziell stärker.",
|
"pnscore_cat_maturity_h3": "Markt-Spielraum (10 Pkt)",
|
||||||
"mscore_cat_demand_h3": "Nachfrageindikatoren",
|
"pnscore_cat_maturity_p": "Invers zur durchschnittlichen Marktreife des Landes. Länder mit bereits gesättigten Märkten (z.B. Spanien) erhalten hier weniger Punkte — der nationale Markt ist wettbewerbsintensiv. Aufstrebende Märkte punkten höher — mehr Raum zum Wachsen.",
|
||||||
"mscore_cat_demand_p": "Signale aus dem laufenden Betrieb bestehender Anlagen — Auslastungsraten, Buchungsdaten, Anzahl aktiver Standorte. Wo sich reale Nachfrage bereits messen lässt, ist das der stärkste Indikator.",
|
"pnscore_read_h2": "Wie man den Score liest",
|
||||||
"mscore_cat_data_h3": "Datenqualität",
|
"pnscore_band_high_label": "80+ — Ausgezeichnet",
|
||||||
"mscore_cat_data_p": "Wie umfassend die Datenlage für eine Stadt ist. Ein Score auf Basis unvollständiger Daten ist weniger belastbar — wir machen das transparent, damit Du weißt, wo eigene Recherche sinnvoll ist.",
|
"pnscore_band_high_p": "Erstklassiges Investitionspotenzial. Erhebliche Versorgungslücken, starkes Einzugsgebiet und günstige Marktbedingungen. Diese Standorte sind erste Wahl für neue Anlagen.",
|
||||||
"mscore_read_h2": "Marktreife-Score: Wie Du ihn liest",
|
"pnscore_band_good_label": "60-79 — Gut",
|
||||||
"mscore_band_high_label": "70–100: Starker Markt",
|
"pnscore_band_good_p": "Starkes Potenzial bei etwas mehr Wettbewerb oder kleinerem Einzugsgebiet. Weiterhin attraktiv für gut positionierte Projekte mit klarer Differenzierungsstrategie.",
|
||||||
"mscore_band_high_p": "Große Bevölkerung, hohe Wirtschaftskraft und nachgewiesene Nachfrage durch bestehende Anlagen. Diese Städte haben validierte Padel-Märkte mit belastbaren Benchmarks für die Finanzplanung.",
|
"pnscore_band_mid_label": "40-59 — Moderat",
|
||||||
"mscore_band_mid_label": "45–69: Solides Mittelfeld",
|
"pnscore_band_mid_p": "Solide Grundlagen, aber der Markt ist teilweise versorgt. Erfolg hängt von präziser Standortwahl, Preisgestaltung und Anlagenqualität ab.",
|
||||||
"mscore_band_mid_p": "Gute Grundlagen mit Wachstumspotenzial. Genug Daten für fundierte Planung, aber weniger Wettbewerb als in den Top-Städten. Häufig der Sweet Spot für Neueinsteiger.",
|
"pnscore_band_low_label": "20-39 — Unterdurchschnittlich",
|
||||||
"mscore_band_low_label": "Unter 45: Früher Markt",
|
"pnscore_band_low_p": "Das Gebiet ist vergleichsweise gut versorgt oder zeigt schwächere Nachfragesignale. Neue Anlagen stehen im stärkeren Wettbewerb und brauchen ein überzeugendes Konzept.",
|
||||||
"mscore_band_low_p": "Weniger validierte Daten oder kleinere Bevölkerung. Das heißt nicht, dass die Stadt unattraktiv ist — es kann weniger Wettbewerb und bessere Konditionen für Früheinsteiger bedeuten. Rechne mit mehr eigener Recherche vor Ort.",
|
"pnscore_sources_h2": "Datenquellen",
|
||||||
"mscore_read_note": "Ein niedriger Score bedeutet nicht automatisch eine schlechte Investition. Er kann auf begrenzte Datenlage oder einen noch jungen Markt hinweisen — weniger Wettbewerb und günstigere Einstiegsbedingungen sind möglich.",
|
"pnscore_sources_p": "Der Score nutzt mehrere offene und proprietäre Datenquellen: GeoNames (globale Städtebevölkerung, 140K+ Standorte), Eurostat (regionales Einkommen, Preisniveaus, NUTS-2-Grenzen), US Census und ONS UK (Bevölkerung/Einkommen außerhalb der EU), OpenStreetMap via Overpass (Padel- und Tennisplatz-Standorte) sowie Playtomic (Anlagenverzeichnisse, Court-Zahlen). Alle Daten werden regelmäßig aktualisiert und durch unsere SQLMesh-Pipeline verarbeitet.",
|
||||||
"mscore_sources_h2": "Datenquellen",
|
"pnscore_limits_h2": "Einschränkungen",
|
||||||
"mscore_sources_p": "Der Market Score basiert auf Daten europäischer Statistikämter (Bevölkerung und Wirtschaftsindikatoren), Buchungsplattformen für Padelanlagen (Standortanzahl, Preise, Auslastung) und geografischen Datenbanken (Standortdaten). Die Daten werden monatlich aktualisiert.",
|
"pnscore_limits_p1": "Der Score bewertet Standortpotenzial, nicht Projektdurchführbarkeit. Er kann standortspezifische Faktoren wie Grundstücksverfügbarkeit, Bebauungsvorschriften, Mietkosten oder lokale Wettbewerbsdetails nicht berücksichtigen. Den Score immer mit eigener Vor-Ort-Recherche kombinieren.",
|
||||||
"mscore_limits_h2": "Einschränkungen",
|
"pnscore_limits_p2": "Die Datenabdeckung variiert nach Land. Europäische Märkte haben die stärkste Datenbasis (Eurostat-Einkommen, dichte Anlagenverzeichnisse). Aufstrebende Märkte haben teils weniger granulare Einkommensdaten, was die Komponenten Wirtschaftskraft und Erschwinglichkeit beeinflusst.",
|
||||||
"mscore_limits_p1": "Der Score bildet die verfügbare Datenlage ab, nicht die absolute Marktwahrheit. Städte, in denen weniger Anlagen auf Buchungsplattformen erfasst sind, können bei den Nachfrageindikatoren niedrigere Werte zeigen — selbst wenn die lokale Nachfrage hoch ist.",
|
"pnscore_cta_markets": "Märkte entdecken",
|
||||||
"mscore_limits_p2": "Der Score berücksichtigt keine lokalen Faktoren wie Immobilienkosten, Genehmigungszeiträume, Wettbewerbsdynamik oder regulatorische Rahmenbedingungen. Diese Aspekte sind entscheidend und erfordern Recherche vor Ort.",
|
"pnscore_cta_planner": "Finanzplaner",
|
||||||
"mscore_limits_p3": "Nutze den Market Score als Ausgangspunkt für die Priorisierung, nicht als finale Investitionsentscheidung. Im Finanzplaner kannst Du Dein konkretes Szenario durchrechnen.",
|
"pnscore_faq_h2": "Häufige Fragen",
|
||||||
"mscore_cta_markets": "Stadtbewertungen ansehen",
|
"pnscore_faq_q1": "Was ist der Padelnomics Score?",
|
||||||
"mscore_cta_planner": "Investment modellieren",
|
"pnscore_faq_a1": "Ein Komposit-Index von 0 bis 100, der bewertet, wie attraktiv ein Standort für den Bau einer neuen Padelanlage ist. Er kombiniert Versorgungslücken, Einzugsgebiet, Wirtschaftskraft, Sportaffinität, Baukosten-Erschwinglichkeit und Markt-Spielraum in einer einzigen Kennzahl.",
|
||||||
"mscore_faq_h2": "Häufig gestellte Fragen",
|
"pnscore_faq_q2": "Wie oft wird der Score aktualisiert?",
|
||||||
"mscore_faq_q1": "Was ist der padelnomics Market Score?",
|
"pnscore_faq_a2": "Der Score wird täglich neu berechnet. Anlagenverzeichnisse, Bevölkerungsdaten und Einkommensstatistiken werden in ihren jeweiligen Intervallen aktualisiert (täglich für Anlagen, monatlich für Bevölkerung, jährlich für Einkommen).",
|
||||||
"mscore_faq_a1": "Ein Komposit-Index von 0 bis 100, der die Attraktivität einer Stadt für Padelanlagen-Investitionen misst. Er kombiniert Demografie, Wirtschaftskraft, Nachfrageindikatoren und Datenqualität in einer vergleichbaren Kennzahl.",
|
"pnscore_faq_q3": "Warum hat eine Stadt mit vielen Padel-Courts einen niedrigen Score?",
|
||||||
"mscore_faq_q2": "Wie oft wird der Score aktualisiert?",
|
"pnscore_faq_a3": "Der Score belohnt unterversorgte Gebiete. Eine Stadt mit hoher Court-Dichte relativ zur Bevölkerung hat eine geringe Versorgungslücke — die gewichtigste Komponente (40 von 100 Punkten). Gut versorgte Städte können dennoch moderat punkten, wenn andere Faktoren (Einzugsgebiet, Wirtschaftskraft) stark sind.",
|
||||||
"mscore_faq_a2": "Monatlich. Neue Daten aus Statistikämtern, Buchungsplattformen und Standortdatenbanken werden regelmäßig extrahiert und verarbeitet. Der Score spiegelt immer die aktuellsten verfügbaren Daten wider.",
|
"pnscore_faq_q4": "Wie beeinflusst Marktreife den Score?",
|
||||||
"mscore_faq_q3": "Warum hat meine Stadt einen niedrigen Score?",
|
"pnscore_faq_a4": "Marktreife fließt auf zwei Wegen ein: Die Versorgungslücke misst direkt die lokale Court-Dichte, und der Markt-Spielraum gewichtet die landesweite Marktreife invers. Länder mit dominantem Padel-Markt (z.B. Spanien) bieten weniger Spielraum als aufstrebende Märkte.",
|
||||||
"mscore_faq_a3": "Meist wegen begrenzter Datenabdeckung oder geringerer Bevölkerung. Ein niedriger Score bedeutet nicht, dass die Stadt unattraktiv ist — sondern dass uns weniger Daten zur Quantifizierung der Chance vorliegen. Eigene Recherche kann die Lücken schließen.",
|
"pnscore_faq_q5": "Kann ich den Score für meinen Businessplan verwenden?",
|
||||||
"mscore_faq_q4": "Kann ich Scores länderübergreifend vergleichen?",
|
"pnscore_faq_a5": "Ja — der Score ist als Screening-Tool für die Standortsuche konzipiert. Nutze ihn, um vielversprechende Standorte vorzuselektieren, und vertiefe dann mit dem Finanzplaner die Umsatz-, Kosten- und Renditeanalyse für Dein konkretes Szenario.",
|
||||||
"mscore_faq_a4": "Ja. Die Methodik ist für alle Märkte einheitlich, sodass ein Score von 72 in Deutschland direkt vergleichbar ist mit einem 72 in Spanien oder Großbritannien.",
|
|
||||||
"mscore_faq_q5": "Garantiert ein hoher Score eine gute Investition?",
|
|
||||||
"mscore_faq_a5": "Nein. Der Score misst die Marktattraktivität auf Makroebene. Deine konkrete Investition hängt von Anlagentyp, Baukosten, Mietkonditionen und Dutzenden weiterer Faktoren ab. Im Finanzplaner kannst Du Dein Szenario mit echten Zahlen durchrechnen.",
|
|
||||||
"mscore_pot_what_h2": "Marktpotenzial-Score: Was er misst",
|
|
||||||
"mscore_pot_what_intro": "Der padelnomics Marktpotenzial-Score bewertet Investitionschancen an Standorten mit wenig oder gar keiner bestehenden Padel-Infrastruktur. Er erfasst alle Standorte weltweit — auch solche ohne eine einzige Anlage. Gedacht für Erstinvestoren auf der Suche nach unbestellten Märkten, nicht für den Vergleich bereits erschlossener Standorte.",
|
|
||||||
"mscore_pot_cat_market_h3": "Adressierbarer Markt",
|
|
||||||
"mscore_pot_cat_market_p": "Logarithmisch skalierte Bevölkerungsgröße, begrenzt auf 500.000 Einwohner. Das Potenzial ist bei mittelgroßen Städten am höchsten — groß genug für eine rentable Anlage, aber noch nicht von Großstadt-Betreibern erschlossen.",
|
|
||||||
"mscore_pot_cat_econ_h3": "Wirtschaftskraft",
|
|
||||||
"mscore_pot_cat_econ_p": "Kaufkraft auf Länderebene (KKS), normiert auf internationale Benchmarks. Maßgeblich für die Zahlungsbereitschaft bei Platzmieten im Zielbereich von 20–35 €/Std.",
|
|
||||||
"mscore_pot_cat_gap_h3": "Angebotslücke",
|
|
||||||
"mscore_pot_cat_gap_p": "Invertierte Anlagendichte: null Plätze pro 100.000 Einwohner ergibt die volle Punktzahl. Das ist das zentrale Signal, das den Marktpotenzial-Score vom Marktreife-Score unterscheidet — der weiße Fleck auf der Karte ist die Chance.",
|
|
||||||
"mscore_pot_cat_catchment_h3": "Versorgungslücke",
|
|
||||||
"mscore_pot_cat_catchment_p": "Entfernung zur nächsten bestehenden Padelanlage. Standorte mehr als 30 km vom nächsten Platz entfernt erhalten die volle Punktzahl — echte Versorgungslücken ohne nahe gelegene Alternative.",
|
|
||||||
"mscore_pot_cat_tennis_h3": "Schlägersportkultur",
|
|
||||||
"mscore_pot_cat_tennis_p": "Tennisplätze im Umkreis von 25 km als Indikator für etablierte Schlägersportnachfrage. Viele neue Padelanlagen entstehen innerhalb bestehender Tennisvereine oder direkt daneben — ein verlässlicher Frühindikator.",
|
|
||||||
"mscore_pot_read_h2": "Marktpotenzial-Score: So liest Du ihn",
|
|
||||||
"mscore_pot_band_high_label": "70–100: Hohes Potenzial",
|
|
||||||
"mscore_pot_band_high_p": "Unterversorgtes Gebiet mit solider Bevölkerungsstruktur und Kaufkraft. Geringes Angebot, weit entfernt von der nächsten Anlage, nachgewiesene Schlägersportkultur. Hohe Priorität für Erstinvestoren.",
|
|
||||||
"mscore_pot_band_mid_label": "45–69: Moderates Potenzial",
|
|
||||||
"mscore_pot_band_mid_p": "Teilweise bereits vorhandenes Angebot, demografische Einschränkungen oder unklare Signallage. Lohnt sich für eine genauere Prüfung — lokale Faktoren können das Bild erheblich verändern.",
|
|
||||||
"mscore_pot_band_low_label": "Unter 45: Geringeres Potenzial",
|
|
||||||
"mscore_pot_band_low_p": "Markt bereits gut versorgt, Bevölkerungszahl gering oder Kaufkraft begrenzt. Konzentriere Dich auf höher bewertete Standorte — es sei denn, Du hast einen konkreten lokalen Vorteil.",
|
|
||||||
"mscore_faq_q6": "Was ist der Unterschied zwischen dem padelnomics Marktreife-Score und dem padelnomics Marktpotenzial-Score?",
|
|
||||||
"mscore_faq_a6": "Der padelnomics Marktreife-Score misst, wie etabliert und ausgereift ein bestehender Padel-Markt ist — er gilt nur für Städte mit mindestens einer Anlage. Der padelnomics Marktpotenzial-Score bewertet Investitionschancen in noch unbestellten Märkten und erfasst alle Standorte weltweit. Angebotslücken und unterversorgte Einzugsgebiete fließen positiv ein — auch dort, wo es noch gar keine Anlagen gibt.",
|
|
||||||
"mscore_faq_q7": "Warum hat mein Ort einen hohen padelnomics Marktpotenzial-Score, aber keine Padelanlagen?",
|
|
||||||
"mscore_faq_a7": "Genau darum geht es. Ein hoher padelnomics Marktpotenzial-Score signalisiert einen unterversorgten Standort: solide Bevölkerungsbasis, wirtschaftliche Kaufkraft, kein bestehendes Angebot und weite Entfernung zur nächsten Anlage. Das sind genau die Signale, die auf eine Pionierchance hinweisen — kein Zeichen für einen schwachen Markt.",
|
|
||||||
"sup_cta_btn": "Kostenlos starten",
|
"sup_cta_btn": "Kostenlos starten",
|
||||||
"sup_basic_free_label": "Kostenlos",
|
"sup_basic_free_label": "Kostenlos",
|
||||||
"sup_pricing_eur_note": "Alle Preise in EUR",
|
"sup_pricing_eur_note": "Alle Preise in EUR",
|
||||||
|
|||||||
@@ -606,6 +606,20 @@
|
|||||||
"mkt_all_countries": "All Countries",
|
"mkt_all_countries": "All Countries",
|
||||||
"mkt_all_regions": "All Regions",
|
"mkt_all_regions": "All Regions",
|
||||||
"mkt_no_results": "No markets found. Try adjusting your filters.",
|
"mkt_no_results": "No markets found. Try adjusting your filters.",
|
||||||
|
"mkt_legend_size": "Bubble size = venue count",
|
||||||
|
"mkt_legend_color": "Color = Padelnomics Score",
|
||||||
|
"map_score_label": "Padelnomics Score",
|
||||||
|
"map_venues": "venues",
|
||||||
|
"map_pop": "pop",
|
||||||
|
"map_click_explore": "Click to explore →",
|
||||||
|
"map_coming_soon": "Coming soon",
|
||||||
|
"map_courts": "courts",
|
||||||
|
"map_indoor": "indoor",
|
||||||
|
"map_outdoor": "outdoor",
|
||||||
|
"map_cities": "cities",
|
||||||
|
"map_existing_venues": "existing venues",
|
||||||
|
"map_km_nearest": "km to nearest court",
|
||||||
|
"map_no_nearby": "No nearby courts",
|
||||||
"waitlist_markets_title": "Markets Intelligence — Coming Soon",
|
"waitlist_markets_title": "Markets Intelligence — Coming Soon",
|
||||||
"waitlist_markets_sub": "Deep-dive market reports for padel investors: construction costs, revenue benchmarks, occupancy data, and ROI analysis by city and region.",
|
"waitlist_markets_sub": "Deep-dive market reports for padel investors: construction costs, revenue benchmarks, occupancy data, and ROI analysis by city and region.",
|
||||||
"waitlist_markets_feature1": "Real cost data from operating venues across 30+ countries",
|
"waitlist_markets_feature1": "Real cost data from operating venues across 30+ countries",
|
||||||
@@ -1615,7 +1629,7 @@
|
|||||||
"bp_lbl_funded_by_equity": "Funded by Equity",
|
"bp_lbl_funded_by_equity": "Funded by Equity",
|
||||||
"bp_lbl_total": "Total",
|
"bp_lbl_total": "Total",
|
||||||
"bp_lbl_venues_per_100k": "Venues per 100K population",
|
"bp_lbl_venues_per_100k": "Venues per 100K population",
|
||||||
"bp_lbl_market_score": "Market Score",
|
"bp_lbl_padelnomics_score": "Padelnomics Score",
|
||||||
"bp_lbl_median_peak_rate": "Median Peak Rate",
|
"bp_lbl_median_peak_rate": "Median Peak Rate",
|
||||||
"bp_lbl_median_offpeak_rate": "Median Off-Peak Rate",
|
"bp_lbl_median_offpeak_rate": "Median Off-Peak Rate",
|
||||||
"bp_lbl_median_occupancy": "Median Occupancy",
|
"bp_lbl_median_occupancy": "Median Occupancy",
|
||||||
@@ -1719,80 +1733,54 @@
|
|||||||
"email_business_plan_preheader": "Professional padel facility financial plan — download now",
|
"email_business_plan_preheader": "Professional padel facility financial plan — download now",
|
||||||
"email_footer_tagline": "The padel business planning platform",
|
"email_footer_tagline": "The padel business planning platform",
|
||||||
"email_footer_copyright": "© {year} {app_name}. You received this email because you have an account or submitted a request.",
|
"email_footer_copyright": "© {year} {app_name}. You received this email because you have an account or submitted a request.",
|
||||||
"footer_market_score": "Market Score",
|
"footer_padelnomics_score": "Padelnomics Score",
|
||||||
"mscore_page_title": "The padelnomics Market Score — How We Measure Market Potential",
|
"pnscore_page_title": "Padelnomics Score — How We Rate Padel Investment Locations",
|
||||||
"mscore_meta_desc": "The padelnomics Market Score rates cities from 0 to 100 on their potential for padel investment. Learn how demographics, economic strength, demand signals, and data coverage feed into the score.",
|
"pnscore_meta_desc": "The Padelnomics Score evaluates investment potential for padel locations across Europe. Learn how supply gaps, catchment area, market maturity, and sports culture combine into a single 0-100 score.",
|
||||||
"mscore_og_desc": "A data-driven composite score (0–100) that measures how attractive a city is for padel court investment. See what goes into it and what it means for your planning.",
|
"pnscore_og_desc": "A single score that tells you where to build a padel center. Methodology, components, and data sources explained.",
|
||||||
"mscore_h1": "The padelnomics Market Score",
|
"pnscore_subtitle": "One score to evaluate padel investment potential — supply gaps, catchment area, market maturity, and sports culture combined into 0-100.",
|
||||||
"mscore_subtitle": "A data-driven measure of how attractive a city is for padel investment.",
|
"pnscore_what_h2": "What Is the Padelnomics Score?",
|
||||||
"mscore_dual_h2": "Two Scores, Two Questions",
|
"pnscore_what_intro": "The Padelnomics Score is a 0-100 composite index that evaluates how attractive a location is for a new padel facility. It combines supply-side gaps (are there enough courts?) with demand-side signals (population, income, sports culture) and adjusts for market maturity. A high score means: there is addressable demand, the area is underserved, and conditions favor a new investment.",
|
||||||
"mscore_dual_intro": "Padelnomics publishes two distinct scores for every market. They answer different questions and are calculated using different methodologies — knowing both is essential for a well-informed investment decision.",
|
"pnscore_components_h2": "What It Measures",
|
||||||
"mscore_reife_chip": "padelnomics Marktreife-Score™",
|
"pnscore_components_intro": "Six weighted components combine into the final score. Each captures a different aspect of investment potential.",
|
||||||
"mscore_reife_question": "How established is this padel market?",
|
"pnscore_cat_market_h3": "Addressable Market (15 pts)",
|
||||||
"mscore_reife_desc": "Calculated for cities with at least one padel venue. Combines population size, economic power, demand evidence from booking platforms, and data completeness.",
|
"pnscore_cat_market_p": "Catchment population within ~24 km (H3 res-5 cell + neighbors). Square-root scaled — a catchment of 1M scores the maximum. Larger catchment means more potential players.",
|
||||||
"mscore_potenzial_chip": "padelnomics Marktpotenzial-Score™",
|
"pnscore_cat_econ_h3": "Economic Power (10 pts)",
|
||||||
"mscore_potenzial_question": "Where should I build a padel court?",
|
"pnscore_cat_econ_p": "Regional income in purchasing power standard (PPS). Higher disposable income supports premium pricing and more frequent play. Data from Eurostat (EU), Census (US), ONS (UK).",
|
||||||
"mscore_potenzial_desc": "Calculated for all locations globally, including those with zero courts. Rewards supply gaps, underserved catchment areas, and racket sport culture — the signals that matter for greenfield investors.",
|
"pnscore_cat_gap_h3": "Supply Deficit (50 pts)",
|
||||||
"mscore_what_h2": "Marktreife-Score: What It Measures",
|
"pnscore_cat_gap_p": "The single biggest component. Measures two signals: court density gap (how far below 5 courts per 100K?) and distance gap (how far to the nearest existing court?). Dampened by market existence — countries with few or no padel venues get reduced credit, since a supply gap without proven demand is speculative. Full credit requires 50+ venues nationally.",
|
||||||
"mscore_what_intro": "The padelnomics Marktreife-Score is a composite index from 0 to 100 that evaluates how established and attractive an existing padel market is. It only applies to cities with at least one padel venue, combining four categories of data into a single number designed to help you prioritize markets worth investigating further.",
|
"pnscore_cat_sports_h3": "Sports Culture (10 pts)",
|
||||||
"mscore_cat_demo_h3": "Demographics",
|
"pnscore_cat_sports_p": "Tennis court density within 25 km as a proxy for racquet sport adoption. Regions with strong tennis infrastructure have a ready audience for padel — a closely related sport with a lower barrier to entry.",
|
||||||
"mscore_cat_demo_p": "Population size as a proxy for the addressable market. Larger cities generally support more venues and higher utilization.",
|
"pnscore_cat_catchment_h3": "Construction Affordability (5 pts)",
|
||||||
"mscore_cat_econ_h3": "Economic Strength",
|
"pnscore_cat_catchment_p": "Income relative to local construction costs (Eurostat Price Level Index). Higher affordability means better margins on the build — your revenue potential isn’t eaten by construction costs.",
|
||||||
"mscore_cat_econ_p": "Regional purchasing power and income indicators. Markets where people have higher disposable income tend to sustain stronger demand for leisure sports like padel.",
|
"pnscore_cat_maturity_h3": "Market Headroom (10 pts)",
|
||||||
"mscore_cat_demand_h3": "Demand Evidence",
|
"pnscore_cat_maturity_p": "Inverse of the country’s average market maturity. Countries with already saturated markets (e.g. Spain) score lower here — the national market is competitive. Emerging markets score higher — more room to grow.",
|
||||||
"mscore_cat_demand_p": "Signals from existing venue activity — occupancy rates, booking data, and the number of operating venues. Where real demand is already measurable, it’s the strongest indicator.",
|
"pnscore_read_h2": "How to Read the Score",
|
||||||
"mscore_cat_data_h3": "Data Completeness",
|
"pnscore_band_high_label": "80+ — Excellent",
|
||||||
"mscore_cat_data_p": "How much data we have for that city. A score influenced by incomplete data is less reliable — we surface this explicitly so you know when to dig deeper on your own.",
|
"pnscore_band_high_p": "Top-tier investment potential. Significant supply gaps, strong catchment, and favorable market conditions. These locations are prime targets for new facilities.",
|
||||||
"mscore_read_h2": "Marktreife-Score: How To Read",
|
"pnscore_band_good_label": "60-79 — Good",
|
||||||
"mscore_band_high_label": "70–100: Strong market",
|
"pnscore_band_good_p": "Strong potential with some competition or smaller catchment. Still attractive for well-positioned projects with a clear differentiation strategy.",
|
||||||
"mscore_band_high_p": "Large population, economic power, and proven demand from existing venues. These cities have validated padel markets with reliable benchmarks for financial planning.",
|
"pnscore_band_mid_label": "40-59 — Moderate",
|
||||||
"mscore_band_mid_label": "45–69: Solid mid-tier",
|
"pnscore_band_mid_p": "Decent fundamentals but the market is partially served. Success depends on precise site selection, pricing, and facility quality.",
|
||||||
"mscore_band_mid_p": "Good fundamentals with room for growth. Enough data to plan with confidence, but less competition than top-tier cities. Often the sweet spot for new entrants.",
|
"pnscore_band_low_label": "20-39 — Below Average",
|
||||||
"mscore_band_low_label": "Below 45: Early-stage market",
|
"pnscore_band_low_p": "The area is comparatively well-served or has limited demand signals. New facilities face stiffer competition and need a strong value proposition.",
|
||||||
"mscore_band_low_p": "Less validated data or smaller populations. This does not mean a city is a bad investment — it may mean less competition and first-mover advantage. Expect to do more local research.",
|
"pnscore_sources_h2": "Data Sources",
|
||||||
"mscore_read_note": "A lower score does not mean a city is a bad investment. It may indicate less available data or a market still developing — which can mean less competition and better terms for early entrants.",
|
"pnscore_sources_p": "The score draws on multiple open and proprietary data sources: GeoNames (global city population, 140K+ locations), Eurostat (regional income, price levels, NUTS-2 boundaries), US Census and ONS UK (population/income outside EU), OpenStreetMap via Overpass (padel and tennis court locations), and Playtomic (venue listings, court counts). All data is refreshed on a regular schedule and processed through our SQLMesh pipeline.",
|
||||||
"mscore_sources_h2": "Data Sources",
|
"pnscore_limits_h2": "Limitations",
|
||||||
"mscore_sources_p": "The Market Score draws on data from European statistical offices (population and economic indicators), court booking platforms (venue counts, pricing, occupancy), and geographic databases (venue locations). Data is refreshed monthly as new extractions run.",
|
"pnscore_limits_p1": "The score evaluates location-level potential, not project-level feasibility. It cannot account for site-specific factors like land availability, zoning, lease costs, or local competition details. Always combine the score with on-the-ground research.",
|
||||||
"mscore_limits_h2": "Limitations",
|
"pnscore_limits_p2": "Data coverage varies by country. European markets have the strongest data (Eurostat income, dense venue listings). Emerging markets may have less granular income data, which affects the economic power and affordability components.",
|
||||||
"mscore_limits_p1": "The score reflects available data, not absolute market truth. Cities where fewer venues are tracked on booking platforms may score lower on demand evidence — even if local demand is strong.",
|
"pnscore_cta_markets": "Explore Markets",
|
||||||
"mscore_limits_p2": "The score does not account for local factors like real estate costs, permitting timelines, competitive dynamics, or regulatory environment. These matter enormously and require on-the-ground research.",
|
"pnscore_cta_planner": "Financial Planner",
|
||||||
"mscore_limits_p3": "Use the Market Score as a starting point for prioritization, not a final investment decision. The financial planner is where you model your specific scenario.",
|
"pnscore_faq_h2": "Frequently Asked Questions",
|
||||||
"mscore_cta_markets": "Browse city scores",
|
"pnscore_faq_q1": "What is the Padelnomics Score?",
|
||||||
"mscore_cta_planner": "Model your investment",
|
"pnscore_faq_a1": "A composite 0-100 index that evaluates how attractive a location is for building a new padel facility. It combines supply gaps, catchment population, economic power, sports culture, construction affordability, and market headroom into a single number.",
|
||||||
"mscore_faq_h2": "Frequently Asked Questions",
|
"pnscore_faq_q2": "How often is the score updated?",
|
||||||
"mscore_faq_q1": "What is the padelnomics Market Score?",
|
"pnscore_faq_a2": "The score is recalculated daily as new data flows through our pipeline. Venue listings, population data, and income statistics are refreshed on their respective schedules (daily for venues, monthly for population, annually for income).",
|
||||||
"mscore_faq_a1": "A composite index from 0 to 100 that measures how attractive a city is for padel court investment. It combines demographics, economic strength, demand evidence, and data completeness into a single comparable number.",
|
"pnscore_faq_q3": "Why does a city with many padel courts score low?",
|
||||||
"mscore_faq_q2": "How often is the score updated?",
|
"pnscore_faq_a3": "The score rewards underserved areas. A city with high court density relative to population has a small supply deficit — the biggest component (40 of 100 points). Well-served cities can still score moderately if other factors (catchment, economics) are strong.",
|
||||||
"mscore_faq_a2": "Monthly. New data from statistical offices, booking platforms, and venue databases is extracted and processed on a regular cycle. Scores reflect the most recent available data.",
|
"pnscore_faq_q4": "How does market maturity affect the score?",
|
||||||
"mscore_faq_q3": "Why is my city’s score low?",
|
"pnscore_faq_a4": "Market maturity is captured in two ways: the supply deficit component directly measures local court density, and the market headroom component inversely weighs country-level maturity. Countries where padel is already dominant (like Spain) provide less headroom than emerging markets.",
|
||||||
"mscore_faq_a3": "Usually because of limited data coverage or smaller population. A low score doesn’t mean the city is unattractive — it means we have less data to quantify the opportunity. Local research can fill the gaps.",
|
"pnscore_faq_q5": "Can I use the score for my business plan?",
|
||||||
"mscore_faq_q4": "Can I compare scores across countries?",
|
"pnscore_faq_a5": "Yes — the score is designed as a screening tool for site selection. Use it to shortlist promising locations, then dive deeper with the financial planner to model revenue, costs, and returns for your specific scenario.",
|
||||||
"mscore_faq_a4": "Yes. The methodology is consistent across all markets we track, so a score of 72 in Germany is directly comparable to a 72 in Spain or the UK.",
|
|
||||||
"mscore_faq_q5": "Does a high score guarantee a good investment?",
|
|
||||||
"mscore_faq_a5": "No. The score measures market attractiveness at a macro level. Your specific investment depends on venue type, build costs, lease terms, and dozens of other factors. Use the financial planner to model your scenario with real numbers.",
|
|
||||||
"mscore_pot_what_h2": "Marktpotenzial-Score: What It Measures",
|
|
||||||
"mscore_pot_what_intro": "The padelnomics Marktpotenzial-Score evaluates investment opportunity for locations with little or no existing padel infrastructure. It covers all locations globally, including those with zero courts — designed for greenfield investors scouting white-space markets, not for benchmarking established venues.",
|
|
||||||
"mscore_pot_cat_market_h3": "Addressable Market",
|
|
||||||
"mscore_pot_cat_market_p": "Log-scaled population, capped at 500K. Opportunity peaks in mid-size cities that can support a court but are not yet served by large-city operators.",
|
|
||||||
"mscore_pot_cat_econ_h3": "Economic Power",
|
|
||||||
"mscore_pot_cat_econ_p": "Country-level purchasing power (PPS), normalised to international benchmarks. Drives willingness to pay for court fees in the €20–35/hr target range.",
|
|
||||||
"mscore_pot_cat_gap_h3": "Supply Gap",
|
|
||||||
"mscore_pot_cat_gap_p": "Inverted venue density: zero courts per 100K residents earns full marks. This is the key signal separating the Marktpotenzial-Score from the Marktreife-Score — white space is the opportunity.",
|
|
||||||
"mscore_pot_cat_catchment_h3": "Catchment Gap",
|
|
||||||
"mscore_pot_cat_catchment_p": "Distance to the nearest existing padel court. Locations more than 30km from any court score maximum points — they represent genuinely underserved catchment areas with no nearby alternative.",
|
|
||||||
"mscore_pot_cat_tennis_h3": "Racket Sport Culture",
|
|
||||||
"mscore_pot_cat_tennis_p": "Tennis courts within 25km as a proxy for established racket sport demand. Many new padel facilities open inside or next to existing tennis clubs, making this a reliable lead indicator.",
|
|
||||||
"mscore_pot_read_h2": "Marktpotenzial-Score: How To Read",
|
|
||||||
"mscore_pot_band_high_label": "70–100: High potential",
|
|
||||||
"mscore_pot_band_high_p": "Underserved area with strong demographics and economic fundamentals. Low supply, significant catchment gap, and proven racket sport culture. Priority market for greenfield investment.",
|
|
||||||
"mscore_pot_band_mid_label": "45–69: Moderate potential",
|
|
||||||
"mscore_pot_band_mid_p": "Some supply already exists, demographic limitations, or mixed signals. Worth investigating further — local factors may significantly change the picture.",
|
|
||||||
"mscore_pot_band_low_label": "Below 45: Lower potential",
|
|
||||||
"mscore_pot_band_low_p": "Market is already well-served, population is small, or economic purchasing power is limited. Focus resources on higher-scoring locations unless you have a specific local advantage.",
|
|
||||||
"mscore_faq_q6": "What is the difference between the padelnomics Marktreife-Score and the padelnomics Marktpotenzial-Score?",
|
|
||||||
"mscore_faq_a6": "The padelnomics Marktreife-Score measures how established and mature an existing padel market is — it only applies to cities with at least one venue. The padelnomics Marktpotenzial-Score measures greenfield investment opportunity and covers all locations globally, rewarding supply gaps and underserved catchment areas where no courts exist yet.",
|
|
||||||
"mscore_faq_q7": "Why does my town have a high padelnomics Marktpotenzial-Score but no padel courts?",
|
|
||||||
"mscore_faq_a7": "That is exactly the point. A high padelnomics Marktpotenzial-Score indicates an underserved location: strong demographics, economic purchasing power, no existing supply, and distance from the nearest court. These are precisely the signals that suggest a greenfield opportunity — not a sign of a weak market.",
|
|
||||||
|
|
||||||
"report_q1_eyebrow": "Global Market Intelligence",
|
"report_q1_eyebrow": "Global Market Intelligence",
|
||||||
"report_q1_meta_description": "77,355 padel courts worldwide, +29% in 18 months. The most complete independent market report on global padel — FIP, Playtomic/PwC, and the Padelnomics data pipeline.",
|
"report_q1_meta_description": "77,355 padel courts worldwide, +29% in 18 months. The most complete independent market report on global padel — FIP, Playtomic/PwC, and the Padelnomics data pipeline.",
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ Public domain: landing page, marketing pages, legal pages, feedback.
|
|||||||
"""
|
"""
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from quart import Blueprint, abort, g, render_template, request, session
|
from quart import Blueprint, abort, g, redirect, render_template, request, session, url_for
|
||||||
|
|
||||||
from ..analytics import fetch_analytics
|
from ..analytics import fetch_analytics
|
||||||
from ..core import check_rate_limit, count_where, csrf_protect, execute, fetch_all, fetch_one
|
from ..core import check_rate_limit, count_where, csrf_protect, execute, fetch_all, fetch_one
|
||||||
@@ -67,9 +67,14 @@ async def about():
|
|||||||
return await render_template("about.html")
|
return await render_template("about.html")
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/padelnomics-score")
|
||||||
|
async def padelnomics_score():
|
||||||
|
return await render_template("padelnomics_score.html")
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/market-score")
|
@bp.route("/market-score")
|
||||||
async def market_score():
|
async def market_score():
|
||||||
return await render_template("market_score.html")
|
return redirect(url_for("public.padelnomics_score"), 301)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/opportunity-map")
|
@bp.route("/opportunity-map")
|
||||||
@@ -79,12 +84,63 @@ async def opportunity_map():
|
|||||||
if not await is_flag_enabled("maps", default=True):
|
if not await is_flag_enabled("maps", default=True):
|
||||||
abort(404)
|
abort(404)
|
||||||
countries = await fetch_analytics("""
|
countries = await fetch_analytics("""
|
||||||
SELECT DISTINCT country_slug, country_name_en
|
SELECT DISTINCT country_slug, country_name_en, country_code
|
||||||
FROM serving.location_profiles
|
FROM serving.location_profiles
|
||||||
WHERE city_slug IS NOT NULL
|
WHERE city_slug IS NOT NULL
|
||||||
ORDER BY country_name_en
|
ORDER BY country_name_en
|
||||||
""")
|
""")
|
||||||
return await render_template("opportunity_map.html", countries=countries)
|
user_cc = g.get("user_country", "")
|
||||||
|
selected_slug = ""
|
||||||
|
if user_cc:
|
||||||
|
for c in countries:
|
||||||
|
if c["country_code"] == user_cc:
|
||||||
|
selected_slug = c["country_slug"]
|
||||||
|
break
|
||||||
|
countries = sorted(
|
||||||
|
countries,
|
||||||
|
key=lambda c: (0 if c["country_code"] == user_cc else 1, c["country_name_en"]),
|
||||||
|
)
|
||||||
|
return await render_template("opportunity_map.html", countries=countries, selected_slug=selected_slug)
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/opportunity-map/data")
|
||||||
|
async def opportunity_map_data():
|
||||||
|
"""HTMX partial: opportunity + reference data islands for Leaflet map."""
|
||||||
|
from ..core import is_flag_enabled
|
||||||
|
if not await is_flag_enabled("maps", default=True):
|
||||||
|
abort(404)
|
||||||
|
country_slug = request.args.get("country", "")
|
||||||
|
if not country_slug:
|
||||||
|
return ""
|
||||||
|
opp_points = await fetch_analytics(
|
||||||
|
"""
|
||||||
|
SELECT location_name, location_slug, lat, lon,
|
||||||
|
opportunity_score, market_score,
|
||||||
|
nearest_padel_court_km, padel_venue_count, population
|
||||||
|
FROM serving.location_profiles
|
||||||
|
WHERE country_slug = ? AND opportunity_score > 0
|
||||||
|
ORDER BY opportunity_score DESC
|
||||||
|
LIMIT 500
|
||||||
|
""",
|
||||||
|
[country_slug],
|
||||||
|
)
|
||||||
|
ref_points = await fetch_analytics(
|
||||||
|
"""
|
||||||
|
SELECT city_name, city_slug, lat, lon,
|
||||||
|
city_padel_venue_count AS padel_venue_count,
|
||||||
|
market_score, population
|
||||||
|
FROM serving.location_profiles
|
||||||
|
WHERE country_slug = ? AND city_slug IS NOT NULL
|
||||||
|
ORDER BY city_padel_venue_count DESC
|
||||||
|
LIMIT 200
|
||||||
|
""",
|
||||||
|
[country_slug],
|
||||||
|
)
|
||||||
|
return await render_template(
|
||||||
|
"partials/opportunity_map_data.html",
|
||||||
|
opp_points=opp_points,
|
||||||
|
ref_points=ref_points,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@bp.route("/imprint")
|
@bp.route("/imprint")
|
||||||
|
|||||||
@@ -1,265 +0,0 @@
|
|||||||
{% extends "base.html" %}
|
|
||||||
|
|
||||||
{% block title %}{{ t.mscore_page_title }}{% endblock %}
|
|
||||||
|
|
||||||
{% block head %}
|
|
||||||
<meta name="description" content="{{ t.mscore_meta_desc }}">
|
|
||||||
<meta property="og:title" content="{{ t.mscore_page_title }}">
|
|
||||||
<meta property="og:description" content="{{ t.mscore_og_desc }}">
|
|
||||||
<script type="application/ld+json">
|
|
||||||
{
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@graph": [
|
|
||||||
{
|
|
||||||
"@type": "WebPage",
|
|
||||||
"name": "{{ t.mscore_page_title }}",
|
|
||||||
"description": "{{ t.mscore_meta_desc }}",
|
|
||||||
"url": "{{ config.BASE_URL }}/{{ lang }}/market-score",
|
|
||||||
"inLanguage": "{{ lang }}",
|
|
||||||
"isPartOf": {
|
|
||||||
"@type": "WebSite",
|
|
||||||
"name": "Padelnomics",
|
|
||||||
"url": "{{ config.BASE_URL }}"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"@type": "BreadcrumbList",
|
|
||||||
"itemListElement": [
|
|
||||||
{"@type": "ListItem", "position": 1, "name": "Home", "item": "{{ config.BASE_URL }}/{{ lang }}"},
|
|
||||||
{"@type": "ListItem", "position": 2, "name": "Market Score", "item": "{{ config.BASE_URL }}/{{ lang }}/market-score"}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"@type": "FAQPage",
|
|
||||||
"mainEntity": [
|
|
||||||
{
|
|
||||||
"@type": "Question",
|
|
||||||
"name": "{{ t.mscore_faq_q1 }}",
|
|
||||||
"acceptedAnswer": {"@type": "Answer", "text": "{{ t.mscore_faq_a1 }}"}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"@type": "Question",
|
|
||||||
"name": "{{ t.mscore_faq_q2 }}",
|
|
||||||
"acceptedAnswer": {"@type": "Answer", "text": "{{ t.mscore_faq_a2 }}"}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"@type": "Question",
|
|
||||||
"name": "{{ t.mscore_faq_q3 }}",
|
|
||||||
"acceptedAnswer": {"@type": "Answer", "text": "{{ t.mscore_faq_a3 }}"}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"@type": "Question",
|
|
||||||
"name": "{{ t.mscore_faq_q4 }}",
|
|
||||||
"acceptedAnswer": {"@type": "Answer", "text": "{{ t.mscore_faq_a4 }}"}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"@type": "Question",
|
|
||||||
"name": "{{ t.mscore_faq_q5 }}",
|
|
||||||
"acceptedAnswer": {"@type": "Answer", "text": "{{ t.mscore_faq_a5 }}"}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"@type": "Question",
|
|
||||||
"name": "{{ t.mscore_faq_q6 }}",
|
|
||||||
"acceptedAnswer": {"@type": "Answer", "text": "{{ t.mscore_faq_a6 }}"}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"@type": "Question",
|
|
||||||
"name": "{{ t.mscore_faq_q7 }}",
|
|
||||||
"acceptedAnswer": {"@type": "Answer", "text": "{{ t.mscore_faq_a7 }}"}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
|
||||||
<main class="container-page py-12">
|
|
||||||
<div class="max-w-3xl mx-auto">
|
|
||||||
|
|
||||||
<!-- Hero -->
|
|
||||||
<header class="text-center mb-12">
|
|
||||||
<h1 class="text-3xl mb-2">
|
|
||||||
<span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span>
|
|
||||||
Market Score
|
|
||||||
</h1>
|
|
||||||
<p class="text-lg text-slate">{{ t.mscore_subtitle }}</p>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<!-- Two Scores -->
|
|
||||||
<section class="card mb-10" style="background:linear-gradient(135deg,#f0f9ff,#e0f2fe);border-color:#bae6fd">
|
|
||||||
<h2 class="text-xl mb-3">{{ t.mscore_dual_h2 }}</h2>
|
|
||||||
<p class="text-slate-dark leading-relaxed mb-6">{{ t.mscore_dual_intro }}</p>
|
|
||||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:1rem">
|
|
||||||
<div style="background:#fff;border-radius:8px;padding:1rem;border:1px solid #bae6fd">
|
|
||||||
<div style="font-size:0.7rem;font-weight:700;letter-spacing:0.06em;color:#0369a1;text-transform:uppercase;margin-bottom:0.4rem">{{ t.mscore_reife_chip }}</div>
|
|
||||||
<div class="font-semibold text-navy mb-1">{{ t.mscore_reife_question }}</div>
|
|
||||||
<p class="text-sm text-slate-dark">{{ t.mscore_reife_desc }}</p>
|
|
||||||
</div>
|
|
||||||
<div style="background:#fff;border-radius:8px;padding:1rem;border:1px solid #bae6fd">
|
|
||||||
<div style="font-size:0.7rem;font-weight:700;letter-spacing:0.06em;color:#0369a1;text-transform:uppercase;margin-bottom:0.4rem">{{ t.mscore_potenzial_chip }}</div>
|
|
||||||
<div class="font-semibold text-navy mb-1">{{ t.mscore_potenzial_question }}</div>
|
|
||||||
<p class="text-sm text-slate-dark">{{ t.mscore_potenzial_desc }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Marktreife-Score: What It Measures -->
|
|
||||||
<section class="mb-10">
|
|
||||||
<h2 class="text-xl mb-4"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> {{ t.mscore_what_h2 }}</h2>
|
|
||||||
<p class="text-slate-dark leading-relaxed mb-6">{{ t.mscore_what_intro }}</p>
|
|
||||||
|
|
||||||
<div class="grid-2">
|
|
||||||
<div class="card">
|
|
||||||
<div style="font-size:1.5rem;margin-bottom:0.5rem">👥</div>
|
|
||||||
<h3 class="font-semibold text-navy mb-1">{{ t.mscore_cat_demo_h3 }}</h3>
|
|
||||||
<p class="text-sm text-slate-dark">{{ t.mscore_cat_demo_p }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div style="font-size:1.5rem;margin-bottom:0.5rem">💶</div>
|
|
||||||
<h3 class="font-semibold text-navy mb-1">{{ t.mscore_cat_econ_h3 }}</h3>
|
|
||||||
<p class="text-sm text-slate-dark">{{ t.mscore_cat_econ_p }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div style="font-size:1.5rem;margin-bottom:0.5rem">📈</div>
|
|
||||||
<h3 class="font-semibold text-navy mb-1">{{ t.mscore_cat_demand_h3 }}</h3>
|
|
||||||
<p class="text-sm text-slate-dark">{{ t.mscore_cat_demand_p }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div style="font-size:1.5rem;margin-bottom:0.5rem">🔍</div>
|
|
||||||
<h3 class="font-semibold text-navy mb-1">{{ t.mscore_cat_data_h3 }}</h3>
|
|
||||||
<p class="text-sm text-slate-dark">{{ t.mscore_cat_data_p }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Marktreife-Score: How To Read -->
|
|
||||||
<section class="card mb-8">
|
|
||||||
<h2 class="text-xl mb-4"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> {{ t.mscore_read_h2 }}</h2>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.25rem">
|
|
||||||
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#16A34A;flex-shrink:0"></span>
|
|
||||||
<span class="font-semibold text-navy">{{ t.mscore_band_high_label }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-slate-dark" style="margin-left:1.75rem">{{ t.mscore_band_high_p }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.25rem">
|
|
||||||
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#D97706;flex-shrink:0"></span>
|
|
||||||
<span class="font-semibold text-navy">{{ t.mscore_band_mid_label }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-slate-dark" style="margin-left:1.75rem">{{ t.mscore_band_mid_p }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.25rem">
|
|
||||||
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#64748B;flex-shrink:0"></span>
|
|
||||||
<span class="font-semibold text-navy">{{ t.mscore_band_low_label }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-slate-dark" style="margin-left:1.75rem">{{ t.mscore_band_low_p }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-slate mt-4" style="border-left:3px solid #E2E8F0;padding-left:0.75rem">{{ t.mscore_read_note }}</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Marktpotenzial-Score: What It Measures -->
|
|
||||||
<section class="mb-10">
|
|
||||||
<h2 class="text-xl mb-4"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> {{ t.mscore_pot_what_h2 }}</h2>
|
|
||||||
<p class="text-slate-dark leading-relaxed mb-6">{{ t.mscore_pot_what_intro }}</p>
|
|
||||||
|
|
||||||
<div class="grid-2">
|
|
||||||
<div class="card">
|
|
||||||
<div style="font-size:1.5rem;margin-bottom:0.5rem">📊</div>
|
|
||||||
<h3 class="font-semibold text-navy mb-1">{{ t.mscore_pot_cat_market_h3 }}</h3>
|
|
||||||
<p class="text-sm text-slate-dark">{{ t.mscore_pot_cat_market_p }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div style="font-size:1.5rem;margin-bottom:0.5rem">💶</div>
|
|
||||||
<h3 class="font-semibold text-navy mb-1">{{ t.mscore_pot_cat_econ_h3 }}</h3>
|
|
||||||
<p class="text-sm text-slate-dark">{{ t.mscore_pot_cat_econ_p }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div style="font-size:1.5rem;margin-bottom:0.5rem">🎯</div>
|
|
||||||
<h3 class="font-semibold text-navy mb-1">{{ t.mscore_pot_cat_gap_h3 }}</h3>
|
|
||||||
<p class="text-sm text-slate-dark">{{ t.mscore_pot_cat_gap_p }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="card">
|
|
||||||
<div style="font-size:1.5rem;margin-bottom:0.5rem">📍</div>
|
|
||||||
<h3 class="font-semibold text-navy mb-1">{{ t.mscore_pot_cat_catchment_h3 }}</h3>
|
|
||||||
<p class="text-sm text-slate-dark">{{ t.mscore_pot_cat_catchment_p }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="card" style="grid-column:span 2">
|
|
||||||
<div style="font-size:1.5rem;margin-bottom:0.5rem">🎾</div>
|
|
||||||
<h3 class="font-semibold text-navy mb-1">{{ t.mscore_pot_cat_tennis_h3 }}</h3>
|
|
||||||
<p class="text-sm text-slate-dark">{{ t.mscore_pot_cat_tennis_p }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Marktpotenzial-Score: Score Bands -->
|
|
||||||
<section class="card mb-8">
|
|
||||||
<h2 class="text-xl mb-4"><span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span> {{ t.mscore_pot_read_h2 }}</h2>
|
|
||||||
<div class="space-y-4">
|
|
||||||
<div>
|
|
||||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.25rem">
|
|
||||||
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#16A34A;flex-shrink:0"></span>
|
|
||||||
<span class="font-semibold text-navy">{{ t.mscore_pot_band_high_label }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-slate-dark" style="margin-left:1.75rem">{{ t.mscore_pot_band_high_p }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.25rem">
|
|
||||||
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#D97706;flex-shrink:0"></span>
|
|
||||||
<span class="font-semibold text-navy">{{ t.mscore_pot_band_mid_label }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-slate-dark" style="margin-left:1.75rem">{{ t.mscore_pot_band_mid_p }}</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.25rem">
|
|
||||||
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#64748B;flex-shrink:0"></span>
|
|
||||||
<span class="font-semibold text-navy">{{ t.mscore_pot_band_low_label }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="text-sm text-slate-dark" style="margin-left:1.75rem">{{ t.mscore_pot_band_low_p }}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Data Sources -->
|
|
||||||
<section class="card mb-8">
|
|
||||||
<h2 class="text-xl mb-4">{{ t.mscore_sources_h2 }}</h2>
|
|
||||||
<p class="text-slate-dark leading-relaxed">{{ t.mscore_sources_p }}</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- Limitations -->
|
|
||||||
<section class="card mb-8">
|
|
||||||
<h2 class="text-xl mb-4">{{ t.mscore_limits_h2 }}</h2>
|
|
||||||
<div class="space-y-3 text-slate-dark leading-relaxed">
|
|
||||||
<p>{{ t.mscore_limits_p1 }}</p>
|
|
||||||
<p>{{ t.mscore_limits_p2 }}</p>
|
|
||||||
<p>{{ t.mscore_limits_p3 }}</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- CTA -->
|
|
||||||
<div class="text-center my-12">
|
|
||||||
<a href="{{ url_for('content.markets') }}" class="btn" style="margin-right:0.75rem">{{ t.mscore_cta_markets }}</a>
|
|
||||||
<a href="{{ url_for('planner.index') }}" class="btn-secondary" style="display:inline-block;padding:0.625rem 1.25rem;border-radius:6px;font-weight:600;font-size:0.875rem;text-decoration:none">{{ t.mscore_cta_planner }}</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- FAQ -->
|
|
||||||
<section>
|
|
||||||
<h2 class="text-xl mb-4">{{ t.mscore_faq_h2 }}</h2>
|
|
||||||
<div class="space-y-4">
|
|
||||||
{% for i in range(1, 8) %}
|
|
||||||
<details style="border:1px solid #E2E8F0;border-radius:8px;padding:0.75rem 1rem">
|
|
||||||
<summary class="font-semibold text-navy" style="cursor:pointer">{{ t['mscore_faq_q' ~ i] }}</summary>
|
|
||||||
<p class="text-sm text-slate-dark mt-2">{{ t['mscore_faq_a' ~ i] }}</p>
|
|
||||||
</details>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
{% endblock %}
|
|
||||||
@@ -24,30 +24,41 @@
|
|||||||
|
|
||||||
<div class="card mb-4" style="padding: 1rem 1.25rem;">
|
<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>
|
<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;">
|
<select id="opp-country-select" name="country" class="form-input" style="max-width:280px;"
|
||||||
|
hx-get="{{ url_for('public.opportunity_map_data') }}"
|
||||||
|
hx-target="#map-data"
|
||||||
|
hx-trigger="change">
|
||||||
<option value="">— choose country —</option>
|
<option value="">— choose country —</option>
|
||||||
{% for c in countries %}
|
{% for c in countries %}
|
||||||
<option value="{{ c.country_slug }}">{{ c.country_name_en }}</option>
|
<option value="{{ c.country_slug }}" {% if c.country_slug == selected_slug %}selected{% endif %}>{{ c.country_name_en }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="opportunity-map"></div>
|
<div id="opportunity-map"></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 |
|
<span style="display:flex; align-items:center; gap:0.3rem;">
|
||||||
<strong>Color:</strong>
|
<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:#16A34A;vertical-align:middle;margin:0 4px"></span>High (≥70)
|
<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;vertical-align:middle;margin:0 4px"></span>Mid (40–70)
|
<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:#3B82F6;vertical-align:middle;margin:0 4px"></span>Low (<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><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>
|
||||||
|
window.__MAP_T = {score_label:"{{ t.map_score_label }}",venues:"{{ t.map_venues }}",pop:"{{ t.map_pop }}",existing_venues:"{{ t.map_existing_venues }}",km_nearest:"{{ t.map_km_nearest }}",no_nearby:"{{ t.map_no_nearby }}"};
|
||||||
(function() {
|
(function() {
|
||||||
|
var sc = PNMarkers.scoreColor;
|
||||||
|
var T = window.__MAP_T;
|
||||||
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 = '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>';
|
var TILES_ATTR = '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>';
|
||||||
|
|
||||||
@@ -57,22 +68,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 >= 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({
|
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>',
|
||||||
@@ -86,54 +81,58 @@
|
|||||||
: (p || '');
|
: (p || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadCountry(slug) {
|
function renderMap() {
|
||||||
oppLayer.clearLayers();
|
oppLayer.clearLayers();
|
||||||
refLayer.clearLayers();
|
refLayer.clearLayers();
|
||||||
if (!slug) return;
|
var oppEl = document.getElementById('opp-data');
|
||||||
|
var refEl = document.getElementById('ref-data');
|
||||||
|
if (!oppEl) return;
|
||||||
|
var oppData = JSON.parse(oppEl.textContent);
|
||||||
|
var refData = JSON.parse(refEl.textContent);
|
||||||
|
|
||||||
fetch('/api/opportunity/' + slug + '.json')
|
refData.forEach(function(c) {
|
||||||
.then(function(r) { return r.json(); })
|
if (!c.lat || !c.lon || !c.padel_venue_count) return;
|
||||||
.then(function(data) {
|
L.marker([c.lat, c.lon], { icon: REF_ICON })
|
||||||
if (!data.length) return;
|
.bindTooltip(c.city_name + ' — ' + c.padel_venue_count + ' ' + T.existing_venues,
|
||||||
var maxPop = Math.max.apply(null, data.map(function(d) { return d.population || 1; }));
|
{ className: 'map-tooltip', direction: 'top', offset: [0, -7] })
|
||||||
|
.addTo(refLayer);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!oppData.length) return;
|
||||||
|
var maxPop = Math.max.apply(null, oppData.map(function(d) { return d.population || 1; }));
|
||||||
var bounds = [];
|
var bounds = [];
|
||||||
data.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 score = loc.opportunity_score;
|
||||||
|
var hex = sc(score);
|
||||||
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) + ' ' + T.km_nearest
|
||||||
: 'No nearby courts';
|
: T.no_nearby;
|
||||||
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>'
|
+ '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + hex + ';vertical-align:middle;margin-right:4px;"></span>'
|
||||||
+ '<span style="color:' + mktColor + ';font-weight:600;">Padelnomics Market Score: ' + (loc.market_score || 0) + '/100</span><br>'
|
+ '<span style="color:' + hex + ';font-weight:600;">' + T.score_label + ': ' + score + '/100</span><br>'
|
||||||
+ dist + ' · Pop. ' + fmtPop(loc.population);
|
+ '<span style="color:#94A3B8;font-size:0.75rem;">' + dist + ' · ' + T.pop + ' ' + fmtPop(loc.population) + '</span>';
|
||||||
L.marker([loc.lat, loc.lon], { icon: makeIcon(size, color) })
|
var icon = PNMarkers.makeIcon({
|
||||||
|
size: size,
|
||||||
|
color: hex,
|
||||||
|
pulse: 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]);
|
||||||
});
|
});
|
||||||
if (bounds.length) map.fitBounds(bounds, { padding: [30, 30] });
|
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() {
|
document.body.addEventListener('htmx:afterSwap', function(e) {
|
||||||
loadCountry(this.value);
|
if (e.detail.target.id === 'map-data') renderMap();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Auto-load if country pre-selected via geo header
|
||||||
|
var sel = document.getElementById('opp-country-select');
|
||||||
|
if (sel.value) htmx.trigger(sel, 'change');
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
196
web/src/padelnomics/public/templates/padelnomics_score.html
Normal file
196
web/src/padelnomics/public/templates/padelnomics_score.html
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}{{ t.pnscore_page_title }}{% endblock %}
|
||||||
|
|
||||||
|
{% block head %}
|
||||||
|
<meta name="description" content="{{ t.pnscore_meta_desc }}">
|
||||||
|
<meta property="og:title" content="{{ t.pnscore_page_title }}">
|
||||||
|
<meta property="og:description" content="{{ t.pnscore_og_desc }}">
|
||||||
|
<script type="application/ld+json">
|
||||||
|
{
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@graph": [
|
||||||
|
{
|
||||||
|
"@type": "WebPage",
|
||||||
|
"name": "{{ t.pnscore_page_title }}",
|
||||||
|
"description": "{{ t.pnscore_meta_desc }}",
|
||||||
|
"url": "{{ config.BASE_URL }}/{{ lang }}/padelnomics-score",
|
||||||
|
"inLanguage": "{{ lang }}",
|
||||||
|
"isPartOf": {
|
||||||
|
"@type": "WebSite",
|
||||||
|
"name": "Padelnomics",
|
||||||
|
"url": "{{ config.BASE_URL }}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "BreadcrumbList",
|
||||||
|
"itemListElement": [
|
||||||
|
{"@type": "ListItem", "position": 1, "name": "Home", "item": "{{ config.BASE_URL }}/{{ lang }}"},
|
||||||
|
{"@type": "ListItem", "position": 2, "name": "Padelnomics Score", "item": "{{ config.BASE_URL }}/{{ lang }}/padelnomics-score"}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "FAQPage",
|
||||||
|
"mainEntity": [
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "{{ t.pnscore_faq_q1 }}",
|
||||||
|
"acceptedAnswer": {"@type": "Answer", "text": "{{ t.pnscore_faq_a1 }}"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "{{ t.pnscore_faq_q2 }}",
|
||||||
|
"acceptedAnswer": {"@type": "Answer", "text": "{{ t.pnscore_faq_a2 }}"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "{{ t.pnscore_faq_q3 }}",
|
||||||
|
"acceptedAnswer": {"@type": "Answer", "text": "{{ t.pnscore_faq_a3 }}"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "{{ t.pnscore_faq_q4 }}",
|
||||||
|
"acceptedAnswer": {"@type": "Answer", "text": "{{ t.pnscore_faq_a4 }}"}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"@type": "Question",
|
||||||
|
"name": "{{ t.pnscore_faq_q5 }}",
|
||||||
|
"acceptedAnswer": {"@type": "Answer", "text": "{{ t.pnscore_faq_a5 }}"}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<main class="container-page py-12">
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
|
||||||
|
<!-- Hero -->
|
||||||
|
<header class="text-center mb-12">
|
||||||
|
<h1 class="text-3xl mb-2">
|
||||||
|
<span style="font-family:'Bricolage Grotesque',sans-serif;font-weight:800;color:#0F172A;letter-spacing:-0.02em">padelnomics</span>
|
||||||
|
Score
|
||||||
|
</h1>
|
||||||
|
<p class="text-lg text-slate">{{ t.pnscore_subtitle }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- What It Is -->
|
||||||
|
<section class="card mb-10" style="background:linear-gradient(135deg,#f0f9ff,#e0f2fe);border-color:#bae6fd">
|
||||||
|
<h2 class="text-xl mb-3">{{ t.pnscore_what_h2 }}</h2>
|
||||||
|
<p class="text-slate-dark leading-relaxed">{{ t.pnscore_what_intro }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Components -->
|
||||||
|
<section class="mb-10">
|
||||||
|
<h2 class="text-xl mb-4">{{ t.pnscore_components_h2 }}</h2>
|
||||||
|
<p class="text-slate-dark leading-relaxed mb-6">{{ t.pnscore_components_intro }}</p>
|
||||||
|
|
||||||
|
<div class="grid-2">
|
||||||
|
<div class="card">
|
||||||
|
<div style="font-size:1.5rem;margin-bottom:0.5rem">👥</div>
|
||||||
|
<h3 class="font-semibold text-navy mb-1">{{ t.pnscore_cat_market_h3 }}</h3>
|
||||||
|
<p class="text-sm text-slate-dark">{{ t.pnscore_cat_market_p }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div style="font-size:1.5rem;margin-bottom:0.5rem">💶</div>
|
||||||
|
<h3 class="font-semibold text-navy mb-1">{{ t.pnscore_cat_econ_h3 }}</h3>
|
||||||
|
<p class="text-sm text-slate-dark">{{ t.pnscore_cat_econ_p }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div style="font-size:1.5rem;margin-bottom:0.5rem">🎯</div>
|
||||||
|
<h3 class="font-semibold text-navy mb-1">{{ t.pnscore_cat_gap_h3 }}</h3>
|
||||||
|
<p class="text-sm text-slate-dark">{{ t.pnscore_cat_gap_p }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div style="font-size:1.5rem;margin-bottom:0.5rem">🎾</div>
|
||||||
|
<h3 class="font-semibold text-navy mb-1">{{ t.pnscore_cat_sports_h3 }}</h3>
|
||||||
|
<p class="text-sm text-slate-dark">{{ t.pnscore_cat_sports_p }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div style="font-size:1.5rem;margin-bottom:0.5rem">📍</div>
|
||||||
|
<h3 class="font-semibold text-navy mb-1">{{ t.pnscore_cat_catchment_h3 }}</h3>
|
||||||
|
<p class="text-sm text-slate-dark">{{ t.pnscore_cat_catchment_p }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div style="font-size:1.5rem;margin-bottom:0.5rem">📊</div>
|
||||||
|
<h3 class="font-semibold text-navy mb-1">{{ t.pnscore_cat_maturity_h3 }}</h3>
|
||||||
|
<p class="text-sm text-slate-dark">{{ t.pnscore_cat_maturity_p }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Score Bands -->
|
||||||
|
<section class="card mb-8">
|
||||||
|
<h2 class="text-xl mb-4">{{ t.pnscore_read_h2 }}</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.25rem">
|
||||||
|
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#15803D;flex-shrink:0"></span>
|
||||||
|
<span class="font-semibold text-navy">{{ t.pnscore_band_high_label }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-slate-dark" style="margin-left:1.75rem">{{ t.pnscore_band_high_p }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.25rem">
|
||||||
|
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#0D9488;flex-shrink:0"></span>
|
||||||
|
<span class="font-semibold text-navy">{{ t.pnscore_band_good_label }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-slate-dark" style="margin-left:1.75rem">{{ t.pnscore_band_good_p }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.25rem">
|
||||||
|
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#D97706;flex-shrink:0"></span>
|
||||||
|
<span class="font-semibold text-navy">{{ t.pnscore_band_mid_label }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-slate-dark" style="margin-left:1.75rem">{{ t.pnscore_band_mid_p }}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="display:flex;align-items:center;gap:0.5rem;margin-bottom:0.25rem">
|
||||||
|
<span style="display:inline-block;width:12px;height:12px;border-radius:50%;background:#EA580C;flex-shrink:0"></span>
|
||||||
|
<span class="font-semibold text-navy">{{ t.pnscore_band_low_label }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-slate-dark" style="margin-left:1.75rem">{{ t.pnscore_band_low_p }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Data Sources -->
|
||||||
|
<section class="card mb-8">
|
||||||
|
<h2 class="text-xl mb-4">{{ t.pnscore_sources_h2 }}</h2>
|
||||||
|
<p class="text-slate-dark leading-relaxed">{{ t.pnscore_sources_p }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Limitations -->
|
||||||
|
<section class="card mb-8">
|
||||||
|
<h2 class="text-xl mb-4">{{ t.pnscore_limits_h2 }}</h2>
|
||||||
|
<div class="space-y-3 text-slate-dark leading-relaxed">
|
||||||
|
<p>{{ t.pnscore_limits_p1 }}</p>
|
||||||
|
<p>{{ t.pnscore_limits_p2 }}</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<div class="text-center my-12">
|
||||||
|
<a href="{{ url_for('content.markets') }}" class="btn" style="margin-right:0.75rem">{{ t.pnscore_cta_markets }}</a>
|
||||||
|
<a href="{{ url_for('planner.index') }}" class="btn-secondary" style="display:inline-block;padding:0.625rem 1.25rem;border-radius:6px;font-weight:600;font-size:0.875rem;text-decoration:none">{{ t.pnscore_cta_planner }}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FAQ -->
|
||||||
|
<section>
|
||||||
|
<h2 class="text-xl mb-4">{{ t.pnscore_faq_h2 }}</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
{% for i in range(1, 6) %}
|
||||||
|
<details style="border:1px solid #E2E8F0;border-radius:8px;padding:0.75rem 1rem">
|
||||||
|
<summary class="font-semibold text-navy" style="cursor:pointer">{{ t['pnscore_faq_q' ~ i] }}</summary>
|
||||||
|
<p class="text-sm text-slate-dark mt-2">{{ t['pnscore_faq_a' ~ i] }}</p>
|
||||||
|
</details>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<script id="opp-data" type="application/json">{{ opp_points | tojson }}</script>
|
||||||
|
<script id="ref-data" type="application/json">{{ ref_points | tojson }}</script>
|
||||||
@@ -879,7 +879,8 @@
|
|||||||
.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 */
|
/* ── Single-color map markers ── */
|
||||||
|
/* Circle: score-colored background, white border, shadow, hover */
|
||||||
.pn-marker {
|
.pn-marker {
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 2.5px solid white;
|
border: 2.5px solid white;
|
||||||
@@ -892,18 +893,32 @@
|
|||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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, 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: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 — high-score locations */
|
||||||
|
.pn-marker--pulse {
|
||||||
|
animation: marker-pulse 2.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes marker-pulse {
|
||||||
|
0%, 100% { transform: scale(1); opacity: 1; }
|
||||||
|
50% { transform: scale(1.2); opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
/* Small fixed venue dot */
|
/* Small fixed venue dot */
|
||||||
.pn-venue {
|
.pn-venue {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
|
|||||||
@@ -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 = '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>';
|
var TILES_ATTR = '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>';
|
||||||
|
var sc = PNMarkers.scoreColor;
|
||||||
|
var T = window.__MAP_T || {};
|
||||||
|
|
||||||
function scoreColor(score) {
|
function fmtPop(p) {
|
||||||
if (score >= 60) return '#16A34A';
|
return p >= 1000000 ? (p / 1000000).toFixed(1) + 'M'
|
||||||
if (score >= 30) return '#D97706';
|
: p >= 1000 ? Math.round(p / 1000) + 'K'
|
||||||
return '#DC2626';
|
: (p || '');
|
||||||
}
|
|
||||||
|
|
||||||
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,26 @@
|
|||||||
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 score = c.opportunity_score || 0;
|
||||||
var pop = c.population >= 1000000
|
var hex = sc(score);
|
||||||
? (c.population / 1000000).toFixed(1) + 'M'
|
var pop = fmtPop(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' : '#3B82F6');
|
|
||||||
var tip = '<strong>' + c.city_name + '</strong><br>'
|
var tip = '<strong>' + c.city_name + '</strong><br>'
|
||||||
+ (c.padel_venue_count || 0) + ' venues'
|
+ '<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:' + hex + ';vertical-align:middle;margin-right:4px;"></span>'
|
||||||
+ (pop ? ' · ' + pop : '')
|
+ '<span style="color:' + hex + ';font-weight:600;">' + (T.score_label || 'Padelnomics Score') + ': ' + Math.round(score) + '/100</span><br>'
|
||||||
+ '<br><span style="color:' + color + ';font-weight:600;">Padelnomics Market Score: ' + Math.round(c.market_score) + '/100</span>'
|
+ '<span style="color:#94A3B8;font-size:0.75rem;">'
|
||||||
+ '<br><span style="color:' + oppColor + ';font-weight:600;">Padelnomics Opportunity Score: ' + Math.round(c.opportunity_score || 0) + '/100</span>';
|
+ (c.padel_venue_count || 0) + ' ' + (T.venues || 'venues')
|
||||||
|
+ (pop ? ' · ' + pop + ' ' + (T.pop || 'pop') : '') + '</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;">' + (T.click_explore || '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;">' + (T.coming_soon || 'Coming soon') + '</span>';
|
||||||
}
|
}
|
||||||
var marker = L.marker([c.lat, c.lon], { icon: makeIcon(size, color, !hasArticle) })
|
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)] })
|
.bindTooltip(tip, { className: 'map-tooltip', direction: 'top', offset: [0, -Math.round(size / 2)] })
|
||||||
.addTo(map);
|
.addTo(map);
|
||||||
if (hasArticle) {
|
if (hasArticle) {
|
||||||
@@ -69,6 +66,23 @@
|
|||||||
bounds.push([c.lat, c.lon]);
|
bounds.push([c.lat, c.lon]);
|
||||||
});
|
});
|
||||||
if (bounds.length) map.fitBounds(bounds, { padding: [24, 24] });
|
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 = 10 + 36 * Math.sqrt((match.padel_venue_count || 1) / maxV);
|
||||||
|
var hIcon = PNMarkers.makeIcon({
|
||||||
|
size: hSize,
|
||||||
|
color: sc(match.opportunity_score || 0),
|
||||||
|
highlight: true,
|
||||||
|
});
|
||||||
|
L.marker([match.lat, match.lon], { icon: hIcon }).addTo(map);
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(function(err) { console.error('Country map fetch failed:', err); });
|
.catch(function(err) { console.error('Country map fetch failed:', err); });
|
||||||
}
|
}
|
||||||
@@ -89,9 +103,9 @@
|
|||||||
var outdoor = v.outdoor_court_count || 0;
|
var outdoor = v.outdoor_court_count || 0;
|
||||||
var total = v.court_count || (indoor + outdoor);
|
var total = v.court_count || (indoor + outdoor);
|
||||||
var courtLine = total
|
var courtLine = total
|
||||||
? total + ' court' + (total > 1 ? 's' : '')
|
? total + ' ' + (T.courts || 'court' + (total > 1 ? 's' : ''))
|
||||||
+ (indoor || outdoor
|
+ (indoor || outdoor
|
||||||
? ' (' + [indoor ? indoor + ' indoor' : '', outdoor ? outdoor + ' outdoor' : ''].filter(Boolean).join(', ') + ')'
|
? ' (' + [indoor ? indoor + ' ' + (T.indoor || 'indoor') : '', outdoor ? outdoor + ' ' + (T.outdoor || 'outdoor') : ''].filter(Boolean).join(', ') + ')'
|
||||||
: '')
|
: '')
|
||||||
: '';
|
: '';
|
||||||
var tip = '<strong>' + v.name + '</strong>' + (courtLine ? '<br>' + courtLine : '');
|
var tip = '<strong>' + v.name + '</strong>' + (courtLine ? '<br>' + courtLine : '');
|
||||||
|
|||||||
51
web/src/padelnomics/static/js/map-markers.js
Normal file
51
web/src/padelnomics/static/js/map-markers.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
/**
|
||||||
|
* Shared map marker utilities — single-color markers 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 single-color circle
|
||||||
|
*
|
||||||
|
* opts = {
|
||||||
|
* size: number, // marker diameter in px
|
||||||
|
* color: string, // hex color (from scoreColor)
|
||||||
|
* muted: boolean, // faded, no click affordance
|
||||||
|
* highlight: boolean, // blue outer glow (user's geo city)
|
||||||
|
* pulse: boolean, // gentle pulse (high score)
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
(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
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeIcon(opts) {
|
||||||
|
var s = Math.round(opts.size);
|
||||||
|
|
||||||
|
var cls = 'pn-marker';
|
||||||
|
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 = '<div class="' + cls + '" style="width:' + s + 'px;height:' + s + 'px;background:' + opts.color + ';"></div>';
|
||||||
|
|
||||||
|
return L.divIcon({
|
||||||
|
className: '',
|
||||||
|
html: html,
|
||||||
|
iconSize: [s, s],
|
||||||
|
iconAnchor: [s / 2, s / 2],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.PNMarkers = {
|
||||||
|
scoreColor: scoreColor,
|
||||||
|
makeIcon: makeIcon,
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -36,6 +36,7 @@
|
|||||||
<meta property="og:image" content="{{ url_for('static', filename='images/logo.png', _external=True) }}">
|
<meta property="og:image" content="{{ url_for('static', filename='images/logo.png', _external=True) }}">
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
|
|
||||||
|
<script>window.__GEO = {country: "{{ user_country }}", city: "{{ user_city }}"};</script>
|
||||||
{% block head %}{% endblock %}
|
{% block head %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -171,7 +172,7 @@
|
|||||||
<li><a href="{{ url_for('planner.index') }}">{{ t.nav_planner }}</a></li>
|
<li><a href="{{ url_for('planner.index') }}">{{ t.nav_planner }}</a></li>
|
||||||
<li><a href="{{ url_for('directory.index') }}">{{ t.nav_directory }}</a></li>
|
<li><a href="{{ url_for('directory.index') }}">{{ t.nav_directory }}</a></li>
|
||||||
<li><a href="{{ url_for('content.markets') }}">{{ t.nav_markets }}</a></li>
|
<li><a href="{{ url_for('content.markets') }}">{{ t.nav_markets }}</a></li>
|
||||||
<li><a href="{{ url_for('public.market_score') }}">{{ t.footer_market_score }}</a></li>
|
<li><a href="{{ url_for('public.padelnomics_score') }}">{{ t.footer_padelnomics_score }}</a></li>
|
||||||
<li><a href="{{ url_for('public.suppliers') }}">{{ t.nav_suppliers }}</a></li>
|
<li><a href="{{ url_for('public.suppliers') }}">{{ t.nav_suppliers }}</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -219,10 +219,10 @@
|
|||||||
<div class="market-stat-label">{{ s.labels.median_offpeak_rate }}</div>
|
<div class="market-stat-label">{{ s.labels.median_offpeak_rate }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if s.market_data.market_score %}
|
{% if s.market_data.opportunity_score %}
|
||||||
<div class="market-stat">
|
<div class="market-stat">
|
||||||
<div class="market-stat-value">{{ "%.0f"|format(s.market_data.market_score) }}/100</div>
|
<div class="market-stat-value">{{ "%.0f"|format(s.market_data.opportunity_score) }}/100</div>
|
||||||
<div class="market-stat-label">{{ s.labels.market_score }}</div>
|
<div class="market-stat-label">{{ s.labels.padelnomics_score }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -57,10 +57,10 @@ _IDENTICAL_VALUE_ALLOWLIST = {
|
|||||||
# Business plan — Indoor/Outdoor same in DE, financial abbreviations
|
# Business plan — Indoor/Outdoor same in DE, financial abbreviations
|
||||||
"bp_indoor", "bp_outdoor",
|
"bp_indoor", "bp_outdoor",
|
||||||
"bp_lbl_ebitda", "bp_lbl_irr", "bp_lbl_moic", "bp_lbl_opex",
|
"bp_lbl_ebitda", "bp_lbl_irr", "bp_lbl_moic", "bp_lbl_opex",
|
||||||
# Market Score — branded term kept in English in DE
|
# Padelnomics Score — branded term kept in English in DE
|
||||||
"footer_market_score",
|
"footer_padelnomics_score", "bp_lbl_padelnomics_score",
|
||||||
# Market Score chip labels — branded product names, same in DE
|
# Map tooltip keys — some are identical in both languages
|
||||||
"mscore_reife_chip", "mscore_potenzial_chip",
|
"map_score_label", "map_indoor", "map_outdoor",
|
||||||
# Brand name "Padelnomics" — same in DE
|
# Brand name "Padelnomics" — same in DE
|
||||||
"landing_vs_col_us",
|
"landing_vs_col_us",
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,36 @@
|
|||||||
"""Tests for the Market Score methodology page."""
|
"""Tests for the Padelnomics Score methodology page."""
|
||||||
|
|
||||||
|
|
||||||
async def test_en_returns_200(client):
|
async def test_en_returns_200(client):
|
||||||
resp = await client.get("/en/market-score")
|
resp = await client.get("/en/padelnomics-score")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
text = await resp.get_data(as_text=True)
|
text = await resp.get_data(as_text=True)
|
||||||
assert "Market Score" in text
|
assert "Padelnomics Score" in text
|
||||||
assert "padelnomics" in text
|
assert "padelnomics" in text
|
||||||
|
|
||||||
|
|
||||||
async def test_de_returns_200(client):
|
async def test_de_returns_200(client):
|
||||||
resp = await client.get("/de/market-score")
|
resp = await client.get("/de/padelnomics-score")
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
text = await resp.get_data(as_text=True)
|
text = await resp.get_data(as_text=True)
|
||||||
assert "Market Score" in text
|
assert "Padelnomics Score" in text
|
||||||
assert "padelnomics" in text
|
assert "padelnomics" in text
|
||||||
|
|
||||||
|
|
||||||
async def test_legacy_redirect(client):
|
async def test_old_market_score_redirects(client):
|
||||||
resp = await client.get("/market-score")
|
resp = await client.get("/en/market-score")
|
||||||
assert resp.status_code == 301
|
assert resp.status_code == 301
|
||||||
assert resp.headers["Location"].endswith("/en/market-score")
|
assert "/padelnomics-score" in resp.headers["Location"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_de_old_market_score_redirects(client):
|
||||||
|
resp = await client.get("/de/market-score")
|
||||||
|
assert resp.status_code == 301
|
||||||
|
assert "/padelnomics-score" in resp.headers["Location"]
|
||||||
|
|
||||||
|
|
||||||
async def test_contains_jsonld(client):
|
async def test_contains_jsonld(client):
|
||||||
resp = await client.get("/en/market-score")
|
resp = await client.get("/en/padelnomics-score")
|
||||||
text = await resp.get_data(as_text=True)
|
text = await resp.get_data(as_text=True)
|
||||||
assert '"@type": "WebPage"' in text
|
assert '"@type": "WebPage"' in text
|
||||||
assert '"@type": "FAQPage"' in text
|
assert '"@type": "FAQPage"' in text
|
||||||
@@ -32,28 +38,27 @@ async def test_contains_jsonld(client):
|
|||||||
|
|
||||||
|
|
||||||
async def test_contains_faq_section(client):
|
async def test_contains_faq_section(client):
|
||||||
resp = await client.get("/en/market-score")
|
resp = await client.get("/en/padelnomics-score")
|
||||||
text = await resp.get_data(as_text=True)
|
text = await resp.get_data(as_text=True)
|
||||||
assert "Frequently Asked Questions" in text
|
assert "Frequently Asked Questions" in text
|
||||||
assert "<details" in text
|
assert "<details" in text
|
||||||
|
|
||||||
|
|
||||||
async def test_de_contains_faq_section(client):
|
async def test_de_contains_faq_section(client):
|
||||||
resp = await client.get("/de/market-score")
|
resp = await client.get("/de/padelnomics-score")
|
||||||
text = await resp.get_data(as_text=True)
|
text = await resp.get_data(as_text=True)
|
||||||
assert "Häufig gestellte Fragen" in text
|
assert "Häufige Fragen" in text
|
||||||
|
|
||||||
|
|
||||||
async def test_contains_og_tags(client):
|
async def test_contains_og_tags(client):
|
||||||
resp = await client.get("/en/market-score")
|
resp = await client.get("/en/padelnomics-score")
|
||||||
text = await resp.get_data(as_text=True)
|
text = await resp.get_data(as_text=True)
|
||||||
assert 'og:title' in text
|
assert 'og:title' in text
|
||||||
assert 'og:description' in text
|
assert 'og:description' in text
|
||||||
|
|
||||||
|
|
||||||
async def test_footer_has_market_score_link(client):
|
async def test_footer_has_padelnomics_score_link(client):
|
||||||
resp = await client.get("/en/market-score")
|
resp = await client.get("/en/padelnomics-score")
|
||||||
text = await resp.get_data(as_text=True)
|
text = await resp.get_data(as_text=True)
|
||||||
assert "/en/market-score" in text
|
assert "/padelnomics-score" in text
|
||||||
# Footer should link to market score page
|
assert "Padelnomics Score" in text
|
||||||
assert "Market Score" in text
|
|
||||||
Reference in New Issue
Block a user