Files
boc/quixzoom-landing-fixed/map.html
T
Bernt 48ea61cdcc docs: add quixzoom-auth-core product to AAMOS
- Product documentation in docs/products/
- Updated MEMORY.md with product info
- quiXzoom Auth Core as AAMOS Identity product
2026-07-14 09:58:53 +00:00

822 lines
28 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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">
<script type="module" src="https://auth.quixzoom.com/site-auth-snippet.js"></script>
</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>