2026-07-05 06:41:32 +00:00
<!DOCTYPE html>
< html lang = "sv" >
< head >
< meta charset = "UTF-8" / >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0, viewport-fit=cover" / >
< title > quiXzoom — Mission Map< / title >
< link rel = "stylesheet" href = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" / >
< script src = "https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" > < / script >
< style >
* { box-sizing : border-box ; margin : 0 ; padding : 0 ; -webkit- tap-highlight-color : transparent ; }
: root {
--ios-blue : #007AFF ;
--ios-green : #34C759 ;
--ios-orange : #FF9500 ;
--ios-red : #FF3B30 ;
--ios-gray : #8E8E93 ;
--ios-gray2 : #AEAEB2 ;
--ios-gray5 : #E5E5EA ;
--ios-gray6 : #F2F2F7 ;
--ios-white : #FFFFFF ;
--ios-label : #000000 ;
--ios-label2 : rgba ( 60 , 60 , 67 , 0.6 ) ;
--ios-label3 : rgba ( 60 , 60 , 67 , 0.3 ) ;
--ios-separator : rgba ( 60 , 60 , 67 , 0.29 ) ;
--safe-top : env ( safe - area - inset - top , 0 px ) ;
--safe-bottom : env ( safe - area - inset - bottom , 0 px ) ;
}
html , body {
height : 100 % ;
font-family : - apple-system , BlinkMacSystemFont , 'SF Pro Display' , 'SF Pro Text' , 'Helvetica Neue' , sans-serif ;
background : var ( - - ios - gray6 ) ;
color : var ( - - ios - label ) ;
overflow : hidden ;
}
2026-07-07 07:11:50 +00:00
/* Header */
2026-07-05 06:41:32 +00:00
# header {
position : fixed ;
top : 0 ; left : 0 ; right : 0 ;
z-index : 1000 ;
background : rgba ( 242 , 242 , 247 , 0.85 ) ;
-webkit- backdrop-filter : blur ( 20 px ) saturate ( 180 % ) ;
backdrop-filter : blur ( 20 px ) saturate ( 180 % ) ;
border-bottom : 0.5 px solid var ( - - ios - separator ) ;
padding : calc ( var ( - - safe - top ) + 12 px ) 16 px 12 px ;
}
# header-inner {
display : flex ;
align-items : center ;
gap : 12 px ;
}
# header-title {
flex : 1 ;
}
# header-title h1 {
font-size : 17 px ;
font-weight : 600 ;
letter-spacing : -0.4 px ;
line-height : 1.2 ;
}
# header-title p {
font-size : 12 px ;
color : var ( - - ios - label2 ) ;
margin-top : 1 px ;
}
# refresh-btn {
width : 32 px ; height : 32 px ;
2026-07-07 07:11:50 +00:00
border-radius : var ( - - radius - full ) ;
2026-07-05 06:41:32 +00:00
background : var ( - - ios - blue ) ;
border : none ;
cursor : pointer ;
display : flex ; align-items : center ; justify-content : center ;
transition : opacity 0.15 s ;
}
# refresh-btn : active { opacity : 0.7 ; }
# refresh-btn svg { width : 16 px ; height : 16 px ; fill : white ; }
2026-07-07 07:11:50 +00:00
/* Search/Filter Bar */
2026-07-05 06:41:32 +00:00
# search-bar {
position : fixed ;
top : calc ( var ( - - safe - top ) + 60 px ) ;
left : 0 ; right : 0 ;
z-index : 1000 ;
padding : 8 px 16 px ;
background : rgba ( 242 , 242 , 247 , 0.85 ) ;
-webkit- backdrop-filter : blur ( 20 px ) saturate ( 180 % ) ;
backdrop-filter : blur ( 20 px ) saturate ( 180 % ) ;
display : flex ;
gap : 8 px ;
}
# search-input-wrap {
flex : 1 ;
position : relative ;
}
# search-input-wrap svg {
position : absolute ;
left : 10 px ;
top : 50 % ;
transform : translateY ( -50 % ) ;
width : 14 px ; height : 14 px ;
fill : var ( - - ios - gray2 ) ;
pointer-events : none ;
}
# search-input {
width : 100 % ;
height : 36 px ;
background : var ( - - ios - white ) ;
border : none ;
2026-07-07 07:11:50 +00:00
border-radius : var ( - - radius - md ) ;
2026-07-05 06:41:32 +00:00
padding : 0 10 px 0 32 px ;
font-size : 15 px ;
font-family : inherit ;
color : var ( - - ios - label ) ;
outline : none ;
box-shadow : 0 1 px 3 px rgba ( 0 , 0 , 0 , 0.08 ) ;
}
# search-input :: placeholder { color : var ( - - ios - gray2 ) ; }
# cat-filter {
height : 36 px ;
background : var ( - - ios - white ) ;
border : none ;
2026-07-07 07:11:50 +00:00
border-radius : var ( - - radius - md ) ;
2026-07-05 06:41:32 +00:00
padding : 0 10 px ;
font-size : 13 px ;
font-family : inherit ;
color : var ( - - ios - label ) ;
outline : none ;
box-shadow : 0 1 px 3 px rgba ( 0 , 0 , 0 , 0.08 ) ;
min-width : 100 px ;
}
2026-07-07 07:11:50 +00:00
/* Map */
2026-07-05 06:41:32 +00:00
# map {
position : fixed ;
top : 0 ; left : 0 ; right : 0 ; bottom : 0 ;
z-index : 1 ;
}
. leaflet-control-zoom {
margin-top : calc ( var ( - - safe - top ) + 128 px ) !important ;
margin-left : 16 px !important ;
}
. leaflet-control-zoom a {
font-size : 18 px !important ;
width : 36 px !important ;
height : 36 px !important ;
line-height : 36 px !important ;
2026-07-07 07:11:50 +00:00
border-radius : var ( - - radius - md ) !important ;
2026-07-05 06:41:32 +00:00
}
2026-07-07 07:11:50 +00:00
/* Custom Pins */
2026-07-05 06:41:32 +00:00
. mission-pin {
width : 36 px ;
height : 36 px ;
2026-07-07 07:11:50 +00:00
border-radius : var ( - - radius - full ) ;
2026-07-05 06:41:32 +00:00
border : 2.5 px solid white ;
display : flex ;
align-items : center ;
justify-content : center ;
box-shadow : 0 2 px 8 px rgba ( 0 , 0 , 0 , 0.25 ) ;
font-size : 15 px ;
cursor : pointer ;
transition : transform 0.15 s ;
}
. mission-pin : hover { transform : scale ( 1.15 ) ; }
. pin-open { background : var ( - - ios - blue ) ; }
. pin-assigned { background : var ( - - ios - orange ) ; }
. pin-completed { background : var ( - - ios - green ) ; }
2026-07-07 07:11:50 +00:00
/* Popup */
2026-07-05 06:41:32 +00:00
. leaflet-popup-content-wrapper {
2026-07-07 07:11:50 +00:00
border-radius : var ( - - radius - lg ) !important ;
2026-07-05 06:41:32 +00:00
box-shadow : 0 8 px 30 px rgba ( 0 , 0 , 0 , 0.18 ) !important ;
padding : 0 !important ;
overflow : hidden ;
}
. leaflet-popup-content {
margin : 0 !important ;
width : 260 px !important ;
}
. leaflet-popup-tip-container { display : none ; }
. popup-card {
padding : 16 px ;
background : var ( - - ios - white ) ;
}
. popup-category {
display : inline-block ;
font-size : 11 px ;
font-weight : 600 ;
text-transform : uppercase ;
letter-spacing : 0.5 px ;
color : var ( - - ios - blue ) ;
background : rgba ( 0 , 122 , 255 , 0.1 ) ;
2026-07-07 07:11:50 +00:00
border-radius : var ( - - radius - sm ) ;
2026-07-05 06:41:32 +00:00
padding : 3 px 8 px ;
margin-bottom : 8 px ;
}
. popup-title {
font-size : 16 px ;
font-weight : 600 ;
letter-spacing : -0.3 px ;
line-height : 1.3 ;
margin-bottom : 10 px ;
}
. popup-meta {
display : grid ;
grid-template-columns : 1 fr 1 fr ;
gap : 8 px ;
}
. popup-meta-item {
background : var ( - - ios - gray6 ) ;
2026-07-07 07:11:50 +00:00
border-radius : var ( - - radius - md ) ;
2026-07-05 06:41:32 +00:00
padding : 8 px 10 px ;
}
. popup-meta-label {
font-size : 11 px ;
color : var ( - - ios - label2 ) ;
margin-bottom : 2 px ;
}
. popup-meta-value {
font-size : 14 px ;
font-weight : 600 ;
}
. popup-reward { color : var ( - - ios - green ) ; }
. popup-status-badge {
display : inline-flex ;
align-items : center ;
gap : 4 px ;
font-size : 12 px ;
font-weight : 600 ;
2026-07-07 07:11:50 +00:00
border-radius : var ( - - radius - xl ) ;
2026-07-05 06:41:32 +00:00
padding : 4 px 10 px ;
margin-top : 10 px ;
}
. status-open { background : rgba ( 0 , 122 , 255 , 0.1 ) ; color : var ( - - ios - blue ) ; }
. status-assigned { background : rgba ( 255 , 149 , 0 , 0.1 ) ; color : var ( - - ios - orange ) ; }
. status-completed { background : rgba ( 52 , 199 , 89 , 0.1 ) ; color : var ( - - ios - green ) ; }
. popup-risk {
margin-top : 10 px ;
border-top : 0.5 px solid var ( - - ios - gray5 ) ;
padding-top : 10 px ;
display : flex ;
align-items : center ;
justify-content : space-between ;
gap : 8 px ;
}
. risk-badge {
display : inline-flex ;
align-items : center ;
gap : 5 px ;
font-size : 11 px ;
font-weight : 700 ;
2026-07-07 07:11:50 +00:00
border-radius : var ( - - radius - md ) ;
2026-07-05 06:41:32 +00:00
padding : 4 px 9 px ;
letter-spacing : 0.2 px ;
}
. risk-L1 { background : rgba ( 52 , 199 , 89 , 0.12 ) ; color : #1c7a3a ; }
. risk-L2 { background : rgba ( 255 , 204 , 0 , 0.15 ) ; color : #7a5a00 ; }
. risk-L3 { background : rgba ( 255 , 149 , 0 , 0.15 ) ; color : #a05a00 ; }
. risk-L4 { background : rgba ( 255 , 59 , 48 , 0.13 ) ; color : #a01a10 ; }
. risk-hint { font-size : 11 px ; color : var ( - - ios - label2 ) ; line-height : 1.3 ; flex : 1 ; text-align : right ; }
2026-07-07 07:11:50 +00:00
/* Bottom Sheet */
2026-07-05 06:41:32 +00:00
# bottom-sheet {
position : fixed ;
bottom : 0 ; left : 0 ; right : 0 ;
z-index : 900 ;
background : rgba ( 242 , 242 , 247 , 0.92 ) ;
-webkit- backdrop-filter : blur ( 20 px ) saturate ( 180 % ) ;
backdrop-filter : blur ( 20 px ) saturate ( 180 % ) ;
border-top : 0.5 px solid var ( - - ios - separator ) ;
2026-07-07 07:11:50 +00:00
border-radius : var ( - - radius - xl ) 20 px 0 0 ;
2026-07-05 06:41:32 +00:00
padding : 12 px 16 px calc ( var ( - - safe - bottom ) + 12 px ) ;
}
# sheet-handle {
width : 36 px ; height : 4 px ;
2026-07-07 07:11:50 +00:00
border-radius : var ( - - radius - sm ) ;
2026-07-05 06:41:32 +00:00
background : var ( - - ios - label3 ) ;
margin : 0 auto 14 px ;
}
# stats-row {
display : flex ;
gap : 10 px ;
margin-bottom : 12 px ;
}
. stat-card {
flex : 1 ;
background : var ( - - ios - white ) ;
2026-07-07 07:11:50 +00:00
border-radius : var ( - - radius - lg ) ;
2026-07-05 06:41:32 +00:00
padding : 12 px 14 px ;
box-shadow : 0 1 px 4 px rgba ( 0 , 0 , 0 , 0.06 ) ;
}
. stat-label {
font-size : 11 px ;
color : var ( - - ios - label2 ) ;
font-weight : 500 ;
margin-bottom : 4 px ;
}
. stat-value {
font-size : 22 px ;
font-weight : 700 ;
letter-spacing : -0.5 px ;
line-height : 1 ;
}
. stat-value . blue { color : var ( - - ios - blue ) ; }
. stat-value . green { color : var ( - - ios - green ) ; }
. stat-value . orange { color : var ( - - ios - orange ) ; }
# legend-row {
display : flex ;
gap : 16 px ;
justify-content : center ;
}
. legend-item {
display : flex ;
align-items : center ;
gap : 6 px ;
font-size : 12 px ;
color : var ( - - ios - label2 ) ;
}
. legend-dot {
width : 10 px ; height : 10 px ;
2026-07-07 07:11:50 +00:00
border-radius : var ( - - radius - full ) ;
2026-07-05 06:41:32 +00:00
}
2026-07-07 07:11:50 +00:00
/* Loading Indicator */
2026-07-05 06:41:32 +00:00
# loading-overlay {
position : fixed ;
top : 0 ; left : 0 ; right : 0 ; bottom : 0 ;
z-index : 2000 ;
background : var ( - - ios - gray6 ) ;
display : flex ;
flex-direction : column ;
align-items : center ;
justify-content : center ;
gap : 16 px ;
transition : opacity 0.3 s ;
}
# loading-overlay . hidden { opacity : 0 ; pointer-events : none ; }
. spinner {
width : 40 px ; height : 40 px ;
border : 3 px solid var ( - - ios - gray5 ) ;
border-top-color : var ( - - ios - blue ) ;
2026-07-07 07:11:50 +00:00
border-radius : var ( - - radius - full ) ;
2026-07-05 06:41:32 +00:00
animation : spin 0.8 s linear infinite ;
}
@ keyframes spin { to { transform : rotate ( 360 deg ) ; } }
# loading-text {
font-size : 14 px ;
color : var ( - - ios - label2 ) ;
}
2026-07-07 07:11:50 +00:00
/* Urban Intelligence Layer */
2026-07-05 06:41:32 +00:00
# ui-toggle {
position : fixed ;
top : calc ( var ( - - safe - top ) + 110 px ) ;
right : 16 px ;
z-index : 1000 ;
background : rgba ( 242 , 242 , 247 , 0.92 ) ;
-webkit- backdrop-filter : blur ( 20 px ) saturate ( 180 % ) ;
backdrop-filter : blur ( 20 px ) saturate ( 180 % ) ;
border : 0.5 px solid var ( - - ios - separator ) ;
2026-07-07 07:11:50 +00:00
border-radius : var ( - - radius - md ) ;
2026-07-05 06:41:32 +00:00
padding : 8 px 12 px ;
display : flex ;
align-items : center ;
gap : 8 px ;
cursor : pointer ;
box-shadow : 0 2 px 8 px rgba ( 0 , 0 , 0 , 0.10 ) ;
font-size : 13 px ;
font-weight : 500 ;
color : var ( - - ios - label ) ;
user-select : none ;
transition : opacity 0.2 s ;
}
# ui-toggle : active { opacity : 0.7 ; }
2026-07-07 07:11:50 +00:00
# ui-dot { width : 10 px ; height : 10 px ; border-radius : var ( - - radius - full ) ; background : var ( - - ios - blue ) ; flex-shrink : 0 ; }
2026-07-05 06:41:32 +00:00
# ui-toggle . off # ui-dot { background : var ( - - ios - gray2 ) ; }
. ui-popup { font-family : - apple-system , BlinkMacSystemFont , 'SF Pro Text' , sans-serif ; min-width : 220 px ; }
. ui-popup-header { display : flex ; align-items : center ; justify-content : space-between ; margin-bottom : 8 px ; }
. ui-popup-title { font-size : 13 px ; font-weight : 700 ; letter-spacing : -0.2 px ; color : #1c1c1e ; }
2026-07-07 07:11:50 +00:00
. ui-confidence { font-size : 11 px ; color : #8e8e93 ; background : #f2f2f7 ; border-radius : var ( - - radius - sm ) ; padding : 2 px 7 px ; font-weight : 500 ; }
2026-07-05 06:41:32 +00:00
. ui-scores { display : grid ; grid-template-columns : 1 fr 1 fr ; gap : 6 px ; margin-bottom : 8 px ; }
2026-07-07 07:11:50 +00:00
. ui-score-item { background : #f2f2f7 ; border-radius : var ( - - radius - md ) ; padding : 6 px 8 px ; }
2026-07-05 06:41:32 +00:00
. ui-score-label { font-size : 10 px ; color : #8e8e93 ; font-weight : 500 ; margin-bottom : 2 px ; }
2026-07-07 07:11:50 +00:00
. ui-score-bar { height : 4 px ; border-radius : var ( - - radius - sm ) ; background : #e5e5ea ; margin-bottom : 2 px ; }
. ui-score-fill { height : 100 % ; border-radius : var ( - - radius - sm ) ; }
2026-07-05 06:41:32 +00:00
. ui-score-val { font-size : 12 px ; font-weight : 700 ; color : #1c1c1e ; }
2026-07-07 07:11:50 +00:00
. ui-contradiction { border-radius : var ( - - radius - md ) ; padding : 7 px 10 px ; font-size : 12 px ; line-height : 1.5 ; margin-bottom : 8 px ; }
2026-07-05 06:41:32 +00:00
. ui-contradiction . low { background : rgba ( 52 , 199 , 89 , 0.12 ) ; color : #1c7a3a ; }
. ui-contradiction . medium { background : rgba ( 255 , 149 , 0 , 0.12 ) ; color : #a05a00 ; }
. ui-contradiction . high { background : rgba ( 255 , 59 , 48 , 0.12 ) ; color : #a01a10 ; }
. ui-contradiction-label { font-weight : 700 ; display : block ; margin-bottom : 2 px ; }
. ui-assessment { font-size : 11 px ; color : #8e8e93 ; border-top : 0.5 px solid #e5e5ea ; padding-top : 6 px ; line-height : 1.4 ; }
2026-07-07 07:11:50 +00:00
/* Refresh pulse */
2026-07-05 06:41:32 +00:00
@ keyframes pulse-ring {
0 % { box-shadow : 0 0 0 0 rgba ( 0 , 122 , 255 , 0.4 ) ; }
70 % { box-shadow : 0 0 0 8 px rgba ( 0 , 122 , 255 , 0 ) ; }
100 % { box-shadow : 0 0 0 0 rgba ( 0 , 122 , 255 , 0 ) ; }
}
# refresh-btn . loading { animation : pulse-ring 1 s ease-out infinite ; }
@ 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 >
< style id = "wcag-contrast-enforcement" >
/* AAMOS GLOBAL CONTRAST ENFORCEMENT — WCAG AA (4.5:1 minimum) */
/* Regel: Ingen text under 70% opacity. Aldrig. */
body { -webkit- font-smoothing : antialiased ; -moz- osx-font-smoothing : grayscale ; }
/* Fix common low-contrast patterns */
[ style * = "rgba(60,60,67,0.4)" ] { color : rgba ( 60 , 60 , 67 , 0.65 ) !important ; }
[ style * = "rgba(60,60,67,0.3)" ] { color : rgba ( 60 , 60 , 67 , 0.65 ) !important ; }
[ style * = "rgba(255,255,255,0.4)" ] { color : rgba ( 255 , 255 , 255 , 0.72 ) !important ; }
[ style * = "rgba(255,255,255,0.3)" ] { color : rgba ( 255 , 255 , 255 , 0.72 ) !important ; }
[ style * = "opacity: 0.4" ] { opacity : 0.72 !important ; }
[ style * = "opacity: 0.3" ] { opacity : 0.72 !important ; }
@ 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 src = "i18n.js" > < / script >
< meta property = "og:type" content = "website" >
< meta property = "og:title" content = "quiXzoom — Mission Map" >
< meta property = "og:description" content = "quiXzoom — Earn per Mission. No Platform Fee." >
< meta property = "og:url" content = "https://www.quixzoom.com/map.html" >
< meta property = "og:image" content = "https://www.quixzoom.com/images/mission-map.jpg" >
< meta property = "og:image:width" content = "1200" >
< meta property = "og:image:height" content = "630" >
< meta property = "og:image:alt" content = "quiXzoom mission map for field data collection" >
< meta name = "twitter:card" content = "summary_large_image" >
< meta name = "twitter:image" content = "https://www.quixzoom.com/images/mission-map.jpg" >
< meta name = "twitter:title" content = "quiXzoom — Mission Map" >
< / head >
< body >
<!-- Loading overlay -->
< div id = "loading-overlay" >
< div class = "spinner" > < / div >
< div id = "loading-text" > Laddar missioner…< / div >
< / div >
<!-- Map container -->
< div id = "map" > < / div >
<!-- Header -->
< div id = "header" >
< div id = "header-inner" >
< div id = "header-title" >
< h1 > quiXzoom · Missioner< / h1 >
< p id = "last-updated" > Uppdaterar…< / p >
< / div >
< button id = "refresh-btn" title = "Uppdatera" onclick = "loadMissions(true)" >
< svg viewBox = "0 0 24 24" > < path d = "M17.65 6.35A7.96 7.96 0 0 0 12 4C7.58 4 4 7.58 4 12s3.58 8 8 8c3.73 0 6.84-2.55 7.73-6h-2.08A5.99 5.99 0 0 1 12 18c-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z" / > < / svg >
< / button >
< / div >
< / div >
<!-- Search/filter bar -->
< div id = "search-bar" >
< div id = "search-input-wrap" >
< svg viewBox = "0 0 24 24" > < path d = "M15.5 14h-.79l-.28-.27A6.47 6.47 0 0 0 16 9.5 6.5 6.5 0 1 0 9.5 16c1.61 0 3.09-.59 4.23-1.57l.27.28v.79l5 4.99L20.49 19l-4.99-5zm-6 0C7.01 14 5 11.99 5 9.5S7.01 5 9.5 5 14 7.01 14 9.5 11.99 14 9.5 14z" / > < / svg >
< input type = "text" id = "search-input" placeholder = "Sök uppdrag…" oninput = "filterMissions()" / >
< / div >
< select id = "cat-filter" onchange = "filterMissions()" >
< option value = "" > Alla kategorier< / option >
< / select >
< / div >
<!-- Bottom sheet -->
< div id = "bottom-sheet" >
< div id = "sheet-handle" > < / div >
< div id = "stats-row" >
< div class = "stat-card" >
< div class = "stat-label" > Öppna uppdrag< / div >
< div class = "stat-value blue" id = "stat-open" > —< / div >
< / div >
< div class = "stat-card" >
< div class = "stat-label" > Total ersättning< / div >
< div class = "stat-value green" id = "stat-reward" > —< / div >
< / div >
< div class = "stat-card" >
< div class = "stat-label" > Pågående< / div >
< div class = "stat-value orange" id = "stat-assigned" > —< / div >
< / div >
< / div >
< div id = "legend-row" >
< div class = "legend-item" > < div class = "legend-dot" style = "background:#007AFF" > < / div > Öppen< / div >
< div class = "legend-item" > < div class = "legend-dot" style = "background:#FF9500" > < / div > Pågående< / div >
< div class = "legend-item" > < div class = "legend-dot" style = "background:#34C759" > < / div > Klar< / div >
< / div >
< / div >
< script >
2026-07-07 07:11:50 +00:00
// Config
2026-07-05 06:41:32 +00:00
const API _URL = '/zoomer/missions?limit=100' ;
const REFRESH _INTERVAL = 30000 ;
const SWEDEN _CENTER = [ 62.0 , 16.5 ] ;
const INITIAL _ZOOM = 5 ;
2026-07-07 07:11:50 +00:00
const PIN _EMOJI = { open : '' , assigned : '' , completed : '<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><circle cx="10" cy="10" r="9" stroke="#22c55e" stroke-width="2"/><path d="M6 10L9 13L14 7" stroke="#22c55e" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/></svg>' } ;
2026-07-05 06:41:32 +00:00
const STATUS _COLOR = { open : '#007AFF' , assigned : '#FF9500' , completed : '#34C759' } ;
const STATUS _LABEL = { open : 'Öppen' , assigned : 'Pågående' , completed : 'Klar' } ;
// Mission safety levels
const RISK _LEVELS = {
L1 : { label : 'L1 — Public Safe' , hint : 'Alla verifierade Zoomers' } ,
L2 : { label : 'L2 — Intermediate' , hint : 'Erfarenhet + kvalitetshistorik krävs' } ,
L3 : { label : 'L3 — Advanced' , hint : 'Hög reputation + säkerhetsutbildning' } ,
L4 : { label : 'L4 — Restricted' , hint : 'Manuell granskning — teamuppdrag' }
} ;
function getRiskLevel ( m ) {
// Derive from reward or explicit field
if ( m . risk _level ) return m . risk _level ;
const r = Number ( m . reward _sek ) || 0 ;
if ( r >= 100 ) return 'L4' ;
if ( r >= 60 ) return 'L3' ;
if ( r >= 35 ) return 'L2' ;
return 'L1' ;
}
2026-07-07 07:11:50 +00:00
// State
2026-07-05 06:41:32 +00:00
let allMissions = [ ] ;
let markers = { } ;
let refreshTimer = null ;
2026-07-07 07:11:50 +00:00
// Map Init
2026-07-05 06:41:32 +00:00
const map = L . map ( 'map' , {
center : SWEDEN _CENTER ,
zoom : INITIAL _ZOOM ,
zoomControl : true ,
attributionControl : false
} ) ;
L . tileLayer ( 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png' , {
maxZoom : 19 ,
attribution : '© OpenStreetMap'
} ) . addTo ( map ) ;
// Subtle attribution
L . control . attribution ( { position : 'bottomright' , prefix : '© OSM' } ) . addTo ( map ) ;
2026-07-07 07:11:50 +00:00
// Marker creation
2026-07-05 06:41:32 +00:00
function createPinIcon ( status ) {
const color = STATUS _COLOR [ status ] || '#8E8E93' ;
const svg = ` <svg xmlns="http://www.w3.org/2000/svg" width="36" height="44" viewBox="0 0 36 44">
<filter id="s" x="-30%" y="-30%" width="160%" height="160%">
<feDropShadow dx="0" dy="2" stdDeviation="2.5" flood-color="rgba(0,0,0,0.25)"/>
</filter>
<g filter="url(#s)">
<circle cx="18" cy="18" r="15" fill=" ${ color } " stroke="white" stroke-width="2.5"/>
<path d="M18 33 L18 44" stroke=" ${ color } " stroke-width="2" opacity="0.5"/>
</g>
</svg> ` ;
return L . divIcon ( {
className : '' ,
html : svg ,
iconSize : [ 36 , 44 ] ,
iconAnchor : [ 18 , 44 ] ,
popupAnchor : [ 0 , - 46 ]
} ) ;
}
function createPopupHTML ( m ) {
const statusClass = ` status- ${ m . status } ` ;
const rewardStr = m . reward _sek ? ` ${ Number ( m . reward _sek ) . toLocaleString ( 'sv-SE' ) } kr ` : '—' ;
const catLabel = m . category ? m . category . replace ( /_/g , ' ' ) : '—' ;
const clientStr = m . client || '—' ;
const rl = getRiskLevel ( m ) ;
return `
<div class="popup-card">
<div class="popup-category"> ${ catLabel } </div>
<div class="popup-title"> ${ escHtml ( m . title ) } </div>
<div class="popup-meta">
<div class="popup-meta-item">
<div class="popup-meta-label">Ersättning</div>
<div class="popup-meta-value popup-reward"> ${ rewardStr } </div>
</div>
<div class="popup-meta-item">
<div class="popup-meta-label">Uppdragsgivare</div>
<div class="popup-meta-value"> ${ escHtml ( clientStr ) } </div>
</div>
</div>
<div class="popup-status-badge ${ statusClass } ">
2026-07-07 07:11:50 +00:00
<span></span> ${ STATUS _LABEL [ m . status ] || m . status }
2026-07-05 06:41:32 +00:00
</div>
<div class="popup-risk">
<span class="risk-badge risk- ${ rl } "> ${ RISK _LEVELS [ rl ] . label } </span>
<span class="risk-hint"> ${ RISK _LEVELS [ rl ] . hint } </span>
</div>
</div> ` ;
}
function escHtml ( s ) {
return String ( s || '' ) . replace ( /&/g , '&' ) . replace ( /</g , '<' ) . replace ( />/g , '>' ) . replace ( /"/g , '"' ) ;
}
2026-07-07 07:11:50 +00:00
// Load Missions
2026-07-05 06:41:32 +00:00
async function loadMissions ( manual = false ) {
const btn = document . getElementById ( 'refresh-btn' ) ;
btn . classList . add ( 'loading' ) ;
try {
// Try internal API first, fallback to zoomer service
let data ;
try {
const r = await fetch ( '/zoomer/missions?limit=100' ) ;
if ( ! r . ok ) throw new Error ( 'not ok' ) ;
data = await r . json ( ) ;
} catch {
const r = await fetch ( 'http://localhost:3304/missions?limit=100' ) ;
data = await r . json ( ) ;
}
const missions = Array . isArray ( data ) ? data : ( data . missions || [ ] ) ;
allMissions = missions ;
renderCategories ( ) ;
filterMissions ( ) ;
updateStats ( ) ;
document . getElementById ( 'last-updated' ) . textContent =
` Uppdaterad ${ new Date ( ) . toLocaleTimeString ( 'sv-SE' , { hour : '2-digit' , minute : '2-digit' } )} ` ;
// Hide loading overlay
document . getElementById ( 'loading-overlay' ) . classList . add ( 'hidden' ) ;
} catch ( e ) {
console . error ( 'Failed to load missions:' , e ) ;
2026-07-07 07:11:50 +00:00
document . getElementById ( 'loading-text' ) . textContent = 'Kunde inte ladda missioner <svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10 3L17 17H3L10 3Z" stroke="#f59e0b" stroke-width="2"/><path d="M10 8V12M10 14V14.5" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/></svg>' ;
2026-07-05 06:41:32 +00:00
} finally {
btn . classList . remove ( 'loading' ) ;
// Schedule next refresh
clearTimeout ( refreshTimer ) ;
refreshTimer = setTimeout ( ( ) => loadMissions ( false ) , REFRESH _INTERVAL ) ;
}
}
function renderCategories ( ) {
const sel = document . getElementById ( 'cat-filter' ) ;
const cats = [ ... new Set ( allMissions . map ( m => m . category ) . filter ( Boolean ) ) ] . sort ( ) ;
const current = sel . value ;
sel . innerHTML = '<option value="">Alla kategorier</option>' ;
cats . forEach ( c => {
const opt = document . createElement ( 'option' ) ;
opt . value = c ;
opt . textContent = c . replace ( /_/g , ' ' ) ;
if ( c === current ) opt . selected = true ;
sel . appendChild ( opt ) ;
} ) ;
}
function filterMissions ( ) {
const query = document . getElementById ( 'search-input' ) . value . toLowerCase ( ) ;
const cat = document . getElementById ( 'cat-filter' ) . value ;
// Remove all existing markers
Object . values ( markers ) . forEach ( m => map . removeLayer ( m ) ) ;
markers = { } ;
const visible = allMissions . filter ( m => {
if ( cat && m . category !== cat ) return false ;
if ( query && ! m . title . toLowerCase ( ) . includes ( query ) && ! ( m . client || '' ) . toLowerCase ( ) . includes ( query ) ) return false ;
return true ;
} ) ;
visible . forEach ( m => {
if ( ! m . lat || ! m . lng ) return ;
const marker = L . marker ( [ m . lat , m . lng ] , { icon : createPinIcon ( m . status ) } ) ;
marker . bindPopup ( createPopupHTML ( m ) , { maxWidth : 280 , minWidth : 260 } ) ;
marker . addTo ( map ) ;
markers [ m . id ] = marker ;
} ) ;
}
function updateStats ( ) {
const open = allMissions . filter ( m => m . status === 'open' ) ;
const assigned = allMissions . filter ( m => m . status === 'assigned' ) ;
const totalReward = open . reduce ( ( s , m ) => s + ( Number ( m . reward _sek ) || 0 ) , 0 ) ;
document . getElementById ( 'stat-open' ) . textContent = open . length ;
document . getElementById ( 'stat-assigned' ) . textContent = assigned . length ;
document . getElementById ( 'stat-reward' ) . textContent =
totalReward >= 1000
? ` ${ ( totalReward / 1000 ) . toFixed ( 1 ) } k `
: ` ${ totalReward } ` ;
}
2026-07-07 07:11:50 +00:00
// Urban Intelligence Layer
2026-07-05 06:41:32 +00:00
// Demo zones — production: fetch from /api/urban-intelligence/zones
const UI _ZONES = [
{ id : 'sthlm-inner' , name : 'Stockholm Innerstad' , assessment : 'green' , confidence : 96 ,
scores : { infrastructure : 9.1 , safety : 8.7 , traffic : 6.2 , night : 7.8 } ,
contradiction : 'low' , contradictionText : 'Observerad miljö stämmer väl överens med officiella data.' ,
coords : [ [ 59.340 , 18.040 ] , [ 59.340 , 18.100 ] , [ 59.310 , 18.100 ] , [ 59.310 , 18.040 ] ] } ,
{ id : 'sthlm-rinkeby' , name : 'Rinkeby– Kista' , assessment : 'orange' , confidence : 81 ,
scores : { infrastructure : 6.2 , safety : 5.1 , traffic : 7.1 , night : 4.2 } ,
contradiction : 'high' , contradictionText : 'Avvikelse: officiell data anger medelhög risk, men observationer visar 41% trasig gatubelysning, hög klotterförekomst och låg kvällsaktivitet.' ,
coords : [ [ 59.390 , 17.910 ] , [ 59.390 , 17.970 ] , [ 59.360 , 17.970 ] , [ 59.360 , 17.910 ] ] } ,
{ id : 'gbg-inner' , name : 'Göteborg Centrum' , assessment : 'yellow' , confidence : 88 ,
scores : { infrastructure : 7.8 , safety : 7.2 , traffic : 5.9 , night : 6.5 } ,
contradiction : 'medium' , contradictionText : 'Viss avvikelse: trafikrisk underrapporterad i officiell data jämfört med observerad trängsel.' ,
coords : [ [ 57.720 , 11.950 ] , [ 57.720 , 12.000 ] , [ 57.695 , 12.000 ] , [ 57.695 , 11.950 ] ] } ,
{ id : 'malmo-inner' , name : 'Malmö Centrum' , assessment : 'yellow' , confidence : 79 ,
scores : { infrastructure : 7.5 , safety : 6.4 , traffic : 6.8 , night : 5.9 } ,
contradiction : 'medium' , contradictionText : 'Nattrisk avviker från officiell statistik. Fler observationer behövs.' ,
coords : [ [ 55.615 , 13.000 ] , [ 55.615 , 13.030 ] , [ 55.595 , 13.030 ] , [ 55.595 , 13.000 ] ] }
] ;
const ASSESS _STYLE = {
green : { color : '#34C759' , fillOpacity : 0.13 , weight : 1.5 , dash : null } ,
yellow : { color : '#FFCC00' , fillOpacity : 0.16 , weight : 1.5 , dash : '5 4' } ,
orange : { color : '#FF9500' , fillOpacity : 0.20 , weight : 2 , dash : '5 4' } ,
red : { color : '#FF3B30' , fillOpacity : 0.25 , weight : 2 , dash : '4 3' } ,
darkred : { color : '#8B0000' , fillOpacity : 0.30 , weight : 2.5 , dash : '4 3' }
} ;
2026-07-07 07:11:50 +00:00
const C _LABELS = { low : 'Contradiction Score: Låg' , medium : 'Contradiction Score: Medel' , high : 'Contradiction Score: HÖG <svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M10 3L17 17H3L10 3Z" stroke="#f59e0b" stroke-width="2"/><path d="M10 8V12M10 14V14.5" stroke="#f59e0b" stroke-width="2" stroke-linecap="round"/></svg>' } ;
2026-07-05 06:41:32 +00:00
let uiGroup = L . layerGroup ( ) . addTo ( map ) ;
let uiOn = true ;
function scoreBar ( v ) {
const pct = Math . round ( v * 10 ) ;
const col = v >= 7.5 ? '#34C759' : v >= 5 ? '#FFCC00' : '#FF3B30' ;
return ` <div class="ui-score-bar"><div class="ui-score-fill" style="width: ${ pct } %;background: ${ col } "></div></div> ` ;
}
function uiPopup ( z ) {
const s = z . scores ;
return ` <div class="ui-popup">
<div class="ui-popup-header"><span class="ui-popup-title"> ${ z . name } </span><span class="ui-confidence">Confidence ${ z . confidence } %</span></div>
<div class="ui-scores">
<div class="ui-score-item"><div class="ui-score-label">Infrastruktur</div> ${ scoreBar ( s . infrastructure ) } <div class="ui-score-val"> ${ s . infrastructure } /10</div></div>
<div class="ui-score-item"><div class="ui-score-label">Trygghet</div> ${ scoreBar ( s . safety ) } <div class="ui-score-val"> ${ s . safety } /10</div></div>
<div class="ui-score-item"><div class="ui-score-label">Trafikrisk</div> ${ scoreBar ( s . traffic ) } <div class="ui-score-val"> ${ s . traffic } /10</div></div>
<div class="ui-score-item"><div class="ui-score-label">Nattrisk</div> ${ scoreBar ( s . night ) } <div class="ui-score-val"> ${ s . night } /10</div></div>
</div>
<div class="ui-contradiction ${ z . contradiction } "><span class="ui-contradiction-label"> ${ C _LABELS [ z . contradiction ] } </span> ${ z . contradictionText } </div>
<div class="ui-assessment">Landvex Assessment — baserat på officiella källor + quiXzoom-observationer. Ej ett faktapåstående om faktiska förhållanden.</div>
</div> ` ;
}
function renderUI ( ) {
uiGroup . clearLayers ( ) ;
if ( ! uiOn ) return ;
UI _ZONES . forEach ( z => {
const st = ASSESS _STYLE [ z . assessment ] ;
L . polygon ( z . coords , {
color : st . color , fillColor : st . color ,
fillOpacity : st . fillOpacity , weight : st . weight ,
dashArray : st . dash
} ) . bindPopup ( uiPopup ( z ) , { maxWidth : 300 , minWidth : 240 } ) . addTo ( uiGroup ) ;
} ) ;
}
renderUI ( ) ;
// Toggle button
const tog = document . createElement ( 'div' ) ;
tog . id = 'ui-toggle' ;
tog . innerHTML = '<div id="ui-dot"></div><span>Urban Intelligence</span>' ;
tog . onclick = ( ) => { uiOn = ! uiOn ; tog . classList . toggle ( 'off' , ! uiOn ) ; renderUI ( ) ; } ;
document . body . appendChild ( tog ) ;
2026-07-07 07:11:50 +00:00
// Start
2026-07-05 06:41:32 +00:00
loadMissions ( ) ;
< / script >
< / body >
< / html >