2026-07-12 12:41:35 +00:00
<!DOCTYPE html>
< html lang = "en" >
< head >
< link rel = "icon" type = "image/x-icon" href = "/favicon.ico" >
< link rel = "icon" type = "image/svg+xml" href = "/favicon.svg" >
< link rel = "icon" type = "image/png" sizes = "32x32" href = "/favicon-32x32.png" >
< link rel = "apple-touch-icon" sizes = "180x180" href = "/apple-touch-icon.png" >
< meta charset = "UTF-8" / >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" / >
< meta name = "apple-mobile-web-app-capable" content = "yes" >
< meta name = "apple-mobile-web-app-status-bar-style" content = "black-translucent" >
< meta name = "theme-color" content = "#f2f2f7" >
< link rel = "apple-touch-icon" href = "https://www.landvex.com/apple-touch-icon.png" >
< meta name = "apple-mobile-web-app-title" content = "Landvex" >
< link rel = "manifest" href = "/intelligence-manifest.json" >
< title > Uppsala — Landvex Intelligence Brief< / title >
< link rel = "stylesheet" href = "/vendor/leaflet.css" / >
< script src = "/vendor/leaflet.js" > < / script >
< style >
* { box-sizing : border-box ; margin : 0 ; padding : 0 ; -webkit- font-smoothing : antialiased }
: root {
--bg : #f2f2f7 ; --s1 : #ffffff ; --s2 : #f2f2f7 ; --s3 : #e5e5ea ;
--border : rgba ( 0 , 0 , 0 , 0.08 ) ; --text : #1c1c1e ; --t2 : rgba ( 28 , 28 , 30 , 0.72 ) ; --t3 : rgba ( 28 , 28 , 30 , 0.50 ) ;
--accent : #007aff ; --accent3 : #0066cc ; --mono : 'SF Mono' , ui-monospace , monospace ;
}
html , body { height : 100 % ; font-family : - apple-system , BlinkMacSystemFont , 'SF Pro Text' , 'Helvetica Neue' , sans-serif ; background : var ( - - bg ) ; color : var ( - - text ) ; overflow : hidden }
. leaflet-control-attribution { display : none !important } . leaflet-bottom . leaflet-right { display : none !important }
# app { display : grid ; grid-template-columns : 260 px 1 fr 320 px ; height : 100 vh ; overflow : hidden }
# left { background : var ( - - s1 ) ; border-right : 1 px solid var ( - - border ) ; overflow-y : auto ; display : flex ; flex-direction : column }
# city-block { padding : 18 px 16 px 14 px ; border-bottom : 1 px solid var ( - - border ) }
# city-eyebrow { font-size : 9 px ; font-weight : 700 ; letter-spacing : 1.5 px ; text-transform : uppercase ; color : var ( - - accent3 ) ; margin-bottom : 6 px }
# city-name { font-size : 24 px ; font-weight : 900 ; letter-spacing : -0.8 px ; color : var ( - - text ) ; line-height : 1 }
# city-region { font-size : 11 px ; color : var ( - - t2 ) ; margin-top : 3 px ; font-weight : 500 }
# city-stats { display : grid ; grid-template-columns : 1 fr 1 fr ; gap : 6 px ; margin-top : 12 px }
. cstat { background : var ( - - s2 ) ; border : 1 px solid var ( - - border ) ; border-radius : var ( - - radius - sm ) ; padding : 8 px 10 px }
. cstat-val { font-size : 16 px ; font-weight : 800 ; color : var ( - - text ) ; letter-spacing : -0.3 px }
. cstat-lbl { font-size : 9 px ; font-weight : 600 ; letter-spacing : 0.5 px ; text-transform : uppercase ; color : var ( - - t3 ) ; margin-top : 1 px }
# profile-block { padding : 12 px 16 px ; border-bottom : 1 px solid var ( - - border ) }
# profile-tabs { display : grid ; grid-template-columns : 1 fr 1 fr 1 fr ; gap : 3 px ; background : rgba ( 0 , 0 , 0 , 0.06 ) ; border-radius : var ( - - radius - md ) ; padding : 2 px }
. ptab { padding : 6 px 4 px ; border-radius : var ( - - radius - md ) ; border : none ; background : transparent ; cursor : pointer ; font-size : 10 px ; font-weight : 600 ; color : var ( - - t2 ) ; transition : all 0.15 s }
. ptab : hover { background : var ( - - s2 ) } . ptab . active { background : #fff ; color : var ( - - text ) ; box-shadow : 0 1 px 4 px rgba ( 0 , 0 , 0 , 0.12 ) }
# district-block { padding : 12 px 16 px }
# district-lbl { font-size : 9 px ; font-weight : 700 ; letter-spacing : 1 px ; text-transform : uppercase ; color : var ( - - t3 ) ; margin-bottom : 8 px }
. dcard { border-radius : var ( - - radius - md ) ; background : var ( - - s2 ) ; padding : 11 px 13 px ; margin-bottom : 6 px ; cursor : pointer ; transition : all 0.12 s }
. dcard : hover , . dcard . active { background : var ( - - s3 ) }
. dcard-head { display : flex ; align-items : center ; justify-content : space-between ; margin-bottom : 3 px }
. dcard-name { font-size : 13 px ; font-weight : 700 ; color : var ( - - text ) }
. dcard-score { font-size : 11 px ; font-weight : 800 ; padding : 1 px 7 px ; border-radius : var ( - - radius - sm ) ; font-family : var ( - - mono ) }
. dcard-desc { font-size : 12 px ; color : var ( - - t2 ) ; line-height : 1.4 ; margin-bottom : 5 px }
. dcard-contra { display : inline-flex ; align-items : center ; gap : 4 px ; font-size : 9 px ; font-weight : 700 ; border-radius : var ( - - radius - sm ) ; padding : 2 px 7 px }
. dc-low { background : rgba ( 22 , 163 , 74 , 0.12 ) ; color : #166534 } . dc-med { background : rgba ( 217 , 119 , 6 , 0.12 ) ; color : #92400e } . dc-high { background : rgba ( 220 , 38 , 38 , 0.12 ) ; color : #991b1b }
# freshness-block { padding : 12 px 16 px ; border-top : 1 px solid var ( - - border ) ; margin-top : auto }
# freshness-title { font-size : 9 px ; font-weight : 700 ; letter-spacing : 1 px ; text-transform : uppercase ; color : var ( - - t3 ) ; margin-bottom : 8 px }
. fresh-row { display : flex ; align-items : center ; justify-content : space-between ; padding : 5 px 0 ; border-bottom : 1 px solid var ( - - border ) } . fresh-row : last-child { border-bottom : none }
. fresh-source { font-size : 10 px ; color : var ( - - t2 ) ; font-weight : 500 }
. fresh-val { font-size : 9 px ; font-weight : 700 ; font-family : var ( - - mono ) ; display : flex ; align-items : center ; gap : 4 px }
. fresh-dot { width : 5 px ; height : 5 px ; border-radius : var ( - - radius - full ) } . fresh-dot . live { background : #22c55e ; animation : pulse-ring 1.5 s ease-in-out infinite } . fresh-dot . recent { background : #fbbf24 }
# map-wrap { position : relative ; overflow : hidden ; background : var ( - - bg ) }
# map { width : 100 % ; height : 100 % }
# map-topbar { position : absolute ; top : 0 ; left : 0 ; right : 0 ; z-index : 1000 ; height : 44 px ; display : flex ; align-items : center ; gap : 10 px ; padding : 0 14 px ; background : rgba ( 0 , 0 , 0 , 0.92 ) ; backdrop-filter : blur ( 20 px ) saturate ( 180 % ) ; border-bottom : 1 px solid rgba ( 0 , 0 , 0 , 0.1 ) }
# map-logo { font-size : 11 px ; font-weight : 800 ; color : var ( - - text ) ; text-decoration : none } # map-logo em { color : var ( - - accent3 ) ; font-style : normal }
. map-sep { width : 1 px ; height : 16 px ; background : rgba ( 0 , 0 , 0 , 0.12 ) }
# map-city-label { font-size : 12 px ; font-weight : 700 ; color : var ( - - text ) ; flex : 1 }
# map-profile-chip { font-size : 10 px ; font-weight : 700 ; border-radius : var ( - - radius - sm ) ; padding : 3 px 10 px ; border : 1 px solid }
# layer-bar { position : absolute ; top : 44 px ; left : 0 ; right : 0 ; z-index : 999 ; display : flex ; align-items : center ; gap : 4 px ; padding : 5 px 8 px ; background : #fff ; border-bottom : 1 px solid rgba ( 0 , 0 , 0 , 0.1 ) ; overflow-x : auto ; scrollbar-width : none }
. ltoggle { display : flex ; align-items : center ; gap : 5 px ; font-size : 11 px ; font-weight : 600 ; color : var ( - - t2 ) ; background : rgba ( 0 , 0 , 0 , 0.05 ) ; border : none ; border-radius : var ( - - radius - xl ) ; padding : 5 px 12 px ; cursor : pointer ; white-space : nowrap ; flex-shrink : 0 }
. ltoggle . on { background : var ( - - accent ) ; color : #1d1d1f } . ltoggle-dot { width : 6 px ; height : 6 px ; border-radius : var ( - - radius - full ) ; flex-shrink : 0 }
. leaflet-control-zoom { margin-top : 96 px !important ; margin-left : 10 px !important }
. leaflet-popup-content-wrapper { background : #fff !important ; border : none !important ; border-radius : 16 px !important ; box-shadow : 0 8 px 32 px rgba ( 0 , 0 , 0 , 0.15 ) !important ; padding : 0 !important }
. leaflet-popup-content { margin : 0 !important ; width : 260 px !important } . leaflet-popup-tip { background : #fff !important }
. mpop { padding : 14 px 15 px } . mpop-eyebrow { font-size : 9 px ; font-weight : 700 ; letter-spacing : 1 px ; text-transform : uppercase ; color : var ( - - accent3 ) ; margin-bottom : 3 px }
. mpop-name { font-size : 14 px ; font-weight : 800 ; color : var ( - - text ) ; margin-bottom : 8 px }
. mpop-score-row { display : flex ; align-items : baseline ; gap : 6 px ; margin-bottom : 10 px }
. mpop-score { font-size : 26 px ; font-weight : 900 ; font-family : var ( - - mono ) }
. mpop-bars { display : flex ; flex-direction : column ; gap : 5 px ; margin-bottom : 10 px ; padding-bottom : 10 px ; border-bottom : 1 px solid var ( - - border ) }
. mpop-bar-row { display : flex ; align-items : center ; gap : 6 px }
. mpop-bar-lbl { font-size : 9 px ; font-weight : 600 ; text-transform : uppercase ; color : var ( - - t3 ) ; width : 72 px ; flex-shrink : 0 }
. mpop-bar-track { flex : 1 ; height : 3 px ; border-radius : var ( - - radius - sm ) ; background : rgba ( 0 , 0 , 0 , 0.06 ) } . mpop-bar-fill { height : 100 % ; border-radius : var ( - - radius - sm ) }
. mpop-bar-val { font-size : 10 px ; font-weight : 700 ; font-family : var ( - - mono ) ; width : 24 px ; text-align : right }
. mpop-contra { font-size : 10 px ; border-radius : var ( - - radius - sm ) ; padding : 6 px 9 px ; line-height : 1.4 ; margin-bottom : 8 px }
. mpop-contra . low { background : rgba ( 22 , 163 , 74 , 0.08 ) ; color : #166534 ; border : 1 px solid rgba ( 22 , 163 , 74 , 0.15 ) }
. mpop-contra . medium { background : rgba ( 217 , 119 , 6 , 0.08 ) ; color : #92400e ; border : 1 px solid rgba ( 217 , 119 , 6 , 0.15 ) }
. mpop-contra . high { background : rgba ( 220 , 38 , 38 , 0.08 ) ; color : #991b1b ; border : 1 px solid rgba ( 220 , 38 , 38 , 0.15 ) }
. mpop-contra-lbl { font-weight : 800 ; font-size : 9 px ; text-transform : uppercase ; display : block ; margin-bottom : 2 px }
. mpop-insight { font-size : 11 px ; color : var ( - - t2 ) ; line-height : 1.5 }
# right { background : var ( - - s1 ) ; border-left : 1 px solid var ( - - border ) ; overflow-y : auto ; display : flex ; flex-direction : column }
# right-header { padding : 14 px 16 px 10 px ; border-bottom : 1 px solid var ( - - border ) }
# right-profile-label { font-size : 9 px ; font-weight : 700 ; letter-spacing : 1 px ; text-transform : uppercase ; color : var ( - - t3 ) ; margin-bottom : 4 px }
# right-title { font-size : 17 px ; font-weight : 700 ; color : var ( - - text ) }
# kpi-grid { padding : 10 px 16 px 8 px ; display : grid ; grid-template-columns : 1 fr 1 fr ; gap : 6 px ; border-bottom : 1 px solid var ( - - border ) }
. kpi { background : var ( - - s2 ) ; border-radius : var ( - - radius - md ) ; padding : 12 px 14 px } . kpi-label { font-size : 10 px ; font-weight : 700 ; letter-spacing : 0.5 px ; text-transform : uppercase ; margin-bottom : 4 px }
. kpi-value { font-size : 22 px ; font-weight : 800 ; font-family : var ( - - mono ) ; line-height : 1 } . kpi-sub { font-size : 11 px ; color : var ( - - t2 ) ; margin-top : 3 px } . kpi . span2 { grid-column : span 2 }
# insights-block { padding : 12 px 16 px ; border-bottom : 1 px solid var ( - - border ) }
# insights-title { font-size : 9 px ; font-weight : 700 ; letter-spacing : 1 px ; text-transform : uppercase ; color : var ( - - t3 ) ; margin-bottom : 10 px }
. insight-row { display : flex ; gap : 9 px ; align-items : flex-start ; padding : 7 px 0 ; border-bottom : 1 px solid var ( - - border ) } . insight-row : last-child { border-bottom : none }
. insight-icon { font-size : 11 px ; font-weight : 800 ; color : var ( - - accent3 ) ; flex-shrink : 0 ; margin-top : 3 px ; width : 12 px }
. insight-text { font-size : 13 px ; color : rgba ( 28 , 28 , 30 , 0.8 ) ; line-height : 1.6 } . insight-text strong { color : var ( - - text ) ; font-weight : 700 }
# contra-block { padding : 12 px 16 px ; border-bottom : 1 px solid var ( - - border ) }
# contra-header { display : flex ; align-items : center ; justify-content : space-between ; margin-bottom : 8 px }
# contra-title { font-size : 9 px ; font-weight : 700 ; letter-spacing : 1 px ; text-transform : uppercase ; color : var ( - - t3 ) ; display : flex ; align-items : center ; gap : 5 px }
. contra-live-dot { width : 5 px ; height : 5 px ; border-radius : var ( - - radius - full ) ; background : #f87171 ; animation : pulse-ring 1.8 s ease-in-out infinite }
. contra-count { font-size : 9 px ; font-weight : 700 ; background : rgba ( 220 , 38 , 38 , 0.12 ) ; color : #991b1b ; padding : 1 px 7 px ; border-radius : var ( - - radius - md ) }
. contra-card { border-radius : var ( - - radius - sm ) ; border : 1 px solid var ( - - border ) ; background : var ( - - s2 ) ; padding : 9 px 11 px ; margin-bottom : 5 px }
. contra-card . c-medium { border-left : 3 px solid rgba ( 217 , 119 , 6 , 0.5 ) } . contra-card . c-high { border-left : 3 px solid rgba ( 220 , 38 , 38 , 0.6 ) }
. contra-card-head { display : flex ; align-items : center ; justify-content : space-between ; margin-bottom : 3 px }
. contra-card-name { font-size : 11 px ; font-weight : 700 ; color : var ( - - text ) } . contra-card-badge { font-size : 8 px ; font-weight : 700 ; text-transform : uppercase ; padding : 1 px 6 px ; border-radius : var ( - - radius - sm ) }
. contra-card-badge . medium { background : rgba ( 217 , 119 , 6 , 0.12 ) ; color : #92400e } . contra-card-badge . high { background : rgba ( 220 , 38 , 38 , 0.12 ) ; color : #991b1b }
. contra-card-desc { font-size : 10 px ; color : var ( - - t2 ) ; line-height : 1.4 }
# right-footer { padding : 10 px 16 px ; font-size : 9 px ; color : var ( - - t3 ) ; line-height : 1.4 ; border-top : 1 px solid var ( - - border ) }
# bottom-sheet { display : none ; position : fixed ; left : 0 ; right : 0 ; bottom : 0 ; z-index : 9999 ; background : #fff ; border-radius : var ( - - radius - lg ) 16 px 0 0 ; box-shadow : 0 -4 px 24 px rgba ( 0 , 0 , 0 , 0.12 ) ; max-height : 72 vh ; overflow-y : auto ; transform : translateY ( 100 % ) ; transition : transform 0.28 s cubic-bezier ( 0.32 , 0.72 , 0 , 1 ) }
# bottom-sheet . open { display : block ; transform : translateY ( 0 ) }
# bottom-sheet-handle { width : 36 px ; height : 4 px ; border-radius : var ( - - radius - sm ) ; background : rgba ( 0 , 0 , 0 , 0.12 ) ; margin : 10 px auto 0 }
# bottom-sheet-close { position : absolute ; top : 12 px ; right : 14 px ; background : rgba ( 0 , 0 , 0 , 0.06 ) ; border : none ; color : var ( - - text ) ; font-size : 16 px ; width : 28 px ; height : 28 px ; border-radius : var ( - - radius - full ) ; cursor : pointer ; display : flex ; align-items : center ; justify-content : center }
# bottom-sheet-content { padding : 8 px 0 env ( safe - area - inset - bottom , 16 px ) }
# bottom-sheet-backdrop { display : none ; position : fixed ; inset : 0 ; z-index : 9998 ; background : rgba ( 0 , 0 , 0 , 0.4 ) } # bottom-sheet-backdrop . open { display : block }
# map-legend { position : absolute ; bottom : 60 px ; right : 10 px ; z-index : 800 ; background : rgba ( 0 , 0 , 0 , 0.92 ) ; backdrop-filter : blur ( 12 px ) ; border : 1 px solid rgba ( 0 , 0 , 0 , 0.08 ) ; border-radius : var ( - - radius - md ) ; padding : 10 px 12 px }
@ keyframes pulse-ring { 0 % { opacity : 1 ; transform : scale ( 1 ) } 50 % { opacity : 0.6 ; transform : scale ( 1.2 ) } 100 % { opacity : 1 ; transform : scale ( 1 ) } }
@ keyframes pulse-wave { 0 % { transform : scale ( 1 ) ; opacity : 0.7 } 100 % { transform : scale ( 3.2 ) ; opacity : 0 } }
. pulse-marker { pointer-events : none } . pulse-outer { position : absolute ; border-radius : var ( - - radius - full ) ; animation : pulse-wave 2.2 s ease-out infinite } . pulse-inner { position : absolute ; border-radius : var ( - - radius - full ) ; transform : translate ( -50 % , -50 % ) }
@ media ( max-width : 900px ) { html , body { height : auto ; overflow-y : auto ; overflow-x : hidden } # app { display : flex ; flex-direction : column ; height : auto ; min-height : 100 dvh } # left { order : 1 ; border-right : none } # city-stats { display : flex ; gap : 8 px ; flex-wrap : nowrap } . cstat { background : transparent ; border : none ; padding : 0 ; flex-shrink : 0 } # district-block { display : none } # freshness-block { display : none } # map-wrap { order : 2 ; height : 60 vh ; min-height : 340 px ; max-height : 520 px } # right { order : 3 ; border-left : none ; border-top : 1 px solid var ( - - border ) } . leaflet-popup { display : none !important } }
@ media ( max-width : 480px ) { # map-wrap { height : 55 vh ; min-height : 300 px } }
@ media ( max-width : 900px ) { . label-marker { display : none !important } }
< / style >
< style >
@ media ( max-width : 640px ) {
. hero h1 { font-size : 2 rem !important ; }
. hero p { font-size : 1 rem !important ; }
. card { padding : 1.5 rem !important ; }
. grid { grid-template-columns : 1 fr !important ; }
. btn { padding : 0.75 rem 1.5 rem !important ; font-size : 1 rem !important ; }
}
< / style >
< script type = "application/ld+json" >
{
"@context" : "https://schema.org" ,
"@type" : [
"Organization" ,
"Corporation"
] ,
"@id" : "https://landvex.com/#organization" ,
"name" : "Landvex" ,
"alternateName" : [
"LandveX" ,
"Landvex AB" ,
"LandveX AB"
] ,
"url" : "https://landvex.com" ,
"logo" : {
"@type" : "ImageObject" ,
"url" : "https://landvex.com/apple-touch-icon.png" ,
"width" : 180 ,
"height" : 180
} ,
"foundingDate" : "2024" ,
"foundingLocation" : {
"@type" : "Place" ,
2026-07-14 15:27:33 +00:00
"addressLocality" : "Stockholm" ,
2026-07-12 12:41:35 +00:00
"addressRegion" : "Stockholm" ,
"addressCountry" : "SE"
} ,
"legalName" : "Landvex Inc" ,
"address" : {
"@type" : "PostalAddress" ,
"addressLocality" : "Houston" ,
"addressRegion" : "TX" ,
"addressCountry" : "US"
} ,
"location" : [
{
"@type" : "Place" ,
"name" : "Houston, Texas, USA (US HQ)" ,
"address" : {
"@type" : "PostalAddress" ,
"addressLocality" : "Houston" ,
"addressRegion" : "TX" ,
"addressCountry" : "US"
}
} ,
{
"@type" : "Place" ,
2026-07-14 15:27:33 +00:00
"name" : "Stockholm, Sweden (EU HQ)" ,
2026-07-12 12:41:35 +00:00
"address" : {
"@type" : "PostalAddress" ,
2026-07-14 15:27:33 +00:00
"addressLocality" : "Stockholm" ,
2026-07-12 12:41:35 +00:00
"addressRegion" : "Stockholm" ,
"addressCountry" : "SE"
}
}
] ,
"taxID" : "pending-EIN" ,
"description" : "Landvex is a decision intelligence company that identifies contradictions between official narratives and observed physical reality. Using the quiXzoom field observation network and the AMOS AI analysis engine, Landvex delivers infrastructure risk indexes, urban intelligence scores, and contradiction reports to infrastructure owners, municipalities, enterprises, and investors globally." ,
2026-07-14 15:27:33 +00:00
"disambiguatingDescription" : "Landvex (LandveX) is a decision intelligence company with US headquarters in Houston, Texas (Landvex Inc.) and European headquarters in Stockholm, Sweden (Landvex AB, org.nr 559141-7042). Landvex delivers control intelligence — infrastructure risk scores, urban intelligence, and contradiction analysis — to municipalities, enterprises, infrastructure operators and investors. Landvex is not a UK meat wholesaler, food distributor, logistics company, package tracker, or land investment platform." ,
2026-07-12 12:41:35 +00:00
"knowsAbout" : [
"control intelligence" ,
"decision intelligence" ,
"infrastructure risk assessment" ,
"urban intelligence" ,
"field data collection" ,
"contradiction detection" ,
"physical world analytics" ,
"geospatial analytics"
] ,
"brand" : {
"@type" : "Brand" ,
"name" : "Landvex" ,
"slogan" : "Control intelligence for the physical world."
} ,
"sameAs" : [
"https://landvex.com" ,
"https://www.linkedin.com/company/landvex" ,
"https://x.com/landvex" ,
"https://twitter.com/landvex" ,
"https://www.crunchbase.com/organization/landvex" ,
"https://github.com/landvex" ,
"https://www.instagram.com/landvex"
] ,
"contactPoint" : {
"@type" : "ContactPoint" ,
"email" : "contact@landvex.com" ,
"contactType" : "sales"
} ,
"slogan" : "Where reported reality conflicts with observed reality."
}
< / script >
< / head >
< body >
< div id = "app" >
< div id = "left" >
< div id = "city-block" >
< div id = "city-eyebrow" > Landvex Intelligence Brief< / div >
< div id = "city-name" > UPPSALA< / div >
< div id = "city-region" > Uppsala County · Kingdom of Sweden< / div >
< div id = "city-stats" >
< div class = "cstat" > < div class = "cstat-val" style = "color:#4ade80" > 84%< / div > < div class = "cstat-lbl" > Confidence< / div > < / div >
< div class = "cstat" > < div class = "cstat-val" id = "district-count" > 5< / div > < div class = "cstat-lbl" > Districts< / div > < / div >
< div class = "cstat" > < div class = "cstat-val" style = "color:#fbbf24" > Low< / div > < div class = "cstat-lbl" > Contradiction< / div > < / div >
< div class = "cstat" > < div class = "cstat-val" > Jun 2026< / div > < div class = "cstat-lbl" > Updated< / div > < / div >
< / div >
< / div >
< div id = "profile-block" >
< div id = "profile-tabs" >
< button class = "ptab active" data-p = "business" onclick = "setProfile('business',this)" > Business< / button >
< button class = "ptab" data-p = "tourist" onclick = "setProfile('tourist',this)" > Tourist< / button >
< button class = "ptab" data-p = "safety" onclick = "setProfile('safety',this)" > Safety< / button >
< button class = "ptab" data-p = "investment" onclick = "setProfile('investment',this)" > Investment< / button >
< button class = "ptab" data-p = "nightlife" onclick = "setProfile('nightlife',this)" > Nightlife< / button >
< button class = "ptab" data-p = "infrastructure" onclick = "setProfile('infrastructure',this)" > Infra< / button >
< / div >
< / div >
< div id = "district-block" >
< div id = "district-lbl" > Districts · ranked by profile< / div >
< div id = "district-list" > < / div >
< / div >
< div id = "freshness-block" >
< div id = "freshness-title" > Data Freshness< / div >
< div class = "fresh-row" > < div class = "fresh-source" > Police events< / div > < div class = "fresh-val" > < div class = "fresh-dot live" > < / div > < span id = "police-fresh" > Loading…< / span > < / div > < / div >
< div class = "fresh-row" > < div class = "fresh-source" > SVT Uppland< / div > < div class = "fresh-val" > < div class = "fresh-dot live" > < / div > < span > Live< / span > < / div > < / div >
< div class = "fresh-row" > < div class = "fresh-source" > SCB demographics< / div > < div class = "fresh-val" > < div class = "fresh-dot recent" > < / div > < span > 2024 annual< / span > < / div > < / div >
< / div >
< / div >
< div id = "bottom-sheet-backdrop" onclick = "closeBottomSheet()" > < / div >
< div id = "bottom-sheet" >
< div id = "bottom-sheet-handle" > < / div >
< button id = "bottom-sheet-close" onclick = "closeBottomSheet()" > × < / button >
< div id = "bottom-sheet-content" > < / div >
< / div >
< div id = "map-wrap" >
< div id = "map-topbar" >
< a href = "https://www.landvex.com" id = "map-logo" > Land< em > vex< / em > < / a >
< div class = "map-sep" > < / div >
< div id = "map-city-label" > Uppsala — < span id = "map-profile-label" > Business< / span > < / div >
< 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 style = "display:flex;gap:4px;margin-left:auto" >
< button onclick = "map.setView([59.8586,17.6389],12)" 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" > UPP< / button >
< button onclick = "map.setView([59.9,17.5],10)" 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" > Region< / button >
< button onclick = "window.location='/intelligence'" 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" > All cities< / button >
< / div >
< / div >
< div id = "layer-bar" >
< div class = "ltoggle on" id = "ltog-districts" onclick = "toggleLayer('districts',this)" > < div class = "ltoggle-dot" style = "background:#4aafef" > < / div > Areas< / div >
< div class = "ltoggle" id = "ltog-pulse" onclick = "toggleLayer('pulse',this)" > < div class = "ltoggle-dot" style = "background:#c084fc;animation:pulse-ring 1.5s ease-in-out infinite" > < / div > Activity< / div >
< div class = "ltoggle" id = "ltog-alerts" onclick = "toggleLayer('alerts',this)" > < div class = "ltoggle-dot" style = "background:#f87171" > < / div > Contradictions< / div >
< div class = "ltoggle on" id = "ltog-police" onclick = "togglePolice(this)" > < div class = "ltoggle-dot" style = "background:#ef4444" > < / div > Police< / div >
< / div >
< div id = "map" > < / div >
< div id = "map-legend" >
< 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: var(--radius-full);background:#22c55e;flex-shrink:0" > < / div > Strong (8– 10)< / 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 (6– 8)< / 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 (4– 6)< / 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 (< 4)< / div >
< / div >
< div style = "font-size:9px;color:rgba(28,28,30,0.35);margin-top:6px" > Tap any area for details< / div >
< / div >
< / div >
< div id = "right" >
< div id = "right-header" >
< div id = "right-profile-label" > Profile< / div >
< div id = "right-title" > Business< / div >
< / div >
< div id = "kpi-grid" > < / div >
< div id = "insights-block" >
< div id = "insights-title" > Key Insights< / div >
< div id = "insights-list" > < / div >
< / div >
< div id = "contra-block" >
< div id = "contra-header" >
< div id = "contra-title" > < div class = "contra-live-dot" > < / div > Contradiction Alerts< / div >
< div class = "contra-count" id = "contra-count" > 1 active< / div >
< / div >
< div id = "contra-list" > < / div >
< / div >
< div id = "news-block" style = "padding:12px 16px;border-bottom:1px solid var(--border)" >
< div style = "font-size:9px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:var(--t3);margin-bottom:8px;display:flex;align-items:center;justify-content:space-between" >
SVT Uppland — Live News
< span style = "font-size:9px;color:var(--t3)" id = "news-time" > Loading…< / span >
< / div >
< div id = "news-list" style = "font-size:10px;color:var(--t3)" > Loading…< / div >
< / div >
< div id = "police-block" style = "padding:12px 16px;border-bottom:1px solid var(--border)" >
< div style = "font-size:9px;font-weight:700;letter-spacing:1px;text-transform:uppercase;color:var(--t3);margin-bottom:8px;display:flex;align-items:center;justify-content:space-between" >
Police Events · Live
< span style = "font-size:9px;font-weight:700;background:rgba(239,68,68,0.12);color:#991b1b;border-radius: var(--radius-sm);padding:1px 7px" id = "police-count" > Loading…< / span >
< / div >
< div id = "police-list" style = "font-size:10px;color:var(--t3)" > Loading…< / div >
< / div >
< div id = "right-footer" >
< a href = "/intelligence" style = "color:var(--accent3)" > ← All Cities< / a > ·
< a href = "/stockholm-intelligence" style = "color:var(--accent3)" > Stockholm< / a > ·
< a href = "/goteborg-intelligence" style = "color:var(--accent3)" > Göteborg< / a > ·
< a href = "/malmo-intelligence" style = "color:var(--accent3)" > Malmö< / a > < br > < br >
Data: Polisen.se · SVT Uppland · SCB · Landvex Intelligence Engine< br >
© 2026 Landvex. Source First.
< / div >
< / div >
< / div >
< script >
const D = [
{ id : 'centrum' , name : 'Centrum' , lat : 59.858 , lng : 17.638 , r : 1200 ,
confidence : { score : 87 , observation _count : 412 } ,
s : { business : 8.1 , tourist : 8.8 , safety : 8.4 , investment : 7.9 , nightlife : 7.6 , infrastructure : 8.6 } ,
c : 'low' , ct : 'University city core. Cathedral, Fyrisån. Strong alignment.' ,
col : '#16a34a' , pulse : { level : 0.62 , label : 'High Activity' } , umi : + 38 ,
ins : { business : 'Uppsala Universitet, tech startups, finance. Sweden\'s 4th city.' , tourist : 'Uppsala Cathedral, castle, Gustavianum. Major cultural destination.' , safety : 'Very safe. University atmosphere.' , investment : 'Strong student/professional demand.' , nightlife : 'Vibrant student scene. Good density of bars.' , infrastructure : 'Uppsala Central — direct Stockholm 38 min. Arlanda 20 min.' } } ,
{ id : 'falhagen' , name : 'Fålhagen' , lat : 59.867 , lng : 17.658 , r : 1000 ,
confidence : { score : 82 , observation _count : 241 } ,
s : { business : 6.2 , tourist : 5.4 , safety : 8.1 , investment : 7.4 , nightlife : 8.2 , infrastructure : 7.8 } ,
c : 'low' , ct : 'Student area, vibrant. East of Fyrisån. Active nightlife.' ,
col : '#16a34a' , pulse : { level : 0.71 , label : 'High Activity' } , umi : + 44 ,
ins : { business : 'Student-driven economy. Cafes, restaurants, local retail.' , tourist : 'Authentic student culture experience.' , safety : 'Generally safe. Night awareness Fri– Sat.' , investment : 'Strong rental demand. Student housing boom.' , nightlife : 'Best nightlife in Uppsala. Student bars dense.' , infrastructure : 'Good bus. Cycling culture strong.' } } ,
{ id : 'luthagen' , name : 'Luthagen' , lat : 59.860 , lng : 17.614 , r : 1100 ,
confidence : { score : 88 , observation _count : 298 } ,
s : { business : 6.8 , tourist : 5.8 , safety : 9.1 , investment : 8.4 , nightlife : 5.2 , infrastructure : 8.2 } ,
c : 'low' , ct : 'Premium residential. Quiet. High-income professionals.' ,
col : '#16a34a' , pulse : { level : 0.34 , label : 'Low Activity' } , umi : + 28 ,
ins : { business : 'Local premium services. Some offices.' , tourist : 'Residential area. Limited appeal.' , safety : 'Very safe. Premium residential quality.' , investment : 'Highest residential values in Uppsala.' , nightlife : 'Limited. Centrum 5 min.' , infrastructure : 'Good bus. Close to Central station.' } } ,
{ id : 'gottsunda' , name : 'Gottsunda' , lat : 59.816 , lng : 17.607 , r : 1800 ,
confidence : { score : 74 , observation _count : 187 } ,
s : { business : 4.8 , tourist : 3.2 , safety : 6.2 , investment : 6.8 , nightlife : 3.8 , infrastructure : 6.9 } ,
c : 'medium' , ct : 'Suburban mix. Some social challenges. Infrastructure upgrades planned.' ,
col : '#ca8a04' , pulse : { level : 0.38 , label : 'Low– Moderate' } , umi : + 14 ,
ins : { business : 'Local services. Limited commercial density.' , tourist : 'No tourist appeal.' , safety : 'Some elevated areas. Generally manageable.' , investment : 'Affordable entry. Uppsala growth spillover.' , nightlife : 'Limited.' , infrastructure : 'Bus dependent. Gottsunda centre improvement planned.' } } ,
{ id : 'bjorklinge' , name : 'Björklinge' , lat : 59.951 , lng : 17.530 , r : 2200 ,
confidence : { score : 79 , observation _count : 143 } ,
s : { business : 4.2 , tourist : 4.8 , safety : 9.4 , investment : 6.8 , nightlife : 2.4 , infrastructure : 5.8 } ,
c : 'low' , ct : 'Rural fringe. Commuter village. Idyllic pastoral character.' ,
col : '#16a34a' , pulse : { level : 0.18 , label : 'Low Activity' } , umi : + 12 ,
ins : { business : 'Local services. Agricultural economy.' , tourist : 'Swedish countryside. Day trip from Uppsala.' , safety : 'Exceptional. Rural safety.' , investment : 'Affordable. Commuter demand growing.' , nightlife : 'Essentially none.' , infrastructure : 'Bus to Uppsala 25 min. Car essential.' } }
] ;
const CONTRA _ALERTS = [
{ distId : 'gottsunda' , name : 'Gottsunda' , level : 'medium' , desc : 'Official safety data slightly optimistic. Some evening risk in outer blocks not reflected in statistics.' , date : '2026-06-15' }
] ;
const PROF = {
business : { label : 'Business' , col : '#2d8fcf' , chipBg : 'rgba(45,143,207,0.15)' , chipBorder : 'rgba(45,143,207,0.35)' , chipCol : '#0066cc' } ,
tourist : { label : 'Tourist' , col : '#22c55e' , chipBg : 'rgba(34,197,94,0.15)' , chipBorder : 'rgba(34,197,94,0.3)' , chipCol : '#16a34a' } ,
safety : { label : 'Safety' , col : '#dc2626' , chipBg : 'rgba(220,38,38,0.15)' , chipBorder : 'rgba(220,38,38,0.3)' , chipCol : '#991b1b' } ,
investment : { label : 'Investment' , col : '#d97706' , chipBg : 'rgba(217,119,6,0.15)' , chipBorder : 'rgba(217,119,6,0.3)' , chipCol : '#92400e' } ,
nightlife : { label : 'Nightlife' , col : '#9333ea' , chipBg : 'rgba(147,51,234,0.15)' , chipBorder : 'rgba(147,51,234,0.3)' , chipCol : '#6b21a8' } ,
infrastructure : { label : 'Infrastructure' , col : '#b45309' , chipBg : 'rgba(180,83,9,0.15)' , chipBorder : 'rgba(180,83,9,0.3)' , chipCol : '#78350f' }
} ;
const INSIGHTS = {
business : [ '<strong>Centrum</strong> — Uppsala Universitet drives knowledge economy. Sweden\'s 4th city business hub.' , '<strong>Fålhagen</strong> — student-driven. Cafes, restaurants, creative economy dense.' , '<strong>Luthagen</strong> — premium residential with quality local services.' , '<strong>Gottsunda</strong> — limited but affordable entry for commercial operators.' ] ,
tourist : [ '<strong>Centrum</strong> — Uppsala Cathedral, Gustavianum, castle. Major cultural destination.' , '<strong>Fålhagen</strong> — authentic student city experience. Unique atmosphere.' , '<strong>Luthagen</strong> — residential premium. Limited tourist appeal.' , '<strong>Björklinge</strong> — Swedish countryside day trip.' ] ,
safety : [ '<strong>Björklinge</strong> — exceptional rural safety. Highest score.' , '<strong>Luthagen</strong> — very safe premium residential.' , '<strong>Centrum</strong> — very safe. Cathedral area well-policed.' , '<strong>Gottsunda</strong> — some elevated risk. Active monitoring.' ] ,
investment : [ '<strong>Luthagen</strong> — highest residential values in Uppsala. Premium demand.' , '<strong>Centrum</strong> — strong student demand. Stable rental yields.' , '<strong>Fålhagen</strong> — student housing boom. Strong rental growth.' , '<strong>Gottsunda</strong> — affordable with upside. Infrastructure upgrades planned.' ] ,
nightlife : [ '<strong>Fålhagen</strong> — best nightlife in Uppsala. Student bar density.' , '<strong>Centrum</strong> — good restaurant and bar scene. University-driven.' , '<strong>Luthagen</strong> — limited. Premium residential character.' , '<strong>Björklinge</strong> — essentially none.' ] ,
infrastructure : [ '<strong>Centrum</strong> — Uppsala Central. Direct Stockholm 38 min, Arlanda 20 min.' , '<strong>Luthagen</strong> — excellent. Close to central station.' , '<strong>Gottsunda</strong> — bus dependent. Improvement planned.' , '<strong>Björklinge</strong> — rural. Car essential. Bus 25 min.' ]
} ;
const KPI = {
business : { main : '8.1' , mainLabel : 'Business Score — Centrum' , sub : 'University city knowledge hub' , extra : '4th' , extraLabel : 'Swedish city by business' } ,
tourist : { main : '8.8' , mainLabel : 'Tourist Score — Centrum' , sub : 'Cathedral · Castle · University' , extra : 'Top 5' , extraLabel : 'Cultural destinations SE' } ,
safety : { main : '9.4' , mainLabel : 'Safety — Björklinge' , sub : 'Rural fringe exceptional' , extra : '8.4' , extraLabel : 'Centrum — very safe' } ,
investment : { main : '8.4' , mainLabel : 'Peak Score — Luthagen' , sub : 'Premium residential demand' , extra : '+44' , extraLabel : 'Top UMI — Fålhagen' } ,
nightlife : { main : '8.2' , mainLabel : 'Nightlife — Fålhagen' , sub : 'Student bar density' , extra : '7.6' , extraLabel : 'Centrum scene' } ,
infrastructure : { main : '8.6' , mainLabel : 'Infra — Centrum' , sub : 'Stockholm 38 min · Arlanda 20' , extra : 'Uppsala C' , extraLabel : 'Regional transport hub' }
} ;
let map , profile = 'business' , polys = { } , pulseMarkers = [ ] , alertMarkers = [ ] ;
let layerState = { districts : true , pulse : false , alerts : false } ;
let policeGroup = L . layerGroup ( ) , policeVisible = true ;
function sc ( v ) { return v >= 8 ? '#22c55e' : v >= 6 ? '#eab308' : v >= 4 ? '#f97316' : '#ef4444' }
function isMobile ( ) { return window . innerWidth <= 900 }
function openBottomSheet ( html ) { document . getElementById ( 'bottom-sheet-content' ) . innerHTML = html ; document . getElementById ( 'bottom-sheet' ) . classList . add ( 'open' ) ; document . getElementById ( 'bottom-sheet-backdrop' ) . classList . add ( 'open' ) }
function closeBottomSheet ( ) { document . getElementById ( 'bottom-sheet' ) . classList . remove ( 'open' ) ; document . getElementById ( 'bottom-sheet-backdrop' ) . classList . remove ( 'open' ) }
function buildPopup ( d , p ) {
const sv = d . s [ p ] , col = sc ( sv ) ;
const cLabel = d . c === 'high' ? 'HIGH CONTRADICTION' : d . c === 'medium' ? 'MEDIUM CONTRADICTION' : 'LOW CONTRADICTION' ;
const barsHtml = Object . entries ( d . s ) . map ( ( [ k , v ] ) => ` <div class="mpop-bar-row"><div class="mpop-bar-lbl"> ${ k } </div><div class="mpop-bar-track"><div class="mpop-bar-fill" style="width: ${ v * 10 } %;background: ${ sc ( v ) } "></div></div><div class="mpop-bar-val" style="color: ${ sc ( v ) } "> ${ v } </div></div> ` ) . join ( '' ) ;
const confBadge = d . confidence ? ` <div style="font-size:9px;color:rgba(28,28,30,0.45);margin-bottom:6px">Confidence: <strong style="color: ${ d . confidence . score >= 85 ? '#166534' : '#92400e' } "> ${ d . confidence . score } %</strong> · ${ d . confidence . observation _count } observations</div> ` : '' ;
return ` <div class="mpop"><div class="mpop-eyebrow">Uppsala · ${ PROF [ p ] . label } </div><div class="mpop-name"> ${ d . name } </div> ${ confBadge } <div class="mpop-score-row"><div class="mpop-score" style="color: ${ col } "> ${ sv . toFixed ( 1 ) } </div><div style="font-size:12px;color:rgba(28,28,30,0.4);font-family:var(--mono)">/10</div><div style="font-size:11px;color:rgba(28,28,30,0.55);line-height:1.3"> ${ PROF [ p ] . label } <br>score</div></div><div class="mpop-bars"> ${ barsHtml } </div><div class="mpop-contra ${ d . c } "><span class="mpop-contra-lbl"> ${ cLabel } </span> ${ d . ct } </div> ${ d . ins && d . ins [ p ] ? ` <div class="mpop-insight"> ${ d . ins [ p ] } </div> ` : '' } ${ d . umi !== undefined ? ` <div style="margin-top:8px;font-size:11px;color:rgba(28,28,30,0.55)">Urban Momentum: <strong style="color: ${ d . umi > 0 ? '#166534' : '#991b1b' } "> ${ d . umi > 0 ? '+' : '' } ${ d . umi } </strong></div> ` : '' } </div> ` ;
}
function buildList ( p ) {
const sorted = [ ... D ] . sort ( ( a , b ) => b . s [ p ] - a . s [ p ] ) ;
document . getElementById ( 'district-list' ) . innerHTML = sorted . map ( d => {
const v = d . s [ p ] , col = sc ( v ) , cClass = d . c === 'high' ? 'dc-high' : d . c === 'medium' ? 'dc-med' : 'dc-low' , cTxt = d . c === 'high' ? ' HIGH CONTRA' : d . c === 'medium' ? '· MEDIUM CONTRA' : '<svg width="16" height="16" viewBox="0 0 16 16" fill="none"><path d="M3 8L6.5 11.5L13 4.5" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg> LOW CONTRA' ;
return ` <div class="dcard" id="dc- ${ d . id } " onclick="focusDist(' ${ d . id } ')"><div class="dcard-head"><div class="dcard-name"> ${ d . name } </div><div class="dcard-score" style="background: ${ col } 18;color: ${ col } "> ${ v . toFixed ( 1 ) } </div></div><div class="dcard-desc"> ${ d . ct . length > 80 ? d . ct . substring ( 0 , 80 ) + '…' : d . ct } </div><div class="dcard-contra ${ cClass } "> ${ cTxt } </div></div> ` ;
} ) . join ( '' ) ;
}
function buildRight ( p ) {
const kpi = KPI [ p ] , pr = PROF [ p ] ;
document . getElementById ( 'right-title' ) . textContent = pr . label ;
const chip = document . getElementById ( 'map-profile-chip' ) ;
chip . textContent = pr . label ; chip . style . background = pr . chipBg ; chip . style . color = pr . chipCol ; chip . style . borderColor = pr . chipBorder ;
document . getElementById ( 'map-profile-label' ) . textContent = pr . label ;
document . getElementById ( 'kpi-grid' ) . innerHTML = ` <div class="kpi span2"><div class="kpi-label" style="color: ${ pr . chipCol } "> ${ kpi . mainLabel } </div><div class="kpi-value" style="color: ${ pr . chipCol } "> ${ kpi . main } </div><div class="kpi-sub"> ${ kpi . sub } </div></div><div class="kpi"><div class="kpi-label" style="color:var(--t3)">Benchmark</div><div class="kpi-value"> ${ kpi . extra } </div><div class="kpi-sub"> ${ kpi . extraLabel } </div></div><div class="kpi"><div class="kpi-label" style="color:var(--t3)">Districts</div><div class="kpi-value"> ${ D . length } </div><div class="kpi-sub">monitored</div></div> ` ;
document . getElementById ( 'insights-list' ) . innerHTML = ( INSIGHTS [ p ] || [ ] ) . map ( t => ` <div class="insight-row"><div class="insight-icon">› </div><div class="insight-text"> ${ t } </div></div> ` ) . join ( '' ) ;
buildContraAlerts ( ) ;
}
function buildContraAlerts ( ) {
document . getElementById ( 'contra-count' ) . textContent = CONTRA _ALERTS . length + ' active' ;
document . getElementById ( 'contra-list' ) . innerHTML = CONTRA _ALERTS . map ( c => ` <div class="contra-card c- ${ c . level } "><div class="contra-card-head"><div class="contra-card-name"> ${ c . name } </div><div class="contra-card-badge ${ c . level } "> ${ c . level . toUpperCase ( ) } </div></div><div class="contra-card-desc"> ${ c . desc } </div><div style="font-size:9px;color:var(--t3);margin-top:4px"> ${ c . date } </div></div> ` ) . join ( '' ) ;
}
function setProfile ( p , btn ) { profile = p ; document . querySelectorAll ( '.ptab' ) . forEach ( t => t . classList . remove ( 'active' ) ) ; btn . classList . add ( 'active' ) ; buildList ( p ) ; buildRight ( p ) ; updatePolyColors ( p ) ; }
function focusDist ( id ) { const d = D . find ( x => x . id === id ) ; if ( ! d ) return ; map . setView ( [ d . lat , d . lng ] , 13 ) ; if ( polys [ id ] ) { polys [ id ] . openPopup ( ) ; if ( isMobile ( ) ) openBottomSheet ( buildPopup ( d , profile ) ) ; } document . querySelectorAll ( '.dcard' ) . forEach ( c => c . classList . remove ( 'active' ) ) ; const el = document . getElementById ( 'dc-' + id ) ; if ( el ) el . classList . add ( 'active' ) ; }
function updatePolyColors ( p ) { D . forEach ( d => { const c = sc ( d . s [ p ] ) ; if ( polys [ d . id ] ) { polys [ d . id ] . setStyle ( { color : c , fillColor : c } ) ; polys [ d . id ] . setPopupContent ( buildPopup ( d , p ) ) ; } } ) ; }
function toggleLayer ( name , btn ) { layerState [ name ] = ! layerState [ name ] ; btn . classList . toggle ( 'on' , layerState [ name ] ) ; if ( name === 'districts' ) D . forEach ( d => { if ( polys [ d . id ] ) { layerState . districts ? polys [ d . id ] . addTo ( map ) : polys [ d . id ] . remove ( ) ; } } ) ; if ( name === 'pulse' ) pulseMarkers . forEach ( m => layerState . pulse ? m . addTo ( map ) : m . remove ( ) ) ; if ( name === 'alerts' ) alertMarkers . forEach ( m => layerState . alerts ? m . addTo ( map ) : m . remove ( ) ) ; }
function togglePolice ( btn ) { policeVisible = ! policeVisible ; btn . classList . toggle ( 'on' , policeVisible ) ; policeVisible ? policeGroup . addTo ( map ) : map . removeLayer ( policeGroup ) ; }
function buildPulseMarkers ( ) {
pulseMarkers . forEach ( m => m . remove ( ) ) ; pulseMarkers = [ ] ;
D . forEach ( d => { if ( ! d . pulse ) return ; const lvl = d . pulse . level , sz = Math . round ( 8 + lvl * 10 ) , col = lvl > 0.7 ? '#c084fc' : lvl > 0.4 ? '#60a5fa' : '#34d399' ;
const icon = L . divIcon ( { className : 'pulse-marker' , html : ` <div style="position:relative;width: ${ sz * 2 } px;height: ${ sz * 2 } px;transform:translate(-50%,-50%);pointer-events:none"><div class="pulse-outer" style="width: ${ sz * 2 } px;height: ${ sz * 2 } px;left:0;top:0;border:1px solid ${ col } ;animation-duration:2.2s;background: ${ col } 06"></div><div class="pulse-inner" style="width: ${ sz } px;height: ${ sz } px;left:50%;top:50%;background: ${ col } 18;border:1px solid ${ col } 66"></div></div> ` , iconSize : [ 1 , 1 ] , iconAnchor : [ 0 , 0 ] } ) ;
const m = L . marker ( [ d . lat , d . lng ] , { icon , interactive : false } ) ; if ( layerState . pulse ) m . addTo ( map ) ; pulseMarkers . push ( m ) ;
} ) ;
}
function buildAlertMarkers ( ) {
alertMarkers . forEach ( m => m . remove ( ) ) ; alertMarkers = [ ] ;
D . filter ( d => d . c === 'high' || d . c === 'medium' ) . forEach ( d => {
const col = d . c === 'high' ? '#f87171' : '#fbbf24' ;
const icon = L . divIcon ( { className : '' , html : ` <div style="width:18px;height:18px;border-radius: var(--radius-sm);background:rgba(0,0,0,0.95);border:2px solid ${ col } ;display:flex;align-items:center;justify-content:center;font-size:10px;box-shadow:0 0 10px ${ col } 66;cursor:pointer"> ${ d . c === 'high' ? '' : '!' } </div> ` , iconSize : [ 18 , 18 ] , iconAnchor : [ 9 , 9 ] } ) ;
const ca = CONTRA _ALERTS . find ( x => x . distId === d . id ) ;
const marker = L . marker ( [ d . lat , d . lng ] , { icon , zIndexOffset : 500 } ) ;
if ( ca ) marker . bindPopup ( ` <div style="padding:10px 12px;min-width:200px"><div style="font-size:9px;font-weight:700;color: ${ col } ;text-transform:uppercase;margin-bottom:4px">CONTRADICTION ${ d . c . toUpperCase ( ) } </div><div style="font-size:11px;font-weight:600;margin-bottom:4px"> ${ d . name } </div><div style="font-size:10px;color:rgba(28,28,30,0.7);line-height:1.4"> ${ ca . desc } </div></div> ` , { maxWidth : 240 } ) ;
if ( layerState . alerts ) marker . addTo ( map ) ; alertMarkers . push ( marker ) ;
} ) ;
}
const INCIDENT _COLORS = { 'Misshandel' : '#ef4444' , 'Rån' : '#dc2626' , 'Skottlossning' : '#7f1d1d' , 'Narkotikabrott' : '#eab308' , 'Stöld' : '#f59e0b' , 'Trafikolycka' : '#3b82f6' , 'default' : '#6b7280' } ;
async function loadPoliceEvents ( ) {
try {
const now = new Date ( ) , ym = now . getFullYear ( ) + '-' + String ( now . getMonth ( ) + 1 ) . padStart ( 2 , '0' ) ;
const r = await fetch ( 'https://polisen.se/api/events?locationname=Uppsala&DateTime=' + ym ) ;
const data = await r . json ( ) ;
const events = data . filter ( e => e . location && e . location . gps && e . location . gps !== '0,0' ) ;
policeGroup . clearLayers ( ) ;
events . forEach ( e => {
const [ lat , lng ] = e . location . gps . split ( ',' ) . map ( Number ) ; if ( isNaN ( lat ) || isNaN ( lng ) ) return ;
const c = INCIDENT _COLORS [ e . type ] || INCIDENT _COLORS . default ;
L . circleMarker ( [ lat , lng ] , { radius : 5 , color : c , fillColor : c , fillOpacity : 0.75 , weight : 1 } )
. bindPopup ( ` <div style="padding:10px 12px;min-width:200px"><div style="font-size:9px;font-weight:700;color: ${ c } ;text-transform:uppercase;margin-bottom:3px"> ${ e . type } </div><div style="font-size:11px;font-weight:600;margin-bottom:4px;line-height:1.4"> ${ e . summary } </div><div style="font-size:9px;color:rgba(28,28,30,0.45)">Polisen.se · ${ new Date ( e . datetime ) . toLocaleString ( 'sv-SE' , { month : 'short' , day : 'numeric' , hour : '2-digit' , minute : '2-digit' } )}</div></div> ` , { maxWidth : 240 } )
. addTo ( policeGroup ) ;
} ) ;
if ( policeVisible ) policeGroup . addTo ( map ) ;
document . getElementById ( 'police-count' ) . textContent = events . length + ' events' ;
document . getElementById ( 'police-fresh' ) . textContent = 'Live' ;
document . getElementById ( 'police-list' ) . innerHTML = events . slice ( 0 , 5 ) . map ( e => ` <div style="padding:5px 0;border-bottom:1px solid var(--border);font-size:11px"><span style="font-weight:600;color: ${ INCIDENT _COLORS [ e . type ] || '#6b7280' } "> ${ e . type } </span> · ${ e . summary . substring ( 0 , 60 ) } ${ e . summary . length > 60 ? '…' : '' } <div style="font-size:9px;color:var(--t3);margin-top:1px"> ${ new Date ( e . datetime ) . toLocaleString ( 'sv-SE' , { month : 'short' , day : 'numeric' , hour : '2-digit' , minute : '2-digit' } )}</div></div> ` ) . join ( '' ) ;
} catch ( e ) { document . getElementById ( 'police-count' ) . textContent = 'unavailable' ; document . getElementById ( 'police-fresh' ) . textContent = 'Unavailable' ; }
}
function timeSince ( date ) { const s = Math . floor ( ( new Date ( ) - date ) / 1000 ) ; if ( s < 3600 ) return Math . floor ( s / 60 ) + 'm ago' ; if ( s < 86400 ) return Math . floor ( s / 3600 ) + 'h ago' ; return Math . floor ( s / 86400 ) + 'd ago' ; }
async function loadNews ( ) {
try {
const r = await fetch ( '/api/svt-uppsala' ) , d = await r . json ( ) ;
const parser = new DOMParser ( ) , xml = parser . parseFromString ( d . contents , 'text/xml' ) , items = xml . querySelectorAll ( 'item' ) ;
let html = '' , count = 0 ;
items . forEach ( item => {
if ( count >= 6 ) return ;
const title = item . querySelector ( 'title' ) , link = item . querySelector ( 'link' ) , pubDate = item . querySelector ( 'pubDate' ) ;
if ( ! title ) return ;
const t = title . textContent . replace ( /<!\[CDATA\[|\]\]>/g , '' ) . trim ( ) , l = link ? link . textContent . trim ( ) : '' , dt = pubDate ? new Date ( pubDate . textContent ) : null , age = dt ? timeSince ( dt ) : '' ;
html += ` <div style="padding:7px 0;border-bottom:1px solid var(--border);cursor:pointer" onclick="window.open(' ${ l } ','_blank')"><div style="font-size:13px;font-weight:600;color:var(--text);line-height:1.4;margin-bottom:2px"> ${ t } </div><div style="font-size:9px;color:var(--t3)"> ${ age } · SVT Uppland</div></div> ` ;
count ++ ;
} ) ;
document . getElementById ( 'news-list' ) . innerHTML = html || '<div style="font-size:10px;color:var(--t3)">No news available</div>' ;
document . getElementById ( 'news-time' ) . textContent = 'Updated ' + new Date ( ) . toLocaleTimeString ( 'sv-SE' , { hour : '2-digit' , minute : '2-digit' } ) ;
} catch ( e ) { document . getElementById ( 'news-list' ) . innerHTML = '<a href="https://svt.se/nyheter/lokalt/uppland" target="_blank" style="color:var(--accent3);font-size:10px">SVT Uppland →</a>' ; }
}
window . addEventListener ( 'DOMContentLoaded' , ( ) => {
map = L . map ( 'map' , { center : [ 59.8586 , 17.6389 ] , zoom : 12 , zoomControl : true , attributionControl : false } ) ;
L . tileLayer ( 'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png' , { maxZoom : 19 , subdomains : 'abcd' } ) . addTo ( map ) ;
D . forEach ( d => {
const c = sc ( d . s . business ) ;
const poly = L . circle ( [ d . lat , d . lng ] , { radius : d . r * 0.65 , color : c , fillColor : c , fillOpacity : 0.14 , weight : 2 , dashArray : d . c === 'high' ? '4 3' : d . c === 'medium' ? '6 4' : null } ) ;
poly . bindPopup ( buildPopup ( d , 'business' ) , { maxWidth : 260 , minWidth : 250 } ) ;
poly . on ( 'click' , ( ) => { if ( isMobile ( ) ) { setTimeout ( ( ) => { map . closePopup ( ) ; openBottomSheet ( buildPopup ( d , profile ) ) ; } , 10 ) ; } } ) ;
poly . addTo ( map ) ; polys [ d . id ] = poly ;
if ( map . getZoom ( ) >= 11 ) { L . marker ( [ d . lat , d . lng ] , { icon : L . divIcon ( { className : 'label-marker' , html : ` <div style="font-size:11px;font-weight:700;color:#1c1c1e;white-space:nowrap;pointer-events:none;background:rgba(0,0,0,0.75);padding:2px 6px;border-radius: 4px"> ${ d . name } </div> ` , iconAnchor : [ 0 , 0 ] } ) , interactive : false } ) . addTo ( map ) ; }
} ) ;
document . getElementById ( 'district-count' ) . textContent = D . length ;
buildPulseMarkers ( ) ; buildAlertMarkers ( ) ; buildList ( 'business' ) ; buildRight ( 'business' ) ;
loadPoliceEvents ( ) ; loadNews ( ) ;
} ) ;
< / script >
< / body >
< / html >