2026-07-05 06:41:32 +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 > Göteborg — 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 ) ;
--b2 : rgba ( 0 , 0 , 0 , 0.12 ) ;
--text : #1c1c1e ;
--t2 : rgba ( 28 , 28 , 30 , 0.72 ) ;
--t3 : rgba ( 28 , 28 , 30 , 0.50 ) ;
--accent : #007aff ;
--accent2 : #0071e3 ;
--accent3 : #0066cc ;
--green : #34c759 ;
--red : #ff3b30 ;
--yellow : #ff9f0a ;
--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 */
# left { background : var ( - - s1 ) ; border-right : 1 px solid var ( - - border ) ; overflow-y : auto ; display : flex ; flex-direction : column ; -webkit- overflow-scrolling : touch }
# 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 }
2026-07-07 07:11:50 +00:00
. cstat { background : var ( - - s2 ) ; border : 1 px solid var ( - - border ) ; border-radius : var ( - - radius - sm ) ; padding : 8 px 10 px }
2026-07-05 06:41:32 +00:00
. 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-lbl { font-size : 9 px ; font-weight : 700 ; letter-spacing : 1 px ; text-transform : uppercase ; color : var ( - - t3 ) ; margin-bottom : 8 px }
2026-07-07 07:11:50 +00:00
# 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 ; display : flex ; flex-direction : column ; align-items : center ; gap : 2 px ; transition : all 0.15 s ; font-size : 10 px ; font-weight : 600 ; color : var ( - - t2 ) }
2026-07-05 06:41:32 +00:00
. 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 }
2026-07-07 07:11:50 +00:00
. dcard { border-radius : var ( - - radius - md ) ; border : none ; background : var ( - - s2 ) ; padding : 11 px 13 px ; margin-bottom : 6 px ; cursor : pointer ; transition : all 0.12 s }
2026-07-05 06:41:32 +00:00
. 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 ) }
2026-07-07 07:11:50 +00:00
. dcard-score { font-size : 11 px ; font-weight : 800 ; padding : 1 px 7 px ; border-radius : var ( - - radius - sm ) ; font-family : var ( - - mono ) }
2026-07-05 06:41:32 +00:00
. dcard-desc { font-size : 12 px ; color : var ( - - t2 ) ; line-height : 1.4 ; margin-bottom : 5 px }
2026-07-07 07:11:50 +00:00
. dcard-contra { display : inline-flex ; align-items : center ; gap : 4 px ; font-size : 9 px ; font-weight : 700 ; letter-spacing : 0.3 px ; border-radius : var ( - - radius - sm ) ; padding : 2 px 7 px }
2026-07-05 06:41:32 +00:00
. 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 ; display : flex ; align-items : center ; gap : 5 px }
2026-07-07 07:11:50 +00:00
# freshness-title :: before { content : '' ; display : inline-block ; width : 5 px ; height : 5 px ; border-radius : var ( - - radius - full ) ; background : #22c55e ; animation : pulse-ring 2 s ease-in-out infinite }
2026-07-05 06:41:32 +00:00
. 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 }
2026-07-07 07:11:50 +00:00
. fresh-dot { width : 5 px ; height : 5 px ; border-radius : var ( - - radius - full ) ; flex-shrink : 0 }
2026-07-05 06:41:32 +00:00
. fresh-dot . live { background : #22c55e ; animation : pulse-ring 1.5 s ease-in-out infinite }
. fresh-dot . recent { background : #fbbf24 }
/* MAP */
# 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 ; letter-spacing : -0.3 px ; 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 }
2026-07-07 07:11:50 +00:00
# map-profile-chip { font-size : 10 px ; font-weight : 700 ; border-radius : var ( - - radius - sm ) ; padding : 3 px 10 px ; border : 1 px solid }
2026-07-05 06:41:32 +00:00
# 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 ; -webkit- overflow-scrolling : touch }
# layer-bar :: -webkit-scrollbar { display : none }
2026-07-07 07:11:50 +00:00
. 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 ; transition : all 0.15 s ; white-space : nowrap ; user-select : none ; flex-shrink : 0 }
2026-07-05 06:41:32 +00:00
. ltoggle . on { background : var ( - - accent ) ; color : #1d1d1f }
2026-07-07 07:11:50 +00:00
. ltoggle-dot { width : 6 px ; height : 6 px ; border-radius : var ( - - radius - full ) ; flex-shrink : 0 }
2026-07-05 06:41:32 +00:00
. leaflet-control-zoom { margin-top : 96 px !important ; margin-left : 10 px !important }
2026-07-07 07:11:50 +00:00
. 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 }
2026-07-05 06:41:32 +00:00
. leaflet-popup-content { margin : 0 !important ; width : 260 px !important }
. leaflet-popup-tip { background : #fff !important }
. mpop { padding : 14 px 15 px ; font-family : - apple-system , sans-serif }
. 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 ) ; letter-spacing : -1 px }
. 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 ; letter-spacing : 0.3 px ; text-transform : uppercase ; color : var ( - - t3 ) ; width : 72 px ; flex-shrink : 0 }
2026-07-07 07:11:50 +00:00
. 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 ) }
2026-07-05 06:41:32 +00:00
. mpop-bar-val { font-size : 10 px ; font-weight : 700 ; font-family : var ( - - mono ) ; width : 24 px ; text-align : right }
2026-07-07 07:11:50 +00:00
. mpop-contra { font-size : 10 px ; border-radius : var ( - - radius - sm ) ; padding : 6 px 9 px ; line-height : 1.4 ; margin-bottom : 8 px }
2026-07-05 06:41:32 +00:00
. 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 ; letter-spacing : 0.5 px ; text-transform : uppercase ; display : block ; margin-bottom : 2 px }
. mpop-insight { font-size : 11 px ; color : var ( - - t2 ) ; line-height : 1.5 }
/* RIGHT */
# right { background : var ( - - s1 ) ; border-left : 1 px solid var ( - - border ) ; overflow-y : auto ; display : flex ; flex-direction : column ; scrollbar-width : none }
# 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 ) }
2026-07-07 07:11:50 +00:00
. kpi { background : var ( - - s2 ) ; border-radius : var ( - - radius - md ) ; padding : 12 px 14 px }
2026-07-05 06:41:32 +00:00
. 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 ; letter-spacing : -0.5 px ; 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 }
2026-07-07 07:11:50 +00:00
. 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 }
2026-07-05 06:41:32 +00:00
. 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 ) }
2026-07-07 07:11:50 +00:00
. contra-card-badge { font-size : 8 px ; font-weight : 700 ; letter-spacing : 0.5 px ; text-transform : uppercase ; padding : 1 px 6 px ; border-radius : var ( - - radius - sm ) }
2026-07-05 06:41:32 +00:00
. 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 (mobile) */
2026-07-07 07:11:50 +00:00
# bottom-sheet { display : none ; position : fixed ; left : 0 ; right : 0 ; bottom : 0 ; z-index : 9999 ; background : #fff ; border-top : 1 px solid rgba ( 0 , 0 , 0 , 0.1 ) ; 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 ) }
2026-07-05 06:41:32 +00:00
# bottom-sheet . open { display : block ; transform : translateY ( 0 ) }
2026-07-07 07:11:50 +00:00
# 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 }
2026-07-05 06:41:32 +00:00
# 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 }
2026-07-07 07:11:50 +00:00
# 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 }
2026-07-05 06:41:32 +00:00
@ 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 }
2026-07-07 07:11:50 +00:00
. 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 % ) }
2026-07-05 06:41:32 +00:00
@ 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-05 06:41:32 +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-05 06:41:32 +00:00
"address" : {
"@type" : "PostalAddress" ,
2026-07-14 15:27:33 +00:00
"addressLocality" : "Stockholm" ,
2026-07-05 06:41:32 +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-05 06:41:32 +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" >
<!-- LEFT -->
< div id = "left" >
< div id = "city-block" >
< div id = "city-eyebrow" > Landvex Intelligence Brief< / div >
< div id = "city-name" > GÖTEBORG< / div >
< div id = "city-region" > Västra Götaland · Kingdom of Sweden< / div >
< div id = "city-stats" >
< div class = "cstat" > < div class = "cstat-val" style = "color:#4ade80" > 89%< / div > < div class = "cstat-lbl" > Confidence< / div > < / div >
< div class = "cstat" > < div class = "cstat-val" id = "district-count" > 8< / div > < div class = "cstat-lbl" > Districts< / div > < / div >
< div class = "cstat" > < div class = "cstat-val" style = "color:#fbbf24" > Medium< / 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-lbl" > Intelligence Profile< / div >
< 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 Väst news< / 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 >
<!-- MOBILE BOTTOM SHEET -->
< 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 >
<!-- MAP -->
< 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" > Göteborg — < 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" >
2026-07-07 07:11:50 +00:00
< button onclick = "map.setView([57.71,11.97],11)" 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" > GBG< / button >
< button onclick = "map.setView([57.0,12.5],8)" 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 >
2026-07-05 06:41:32 +00:00
< / 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" >
2026-07-07 07:11:50 +00:00
< 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 >
2026-07-05 06:41:32 +00:00
< / div >
< div style = "font-size:9px;color:rgba(28,28,30,0.35);margin-top:6px" > Tap any area for details< / div >
< / div >
< / div >
<!-- RIGHT -->
< 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 Väst — 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 news…< / 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
2026-07-07 07:11:50 +00:00
< 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 >
2026-07-05 06:41:32 +00:00
< / 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 = "/malmo-intelligence" style = "color:var(--accent3)" > Malmö< / a > ·
< a href = "/uppsala-intelligence" style = "color:var(--accent3)" > Uppsala< / a > < br > < br >
Data: Polisen.se · SVT Väst · SCB · Landvex Intelligence Engine< br >
© 2026 Landvex. Source First.
< / div >
< / div >
< / div >
< script >
2026-07-07 07:11:50 +00:00
/* DATA */
2026-07-05 06:41:32 +00:00
const D = [
{ id : 'centrum' , name : 'Centrum' , lat : 57.707 , lng : 11.967 , r : 1400 ,
confidence : { score : 89 , observation _count : 512 } ,
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. Commercial activity slightly above classification.' ,
col : '#16a34a' , pulse : { level : 0.68 , label : 'High Activity' } , umi : + 28 ,
ins : { business : 'Nordstan, finance district. Strong commercial core.' , tourist : 'Avenyn, Haga. Top Swedish destination.' , safety : 'Generally safe. Some evening incidents on Avenyn.' , investment : 'Stable market. Good yields.' , nightlife : 'Best nightlife outside Stockholm. Avenyn cluster.' , infrastructure : 'Excellent tram network. Centralstation hub.' } } ,
{ id : 'haga' , name : 'Haga' , lat : 57.698 , lng : 11.956 , r : 700 ,
confidence : { score : 94 , observation _count : 378 } ,
s : { business : 6.8 , tourist : 9.2 , safety : 8.6 , investment : 8.8 , nightlife : 7.4 , infrastructure : 7.2 } ,
c : 'low' , ct : 'High alignment. Premium gentrified neighbourhood.' ,
col : '#16a34a' , pulse : { level : 0.52 , label : 'Moderate Activity' } , umi : + 42 ,
ins : { business : 'Boutiques, cafes, creative offices.' , tourist : 'Most photographed neighbourhood in Gothenburg.' , safety : 'Very safe. Residential premium.' , investment : 'Rapid appreciation. Limited supply.' , nightlife : 'Cosy bars, wine culture.' , infrastructure : 'Good tram access. Parking limited.' } } ,
{ id : 'linne' , name : 'Linnéstaden' , lat : 57.696 , lng : 11.946 , r : 900 ,
confidence : { score : 88 , observation _count : 341 } ,
s : { business : 7.1 , tourist : 8.4 , safety : 8.2 , investment : 8.5 , nightlife : 8.8 , infrastructure : 7.4 } ,
c : 'low' , ct : 'Hip, restaurant-dense. Strong gentrification signal.' ,
col : '#16a34a' , pulse : { level : 0.72 , label : 'High Activity' } , umi : + 54 ,
ins : { business : 'Restaurant row, design studios, creative SMEs.' , tourist : 'Linnégatan — best street food & bars.' , safety : 'Safe. Young professional area.' , investment : 'Fast appreciation. Design-led gentrification.' , nightlife : 'Best restaurant/bar density in Gothenburg.' , infrastructure : 'Tram Linnéplatsen. Cycling excellent.' } } ,
{ id : 'majorna' , name : 'Majorna' , lat : 57.693 , lng : 11.930 , r : 1000 ,
confidence : { score : 87 , observation _count : 261 } ,
s : { business : 5.9 , tourist : 6.8 , safety : 8.1 , investment : 8.2 , nightlife : 6.8 , infrastructure : 6.9 } ,
c : 'low' , ct : 'Artsy, working class heritage. Active gentrification.' ,
col : '#16a34a' , pulse : { level : 0.46 , label : 'Moderate Activity' } , umi : + 48 ,
ins : { business : 'Local businesses, creative industry.' , tourist : 'Authentic waterfront, local culture.' , safety : 'Safe residential.' , investment : 'Active gentrification. Good entry prices.' , nightlife : 'Growing bar scene.' , infrastructure : 'Tram access. Cycling culture strong.' } } ,
{ id : 'askim' , name : 'Askim' , lat : 57.633 , lng : 11.930 , r : 2000 ,
confidence : { score : 88 , observation _count : 223 } ,
s : { business : 5.8 , tourist : 7.2 , safety : 9.1 , investment : 8.1 , nightlife : 3.4 , infrastructure : 7.2 } ,
c : 'low' , ct : 'Coastal family premium. Stable alignment.' ,
col : '#16a34a' , pulse : { level : 0.32 , label : 'Low Activity' } , umi : + 22 ,
ins : { business : 'Frölunda Torg retail cluster nearby.' , tourist : 'Beaches, archipelago access.' , safety : 'Very safe. Family coastal area.' , investment : 'Premium coastal demand. Strong family market.' , nightlife : 'Limited.' , infrastructure : 'Tram connections. E6 access.' } } ,
{ id : 'angered' , name : 'Angered' , lat : 57.798 , lng : 12.033 , r : 2200 ,
confidence : { score : 74 , observation _count : 187 } ,
s : { business : 3.2 , tourist : 1.8 , safety : 4.1 , investment : 5.8 , nightlife : 2.9 , infrastructure : 5.4 } ,
c : 'high' , ct : 'CONTRADICTION HIGH: Official data underrepresents observed conditions. Infrastructure decay, elevated incidents.' ,
col : '#dc2626' , pulse : { level : 0.21 , label : 'Sparse Activity' } , umi : - 18 ,
ins : { business : 'Very limited. Local services only.' , tourist : 'No tourist appeal.' , safety : 'Elevated risk. Night incidents common.' , investment : 'Redevelopment opportunity. Government priority.' , nightlife : 'Not recommended.' , infrastructure : 'Metro but poor local street conditions.' } } ,
{ id : 'molndal' , name : 'Mölndal' , lat : 57.656 , lng : 12.014 , r : 1600 ,
confidence : { score : 91 , observation _count : 294 } ,
s : { business : 8.8 , tourist : 3.4 , safety : 8.6 , investment : 8.4 , nightlife : 3.8 , infrastructure : 8.2 } ,
c : 'low' , ct : 'Pharma/tech hub. AstraZeneca, Volvo Cars. Strong alignment.' ,
col : '#16a34a' , pulse : { level : 0.44 , label : 'Moderate Activity' } , umi : + 64 ,
ins : { business : 'AstraZeneca global R&D, Volvo Cars HQ. Sweden\'s pharma capital.' , tourist : 'No tourist appeal.' , safety : 'Excellent. Corporate security culture.' , investment : 'Highest investment score in region.' , nightlife : 'Limited — corporate commute.' , infrastructure : 'Tram to Göteborg. E20 access.' } } ,
{ id : 'kungsbacka' , name : 'Kungsbacka' , lat : 57.489 , lng : 12.076 , r : 2200 ,
confidence : { score : 88 , observation _count : 198 } ,
s : { business : 5.8 , tourist : 4.8 , safety : 9.2 , investment : 7.8 , nightlife : 2.8 , infrastructure : 6.8 } ,
c : 'low' , ct : 'Premium commuter belt. Coastal charm. Alignment strong.' ,
col : '#16a34a' , pulse : { level : 0.28 , label : 'Low Activity' } , umi : + 28 ,
ins : { business : 'Local services. Growing commuter hub.' , tourist : 'Coastal access, archipelago.' , safety : 'Excellent.' , investment : 'Strong demand. Gothenburg spillover premium.' , nightlife : 'Very limited.' , infrastructure : 'Pendeltåg to Göteborg 25 min. E6.' } }
] ;
const CONTRA _ALERTS = [
{ distId : 'angered' , name : 'Angered' , level : 'high' , desc : 'Official safety data significantly understates observed conditions. Infrastructure decay confirmed. Elevated incident density.' , date : '2026-06-18' } ,
{ distId : 'centrum' , name : 'Centrum' , level : 'medium' , desc : 'Avenyn night incidents underreported in official statistics. Traffic congestion data lag.' , 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>Mölndal pharma cluster</strong> — AstraZeneca + Volvo Cars R&D. Highest business score in region.' , '<strong>Centrum</strong> — Nordstan shopping centre, financial district. Sweden\'s second commercial city.' , '<strong>Linnéstaden</strong> — fast-growing creative SME base. Restaurant & design economy.' , '<strong>Angered contradiction</strong> — commercial vacancy 19% vs official 8%. Gap widening.' ] ,
tourist : [ '<strong>Haga</strong> — most photographed neighbourhood in Sweden. Premium heritage tourism.' , '<strong>Centrum / Avenyn</strong> — main tourist spine. Strong seasonal peaks.' , '<strong>Linnéstaden</strong> — authentic local culture. Growing food tourism.' , '<strong>Askim coast</strong> — beach & archipelago access. Summer tourism peak.' ] ,
safety : [ '<strong>Askim & Kungsbacka</strong> — highest safety scores. Premium residential.' , '<strong>Angered CONTRADICTION</strong> — official vs observed gap significant. Active monitoring.' , '<strong>Centrum Avenyn</strong> — generally safe. Night awareness needed Fri– Sat.' , '<strong>Haga & Majorna</strong> — very safe. Strong community cohesion.' ] ,
investment : [ '<strong>Mölndal</strong> — highest investment score. Pharma/tech driving appreciation.' , '<strong>Linnéstaden</strong> — fastest appreciation. Design-led gentrification.' , '<strong>Haga</strong> — limited supply + high demand = strong upside.' , '<strong>Angered</strong> — high risk, high potential. Government priority redevelopment.' ] ,
nightlife : [ '<strong>Centrum Avenyn</strong> — best nightlife cluster outside Stockholm.' , '<strong>Linnéstaden</strong> — Linnégatan restaurant row. Premium food & bar scene.' , '<strong>Haga</strong> — intimate wine bars. Cultural nightlife.' , '<strong>Mölndal / Kungsbacka</strong> — very limited. Commuter profile.' ] ,
infrastructure : [ '<strong>Centrum</strong> — excellent tram network. Centralstationen hub.' , '<strong>Mölndal</strong> — good tram + E20. Corporate campus access.' , '<strong>Askim</strong> — E6 access. Tram good.' , '<strong>Angered</strong> — metro accessible but local street infrastructure poor.' ]
} ;
const KPI = {
business : { main : '8.4' , mainLabel : 'Avg Business Score' , sub : 'Nordstan anchor · Pharma cluster' , extra : '2nd commercial city SE' , extraLabel : 'National rank' } ,
tourist : { main : '8.9' , mainLabel : 'Tourist Score — Centrum' , sub : 'Haga · Avenyn · Linnégatan' , extra : 'Top 3 Sweden' , extraLabel : 'Tourist destination' } ,
safety : { main : '4.1' , mainLabel : 'Lowest Safety — Angered' , sub : 'CONTRADICTION: data lag' , extra : '9.1' , extraLabel : 'Highest — Askim' } ,
investment : { main : '8.8' , mainLabel : 'Peak Score — Mölndal' , sub : 'Pharma R&D driving RE' , extra : '+54' , extraLabel : 'Top UMI — Linnéstaden' } ,
nightlife : { main : '8.8' , mainLabel : 'Nightlife Score — Linnéstaden' , sub : 'Restaurant row · bars' , extra : '8.6' , extraLabel : 'Avenyn — Centrum' } ,
infrastructure : { main : '8.8' , mainLabel : 'Infra Score — Centrum' , sub : 'Tram network · Centralstation' , extra : 'Kungsbacka 25 min' , extraLabel : 'Pendeltåg to Centrum' }
} ;
let map , profile = 'business' , polys = { } , pulseMarkers = [ ] , alertMarkers = [ ] ;
let layerState = { districts : true , pulse : false , alerts : false } ;
let policeGroup = L . layerGroup ( ) , policeVisible = true ;
function sc ( v ) {
if ( v >= 8 ) return '#22c55e' ;
if ( v >= 6 ) return '#eab308' ;
if ( v >= 4 ) return '#f97316' ;
return '#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 ] ;
const 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' : d . confidence . score >= 70 ? '#92400e' : '#991b1b' } "> ${ d . confidence . score } %</strong> · ${ d . confidence . observation _count } observations</div> ` : '' ;
return ` <div class="mpop">
<div class="mpop-eyebrow">Göteborg · ${ 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 ] ) ;
const el = document . getElementById ( 'district-list' ) ;
el . innerHTML = sorted . map ( d => {
const v = d . s [ p ] ;
const col = sc ( v ) ;
const cClass = d . c === 'high' ? 'dc-high' : d . c === 'medium' ? 'dc-med' : 'dc-low' ;
2026-07-07 07:11:50 +00:00
const 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' ;
2026-07-05 06:41:32 +00:00
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 ] ;
document . getElementById ( 'right-title' ) . textContent = PROF [ p ] . label ;
document . getElementById ( 'right-profile-label' ) . textContent = 'Profile' ;
const chip = document . getElementById ( 'map-profile-chip' ) ;
const pr = PROF [ p ] ;
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)">City Rank</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> ` ;
const ins = INSIGHTS [ p ] || [ ] ;
document . getElementById ( 'insights-list' ) . innerHTML = ins . map ( t => `
<div class="insight-row">
<div class="insight-icon">› </div>
<div class="insight-text"> ${ t } </div>
</div> ` ) . join ( '' ) ;
buildContraAlerts ( ) ;
}
function buildContraAlerts ( ) {
const list = document . getElementById ( 'contra-list' ) ;
const count = document . getElementById ( 'contra-count' ) ;
count . textContent = CONTRA _ALERTS . length + ' active' ;
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 ) ;
const 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 : '' ,
2026-07-07 07:11:50 +00:00
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> ` ,
2026-07-05 06:41:32 +00:00
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;font-family:-apple-system,sans-serif;min-width:200px">
<div style="font-size:9px;font-weight:700;letter-spacing:0.8px;text-transform:uppercase;color: ${ col } ;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 ) ;
} ) ;
}
/* POLICE */
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 ( ) ;
const ym = now . getFullYear ( ) + '-' + String ( now . getMonth ( ) + 1 ) . padStart ( 2 , '0' ) ;
const r = await fetch ( 'https://polisen.se/api/events?locationname=Göteborg&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' ;
}
}
/* NEWS */
async function loadNews ( ) {
try {
const r = await fetch ( '/api/svt-goteborg' ) ;
const d = await r . json ( ) ;
const parser = new DOMParser ( ) ;
const xml = parser . parseFromString ( d . contents , 'text/xml' ) ;
const items = xml . querySelectorAll ( 'item' ) ;
let html = '' , count = 0 ;
items . forEach ( item => {
if ( count >= 6 ) return ;
const title = item . querySelector ( 'title' ) ;
const link = item . querySelector ( 'link' ) ;
const pubDate = item . querySelector ( 'pubDate' ) ;
if ( ! title ) return ;
const t = title . textContent . replace ( /<!\[CDATA\[|\]\]>/g , '' ) . trim ( ) ;
const l = link ? link . textContent . trim ( ) : '' ;
const dt = pubDate ? new Date ( pubDate . textContent ) : null ;
const 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 Väst</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/vast" target="_blank" style="color:var(--accent3);font-size:10px">SVT Väst →</a>' ;
}
}
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' ;
}
window . addEventListener ( 'DOMContentLoaded' , ( ) => {
map = L . map ( 'map' , { center : [ 57.7089 , 11.9746 ] , zoom : 11 , 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 ( ) >= 10 ) {
L . marker ( [ d . lat , d . lng ] , {
2026-07-07 07:11:50 +00:00
icon : L . divIcon ( { className : 'label-marker' , html : ` <div style="font-size:11px;font-weight:700;color:#1c1c1e;text-shadow:0 1px 4px rgba(0,0,0,0.8);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 ] } ) ,
2026-07-05 06:41:32 +00:00
interactive : false
} ) . addTo ( map ) ;
}
} ) ;
document . getElementById ( 'district-count' ) . textContent = D . length ;
buildPulseMarkers ( ) ;
buildAlertMarkers ( ) ;
buildList ( 'business' ) ;
buildRight ( 'business' ) ;
loadPoliceEvents ( ) ;
loadNews ( ) ;
} ) ;
< / script >
< / body >
< / html >