feat: Passwordless cross-device authentication

- Arkitektur: docs/auth/passwordless-architecture.md
- Backend: iom/quixzoom-auth-service/ (FastAPI + Redis)
- Webb: quixzoom-market-pages/se/login/ (QR-kod + polling)
- App: iom/quixzoom-app/src/features/auth/ (push + deep links)

Flöde: QR-kod → app-godkännande → webb-inloggad
This commit is contained in:
Bernt
2026-07-07 07:11:50 +00:00
parent 4aa984ad74
commit 6989a98d75
61843 changed files with 5491611 additions and 872231 deletions
+171 -171
View File
@@ -67,15 +67,15 @@ html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text'
#city-name{font-size:24px;font-weight:900;letter-spacing:-0.8px;color:var(--text);line-height:1;font-family:'Inter',system-ui,sans-serif}
#city-region{font-size:11px;color:var(--t2);margin-top:3px;font-weight:500}
#city-stats{display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-top:12px}
.cstat{background:var(--s2);border:1px solid var(--border);border-radius:7px;padding:8px 10px}
.cstat{background:var(--s2);border:1px solid var(--border);border-radius: var(--radius-sm);padding:8px 10px}
.cstat-val{font-size:16px;font-weight:800;color:var(--text);letter-spacing:-0.3px}
.cstat-lbl{font-size:9px;font-weight:600;letter-spacing:0.5px;text-transform:uppercase;color:var(--t3);margin-top:1px}
/* Profile tabs */
#profile-block{padding:12px 16px;border-bottom:1px solid var(--border)}
#profile-lbl{font-size:9px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:var(--t3);margin-bottom:8px}
#profile-tabs{display:grid;grid-template-columns:1fr 1fr 1fr;gap:3px;background:rgba(0,0,0,0.06);border-radius:10px;padding:2px}
.ptab{padding:6px 4px;border-radius:8px;border:none;background:transparent;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:2px;transition:all 0.15s;font-size:10px;font-weight:600;color:var(--t2);letter-spacing:0.2px}
#profile-tabs{display:grid;grid-template-columns:1fr 1fr 1fr;gap:3px;background:rgba(0,0,0,0.06);border-radius: var(--radius-md);padding:2px}
.ptab{padding:6px 4px;border-radius: var(--radius-md);border:none;background:transparent;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:2px;transition:all 0.15s;font-size:10px;font-weight:600;color:var(--t2);letter-spacing:0.2px}
.ptab .picon{font-size:14px;line-height:1}
.ptab:hover{background:var(--s2);color:var(--text)}
.ptab.active{background:#ffffff;color:var(--text);box-shadow:0 1px 4px rgba(0,0,0,0.12)}
@@ -89,13 +89,13 @@ html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text'
/* Districts */
#district-block{padding:12px 16px}
#district-lbl{font-size:9px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:var(--t3);margin-bottom:8px}
.dcard{border-radius:12px;border:none;background:var(--s2);padding:11px 13px;margin-bottom:6px;cursor:pointer;transition:all 0.12s}
.dcard{border-radius: var(--radius-md);border:none;background:var(--s2);padding:11px 13px;margin-bottom:6px;cursor:pointer;transition:all 0.12s}
.dcard:hover,.dcard.active{border-color:var(--b2);background:var(--s3)}
.dcard-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:3px}
.dcard-name{font-size:13px;font-weight:700;color:var(--text)}
.dcard-score{font-size:11px;font-weight:800;padding:1px 7px;border-radius:4px;font-family:var(--mono)}
.dcard-score{font-size:11px;font-weight:800;padding:1px 7px;border-radius: var(--radius-sm);font-family:var(--mono)}
.dcard-desc{font-size:12px;color:var(--t2);line-height:1.4;margin-bottom:5px}
.dcard-contra{display:inline-flex;align-items:center;gap:4px;font-size:9px;font-weight:700;letter-spacing:0.3px;border-radius:4px;padding:2px 7px}
.dcard-contra{display:inline-flex;align-items:center;gap:4px;font-size:9px;font-weight:700;letter-spacing:0.3px;border-radius: var(--radius-sm);padding:2px 7px}
.dc-low{background:rgba(22,163,74,0.12);color:#4ade80}
.dc-med{background:rgba(217,119,6,0.12);color:#fbbf24}
.dc-high{background:rgba(220,38,38,0.12);color:#f87171}
@@ -103,12 +103,12 @@ html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text'
/* Data Freshness */
#freshness-block{padding:12px 16px;border-top:1px solid var(--border);margin-top:auto}
#freshness-title{font-size:9px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:var(--t3);margin-bottom:8px;display:flex;align-items:center;gap:5px}
#freshness-title::before{content:'';display:inline-block;width:5px;height:5px;border-radius:50%;background:#22c55e;animation:pulse-ring 2s ease-in-out infinite}
#freshness-title::before{content:'';display:inline-block;width:5px;height:5px;border-radius: var(--radius-full);background:#22c55e;animation:pulse-ring 2s ease-in-out infinite}
.fresh-row{display:flex;align-items:center;justify-content:space-between;padding:5px 0;border-bottom:1px solid var(--border)}
.fresh-row:last-child{border-bottom:none}
.fresh-source{font-size:10px;color:var(--t2);font-weight:500}
.fresh-val{font-size:9px;font-weight:700;font-family:var(--mono);display:flex;align-items:center;gap:4px}
.fresh-dot{width:5px;height:5px;border-radius:50%;flex-shrink:0}
.fresh-dot{width:5px;height:5px;border-radius: var(--radius-full);flex-shrink:0}
.fresh-dot.live{background:#22c55e;animation:pulse-ring 1.5s ease-in-out infinite}
.fresh-dot.recent{background:#fbbf24}
.fresh-dot.stale{background:#f87171}
@@ -121,33 +121,33 @@ html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text'
#map-logo em{color:var(--accent3);font-style:normal}
.map-sep{width:1px;height:16px;background:rgba(0,0,0,0.12)}
#map-city-label{font-size:12px;font-weight:700;color:var(--text);flex:1}
#map-profile-chip{font-size:10px;font-weight:700;border-radius:4px;padding:3px 10px;border:1px solid;letter-spacing:0.3px}
#map-profile-chip{font-size:10px;font-weight:700;border-radius: var(--radius-sm);padding:3px 10px;border:1px solid;letter-spacing:0.3px}
#map-conf{font-size:10px;font-weight:600;color:var(--t2)}
/* Layer toggle bar */
#layer-bar{position:absolute;top:44px;left:0;right:0;z-index:999;display:flex;align-items:center;gap:4px;padding:5px 8px;background:#ffffff;border-bottom:1px solid rgba(0,0,0,0.1);overflow-x:auto;scrollbar-width:none;flex-wrap:nowrap;-webkit-overflow-scrolling:touch}
#layer-bar::-webkit-scrollbar{display:none}
#layer-bar-label{font-size:9px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:var(--t3);white-space:nowrap;margin-right:4px;flex-shrink:0}
.ltoggle{display:flex;align-items:center;gap:5px;font-size:11px;font-weight:600;color:var(--t2);background:rgba(0,0,0,0.05);border:none;border-radius:20px;padding:5px 12px;cursor:pointer;transition:all 0.15s;white-space:nowrap;user-select:none;flex-shrink:0}
.ltoggle{display:flex;align-items:center;gap:5px;font-size:11px;font-weight:600;color:var(--t2);background:rgba(0,0,0,0.05);border:none;border-radius: var(--radius-xl);padding:5px 12px;cursor:pointer;transition:all 0.15s;white-space:nowrap;user-select:none;flex-shrink:0}
.ltoggle:hover{border-color:var(--b2);color:var(--text)}
.ltoggle.on{background:var(--accent);color: #1d1d1ffff;}
.ltoggle-dot{width:6px;height:6px;border-radius:50%;flex-shrink:0}
.ltoggle-dot{width:6px;height:6px;border-radius: var(--radius-full);flex-shrink:0}
.ltoggle-dot.d-districts{background:#4aafef}
.ltoggle-dot.d-roadwork{background:#fb923c}
.ltoggle-dot.d-pulse{background:#c084fc;animation:pulse-ring 1.5s ease-in-out infinite}
.ltoggle-dot.d-alerts{background:#f87171}
.leaflet-control-zoom{margin-top:96px !important;margin-left:10px !important}
.leaflet-control-zoom a{background:rgba(11,18,32,0.9) !important;color:var(--text) !important;border-color:var(--b2) !important;width:30px !important;height:30px !important;line-height:30px !important;border-radius:5px !important;font-size:16px !important}
.leaflet-control-zoom a{background:rgba(11,18,32,0.9) !important;color:var(--text) !important;border-color:var(--b2) !important;width:30px !important;height:30px !important;line-height:30px !important;border-radius: var(--radius-sm) !important;font-size:16px !important}
.leaflet-popup-pane{z-index:1500 !important}
.leaflet-popup-content-wrapper{background:#ffffff !important;border:none !important;border-radius:16px !important;box-shadow:0 8px 32px rgba(0,0,0,0.15) !important;padding:0 !important;max-height:60vh !important;overflow-y:auto !important}
.leaflet-popup-content-wrapper{background:#ffffff !important;border:none !important;border-radius: var(--radius-lg) !important;box-shadow:0 8px 32px rgba(0,0,0,0.15) !important;padding:0 !important;max-height:60vh !important;overflow-y:auto !important}
.leaflet-popup-content{margin:0 !important;width:260px !important;max-height:55vh !important;overflow-y:auto !important}
@media(max-width:900px){
/* Hide Leaflet popup on mobile — use bottom sheet instead */
.leaflet-popup{display:none !important}
}
/* ── MOBILE BOTTOM SHEET ── */
/* MOBILE BOTTOM SHEET */
#bottom-sheet{
display:none;
position:fixed;
@@ -155,7 +155,7 @@ html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text'
z-index:9999;
background:#ffffff;
border-top:1px solid rgba(0,0,0,0.1);
border-radius:16px 16px 0 0;
border-radius: var(--radius-lg) 16px 0 0;
box-shadow:0 -4px 24px rgba(0,0,0,0.12);
max-height:72vh;
overflow-y:auto;
@@ -168,7 +168,7 @@ html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text'
transform:translateY(0);
}
#bottom-sheet-handle{
width:36px;height:4px;border-radius:2px;
width:36px;height:4px;border-radius: var(--radius-sm);
background:rgba(0,0,0,0.2);
margin:10px auto 0;
}
@@ -176,7 +176,7 @@ html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text'
position:absolute;top:12px;right:14px;
background:rgba(0,0,0,0.1);border:none;
color:var(--text);font-size:16px;
width:28px;height:28px;border-radius:50%;
width:28px;height:28px;border-radius: var(--radius-full);
cursor:pointer;display:flex;align-items:center;justify-content:center;
line-height:1;
}
@@ -204,10 +204,10 @@ html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text'
.mpop-bars{display:flex;flex-direction:column;gap:5px;margin-bottom:10px;padding-bottom:10px;border-bottom:1px solid var(--border)}
.mpop-bar-row{display:flex;align-items:center;gap:6px}
.mpop-bar-lbl{font-size:9px;font-weight:600;letter-spacing:0.3px;text-transform:uppercase;color:var(--t3);width:72px;flex-shrink:0}
.mpop-bar-track{flex:1;height:3px;border-radius:2px;background:rgba(0,0,0,0.04)}
.mpop-bar-fill{height:100%;border-radius:2px;transition:width 0.3s}
.mpop-bar-track{flex:1;height:3px;border-radius: var(--radius-sm);background:rgba(0,0,0,0.04)}
.mpop-bar-fill{height:100%;border-radius: var(--radius-sm);transition:width 0.3s}
.mpop-bar-val{font-size:10px;font-weight:700;font-family:var(--mono);width:24px;text-align:right}
.mpop-contra{font-size:10px;border-radius:6px;padding:6px 9px;line-height:1.4;margin-bottom:8px}
.mpop-contra{font-size:10px;border-radius: var(--radius-sm);padding:6px 9px;line-height:1.4;margin-bottom:8px}
.mpop-contra.low{background:rgba(22,163,74,0.1);color:#4ade80;border:1px solid rgba(22,163,74,0.15)}
.mpop-contra.medium{background:rgba(217,119,6,0.1);color:#fbbf24;border:1px solid rgba(217,119,6,0.15)}
.mpop-contra.high{background:rgba(220,38,38,0.1);color:#f87171;border:1px solid rgba(220,38,38,0.15)}
@@ -219,7 +219,7 @@ html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text'
.rwpop-eyebrow{font-size:9px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:#fb923c;margin-bottom:3px}
.rwpop-name{font-size:13px;font-weight:800;color:var(--text);margin-bottom:6px}
.rwpop-desc{font-size:11px;color:var(--t2);line-height:1.5;margin-bottom:6px}
.rwpop-badge{display:inline-flex;align-items:center;gap:4px;font-size:9px;font-weight:700;border-radius:4px;padding:2px 8px;background:rgba(251,146,60,0.12);border:1px solid rgba(251,146,60,0.25);color:#fb923c}
.rwpop-badge{display:inline-flex;align-items:center;gap:4px;font-size:9px;font-weight:700;border-radius: var(--radius-sm);padding:2px 8px;background:rgba(251,146,60,0.12);border:1px solid rgba(251,146,60,0.25);color:#fb923c}
.rwpop-src{font-size:9px;color:var(--t3);margin-top:6px}
/* Live Pulse animation */
@@ -233,11 +233,11 @@ html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text'
100%{transform:scale(3.2);opacity:0}
}
.src-chain{font-size:9px;color:rgba(220,232,245,0.25);border-top:1px solid rgba(0,0,0,0.03);padding-top:5px;margin-top:5px;line-height:1.6}
.src-tag{display:inline-flex;align-items:center;gap:3px;background:rgba(0,0,0,0.04);border-radius:3px;padding:1px 5px;margin:1px 2px 1px 0;font-size:9px;font-weight:600;color:rgba(220,232,245,0.35)}
.src-tag{display:inline-flex;align-items:center;gap:3px;background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:1px 5px;margin:1px 2px 1px 0;font-size:9px;font-weight:600;color:rgba(220,232,245,0.35)}
.src-data{color:#38bdf8}.src-obs{color:#4ade80}.src-model{color:#a78bfa}.src-forecast{color:#f59e0b}
.pulse-marker{pointer-events:none}
.pulse-outer{position:absolute;border-radius:50%;animation:pulse-wave 2.2s ease-out infinite}
.pulse-inner{position:absolute;border-radius:50%;transform:translate(-50%,-50%)}
.pulse-outer{position:absolute;border-radius: var(--radius-full);animation:pulse-wave 2.2s ease-out infinite}
.pulse-inner{position:absolute;border-radius: var(--radius-full);transform:translate(-50%,-50%)}
/* RIGHT */
#right{background:var(--s1);border-left:1px solid rgba(0,0,0,0.08);overflow-y:auto;display:flex;flex-direction:column;scrollbar-width:none}
@@ -245,7 +245,7 @@ html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text'
#right-profile-label{font-size:9px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:var(--t3);margin-bottom:4px}
#right-title{font-size:17px;font-weight:700;color:var(--text);letter-spacing:-0.3px}
#kpi-grid{padding:10px 16px 8px;display:grid;grid-template-columns:1fr 1fr;gap:6px;border-bottom:1px solid var(--border)}
.kpi{background:var(--s2);border:none;border-radius:12px;padding:12px 14px}
.kpi{background:var(--s2);border:none;border-radius: var(--radius-md);padding:12px 14px}
.kpi-label{font-size:10px;font-weight:700;letter-spacing:0.5px;text-transform:uppercase;margin-bottom:4px}
.kpi-value{font-size:22px;font-weight:800;letter-spacing:-0.5px;font-family:var(--mono);line-height:1}
.kpi-sub{font-size:11px;color:var(--t2);margin-top:3px;line-height:1.3}
@@ -263,37 +263,37 @@ html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text'
#contra-block{padding:12px 16px;border-bottom:1px solid var(--border)}
#contra-header{display:flex;align-items:center;justify-content:space-between;margin-bottom:8px}
#contra-title{font-size:9px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:var(--t3);display:flex;align-items:center;gap:5px}
.contra-live-dot{width:5px;height:5px;border-radius:50%;background:#f87171;animation:pulse-ring 1.8s ease-in-out infinite}
.contra-count{font-size:9px;font-weight:700;font-family:var(--mono);background:rgba(220,38,38,0.15);border:1px solid rgba(220,38,38,0.25);color:#f87171;padding:1px 7px;border-radius:10px}
.contra-card{border-radius:7px;border:1px solid var(--border);background:var(--s2);padding:9px 11px;margin-bottom:5px}
.contra-live-dot{width:5px;height:5px;border-radius: var(--radius-full);background:#f87171;animation:pulse-ring 1.8s ease-in-out infinite}
.contra-count{font-size:9px;font-weight:700;font-family:var(--mono);background:rgba(220,38,38,0.15);border:1px solid rgba(220,38,38,0.25);color:#f87171;padding:1px 7px;border-radius: var(--radius-md)}
.contra-card{border-radius: var(--radius-sm);border:1px solid var(--border);background:var(--s2);padding:9px 11px;margin-bottom:5px}
.contra-card.c-medium{border-left:3px solid rgba(217,119,6,0.5)}
.contra-card.c-high{border-left:3px solid rgba(220,38,38,0.6)}
.contra-card-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:3px}
.contra-card-name{font-size:11px;font-weight:700;color:var(--text)}
.contra-card-badge{font-size:8px;font-weight:700;letter-spacing:0.5px;text-transform:uppercase;padding:1px 6px;border-radius:3px}
.contra-card-badge{font-size:8px;font-weight:700;letter-spacing:0.5px;text-transform:uppercase;padding:1px 6px;border-radius: var(--radius-sm)}
.contra-card-badge.medium{background:rgba(217,119,6,0.12);color:#fbbf24}
.contra-card-badge.high{background:rgba(220,38,38,0.12);color:#f87171}
.contra-card-desc{font-size:10px;color:var(--t2);line-height:1.4;margin-bottom:6px}
.contra-card-meta{display:flex;align-items:center;justify-content:space-between}
.contra-card-date{font-size:9px;color:var(--t3);font-family:var(--mono)}
.contra-view-btn{font-size:9px;font-weight:700;letter-spacing:0.3px;padding:3px 9px;border-radius:4px;border:1px solid var(--b2);background:transparent;color:var(--accent3);cursor:pointer;transition:all 0.12s}
.contra-view-btn{font-size:9px;font-weight:700;letter-spacing:0.3px;padding:3px 9px;border-radius: var(--radius-sm);border:1px solid var(--b2);background:transparent;color:var(--accent3);cursor:pointer;transition:all 0.12s}
.contra-view-btn:hover{background:rgba(74,175,239,0.1);border-color:var(--accent2)}
#caution-block{padding:12px 16px;border-bottom:1px solid var(--border)}
.caution-card{background:rgba(220,38,38,0.06);border:1px solid rgba(220,38,38,0.15);border-radius:8px;padding:10px 12px}
.caution-card{background:rgba(220,38,38,0.06);border:1px solid rgba(220,38,38,0.15);border-radius: var(--radius-md);padding:10px 12px}
.caution-lbl{font-size:10px;font-weight:700;letter-spacing:0.5px;text-transform:uppercase;color:var(--red3);margin-bottom:6px}
.caution-row{display:flex;gap:7px;align-items:flex-start;padding:3px 0;font-size:12px;color:var(--text);line-height:1.5}
.caution-dot{width:4px;height:4px;border-radius:50%;background:var(--red2);flex-shrink:0;margin-top:4px}
.caution-dot{width:4px;height:4px;border-radius: var(--radius-full);background:var(--red2);flex-shrink:0;margin-top:4px}
#right-footer{padding:10px 16px;font-size:9px;color:var(--t3);line-height:1.4;border-top:1px solid var(--border)}
#right-footer a{color:var(--accent3);text-decoration:none}
/* ── MOBILE — iOS native style ── */
/* MOBILE — iOS native style */
@media(max-width:900px){
html,body{height:auto;overflow-y:auto;overflow-x:hidden;-webkit-overflow-scrolling:touch}
#app{display:flex;flex-direction:column;height:auto;min-height:100dvh;overflow:visible}
/* ── TOP BAR — kompakt native ── */
/* TOP BAR — kompakt native */
#left{order:1;border-right:none;border-bottom:none;background:var(--s1);display:flex;flex-direction:column;height:auto;overflow:visible}
#city-block{padding:12px 16px 8px}
#city-eyebrow{font-size:9px;letter-spacing:1.5px}
@@ -306,12 +306,12 @@ html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text'
/* hide less important stats */
.cstat:nth-child(3),.cstat:nth-child(4){display:none}
/* ── PROFILE TABS — iOS segmented control style ── */
/* PROFILE TABS — iOS segmented control style */
#profile-block{padding:8px 16px;border-bottom:1px solid var(--border)}
#profile-lbl{display:none}
#profile-tabs{display:flex;overflow-x:auto;gap:0;scrollbar-width:none;background:rgba(0,0,0,0.04);border-radius:10px;padding:2px}
#profile-tabs{display:flex;overflow-x:auto;gap:0;scrollbar-width:none;background:rgba(0,0,0,0.04);border-radius: var(--radius-md);padding:2px}
#profile-tabs::-webkit-scrollbar{display:none}
.ptab{flex-shrink:0;flex-direction:row;gap:4px;padding:6px 14px;font-size:12px;font-weight:600;border-radius:8px;border:none;background:transparent;white-space:nowrap;min-width:auto;color:rgba(220,232,245,0.55)}
.ptab{flex-shrink:0;flex-direction:row;gap:4px;padding:6px 14px;font-size:12px;font-weight:600;border-radius: var(--radius-md);border:none;background:transparent;white-space:nowrap;min-width:auto;color:rgba(220,232,245,0.55)}
.ptab .picon{display:none}
.ptab.active{background:rgba(220,232,245,0.15);color:#dce8f5;font-weight:700}
/* Active profile colors */
@@ -322,18 +322,18 @@ html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text'
.ptab.active.p-nightlife{background:rgba(147,51,234,0.2);color:#c084fc}
.ptab.active.p-infrastructure{background:rgba(180,83,9,0.2);color:#fcd34d}
/* ── HIDE DISTRICT LIST + FRESHNESS on mobile ── */
/* HIDE DISTRICT LIST + FRESHNESS on mobile */
#district-block{display:none}
#freshness-block{display:none}
/* ── MAP — hero element, karta first ── */
/* MAP — hero element, karta first */
#map-wrap{order:2;height:60vh;min-height:340px;max-height:520px;position:relative}
#map-topbar{height:44px;padding:0 14px;display:flex;align-items:center}
#map-logo{font-size:14px;font-weight:800}
.map-sep{display:none}
#map-city-label{font-size:12px;font-weight:600}
#map-conf{display:none}
#map-profile-chip{font-size:10px;padding:3px 10px;border-radius:20px}
#map-profile-chip{font-size:10px;padding:3px 10px;border-radius: var(--radius-xl)}
/* Layer bar — scrollable, compact */
#layer-bar{
@@ -349,19 +349,19 @@ html,body{height:100%;font-family:-apple-system,BlinkMacSystemFont,'SF Pro Text'
-webkit-overflow-scrolling:touch
}
#layer-bar::-webkit-scrollbar{display:none}
.ltoggle{font-size:10px;padding:4px 10px;border-radius:20px;gap:4px;flex-shrink:0;color:rgba(28,28,30,0.65)}
.ltoggle{font-size:10px;padding:4px 10px;border-radius: var(--radius-xl);gap:4px;flex-shrink:0;color:rgba(28,28,30,0.65)}
.ltoggle-dot{width:6px;height:6px}
.leaflet-control-zoom{margin-top:92px !important;margin-left:10px !important}
.leaflet-control-zoom a{background:#ffffff !important;color:#1c1c1e !important;border:none !important;border-bottom:1px solid rgba(0,0,0,0.08) !important}
.leaflet-control-zoom a:last-child{border-bottom:none !important}
.leaflet-control-zoom a{width:30px !important;height:30px !important;line-height:30px !important;font-size:16px !important;border-radius:8px !important}
.leaflet-control-zoom a{width:30px !important;height:30px !important;line-height:30px !important;font-size:16px !important;border-radius: var(--radius-md) !important}
/* ── RIGHT PANEL — card stack below map ── */
/* RIGHT PANEL — card stack below map */
#right{order:3;border-left:none;border-top:1px solid var(--border);overflow:visible;height:auto}
#right-header{padding:12px 16px 8px}
#right-title{font-size:14px;font-weight:800}
#kpi-grid{grid-template-columns:1fr 1fr;gap:6px;padding:0 16px 12px}
.kpi{border-radius:10px;padding:10px 12px}
.kpi{border-radius: var(--radius-md);padding:10px 12px}
.kpi-label{font-size:9px}
.kpi-value{font-size:17px}
.kpi-sub{font-size:9px}
@@ -619,9 +619,9 @@ if('serviceWorker' in navigator){
<div id="map-profile-chip" style="background:rgba(0,122,255,0.12);color:#0066cc;border-color:rgba(0,122,255,0.25)">Business</div>
<div id="map-conf" style="font-size:9px;color:rgba(220,232,245,0.3)">Source First</div>
<div style="display:flex;gap:4px;margin-left:auto">
<button onclick="map.setView([59.370,18.040],9)" style="background:rgba(0,0,0,0.06);border:none;border-radius:6px;padding:4px 8px;font-size:10px;font-weight:600;color:rgba(28,28,30,0.6);cursor:pointer">SE</button>
<button onclick="map.setView([52.5,10.0],5)" style="background:rgba(0,0,0,0.06);border:none;border-radius:6px;padding:4px 8px;font-size:10px;font-weight:600;color:rgba(28,28,30,0.6);cursor:pointer">EU</button>
<button onclick="map.setView([25.0,40.0],3)" style="background:rgba(0,0,0,0.06);border:none;border-radius:6px;padding:4px 8px;font-size:10px;font-weight:600;color:rgba(28,28,30,0.6);cursor:pointer">Global</button>
<button onclick="map.setView([59.370,18.040],9)" style="background:rgba(0,0,0,0.06);border:none;border-radius: var(--radius-sm);padding:4px 8px;font-size:10px;font-weight:600;color:rgba(28,28,30,0.6);cursor:pointer">SE</button>
<button onclick="map.setView([52.5,10.0],5)" style="background:rgba(0,0,0,0.06);border:none;border-radius: var(--radius-sm);padding:4px 8px;font-size:10px;font-weight:600;color:rgba(28,28,30,0.6);cursor:pointer">EU</button>
<button onclick="map.setView([25.0,40.0],3)" style="background:rgba(0,0,0,0.06);border:none;border-radius: var(--radius-sm);padding:4px 8px;font-size:10px;font-weight:600;color:rgba(28,28,30,0.6);cursor:pointer">Global</button>
</div>
</div>
<!-- Layer Toggles -->
@@ -647,14 +647,14 @@ if('serviceWorker' in navigator){
</div>
</div>
<div id="map"></div>
<div id="map-legend" style="position:absolute;bottom:60px;right:10px;z-index:800;background:rgba(0,0,0,0.92);backdrop-filter:blur(12px);border:1px solid rgba(0,0,0,0.08);border-radius:10px;padding:10px 12px;font-family:-apple-system,sans-serif">
<div id="map-legend" style="position:absolute;bottom:60px;right:10px;z-index:800;background:rgba(0,0,0,0.92);backdrop-filter:blur(12px);border:1px solid rgba(0,0,0,0.08);border-radius: var(--radius-md);padding:10px 12px;font-family:-apple-system,sans-serif">
<div style="font-size:9px;font-weight:700;letter-spacing:0.8px;text-transform:uppercase;color:rgba(28,28,30,0.45);margin-bottom:7px">Intelligence Score</div>
<div style="display:flex;flex-direction:column;gap:5px">
<div style="display:flex;align-items:center;gap:7px;font-size:11px;color:rgba(28,28,30,0.75)"><div style="width:12px;height:12px;border-radius:50%;background:#22c55e;flex-shrink:0"></div>Strong (810)</div>
<div style="display:flex;align-items:center;gap:7px;font-size:11px;color:rgba(28,28,30,0.75)"><div style="width:12px;height:12px;border-radius:50%;background:#eab308;flex-shrink:0"></div>Moderate (68)</div>
<div style="display:flex;align-items:center;gap:7px;font-size:11px;color:rgba(28,28,30,0.75)"><div style="width:12px;height:12px;border-radius:50%;background:#f97316;flex-shrink:0"></div>Weak (46)</div>
<div style="display:flex;align-items:center;gap:7px;font-size:11px;color:rgba(28,28,30,0.75)"><div style="width:12px;height:12px;border-radius:50%;background:#ef4444;flex-shrink:0"></div>Poor (&lt;4)</div>
<div style="display:flex;align-items:center;gap:7px;font-size:11px;color:rgba(28,28,30,0.75);border-top:1px solid rgba(0,0,0,0.08);padding-top:5px"><div style="width:12px;height:3px;background:rgba(239,68,68,0.6);flex-shrink:0;border-radius:1px"></div>Contradiction</div>
<div style="display:flex;align-items:center;gap:7px;font-size:11px;color:rgba(28,28,30,0.75)"><div style="width:12px;height:12px;border-radius: var(--radius-full);background:#22c55e;flex-shrink:0"></div>Strong (810)</div>
<div style="display:flex;align-items:center;gap:7px;font-size:11px;color:rgba(28,28,30,0.75)"><div style="width:12px;height:12px;border-radius: var(--radius-full);background:#eab308;flex-shrink:0"></div>Moderate (68)</div>
<div style="display:flex;align-items:center;gap:7px;font-size:11px;color:rgba(28,28,30,0.75)"><div style="width:12px;height:12px;border-radius: var(--radius-full);background:#f97316;flex-shrink:0"></div>Weak (46)</div>
<div style="display:flex;align-items:center;gap:7px;font-size:11px;color:rgba(28,28,30,0.75)"><div style="width:12px;height:12px;border-radius: var(--radius-full);background:#ef4444;flex-shrink:0"></div>Poor (&lt;4)</div>
<div style="display:flex;align-items:center;gap:7px;font-size:11px;color:rgba(28,28,30,0.75);border-top:1px solid rgba(0,0,0,0.08);padding-top:5px"><div style="width:12px;height:3px;background:rgba(239,68,68,0.6);flex-shrink:0;border-radius: 1px"></div>Contradiction</div>
</div>
<div style="font-size:9px;color:rgba(28,28,30,0.35);margin-top:6px">Tap any area for details</div>
</div>
@@ -691,7 +691,7 @@ if('serviceWorker' in navigator){
<div id="events-engine" style="padding:12px 16px;border-bottom:1px solid var(--border)">
<div style="font-size:10px;font-weight:700;letter-spacing:0.5px;text-transform:uppercase;color:rgba(28,28,30,0.45);margin-bottom:8px;display:flex;align-items:center;justify-content:space-between">
Change Events
<span style="font-size:9px;font-weight:700;background:rgba(239,68,68,0.15);color:#f87171;border-radius:4px;padding:1px 7px" id="event-critical-count">0 critical</span>
<span style="font-size:9px;font-weight:700;background:rgba(239,68,68,0.15);color:#f87171;border-radius: var(--radius-sm);padding:1px 7px" id="event-critical-count">0 critical</span>
</div>
<div id="event-list"></div>
</div>
@@ -702,17 +702,17 @@ if('serviceWorker' in navigator){
Weekly Intelligence Brief
<span style="font-size:11px;color:rgba(28,28,30,0.55)">Week 25 · Jun 1621</span>
</div>
<div style="background:#fff5f5;border-radius:8px;padding:10px 12px;margin-bottom:6px;border-left:3px solid #ef4444">
<div style="background:#fff5f5;border-radius: var(--radius-md);padding:10px 12px;margin-bottom:6px;border-left:3px solid #ef4444">
<div style="font-size:13px;font-weight:700;color:#d70015;margin-bottom:4px">01 · Security deterioration, Rinkeby</div>
<div style="font-size:12px;color:rgba(28,28,30,0.75);line-height:1.5">Police events up 280% vs 4-week avg. Contradiction Index: HIGH. Official classification has not been updated. Recommend: escalate to municipal review.</div>
<div style="font-size:10px;color:rgba(28,28,30,0.45);margin-top:4px">Sources: Polisen.se · quiXzoom 1 102 obs · Contradiction confirmed</div>
</div>
<div style="background:#faf5ff;border-radius:8px;padding:10px 12px;margin-bottom:6px;border-left:3px solid #a855f7">
<div style="background:#faf5ff;border-radius: var(--radius-md);padding:10px 12px;margin-bottom:6px;border-left:3px solid #a855f7">
<div style="font-size:13px;font-weight:700;color:#6e1e8f;margin-bottom:4px">02 · Strategic signal: Nacka acceleration</div>
<div style="font-size:12px;color:rgba(28,28,30,0.75);line-height:1.5">Tunnel milestone confirmed 14 months ahead of schedule. Commercial permit applications +34% MoM. Investment thesis: accelerating.</div>
<div style="font-size:10px;color:rgba(28,28,30,0.45);margin-top:4px">Sources: Trafikverket · Nacka kommun · quiXzoom 389 obs</div>
</div>
<div style="background:#fff7ed;border-radius:8px;padding:10px 12px;border-left:3px solid #f97316">
<div style="background:#fff7ed;border-radius: var(--radius-md);padding:10px 12px;border-left:3px solid #f97316">
<div style="font-size:13px;font-weight:700;color:#b86a00;margin-bottom:4px">03 · Commercial vacancy rising, Södermalm</div>
<div style="font-size:12px;color:rgba(28,28,30,0.75);line-height:1.5">18 new vacancies detected on Götgatan corridor. Pattern matches early-stage commercial stress. Monitor: if +8 more in 30 days, escalate to Important.</div>
<div style="font-size:10px;color:rgba(28,28,30,0.45);margin-top:4px">Sources: quiXzoom 47 obs · Fastighetsregistret · quiXzoom Change Detection</div>
@@ -723,32 +723,32 @@ if('serviceWorker' in navigator){
<div id="macro-block" style="padding:12px 16px;border-bottom:1px solid var(--border)">
<div style="font-size:10px;font-weight:700;letter-spacing:0.5px;text-transform:uppercase;color:rgba(28,28,30,0.45);margin-bottom:8px">City Macro Intelligence</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:5px;margin-bottom:8px" id="macro-grid">
<div style="background:var(--s2);border:1px solid var(--border);border-radius:7px;padding:8px 10px">
<div style="background:var(--s2);border:1px solid var(--border);border-radius: var(--radius-sm);padding:8px 10px">
<div style="font-size:9px;color:var(--t3);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:3px">GDP / capita</div>
<div style="font-size:15px;font-weight:800;color:#4ade80;font-family:var(--mono)" id="gdp-val">Loading</div>
<div style="font-size:9px;color:var(--t3);margin-top:1px">USD · World Bank</div>
</div>
<div style="background:var(--s2);border:1px solid var(--border);border-radius:7px;padding:8px 10px">
<div style="background:var(--s2);border:1px solid var(--border);border-radius: var(--radius-sm);padding:8px 10px">
<div style="font-size:9px;color:var(--t3);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:3px">Political Stability</div>
<div style="font-size:15px;font-weight:800;color:#38bdf8;font-family:var(--mono)">8.9<span style="font-size:10px;font-weight:500">/10</span></div>
<div style="font-size:9px;color:var(--t3);margin-top:1px">WGI 2023 · top 5% global</div>
</div>
<div style="background:var(--s2);border:1px solid var(--border);border-radius:7px;padding:8px 10px">
<div style="background:var(--s2);border:1px solid var(--border);border-radius: var(--radius-sm);padding:8px 10px">
<div style="font-size:9px;color:var(--t3);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:3px">Rule of Law</div>
<div style="font-size:15px;font-weight:800;color:#38bdf8;font-family:var(--mono)">9.2<span style="font-size:10px;font-weight:500">/10</span></div>
<div style="font-size:9px;color:var(--t3);margin-top:1px">WGI 2023 · Rank 6 global</div>
</div>
<div style="background:var(--s2);border:1px solid var(--border);border-radius:7px;padding:8px 10px">
<div style="background:var(--s2);border:1px solid var(--border);border-radius: var(--radius-sm);padding:8px 10px">
<div style="font-size:9px;color:var(--t3);text-transform:uppercase;letter-spacing:0.5px;margin-bottom:3px">Corruption Index</div>
<div style="font-size:15px;font-weight:800;color:#4ade80;font-family:var(--mono)">82<span style="font-size:10px;font-weight:500">/100</span></div>
<div style="font-size:9px;color:var(--t3);margin-top:1px">TI CPI 2024 · Rank 6</div>
</div>
</div>
<!-- Security level -->
<div style="background:rgba(34,197,94,0.08);border:1px solid rgba(34,197,94,0.18);border-radius:7px;padding:8px 11px">
<div style="background:rgba(34,197,94,0.08);border:1px solid rgba(34,197,94,0.18);border-radius: var(--radius-sm);padding:8px 11px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px">
<div style="font-size:10px;font-weight:700;color:#4ade80">Security Assessment</div>
<div style="font-size:9px;font-weight:700;background:rgba(34,197,94,0.15);color:#4ade80;border-radius:4px;padding:1px 7px">LOW RISK</div>
<div style="font-size:9px;font-weight:700;background:rgba(34,197,94,0.15);color:#4ade80;border-radius: var(--radius-sm);padding:1px 7px">LOW RISK</div>
</div>
<div style="font-size:10px;color:rgba(220,232,245,0.55);line-height:1.5">Sweden maintains one of Europes most stable security environments. No active conflict. Terrorism risk: low-medium (NCSD level 3/5). Gang-related violence concentrated in specific outer suburbs.</div>
<div style="font-size:9px;color:rgba(220,232,245,0.3);margin-top:5px">Sources: SÄPO 2025 · GPI 2024 (rank 14) · OSAC</div>
@@ -773,10 +773,10 @@ if('serviceWorker' in navigator){
<div id="right-footer">
<div style="font-size:9px;color:rgba(28,28,30,0.5);line-height:1.6">
<div style="font-weight:700;color:rgba(28,28,30,0.65);margin-bottom:4px;letter-spacing:0.5px;text-transform:uppercase">Source First Architecture</div>
<span style="background:rgba(56,189,248,0.1);color:#38bdf8;border-radius:3px;padding:1px 5px;margin-right:3px;font-weight:600">DATA</span> Identifiable sources with date + confidence<br>
<span style="background:rgba(74,222,128,0.1);color:#4ade80;border-radius:3px;padding:1px 5px;margin-right:3px;font-weight:600">OBS</span> quiXzoom verified field observations<br>
<span style="background:rgba(167,139,250,0.1);color:#a78bfa;border-radius:3px;padding:1px 5px;margin-right:3px;font-weight:600">MODEL</span> Transparent index with listed inputs<br>
<span style="background:rgba(245,158,11,0.1);color:#f59e0b;border-radius:3px;padding:1px 5px;margin-right:3px;font-weight:600">FORECAST</span> Statistical patterns, not predictions<br>
<span style="background:rgba(56,189,248,0.1);color:#38bdf8;border-radius: var(--radius-sm);padding:1px 5px;margin-right:3px;font-weight:600">DATA</span> Identifiable sources with date + confidence<br>
<span style="background:rgba(74,222,128,0.1);color:#4ade80;border-radius: var(--radius-sm);padding:1px 5px;margin-right:3px;font-weight:600">OBS</span> quiXzoom verified field observations<br>
<span style="background:rgba(167,139,250,0.1);color:#a78bfa;border-radius: var(--radius-sm);padding:1px 5px;margin-right:3px;font-weight:600">MODEL</span> Transparent index with listed inputs<br>
<span style="background:rgba(245,158,11,0.1);color:#f59e0b;border-radius: var(--radius-sm);padding:1px 5px;margin-right:3px;font-weight:600">FORECAST</span> Statistical patterns, not predictions<br>
<a href="/methodology/" style="color:rgba(220,232,245,0.3);margin-top:4px;display:block">Full methodology →</a>
</div>
</div>
@@ -878,7 +878,7 @@ const D=[
col:'#16a34a', umi:+44,
confidence:{score:92,observation_count:614},pulse:{level:0.74,label:'High Activity'}},
/* ── Stockholm extra districts ── */
/* Stockholm extra districts */
{id:'djurgarden', name:'Djurgården', lat:59.326, lng:18.110, r:700,
s:{business:5.2,tourist:9.8,safety:9.1,investment:7.4,nightlife:3.2,infrastructure:8.4},
c:'low', ct:'Observed environment matches official data. Premium cultural destination.',
@@ -907,7 +907,7 @@ const D=[
col:'#d97706', umi:+14,
confidence:{score:82,observation_count:198},pulse:{level:0.28,label:'Low Activity'}},
/* ── Göteborg districts ── */
/* Göteborg districts */
{id:'gbg-centrum', name:'Göteborg Centrum', lat:57.706, lng:11.967, r:1200,
s:{business:8.4,tourist:8.9,safety:7.8,investment:8.1,nightlife:8.6,infrastructure:8.8},
c:'medium', ct:'Some traffic congestion underreported in official data. Commercial activity slightly above classification.',
@@ -950,7 +950,7 @@ const D=[
col:'#16a34a', umi:+22,
confidence:{score:88,observation_count:223},pulse:{level:0.32,label:'Low Activity'}},
/* ── Malmö districts ── */
/* Malmö districts */
{id:'malmo-centrum', name:'Malmö Centrum', lat:55.604, lng:13.003, r:1100,
s:{business:7.8,tourist:8.2,safety:7.1,investment:7.8,nightlife:7.9,infrastructure:8.4},
c:'medium', ct:'Night safety slightly below official classification. Commercial activity aligns well.',
@@ -1112,7 +1112,7 @@ const D=[
ins:{business:'Port commerce, Pfizer, regional HQ.',tourist:'Ferry to Denmark, Kärnan.',safety:'Good.',investment:'Appreciation trend positive.',nightlife:'Growing.',infrastructure:'Tåg, ferry. Direct to Copenhagen.'},
col:'#16a34a', umi:+24},
/* ── Stockholm kranskommuner (batch 2) ── */
/* Stockholm kranskommuner (batch 2) */
{id:'sthlm-botkyrka', name:'Botkyrka', lat:59.201, lng:17.834, r:2400,
s:{business:5.1,tourist:3.2,safety:5.8,investment:6.8,nightlife:3.1,infrastructure:6.9},
c:'high', ct:'Contradiction detected: official data underreports social challenges in Alby/Norsborg. Infrastructure maintenance gaps observed.',
@@ -1209,7 +1209,7 @@ const D=[
ins:{business:'Tourism, local.',tourist:'Birka Viking UNESCO, Drottningholm.',safety:'Top safety.',investment:'Premium island living.',nightlife:'None.',infrastructure:'Bridge, ferry.'},
col:'#16a34a', umi:+16},
// ── GÖTEBORGSREGIONEN ──────────────────────────────────────────
// GÖTEBORGSREGIONEN
{id:'gbg-partille', name:'Partille', lat:57.738, lng:12.106, r:1600,
s:{business:6.8,tourist:3.4,safety:8.8,investment:7.8,nightlife:3.2,infrastructure:7.6},
c:'low', ct:'Affluent eastern suburb. Allum retail strong.',
@@ -1258,7 +1258,7 @@ const D=[
ins:{business:'Tourism, boatbuilding.',tourist:'West coast archipelago.',safety:'Top.',investment:'Premium summer.',nightlife:'Summer only.',infrastructure:'Bridge, ferry.'},
col:'#16a34a', umi:+12},
// ── MALMÖ / SKÅNE ──────────────────────────────────────────────
// MALMÖ / SKÅNE
{id:'skane-kristianstad', name:'Kristianstad', lat:56.031, lng:14.154, r:2400,
s:{business:6.8,tourist:6.4,safety:8.6,investment:7.1,nightlife:5.2,infrastructure:7.4},
c:'low', ct:'Regional capital eastern Skåne. Stable.',
@@ -1307,7 +1307,7 @@ const D=[
ins:{business:'Local services.',tourist:'Falsterbo, beaches.',safety:'Highest in Skåne.',investment:'Premium coastal demand.',nightlife:'Very limited.',infrastructure:'Bus.'},
col:'#16a34a', umi:+16},
// ── UPPSALA ────────────────────────────────────────────────────
// UPPSALA
{id:'uppsala-centrum', name:'Uppsala', lat:59.858, lng:17.645, r:2200,
s:{business:8.1,tourist:7.8,safety:8.6,investment:8.2,nightlife:7.4,infrastructure:8.4},
c:'low', ct:'University city. Strong alignment official/observed.',
@@ -1320,98 +1320,98 @@ const D=[
ins:{business:'Local services, growing.',tourist:'No appeal.',safety:'Excellent.',investment:'Top growth rate Sweden.',nightlife:'None.',infrastructure:'Pendeltåg between Uppsala/Stockholm.'},
col:'#16a34a', umi:+82},
// ── LINKÖPING ──────────────────────────────────────────────────
// LINKÖPING
{id:'linkoping-centrum', name:'Linköping', lat:58.411, lng:15.621, r:2200,
s:{business:8.4,tourist:7.1,safety:8.8,investment:7.8,nightlife:6.8,infrastructure:8.2},
c:'low', ct:'Tech/defence capital. Saab. Strong alignment.',
ins:{business:'Saab, Ericsson, LiU. Tech powerhouse.',tourist:'Old Linköping, Kolmården nearby.',safety:'Very safe.',investment:'Strong tech-driven demand.',nightlife:'Good student scene.',infrastructure:'E4, tåg. Good.'},
col:'#16a34a', umi:+38},
// ── ÖREBRO ─────────────────────────────────────────────────────
// ÖREBRO
{id:'orebro-centrum', name:'Örebro', lat:59.275, lng:15.213, r:2200,
s:{business:7.2,tourist:6.8,safety:7.8,investment:7.4,nightlife:6.4,infrastructure:7.8},
c:'medium', ct:'Some inner-city areas higher risk than official classification.',
ins:{business:'Regional capital. Logistics hub, Gevalia.',tourist:'Örebro Castle.',safety:'Generally good. Some areas watch.',investment:'Growing regional.',nightlife:'Good for size.',infrastructure:'Central Sweden junction. E18/E20.'},
col:'#d97706', umi:+28},
// ── VÄSTERÅS ───────────────────────────────────────────────────
// VÄSTERÅS
{id:'vasteras-centrum', name:'Västerås', lat:59.609, lng:16.545, r:2200,
s:{business:7.8,tourist:5.8,safety:7.6,investment:7.2,nightlife:5.8,infrastructure:7.8},
c:'medium', ct:'ABB HQ town. Some social challenges not fully reflected.',
ins:{business:'ABB global HQ. Heavy industry. Growing tech.',tourist:'Castle, Mälaren.',safety:'Good overall. Some areas.',investment:'Industrial premium.',nightlife:'Growing.',infrastructure:'Lake port, tåg Stockholm 60 min.'},
col:'#d97706', umi:+24},
// ── NORRKÖPING ─────────────────────────────────────────────────
// NORRKÖPING
{id:'norrkoping-centrum', name:'Norrköping', lat:58.594, lng:16.188, r:2000,
s:{business:7.1,tourist:7.4,safety:7.2,investment:7.4,nightlife:6.2,infrastructure:7.6},
c:'medium', ct:'Industrial heritage city. Some contradiction in safety data.',
ins:{business:'Industristaden transformation. Tech startups.',tourist:'Motala Ström, industrial museums.',safety:'Some areas elevated.',investment:'Gentrification active.',nightlife:'Good student scene.',infrastructure:'E4, tåg. Direct Stockholm/Malmö.'},
col:'#d97706', umi:+36},
// ── JÖNKÖPING ──────────────────────────────────────────────────
// JÖNKÖPING
{id:'jonkoping-centrum', name:'Jönköping', lat:57.782, lng:14.165, r:2200,
s:{business:8.2,tourist:6.8,safety:8.4,investment:7.8,nightlife:6.2,infrastructure:7.8},
c:'low', ct:'Match capital. Strong evangelical heritage. Business hub.',
ins:{business:'Husqvarna, IKEA (nearby), match capital history.',tourist:'Tändsticsmuseet, Vättern.',safety:'Good.',investment:'Strong business demand.',nightlife:'Decent.',infrastructure:'E4, Göteborg/Stockholm junction.'},
col:'#16a34a', umi:+32},
// ── UMEÅ ───────────────────────────────────────────────────────
// UMEÅ
{id:'umea-centrum', name:'Umeå', lat:63.826, lng:20.263, r:2200,
s:{business:7.4,tourist:6.8,safety:9.2,investment:7.6,nightlife:7.4,infrastructure:7.6},
c:'low', ct:'Fastest growing northern city. Strong alignment.',
ins:{business:'Umeå University, ITS. Tech cluster.',tourist:'Bildmuseet, cultural capital.',safety:'Excellent.',investment:'Consistent growth. Young demographic.',nightlife:'Very good for city size.',infrastructure:'E4, tåg, airport.'},
col:'#16a34a', umi:+54},
// ── LULEÅ ──────────────────────────────────────────────────────
// LULEÅ
{id:'lulea-centrum', name:'Luleå', lat:65.584, lng:22.157, r:2000,
s:{business:7.8,tourist:5.8,safety:9.4,investment:8.4,nightlife:5.8,infrastructure:7.4},
c:'low', ct:'Data center capital of Europe. SSAB, LKAB. Strategic.',
ins:{business:'H2 Green Steel, SSAB, Facebook/Meta data center.',tourist:'Gammelstad UNESCO.',safety:'Excellent.',investment:'Green industry transformation — massive.',nightlife:'OK for size.',infrastructure:'E4, tåg, airport.'},
col:'#16a34a', umi:+88},
// ── KIRUNA ─────────────────────────────────────────────────────
// KIRUNA
{id:'kiruna-centrum', name:'Kiruna', lat:67.856, lng:20.225, r:2400,
s:{business:7.2,tourist:8.4,safety:9.6,investment:8.8,nightlife:4.8,infrastructure:6.8},
c:'high', ct:'City moving 3km east due to LKAB mining. Massive contradiction: official maps show old location, physical reality is new construction.',
ins:{business:'LKAB iron ore. Space industry (ESRANGE).',tourist:'Northern lights, Icehotel, LKAB tours.',safety:'Very safe.',investment:'Mining premium. City move = unique opportunity.',nightlife:'Limited.',infrastructure:'Airport, tåg. Remote.'},
col:'#f97316', umi:+62},
// ── SUNDSVALL ──────────────────────────────────────────────────
// SUNDSVALL
{id:'sundsvall-centrum', name:'Sundsvall', lat:62.391, lng:17.307, r:2000,
s:{business:6.8,tourist:5.8,safety:8.6,investment:6.8,nightlife:5.4,infrastructure:7.2},
c:'low', ct:'Stone city. Timrå data centers. Regional hub.',
ins:{business:'Timrå data centers, paper industry.',tourist:'Stone city centre.',safety:'Good.',investment:'Stable regional.',nightlife:'Good for size.',infrastructure:'E4, tåg, airport.'},
col:'#16a34a', umi:+22},
// ── GÄVLE ──────────────────────────────────────────────────────
// GÄVLE
{id:'gavle-centrum', name:'Gävle', lat:60.675, lng:17.142, r:2000,
s:{business:6.8,tourist:6.2,safety:7.8,investment:6.8,nightlife:5.8,infrastructure:7.4},
c:'medium', ct:'Goat city. Port town. Some areas higher risk.',
ins:{business:'Port, paper, Gevalia (coffee).',tourist:'Julbocken, old town.',safety:'Generally good.',investment:'Stable port economy.',nightlife:'Decent.',infrastructure:'E4, tåg Stockholm 90 min.'},
col:'#d97706', umi:+18},
// ── KARLSTAD ───────────────────────────────────────────────────
// KARLSTAD
{id:'karlstad-centrum', name:'Karlstad', lat:59.379, lng:13.503, r:2000,
s:{business:7.1,tourist:7.2,safety:8.8,investment:7.2,nightlife:6.8,infrastructure:7.4},
c:'low', ct:'Sun city. Klarälven delta. Regional capital Värmland.',
ins:{business:'Regional capital. Tetra Pak R&D.',tourist:'Klarälven, sun city.',safety:'Very safe.',investment:'Good regional.',nightlife:'Good university city.',infrastructure:'E18, tåg.'},
col:'#16a34a', umi:+26},
// ── BORÅS ──────────────────────────────────────────────────────
// BORÅS
{id:'boras-centrum', name:'Borås', lat:57.721, lng:12.940, r:1800,
s:{business:7.4,tourist:5.8,safety:7.8,investment:7.2,nightlife:5.4,infrastructure:7.4},
c:'medium', ct:'Textile city. E-commerce boom. Some social challenges.',
ins:{business:'Textilhögskolan, e-commerce logistics. BORÅS 2030.',tourist:'Textile museum, art.',safety:'Generally good.',investment:'E-commerce driving growth.',nightlife:'Growing.',infrastructure:'Bus to Göteborg.'},
col:'#d97706', umi:+34},
// ── VISBY / GOTLAND ───────────────────────────────────────────
// VISBY / GOTLAND
{id:'gotland-visby', name:'Visby', lat:57.638, lng:18.295, r:1800,
s:{business:5.8,tourist:9.8,safety:9.6,investment:7.8,nightlife:8.4,infrastructure:5.8},
c:'low', ct:'UNESCO medieval city. Medieval Week peak. Summer extreme.',
ins:{business:'Tourism, local services.',tourist:'UNESCO. Medieval Week. Top Sweden.',safety:'Exceptional.',investment:'Summer premium. Year-round growing.',nightlife:'Medieval Week world-class. Summer peak.',infrastructure:'Ferry Nynäshamn/Oskarshamn.'},
col:'#16a34a', umi:+28},
/* ── Stockholm fler stadsdelar (batch 3) ── */
/* Stockholm fler stadsdelar (batch 3) */
{id:'bromma',name:'Bromma',lat:59.337,lng:17.938,r:2200,
confidence:{score:81,observation_count:445},
s:{business:6.8,tourist:5.2,safety:8.9,investment:7.8,nightlife:5.1,infrastructure:7.9},
@@ -1564,7 +1564,7 @@ function renderUMI() {
const sign = umi >= 0 ? '+' : '';
const icon = L.divIcon({
className: '',
html: `<div style="background:${col};color: #1d1d1f;font-size:11px;font-weight:800;padding:3px 7px;border-radius:20px;white-space:nowrap;box-shadow:0 2px 6px rgba(0,0,0,0.2);font-family:-apple-system,sans-serif">${sign}${umi}</div>`,
html: `<div style="background:${col};color: #1d1d1f;font-size:11px;font-weight:800;padding:3px 7px;border-radius: var(--radius-xl);white-space:nowrap;box-shadow:0 2px 6px rgba(0,0,0,0.2);font-family:-apple-system,sans-serif">${sign}${umi}</div>`,
iconAnchor: [20, 10]
});
const m = L.marker([d.lat - 0.008, d.lng], {icon, interactive: false});
@@ -1635,7 +1635,7 @@ function buildPopup(d,p){
return `<div class="mpop">
<div class="mpop-eyebrow" style="display:flex;align-items:center;justify-content:space-between">
<span>LANDVEX ASSESSMENT</span>
<span style="background:${d.umi>=0?'#16a34a':'#dc2626'};color: #1d1d1f;font-size:10px;font-weight:800;padding:2px 8px;border-radius:20px">UMI ${d.umi>=0?'+':''}${d.umi||0}</span>
<span style="background:${d.umi>=0?'#16a34a':'#dc2626'};color: #1d1d1f;font-size:10px;font-weight:800;padding:2px 8px;border-radius: 20px">UMI ${d.umi>=0?'+':''}${d.umi||0}</span>
</div>
<div class="mpop-name">${d.name}</div>
<div class="mpop-score-row">
@@ -1647,7 +1647,7 @@ function buildPopup(d,p){
<div class="mpop-contra ${cl}"><span class="mpop-contra-lbl">${clabels[cl]}</span>${d.ct}</div>
${confidenceBlock(d.id)}
<div class="mpop-insight">${d.ins[p]}</div>
${d.observation ? `<div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius:8px;padding:8px 10px;margin-top:8px;font-size:10px;font-family:-apple-system,sans-serif">
${d.observation ? `<div style="background:#f0fdf4;border:1px solid #bbf7d0;border-radius: var(--radius-md);padding:8px 10px;margin-top:8px;font-size:10px;font-family:-apple-system,sans-serif">
<div style="font-weight:700;color:#16a34a;margin-bottom:3px">quiXzoom Field Verified</div>
<div style="color:#166534">Coverage: ${d.observation.coverage}% · ${d.observation.frames} frames · ${d.observation.verified}</div>
<div style="color:#15803d;margin-top:2px">Observation ID: ${d.observation.id}</div>
@@ -1675,7 +1675,7 @@ function setProfile(p,btn){
const pr=PROF[p];
const chip=document.getElementById('map-profile-chip');
chip.textContent=pr.label;
chip.style.cssText=`font-size:10px;font-weight:700;border-radius:4px;padding:3px 10px;border:1px solid;letter-spacing:0.3px;background:${pr.chipBg};color:${pr.chipCol};border-color:${pr.chipBorder}`;
chip.style.cssText=`font-size:10px;font-weight:700;border-radius: var(--radius-sm);padding:3px 10px;border:1px solid;letter-spacing:0.3px;background:${pr.chipBg};color:${pr.chipCol};border-color:${pr.chipBorder}`;
const lbl=document.getElementById('map-profile-label');
if(lbl)lbl.textContent=pr.label.split(' ').slice(1).join(' ');
buildList(p);buildRight(p);
@@ -1692,7 +1692,7 @@ function setProfile(p,btn){
}
/* FOCUS DISTRICT */
/* ── MOBILE BOTTOM SHEET ───────────────────────────────────── */
/* MOBILE BOTTOM SHEET */
function isMobile(){return window.innerWidth<=900;}
function openBottomSheet(html){
@@ -1746,7 +1746,7 @@ function buildRoadworkMarkers(){
const col=rwSevCol(rw.severity);
const icon=L.divIcon({
className:'',
html:`<div style="width:14px;height:14px;border-radius:50%;background:${col}44;border:2px solid ${col};cursor:pointer;box-shadow:0 0 6px ${col}66"></div>`,
html:`<div style="width:14px;height:14px;border-radius: var(--radius-full);background:${col}44;border:2px solid ${col};cursor:pointer;box-shadow:0 0 6px ${col}66"></div>`,
iconSize:[14,14],iconAnchor:[7,7]
});
const marker=L.marker([rw.lat,rw.lng],{icon});
@@ -1805,7 +1805,7 @@ function buildAlertMarkers(){
const icon=L.divIcon({
className:'',
html:`<div style="
width:18px;height:18px;border-radius:3px;
width:18px;height:18px;border-radius: var(--radius-sm);
background:rgba(15,25,41,0.92);
border:2px solid ${col};
display:flex;align-items:center;justify-content:center;
@@ -1864,7 +1864,7 @@ window.addEventListener('DOMContentLoaded',()=>{
polys[d.id]=poly;
if(map.getZoom()>=8){const labelM=L.marker([d.lat,d.lng],{
icon:L.divIcon({className:'label-marker',html:`<div style="font-size:11px;font-weight:700;color: #1d1d1f;text-shadow:0 1px 6px rgba(0,0,0,1),0 0 12px rgba(0,0,0,0.8);white-space:nowrap;pointer-events:none;letter-spacing:-0.2px;background:rgba(0,0,0,0.35);padding:2px 6px;border-radius:4px">${d.name}</div>`,iconAnchor:[0,0]}),
icon:L.divIcon({className:'label-marker',html:`<div style="font-size:11px;font-weight:700;color: #1d1d1f;text-shadow:0 1px 6px rgba(0,0,0,1),0 0 12px rgba(0,0,0,0.8);white-space:nowrap;pointer-events:none;letter-spacing:-0.2px;background:rgba(0,0,0,0.35);padding:2px 6px;border-radius: 4px">${d.name}</div>`,iconAnchor:[0,0]}),
interactive:false
});
labelM.addTo(map);}
@@ -1899,7 +1899,7 @@ window.addEventListener('DOMContentLoaded',()=>{
setInterval(updateFreshnessClock,30000);
});
/* ── MACRO INTELLIGENCE ───────────────────────────────────────── */
/* MACRO INTELLIGENCE */
async function loadGDP(){
// World Bank API blocked from server-side — use cached 2024 value
document.getElementById('gdp-val').textContent='$57k';
@@ -1970,12 +1970,12 @@ function renderUrbanEvents(){
<div style="flex:1">
<div style="font-size:13px;font-weight:600;color:#1c1c1e;margin-bottom:3px">${e.title}</div>
<div style="font-size:9px;color:rgba(220,232,245,0.4);line-height:1.3">${e.impact}</div>
<div style="display:inline-block;font-size:9px;font-weight:700;margin-top:3px;padding:1px 6px;border-radius:3px;background:rgba(0,0,0,0.03);color:rgba(220,232,245,0.4)">${e.type}</div>
<div style="display:inline-block;font-size:9px;font-weight:700;margin-top:3px;padding:1px 6px;border-radius: var(--radius-sm);background:rgba(0,0,0,0.03);color:rgba(220,232,245,0.4)">${e.type}</div>
</div>
</div>`).join('');
}
/* ── POLICE EVENTS (Polisen.se live API) ─────────────────────────────────── */
/* POLICE EVENTS (Polisen.se live API) */
const INCIDENT_COLORS={
'Misshandel':'#ef4444','Misshandel, grov':'#dc2626',
'Rån':'#dc2626','Rån, försök':'#f97316',
@@ -2059,7 +2059,7 @@ function updateDistrictSafetyFromPolice(){
});
}
/* ── EVENT ENGINE ─────────────────────────────────────────────── */
/* EVENT ENGINE */
const EVENTS = [
{id:'e1',district:'rinkeby',type:'critical',category:'Safety',title:'Shooting incident reported',detail:'2nd incident this week near Rinkeby torg. Police events API: +340% vs 30-day avg.',date:'Jun 21, 02:32',sources:['Polisen.se live','quiXzoom observation'],confidence:94},
{id:'e2',district:'sodermalm',type:'important',category:'Commercial',title:'18 new vacancies detected',detail:'Götgatan corridor shows accelerating vacancy. Up from 12 last month.',date:'Jun 20',sources:['quiXzoom observation','Fastighetsregistret'],confidence:88},
@@ -2082,7 +2082,7 @@ function renderEvents(){
<div style="padding:7px 0;border-bottom:1px solid rgba(0,0,0,0.04);cursor:pointer" onclick="focusDist('${e.district}')">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:6px;margin-bottom:3px">
<div style="font-size:13px;font-weight:700;color:#1c1c1e;line-height:1.3;flex:1">${e.title}</div>
<span style="font-size:10px;font-weight:800;border-radius:4px;padding:3px 7px;white-space:nowrap;background:${EVENT_COLORS[e.type]}18;color:${EVENT_COLORS[e.type]};flex-shrink:0">${EVENT_LABELS[e.type]}</span>
<span style="font-size:10px;font-weight:800;border-radius: var(--radius-sm);padding:3px 7px;white-space:nowrap;background:${EVENT_COLORS[e.type]}18;color:${EVENT_COLORS[e.type]};flex-shrink:0">${EVENT_LABELS[e.type]}</span>
</div>
<div style="font-size:12px;color:rgba(28,28,30,0.7);line-height:1.4;margin-bottom:4px">${e.detail}</div>
<div style="display:flex;align-items:center;justify-content:space-between">
@@ -2092,7 +2092,7 @@ function renderEvents(){
</div>`).join('');
}
/* ── CONFIDENCE ENGINE ─────────────────────────────────────────── */
/* CONFIDENCE ENGINE */
const confSources = {
city: [{src:'Official data',w:35,c:96},{src:'quiXzoom obs',w:40,c:96},{src:'Cross-validation',w:25,c:94}],
ostermalm: [{src:'Official data',w:35,c:98},{src:'quiXzoom obs',w:40,c:94},{src:'Cross-validation',w:25,c:96}],
@@ -2108,15 +2108,15 @@ function confidenceBlock(distId){
if(!sources) return '';
const weighted = Math.round(sources.reduce((s,x)=>s+x.c*x.w/100,0));
const col = weighted>=88?'#4ade80':weighted>=75?'#fbbf24':'#f87171';
return `<div style="background:rgba(0,0,0,0.03);border-radius:7px;padding:7px 9px;margin:6px 0">
return `<div style="background:rgba(0,0,0,0.03);border-radius: var(--radius-sm);padding:7px 9px;margin:6px 0">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:5px">
<div style="font-size:9px;font-weight:700;letter-spacing:0.5px;text-transform:uppercase;color:rgba(220,232,245,0.3)">Confidence Engine</div>
<div style="font-size:13px;font-weight:900;color:${col};font-family:monospace">${weighted}%</div>
</div>
${sources.map(s=>`<div style="display:flex;align-items:center;gap:6px;margin-bottom:3px;font-size:9px">
<span style="width:90px;color:rgba(220,232,245,0.4)">${s.src}</span>
<div style="flex:1;height:3px;background:rgba(0,0,0,0.04);border-radius:2px">
<div style="width:${s.c}%;height:100%;background:${s.c>=88?'#4ade80':s.c>=75?'#fbbf24':'#f87171'};border-radius:2px"></div>
<div style="flex:1;height:3px;background:rgba(0,0,0,0.04);border-radius: 2px">
<div style="width:${s.c}%;height:100%;background:${s.c>=88?'#4ade80':s.c>=75?'#fbbf24':'#f87171'};border-radius: 2px"></div>
</div>
<span style="width:28px;text-align:right;font-weight:700;color:rgba(220,232,245,0.6)">${s.c}%</span>
</div>`).join('')}
@@ -2125,7 +2125,7 @@ function confidenceBlock(distId){
}
/* SCB DATA (hårdkodad för Stockholm stadsdelar — uppdateras via SCB API) */
/* ── PRICE INDEX DATA ─────────────────────────────────────────── */
/* PRICE INDEX DATA */
// Sources: Hemnet Q1 2026, SvD Bästa Nyheter, Prisjakt, local surveys
const PRICE_DATA={
// beer_bar/pub: 33cl draft SEK
@@ -2145,7 +2145,7 @@ const PRICE_DATA={
rinkeby: {beer_bar:52, beer_pub:44, coffee:38, lunch:95, dinner_mid:195, apt_rent_sqm:1180, apt_buy_sqm:31000, hotel_night:680, seven_eleven:71, housing_idx:23}
};
/* ── SCHOOL DATA ──────────────────────────────────────────── */
/* SCHOOL DATA */
// Source: Skolverket öppen statistik 2023/2024, SIRIS-databasen
// avg_merit_gr9: genomsnittligt meritvärde åk 9 (max 340)
// pct_qualified_gr9: andel behöriga till gymnasiet (%)
@@ -2234,7 +2234,7 @@ const SCHOOL_DATA={
}
};
/* ── HEALTH & SOCIAL DATA ──────────────────────────────────── */
/* HEALTH & SOCIAL DATA */
// Sources: Socialstyrelsen, Väntetider i vård, Folkihälsomyndigheten, Region Stockholm 2024
// wait_gp_days: median days to get GP appointment
// wait_specialist_days: median days to specialist
@@ -2317,17 +2317,17 @@ function getHealthPopup(distId){
<!-- Key stats -->
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:4px;margin-bottom:6px">
<div style="background:rgba(0,0,0,0.04);border-radius:6px;padding:6px 7px;text-align:center">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:6px 7px;text-align:center">
<div style="font-size:9px;color:rgba(220,232,245,0.35);margin-bottom:2px">Life exp.</div>
<div style="font-size:14px;font-weight:900;color:${leColor};font-family:var(--mono)">${h.life_expectancy}</div>
<div style="font-size:8px;color:rgba(220,232,245,0.25)">years</div>
</div>
<div style="background:rgba(0,0,0,0.04);border-radius:6px;padding:6px 7px;text-align:center">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:6px 7px;text-align:center">
<div style="font-size:9px;color:rgba(220,232,245,0.35);margin-bottom:2px">Drug deaths</div>
<div style="font-size:14px;font-weight:900;color:${drugColor};font-family:var(--mono)">${h.drug_deaths_per100k}</div>
<div style="font-size:8px;color:rgba(220,232,245,0.25)">/100k</div>
</div>
<div style="background:rgba(0,0,0,0.04);border-radius:6px;padding:6px 7px;text-align:center">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:6px 7px;text-align:center">
<div style="font-size:9px;color:rgba(220,232,245,0.35);margin-bottom:2px">Mental health</div>
<div style="font-size:14px;font-weight:900;color:${h.mental_health_pct>18?'#f87171':h.mental_health_pct>14?'#fbbf24':'#4ade80'};font-family:var(--mono)">${h.mental_health_pct}%</div>
<div style="font-size:8px;color:rgba(220,232,245,0.25)">seeking care</div>
@@ -2335,7 +2335,7 @@ function getHealthPopup(distId){
</div>
<!-- Wait times -->
<div style="background:rgba(0,0,0,0.04);border-radius:6px;padding:7px 9px;margin-bottom:6px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:7px 9px;margin-bottom:6px">
<div style="font-size:9px;color:rgba(220,232,245,0.35);font-weight:700;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:5px">Healthcare wait times</div>
<div style="display:flex;flex-direction:column;gap:4px;font-size:9px">
<div style="display:flex;align-items:center;justify-content:space-between">
@@ -2355,25 +2355,25 @@ function getHealthPopup(distId){
<!-- Additional stats -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;margin-bottom:6px;font-size:10px">
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:5px 8px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:5px 8px">
<div style="color:rgba(220,232,245,0.35);font-size:9px">Obesity rate</div>
<div style="color:${h.obesity_pct>18?'#f87171':h.obesity_pct>12?'#fbbf24':'#4ade80'};font-weight:700">${h.obesity_pct}%</div>
</div>
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:5px 8px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:5px 8px">
<div style="color:rgba(220,232,245,0.35);font-size:9px">Long-term sick leave</div>
<div style="color:${h.sick_leave_pct>7?'#f87171':h.sick_leave_pct>5?'#fbbf24':'#4ade80'};font-weight:700">${h.sick_leave_pct}%</div>
</div>
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:5px 8px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:5px 8px">
<div style="color:rgba(220,232,245,0.35);font-size:9px">Nearest hospital</div>
<div style="color:#dce8f5;font-weight:600;font-size:9px;line-height:1.3">${h.nearest_hospital}</div>
</div>
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:5px 8px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:5px 8px">
<div style="color:rgba(220,232,245,0.35);font-size:9px">Elder care score</div>
<div style="color:${h.elder_care_satisfaction>=8?'#4ade80':h.elder_care_satisfaction>=7?'#fbbf24':'#f87171'};font-weight:700">${h.elder_care_satisfaction}/10</div>
</div>
</div>
${isContra?`<div style="background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.18);border-radius:6px;padding:6px 9px;font-size:10px;color:rgba(220,232,245,0.55);line-height:1.4;margin-bottom:6px">
${isContra?`<div style="background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.18);border-radius: var(--radius-sm);padding:6px 9px;font-size:10px;color:rgba(220,232,245,0.55);line-height:1.4;margin-bottom:6px">
<span style="font-weight:700;color:#f87171">Contradiction: </span>${h.context}
</div>`:''}
@@ -2402,7 +2402,7 @@ function getSchoolPopup(distId){
<div style="font-size:9px;font-weight:700;letter-spacing:0.8px;text-transform:uppercase;color:rgba(220,232,245,0.3);margin-bottom:7px">SCHOOL PERFORMANCE · SKOLVERKET 2024</div>
<!-- Merit score header -->
<div style="background:rgba(0,0,0,0.04);border-radius:7px;padding:8px 10px;margin-bottom:6px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:8px 10px;margin-bottom:6px">
<div style="display:flex;align-items:flex-start;justify-content:space-between;margin-bottom:6px">
<div>
<div style="font-size:9px;color:rgba(220,232,245,0.35);text-transform:uppercase;letter-spacing:0.5px">Avg merit grade 9</div>
@@ -2414,17 +2414,17 @@ function getSchoolPopup(distId){
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:4px;margin-bottom:6px">
<div style="background:rgba(0,0,0,0.03);border-radius:5px;padding:5px 6px;text-align:center">
<div style="background:rgba(0,0,0,0.03);border-radius: var(--radius-sm);padding:5px 6px;text-align:center">
<div style="font-size:9px;color:rgba(220,232,245,0.3)">Qualified</div>
<div style="font-size:12px;font-weight:800;color:${qualColor}">${sc.pct_qualified_gr9}%</div>
<div style="font-size:8px;color:rgba(220,232,245,0.25)">to gymnasium</div>
</div>
<div style="background:rgba(0,0,0,0.03);border-radius:5px;padding:5px 6px;text-align:center">
<div style="background:rgba(0,0,0,0.03);border-radius: var(--radius-sm);padding:5px 6px;text-align:center">
<div style="font-size:9px;color:rgba(220,232,245,0.3)">Pass all</div>
<div style="font-size:12px;font-weight:800;color:${meritColor}">${sc.pct_passed_all}%</div>
<div style="font-size:8px;color:rgba(220,232,245,0.25)">subjects</div>
</div>
<div style="background:rgba(0,0,0,0.03);border-radius:5px;padding:5px 6px;text-align:center">
<div style="background:rgba(0,0,0,0.03);border-radius: var(--radius-sm);padding:5px 6px;text-align:center">
<div style="font-size:9px;color:rgba(220,232,245,0.3)">Trend</div>
<div style="font-size:12px;font-weight:800;color:${trendColor}">${sc.grade_trend.split(' ')[0]}</div>
<div style="font-size:8px;color:rgba(220,232,245,0.25)">vs 2021</div>
@@ -2432,7 +2432,7 @@ function getSchoolPopup(distId){
</div>
<div style="display:flex;align-items:center;justify-content:space-between;font-size:9px">
<span style="color:rgba(220,232,245,0.4)">${sc.school_count} schools · ${sc.teacher_ratio} pupils/teacher</span>
<span style="padding:1px 7px;border-radius:3px;font-weight:700;${sc.inspection_status==='Approved'?'background:rgba(34,197,94,0.12);color:#4ade80':sc.inspection_status==='Under review'?'background:rgba(239,68,68,0.12);color:#f87171':'background:rgba(251,191,36,0.12);color:#fbbf24'}">${sc.inspection_status}</span>
<span style="padding:1px 7px;border-radius: var(--radius-sm);font-weight:700;${sc.inspection_status==='Approved'?'background:rgba(34,197,94,0.12);color:#4ade80':sc.inspection_status==='Under review'?'background:rgba(239,68,68,0.12);color:#f87171':'background:rgba(251,191,36,0.12);color:#fbbf24'}">${sc.inspection_status}</span>
</div>
</div>
@@ -2456,7 +2456,7 @@ function getSchoolPopup(distId){
</div>`;
}
/* ── HOUSING STOCK DATA ─────────────────────────────────── */
/* HOUSING STOCK DATA */
// Sources: SCB Fastighetsregistret, Boverket, Stockholm stad 2024
// build_era: dominant construction period
// median_build_year: median year of existing stock
@@ -2560,12 +2560,12 @@ function getHousingPopup(distId){
if(!h)return '';
const bciColor=h.condition_idx>=8.5?'#4ade80':h.condition_idx>=7?'#fbbf24':h.condition_idx>=5?'#f97316':'#f87171';
const trendColor=h.bci_trend.startsWith('+')?'#4ade80':h.bci_trend.startsWith('-')?'#f87171':'#fbbf24';
const segBar=(pct,col)=>`<div style="display:inline-block;height:6px;width:${pct*1.4}px;background:${col};border-radius:1px;margin-right:1px"></div>`;
const segBar=(pct,col)=>`<div style="display:inline-block;height:6px;width:${pct*1.4}px;background:${col};border-radius: 1px;margin-right:1px"></div>`;
return `<div style="border-top:1px solid rgba(0,0,0,0.04);margin-top:8px;padding-top:8px;font-family:-apple-system,sans-serif">
<div style="font-size:9px;font-weight:700;letter-spacing:0.8px;text-transform:uppercase;color:rgba(220,232,245,0.3);margin-bottom:7px">HOUSING STOCK INTELLIGENCE</div>
<!-- BCI header -->
<div style="background:rgba(0,0,0,0.04);border-radius:7px;padding:8px 10px;margin-bottom:6px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:8px 10px;margin-bottom:6px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:5px">
<div>
<div style="font-size:9px;color:rgba(220,232,245,0.35);text-transform:uppercase;letter-spacing:0.5px">Building Condition Index</div>
@@ -2581,26 +2581,26 @@ function getHousingPopup(distId){
<!-- Build era & renovation -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;margin-bottom:6px;font-size:10px">
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:6px 8px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:6px 8px">
<div style="color:rgba(220,232,245,0.35);font-size:9px;margin-bottom:2px">Build era</div>
<div style="color:#dce8f5;font-weight:600;font-size:10px;line-height:1.3">${h.build_era}</div>
</div>
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:6px 8px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:6px 8px">
<div style="color:rgba(220,232,245,0.35);font-size:9px;margin-bottom:2px">Renovated post-2000</div>
<div style="color:#38bdf8;font-weight:700">${h.renovated_pct}% of stock</div>
</div>
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:6px 8px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:6px 8px">
<div style="color:rgba(220,232,245,0.35);font-size:9px;margin-bottom:2px">Avg apartment</div>
<div style="color:#dce8f5;font-weight:700">${h.avg_apt_size} sqm</div>
</div>
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:6px 8px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:6px 8px">
<div style="color:rgba(220,232,245,0.35);font-size:9px;margin-bottom:2px">Commercial vacancy</div>
<div style="color:${h.vacancy_commercial_pct>12?'#f87171':h.vacancy_commercial_pct>7?'#fbbf24':'#4ade80'};font-weight:700">${h.vacancy_commercial_pct}%</div>
</div>
</div>
<!-- Tenure mix -->
<div style="background:rgba(0,0,0,0.04);border-radius:6px;padding:7px 9px;margin-bottom:6px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:7px 9px;margin-bottom:6px">
<div style="font-size:9px;color:rgba(220,232,245,0.35);margin-bottom:5px">Tenure mix</div>
<div style="margin-bottom:4px">
${segBar(h.brf_pct,'#4ade80')}
@@ -2628,7 +2628,7 @@ function getHousingPopup(distId){
</div>`;
}
/* ── COMMERCIAL VITALITY INDEX ───────────────────────────── */
/* COMMERCIAL VITALITY INDEX */
// Sources: SCB NV0101 (företag per SNI), Bolagsverket nyregistrerade/konkurser,
// quiXzoom kommersiell aktivitet, Fastighetsregistret vakanser
// SNI-mix: dominanta branscher per stadsdel
@@ -2734,7 +2734,7 @@ const CVI_DATA={
}
};
/* ── ECONOMIC DNA ────────────────────────────────────────── */
/* ECONOMIC DNA */
// Aggregated area-level data only. No individual data.
// Sources: SCB disp.inkomst, Skatteverket regional, Fastighetsregistret, Bolagsverket
// purchasing_power_idx: area purchasing power vs Stockholm avg (100)
@@ -2829,25 +2829,25 @@ function getEconomicDNA(distId){
<div style="font-size:9px;font-weight:700;letter-spacing:0.8px;text-transform:uppercase;color:rgba(220,232,245,0.3);margin-bottom:7px">ECONOMIC DNA</div>
<!-- Profile -->
<div style="background:rgba(0,0,0,0.04);border-radius:7px;padding:8px 10px;margin-bottom:6px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:8px 10px;margin-bottom:6px">
<div style="font-size:11px;font-weight:800;color:#dce8f5;margin-bottom:4px;line-height:1.3">${e.profile}</div>
<div style="display:flex;flex-wrap:wrap;gap:3px;margin-bottom:6px">
${e.profile_tags.map(t=>`<span style="font-size:9px;background:rgba(0,0,0,0.04);color:rgba(220,232,245,0.5);border-radius:4px;padding:2px 6px">${t}</span>`).join('')}
${e.profile_tags.map(t=>`<span style="font-size:9px;background:rgba(0,0,0,0.04);color:rgba(220,232,245,0.5);border-radius: var(--radius-sm);padding:2px 6px">${t}</span>`).join('')}
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;font-size:10px">
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:5px 7px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:5px 7px">
<div style="font-size:9px;color:rgba(220,232,245,0.3)">Purchasing power index</div>
<div style="font-size:14px;font-weight:900;color:${ppColor};font-family:var(--mono)">${e.purchasing_power_idx}<span style="font-size:9px;font-weight:400;color:rgba(220,232,245,0.3)"> (avg=100)</span></div>
</div>
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:5px 7px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:5px 7px">
<div style="font-size:9px;color:rgba(220,232,245,0.3)">HH disposable income</div>
<div style="font-size:12px;font-weight:800;color:#dce8f5">${inc} kr</div>
</div>
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:5px 7px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:5px 7px">
<div style="font-size:9px;color:rgba(220,232,245,0.3)">Commercial/residential</div>
<div style="font-size:13px;font-weight:900;color:${crColor};font-family:var(--mono)">${e.commercial_vs_residential}x</div>
</div>
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:5px 7px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:5px 7px">
<div style="font-size:9px;color:rgba(220,232,245,0.3)">Property value index</div>
<div style="font-size:12px;font-weight:800;color:#dce8f5">${e.property_value_idx}<span style="font-size:9px;color:rgba(220,232,245,0.3)"> (avg=100)</span></div>
</div>
@@ -2855,13 +2855,13 @@ function getEconomicDNA(distId){
</div>
<!-- Gap / Warning -->
${e.early_warning?`<div style="background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.2);border-radius:7px;padding:7px 10px;margin-bottom:6px">
${e.early_warning?`<div style="background:rgba(239,68,68,0.08);border:1px solid rgba(239,68,68,0.2);border-radius: var(--radius-sm);padding:7px 10px;margin-bottom:6px">
<div style="font-size:9px;font-weight:800;color:#f87171;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:3px">Early Warning Signal</div>
<div style="font-size:12px;color:rgba(28,28,30,0.75);line-height:1.5">${e.key_insight}</div>
</div>`:e.gap_opportunity?`<div style="background:rgba(56,189,248,0.07);border:1px solid rgba(56,189,248,0.15);border-radius:7px;padding:7px 10px;margin-bottom:6px">
</div>`:e.gap_opportunity?`<div style="background:rgba(56,189,248,0.07);border:1px solid rgba(56,189,248,0.15);border-radius: var(--radius-sm);padding:7px 10px;margin-bottom:6px">
<div style="font-size:9px;font-weight:800;color:#38bdf8;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:3px">Gap Opportunity Detected</div>
<div style="font-size:12px;color:rgba(28,28,30,0.75);line-height:1.5">${e.key_insight}</div>
</div>`:`<div style="background:rgba(0,0,0,0.03);border-radius:7px;padding:7px 10px;margin-bottom:6px">
</div>`:`<div style="background:rgba(0,0,0,0.03);border-radius: var(--radius-sm);padding:7px 10px;margin-bottom:6px">
<div style="font-size:10px;color:rgba(220,232,245,0.5);line-height:1.4">${e.key_insight}</div>
</div>`}
@@ -2883,7 +2883,7 @@ function getCVIPopup(distId){
<div style="font-size:9px;font-weight:700;letter-spacing:0.8px;text-transform:uppercase;color:rgba(220,232,245,0.3);margin-bottom:7px">COMMERCIAL VITALITY INDEX</div>
<!-- CVI header -->
<div style="background:rgba(0,0,0,0.04);border-radius:7px;padding:8px 10px;margin-bottom:6px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:8px 10px;margin-bottom:6px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
<div>
<div style="font-size:9px;color:rgba(220,232,245,0.35);text-transform:uppercase;letter-spacing:0.5px">CVI Score</div>
@@ -2895,15 +2895,15 @@ function getCVIPopup(distId){
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:4px;font-size:9px">
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:5px 6px;text-align:center">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:5px 6px;text-align:center">
<div style="color:rgba(220,232,245,0.3)">Active co.</div>
<div style="color:#dce8f5;font-weight:800">${c.active_companies.toLocaleString('sv-SE')}</div>
</div>
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:5px 6px;text-align:center">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:5px 6px;text-align:center">
<div style="color:rgba(220,232,245,0.3)">New YTD</div>
<div style="color:#4ade80;font-weight:800">+${c.new_registrations_ytd}</div>
</div>
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:5px 6px;text-align:center">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:5px 6px;text-align:center">
<div style="color:rgba(220,232,245,0.3)">Bankrupt</div>
<div style="color:${bkColor};font-weight:800">${c.bankruptcies_ytd}</div>
</div>
@@ -2911,13 +2911,13 @@ function getCVIPopup(distId){
</div>
<!-- SNI sector mix -->
<div style="background:rgba(0,0,0,0.04);border-radius:6px;padding:7px 9px;margin-bottom:6px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:7px 9px;margin-bottom:6px">
<div style="font-size:9px;color:rgba(220,232,245,0.35);font-weight:700;text-transform:uppercase;letter-spacing:0.5px;margin-bottom:5px">Sector mix (SNI)</div>
${c.sni_mix.map(s=>`<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px;font-size:9px">
<span style="width:16px;font-weight:800;color:rgba(220,232,245,0.3);flex-shrink:0">${s.code}</span>
<span style="flex:1;color:rgba(220,232,245,0.5)">${s.name}</span>
<div style="width:60px;height:3px;background:rgba(0,0,0,0.04);border-radius:2px">
<div style="width:${s.pct*2}px;height:100%;background:#38bdf8;border-radius:2px"></div>
<div style="width:60px;height:3px;background:rgba(0,0,0,0.04);border-radius: 2px">
<div style="width:${s.pct*2}px;height:100%;background:#38bdf8;border-radius: 2px"></div>
</div>
<span style="width:28px;text-align:right;color:#dce8f5;font-weight:700">${s.pct}%</span>
</div>`).join('')}
@@ -2925,26 +2925,26 @@ function getCVIPopup(distId){
<!-- Revenue & employment -->
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;margin-bottom:6px;font-size:10px">
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:5px 8px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:5px 8px">
<div style="color:rgba(220,232,245,0.35);font-size:9px">Revenue trend YoY</div>
<div style="color:${revColor};font-weight:800">${revSign}${c.revenue_trend}%</div>
</div>
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:5px 8px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:5px 8px">
<div style="color:rgba(220,232,245,0.35);font-size:9px">Est. employees</div>
<div style="color:#dce8f5;font-weight:700">${c.employees_est.toLocaleString('sv-SE')}</div>
</div>
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:5px 8px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:5px 8px">
<div style="color:rgba(220,232,245,0.35);font-size:9px">Commercial vacancy</div>
<div style="color:${c.vacancy_commercial>12?'#f87171':c.vacancy_commercial>7?'#fbbf24':'#4ade80'};font-weight:700">${c.vacancy_commercial}%</div>
</div>
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:5px 8px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:5px 8px">
<div style="color:rgba(220,232,245,0.35);font-size:9px">Gentrif. signal</div>
<div style="color:${c.gentrification_signal?'#a78bfa':'rgba(220,232,245,0.4)'};font-weight:700">${c.gentrification_signal?'DETECTED':'None'}</div>
</div>
</div>
<!-- Growth sector -->
<div style="background:rgba(56,189,248,0.06);border:1px solid rgba(56,189,248,0.12);border-radius:6px;padding:7px 9px;margin-bottom:5px">
<div style="background:rgba(56,189,248,0.06);border:1px solid rgba(56,189,248,0.12);border-radius: var(--radius-sm);padding:7px 9px;margin-bottom:5px">
<div style="font-size:9px;color:rgba(56,189,248,0.7);font-weight:700;margin-bottom:2px">Top growth sector</div>
<div style="font-size:10px;color:rgba(220,232,245,0.6);line-height:1.4">${c.top_growth_sector}</div>
</div>
@@ -2962,7 +2962,7 @@ function getPricePopup(distId){
if(!p)return '';
const pb=(val,max,col)=>{
const pct=Math.min(100,Math.round((val/max)*100));
return `<div style="flex:1;height:4px;border-radius:2px;background:rgba(0,0,0,0.04)"><div style="width:${pct}%;height:100%;background:${col};border-radius:2px"></div></div>`;
return `<div style="flex:1;height:4px;border-radius: var(--radius-sm);background:rgba(0,0,0,0.04)"><div style="width:${pct}%;height:100%;background:${col};border-radius: 2px"></div></div>`;
};
const idxColor=idx=>idx>=85?'#f87171':idx>=60?'#fbbf24':'#4ade80';
const beerIdx=Math.round((p.beer_bar/129)*100);
@@ -2975,26 +2975,26 @@ function getPricePopup(distId){
<!-- Index summary row -->
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:4px;margin-bottom:7px">
<div style="background:rgba(0,0,0,0.04);border-radius:6px;padding:6px 5px;text-align:center">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:6px 5px;text-align:center">
<div style="font-size:9px;color:rgba(220,232,245,0.35);margin-bottom:2px">Beer</div>
<div style="font-size:13px;font-weight:900;color:${idxColor(beerIdx)};font-family:var(--mono)">${beerIdx}</div>
</div>
<div style="background:rgba(0,0,0,0.04);border-radius:6px;padding:6px 5px;text-align:center">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:6px 5px;text-align:center">
<div style="font-size:9px;color:rgba(220,232,245,0.35);margin-bottom:2px">Hotel</div>
<div style="font-size:13px;font-weight:900;color:${idxColor(hotelIdx)};font-family:var(--mono)">${hotelIdx}</div>
</div>
<div style="background:rgba(0,0,0,0.04);border-radius:6px;padding:6px 5px;text-align:center">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:6px 5px;text-align:center">
<div style="font-size:9px;color:rgba(220,232,245,0.35);margin-bottom:2px">7-Eleven</div>
<div style="font-size:13px;font-weight:900;color:${idxColor(sevenIdx)};font-family:var(--mono)">${sevenIdx}</div>
</div>
<div style="background:rgba(0,0,0,0.04);border-radius:6px;padding:6px 5px;text-align:center">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:6px 5px;text-align:center">
<div style="font-size:9px;color:rgba(220,232,245,0.35);margin-bottom:2px">Housing</div>
<div style="font-size:13px;font-weight:900;color:${idxColor(housingIdx)};font-family:var(--mono)">${housingIdx}</div>
</div>
</div>
<!-- Food & drink detail -->
<div style="background:rgba(0,0,0,0.03);border-radius:6px;padding:7px 9px;margin-bottom:5px">
<div style="background:rgba(0,0,0,0.03);border-radius: var(--radius-sm);padding:7px 9px;margin-bottom:5px">
<div style="font-size:9px;color:rgba(220,232,245,0.3);font-weight:700;letter-spacing:0.5px;text-transform:uppercase;margin-bottom:5px">Food & Drink</div>
<div style="display:flex;flex-direction:column;gap:4px;font-size:9px">
<div style="display:flex;align-items:center;gap:6px"><span style="width:68px;color:rgba(220,232,245,0.45)">Bar / club</span>${pb(p.beer_bar,140,'#60a5fa')}<span style="width:40px;text-align:right;color:#dce8f5;font-weight:700">${p.beer_bar} kr</span></div>
@@ -3007,7 +3007,7 @@ function getPricePopup(distId){
</div>
<!-- Accommodation -->
<div style="background:rgba(0,0,0,0.03);border-radius:6px;padding:7px 9px;margin-bottom:5px">
<div style="background:rgba(0,0,0,0.03);border-radius: var(--radius-sm);padding:7px 9px;margin-bottom:5px">
<div style="font-size:9px;color:rgba(220,232,245,0.3);font-weight:700;letter-spacing:0.5px;text-transform:uppercase;margin-bottom:5px">Accommodation</div>
<div style="display:flex;flex-direction:column;gap:4px;font-size:9px">
<div style="display:flex;align-items:center;gap:6px"><span style="width:68px;color:rgba(220,232,245,0.45)">Hotel / night</span>${pb(p.hotel_night,2500,'#f97316')}<span style="width:40px;text-align:right;color:#dce8f5;font-weight:700">${p.hotel_night} kr</span></div>
@@ -3059,8 +3059,8 @@ const SCB_DATA={
};
function ageBar(pct, color){
return `<div style="flex:1;height:4px;border-radius:2px;background:rgba(0,0,0,0.04);overflow:hidden">
<div style="width:${pct}%;height:100%;background:${color};border-radius:2px"></div></div>`;
return `<div style="flex:1;height:4px;border-radius: var(--radius-sm);background:rgba(0,0,0,0.04);overflow:hidden">
<div style="width:${pct}%;height:100%;background:${color};border-radius: 2px"></div></div>`;
}
function getSCBPopup(distId){
@@ -3072,25 +3072,25 @@ function getSCBPopup(distId){
return `<div style="border-top:1px solid rgba(0,0,0,0.04);margin-top:8px;padding-top:8px;font-family:-apple-system,sans-serif">
<div style="font-size:9px;font-weight:700;letter-spacing:0.8px;text-transform:uppercase;color:rgba(220,232,245,0.3);margin-bottom:6px">SCB DEMOGRAPHICS</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:4px;font-size:10px;margin-bottom:8px">
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:5px 7px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:5px 7px">
<div style="color:rgba(220,232,245,0.4);font-size:9px">Population</div>
<div style="color:#dce8f5;font-weight:700">${s.population.toLocaleString('sv-SE')}</div>
</div>
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:5px 7px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:5px 7px">
<div style="color:rgba(220,232,245,0.4);font-size:9px">Median income</div>
<div style="color:#4ade80;font-weight:700">${inc} kr</div>
</div>
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:5px 7px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:5px 7px">
<div style="color:rgba(220,232,245,0.4);font-size:9px">Unemployment</div>
<div style="color:${uColor};font-weight:700">${s.unemployment}%</div>
</div>
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:5px 7px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:5px 7px">
<div style="color:rgba(220,232,245,0.4);font-size:9px">Higher education</div>
<div style="color:#38bdf8;font-weight:700">${s.education_pct}%</div>
</div>
</div>
<!-- Age distribution -->
<div style="background:rgba(0,0,0,0.04);border-radius:6px;padding:7px 9px;margin-bottom:5px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:7px 9px;margin-bottom:5px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
<div style="font-size:9px;color:rgba(220,232,245,0.4)">Age distribution</div>
<div style="font-size:10px;font-weight:800;color:${ageColor}">Median ${s.medianAge} yrs</div>
@@ -3104,7 +3104,7 @@ function getSCBPopup(distId){
</div>
<!-- Education levels -->
<div style="background:rgba(0,0,0,0.04);border-radius:6px;padding:7px 9px;margin-bottom:5px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:7px 9px;margin-bottom:5px">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:6px">
<div style="font-size:9px;color:rgba(220,232,245,0.4)">Education profile</div>
<div style="font-size:10px;font-weight:800;color:#38bdf8">${s.edu_hogskola_kort+s.edu_hogskola_lang+s.edu_forskning}% university+</div>
@@ -3120,15 +3120,15 @@ function getSCBPopup(distId){
<!-- Social profile -->
<div style="display:grid;grid-template-columns:1fr 1fr 1fr;gap:4px;font-size:10px">
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:5px 7px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:5px 7px">
<div style="color:rgba(220,232,245,0.35);font-size:9px">Foreign-born</div>
<div style="color:${s.foreign_born_pct>60?'#f87171':s.foreign_born_pct>30?'#fbbf24':'#dce8f5'};font-weight:700">${s.foreign_born_pct}%</div>
</div>
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:5px 7px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:5px 7px">
<div style="color:rgba(220,232,245,0.35);font-size:9px">Students</div>
<div style="color:#dce8f5;font-weight:700">${s.students_pct}%</div>
</div>
<div style="background:rgba(0,0,0,0.04);border-radius:5px;padding:5px 7px">
<div style="background:rgba(0,0,0,0.04);border-radius: var(--radius-sm);padding:5px 7px">
<div style="color:rgba(220,232,245,0.35);font-size:9px">Self-employed</div>
<div style="color:#dce8f5;font-weight:700">${s.self_employed_pct}%</div>
</div>