Files
boc/quixzoom-landing-fixed/map.html
T

821 lines
28 KiB
HTML
Raw Normal View History

<!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, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
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;
}
/* Header */
#header {
position: fixed;
top: 0; left: 0; right: 0;
z-index: 1000;
background: rgba(242,242,247,0.85);
-webkit-backdrop-filter: blur(20px) saturate(180%);
backdrop-filter: blur(20px) saturate(180%);
border-bottom: 0.5px solid var(--ios-separator);
padding: calc(var(--safe-top) + 12px) 16px 12px;
}
#header-inner {
display: flex;
align-items: center;
gap: 12px;
}
#header-title {
flex: 1;
}
#header-title h1 {
font-size: 17px;
font-weight: 600;
letter-spacing: -0.4px;
line-height: 1.2;
}
#header-title p {
font-size: 12px;
color: var(--ios-label2);
margin-top: 1px;
}
#refresh-btn {
width: 32px; height: 32px;
border-radius: var(--radius-full);
background: var(--ios-blue);
border: none;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: opacity 0.15s;
}
#refresh-btn:active { opacity: 0.7; }
#refresh-btn svg { width: 16px; height: 16px; fill: white; }
/* Search/Filter Bar */
#search-bar {
position: fixed;
top: calc(var(--safe-top) + 60px);
left: 0; right: 0;
z-index: 1000;
padding: 8px 16px;
background: rgba(242,242,247,0.85);
-webkit-backdrop-filter: blur(20px) saturate(180%);
backdrop-filter: blur(20px) saturate(180%);
display: flex;
gap: 8px;
}
#search-input-wrap {
flex: 1;
position: relative;
}
#search-input-wrap svg {
position: absolute;
left: 10px;
top: 50%;
transform: translateY(-50%);
width: 14px; height: 14px;
fill: var(--ios-gray2);
pointer-events: none;
}
#search-input {
width: 100%;
height: 36px;
background: var(--ios-white);
border: none;
border-radius: var(--radius-md);
padding: 0 10px 0 32px;
font-size: 15px;
font-family: inherit;
color: var(--ios-label);
outline: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
}
#search-input::placeholder { color: var(--ios-gray2); }
#cat-filter {
height: 36px;
background: var(--ios-white);
border: none;
border-radius: var(--radius-md);
padding: 0 10px;
font-size: 13px;
font-family: inherit;
color: var(--ios-label);
outline: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.08);
min-width: 100px;
}
/* Map */
#map {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
z-index: 1;
}
.leaflet-control-zoom {
margin-top: calc(var(--safe-top) + 128px) !important;
margin-left: 16px !important;
}
.leaflet-control-zoom a {
font-size: 18px !important;
width: 36px !important;
height: 36px !important;
line-height: 36px !important;
border-radius: var(--radius-md) !important;
}
/* Custom Pins */
.mission-pin {
width: 36px;
height: 36px;
border-radius: var(--radius-full);
border: 2.5px solid white;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0,0,0,0.25);
font-size: 15px;
cursor: pointer;
transition: transform 0.15s;
}
.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); }
/* Popup */
.leaflet-popup-content-wrapper {
border-radius: var(--radius-lg) !important;
box-shadow: 0 8px 30px rgba(0,0,0,0.18) !important;
padding: 0 !important;
overflow: hidden;
}
.leaflet-popup-content {
margin: 0 !important;
width: 260px !important;
}
.leaflet-popup-tip-container { display: none; }
.popup-card {
padding: 16px;
background: var(--ios-white);
}
.popup-category {
display: inline-block;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--ios-blue);
background: rgba(0,122,255,0.1);
border-radius: var(--radius-sm);
padding: 3px 8px;
margin-bottom: 8px;
}
.popup-title {
font-size: 16px;
font-weight: 600;
letter-spacing: -0.3px;
line-height: 1.3;
margin-bottom: 10px;
}
.popup-meta {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.popup-meta-item {
background: var(--ios-gray6);
border-radius: var(--radius-md);
padding: 8px 10px;
}
.popup-meta-label {
font-size: 11px;
color: var(--ios-label2);
margin-bottom: 2px;
}
.popup-meta-value {
font-size: 14px;
font-weight: 600;
}
.popup-reward { color: var(--ios-green); }
.popup-status-badge {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 600;
border-radius: var(--radius-xl);
padding: 4px 10px;
margin-top: 10px;
}
.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: 10px;
border-top: 0.5px solid var(--ios-gray5);
padding-top: 10px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.risk-badge {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 11px;
font-weight: 700;
border-radius: var(--radius-md);
padding: 4px 9px;
letter-spacing: 0.2px;
}
.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: 11px; color: var(--ios-label2); line-height: 1.3; flex: 1; text-align: right; }
/* Bottom Sheet */
#bottom-sheet {
position: fixed;
bottom: 0; left: 0; right: 0;
z-index: 900;
background: rgba(242,242,247,0.92);
-webkit-backdrop-filter: blur(20px) saturate(180%);
backdrop-filter: blur(20px) saturate(180%);
border-top: 0.5px solid var(--ios-separator);
border-radius: var(--radius-xl) 20px 0 0;
padding: 12px 16px calc(var(--safe-bottom) + 12px);
}
#sheet-handle {
width: 36px; height: 4px;
border-radius: var(--radius-sm);
background: var(--ios-label3);
margin: 0 auto 14px;
}
#stats-row {
display: flex;
gap: 10px;
margin-bottom: 12px;
}
.stat-card {
flex: 1;
background: var(--ios-white);
border-radius: var(--radius-lg);
padding: 12px 14px;
box-shadow: 0 1px 4px rgba(0,0,0,0.06);
}
.stat-label {
font-size: 11px;
color: var(--ios-label2);
font-weight: 500;
margin-bottom: 4px;
}
.stat-value {
font-size: 22px;
font-weight: 700;
letter-spacing: -0.5px;
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: 16px;
justify-content: center;
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
color: var(--ios-label2);
}
.legend-dot {
width: 10px; height: 10px;
border-radius: var(--radius-full);
}
/* Loading Indicator */
#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: 16px;
transition: opacity 0.3s;
}
#loading-overlay.hidden { opacity: 0; pointer-events: none; }
.spinner {
width: 40px; height: 40px;
border: 3px solid var(--ios-gray5);
border-top-color: var(--ios-blue);
border-radius: var(--radius-full);
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
#loading-text {
font-size: 14px;
color: var(--ios-label2);
}
/* Urban Intelligence Layer */
#ui-toggle {
position: fixed;
top: calc(var(--safe-top) + 110px);
right: 16px;
z-index: 1000;
background: rgba(242,242,247,0.92);
-webkit-backdrop-filter: blur(20px) saturate(180%);
backdrop-filter: blur(20px) saturate(180%);
border: 0.5px solid var(--ios-separator);
border-radius: var(--radius-md);
padding: 8px 12px;
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.10);
font-size: 13px;
font-weight: 500;
color: var(--ios-label);
user-select: none;
transition: opacity 0.2s;
}
#ui-toggle:active { opacity: 0.7; }
#ui-dot { width:10px;height:10px;border-radius: var(--radius-full);background:var(--ios-blue);flex-shrink:0; }
#ui-toggle.off #ui-dot { background: var(--ios-gray2); }
.ui-popup { font-family: -apple-system,BlinkMacSystemFont,'SF Pro Text',sans-serif; min-width:220px; }
.ui-popup-header { display:flex;align-items:center;justify-content:space-between;margin-bottom:8px; }
.ui-popup-title { font-size:13px;font-weight:700;letter-spacing:-0.2px;color:#1c1c1e; }
.ui-confidence { font-size:11px;color:#8e8e93;background:#f2f2f7;border-radius: var(--radius-sm);padding:2px 7px;font-weight:500; }
.ui-scores { display:grid;grid-template-columns:1fr 1fr;gap:6px;margin-bottom:8px; }
.ui-score-item { background:#f2f2f7;border-radius: var(--radius-md);padding:6px 8px; }
.ui-score-label { font-size:10px;color:#8e8e93;font-weight:500;margin-bottom:2px; }
.ui-score-bar { height:4px;border-radius: var(--radius-sm);background:#e5e5ea;margin-bottom:2px; }
.ui-score-fill { height:100%;border-radius: var(--radius-sm); }
.ui-score-val { font-size:12px;font-weight:700;color:#1c1c1e; }
.ui-contradiction { border-radius: var(--radius-md);padding:7px 10px;font-size:12px;line-height:1.5;margin-bottom:8px; }
.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:2px; }
.ui-assessment { font-size:11px;color:#8e8e93;border-top:0.5px solid #e5e5ea;padding-top:6px;line-height:1.4; }
/* Refresh pulse */
@keyframes pulse-ring {
0% { box-shadow: 0 0 0 0 rgba(0,122,255,0.4); }
70% { box-shadow: 0 0 0 8px rgba(0,122,255,0); }
100% { box-shadow: 0 0 0 0 rgba(0,122,255,0); }
}
#refresh-btn.loading { animation: pulse-ring 1s ease-out infinite; }
@media (max-width: 640px) {
.hero h1 { font-size: 2rem !important; }
.hero p { font-size: 1rem !important; }
.card { padding: 1.5rem !important; }
.grid { grid-template-columns: 1fr !important; }
.btn { padding: 0.75rem 1.5rem !important; font-size: 1rem !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: 2rem !important; }
.hero p { font-size: 1rem !important; }
.card { padding: 1.5rem !important; }
.grid { grid-template-columns: 1fr !important; }
.btn { padding: 0.75rem 1.5rem !important; font-size: 1rem !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>
// Config
const API_URL = '/zoomer/missions?limit=100';
const REFRESH_INTERVAL = 30000;
const SWEDEN_CENTER = [62.0, 16.5];
const INITIAL_ZOOM = 5;
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>' };
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';
}
// State
let allMissions = [];
let markers = {};
let refreshTimer = null;
// Map Init
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);
// Marker creation
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}">
<span></span> ${STATUS_LABEL[m.status] || m.status}
</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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Load Missions
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);
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>';
} 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}`;
}
// Urban Intelligence Layer
// 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:'RinkebyKista', 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'}
};
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>'};
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);
// Start
loadMissions();
</script>
</body>
</html>