1221 lines
47 KiB
Plaintext
1221 lines
47 KiB
Plaintext
<!DOCTYPE html>
|
|
<html lang="<%= typeof lang !== 'undefined' ? lang : 'id' %>" class="scroll-smooth">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
|
<title><%= typeof title !== 'undefined' ? title : 'Peta Geografis (GIS Real Map)' %> | <%= company %></title>
|
|
|
|
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600;700;800&display=swap" rel="stylesheet">
|
|
|
|
<!-- Tabler Icons -->
|
|
<!-- Leaflet CSS -->
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
|
|
|
|
<!-- Base Admin CSS -->
|
|
<link rel="stylesheet" href="/css/admin.css">
|
|
|
|
<script src="https://cdn.tailwindcss.com"></script>
|
|
<script>
|
|
tailwind.config = {
|
|
darkMode: 'class',
|
|
theme: {
|
|
extend: {
|
|
fontFamily: {
|
|
sans: ['"Plus Jakarta Sans"', 'sans-serif'],
|
|
mono: ['"JetBrains Mono"', 'monospace']
|
|
},
|
|
colors: {
|
|
primary: { 400: '#60A5FA', 500: '#3B82F6', 600: '#2563EB' },
|
|
accent: { 400: '#34D399', 500: '#10B981', 600: '#059669' },
|
|
fun: { pink: '#EC4899', purple: '#8B5CF6', yellow: '#F59E0B' }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style>
|
|
body {
|
|
background-color: #080C15;
|
|
color: #F8FAFC;
|
|
overflow-x: hidden;
|
|
}
|
|
.glass-panel {
|
|
background: rgba(15, 23, 42, 0.85);
|
|
backdrop-filter: blur(16px);
|
|
-webkit-backdrop-filter: blur(16px);
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
}
|
|
|
|
/* Fullscreen GIS Map Viewport */
|
|
#gis-map-viewport {
|
|
position: relative;
|
|
width: 100%;
|
|
height: calc(100vh - 185px);
|
|
min-height: 600px;
|
|
border-radius: 1rem;
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
box-shadow: inset 0 2px 25px rgba(0, 0, 0, 0.85);
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* Leaflet Basemap Overrides */
|
|
.leaflet-container {
|
|
background-color: #080C15 !important;
|
|
font-family: inherit !important;
|
|
}
|
|
.leaflet-control-zoom {
|
|
border: none !important;
|
|
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.6) !important;
|
|
border-radius: 10px !important;
|
|
overflow: hidden;
|
|
}
|
|
.leaflet-control-zoom a {
|
|
background-color: rgba(15, 23, 42, 0.9) !important;
|
|
color: #94A3B8 !important;
|
|
border: 1px solid rgba(255, 255, 255, 0.1) !important;
|
|
width: 32px !important;
|
|
height: 32px !important;
|
|
line-height: 30px !important;
|
|
font-size: 15px !important;
|
|
}
|
|
.leaflet-control-zoom a:hover {
|
|
background-color: #334155 !important;
|
|
color: #FFFFFF !important;
|
|
}
|
|
|
|
/* Animated Flowing Laser Cable Curves (Online Status) */
|
|
@keyframes flowGisCable {
|
|
0% { stroke-dashoffset: 40; }
|
|
100% { stroke-dashoffset: 0; }
|
|
}
|
|
.gis-cable-flow {
|
|
stroke-dasharray: 8 12;
|
|
animation: flowGisCable 1.6s linear infinite;
|
|
}
|
|
|
|
/* Cable Status: OFFLINE (STATIS MERAH) */
|
|
.gis-cable-offline {
|
|
stroke: #EF4444 !important;
|
|
stroke-width: 3.5px !important;
|
|
stroke-dasharray: none !important;
|
|
animation: none !important;
|
|
filter: drop-shadow(0 0 6px rgba(239, 68, 68, 0.95)) !important;
|
|
}
|
|
|
|
/* Cable Status: STANDBY (GRAY DASHED) */
|
|
.gis-cable-standby {
|
|
stroke: #64748B !important;
|
|
stroke-width: 2px !important;
|
|
stroke-dasharray: 5 5 !important;
|
|
animation: none !important;
|
|
opacity: 0.65 !important;
|
|
}
|
|
|
|
/* Custom Leaflet Markers */
|
|
.gis-pin-wrapper {
|
|
background: transparent;
|
|
border: none;
|
|
}
|
|
.gis-marker-node {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
cursor: pointer;
|
|
transform: translate(-50%, -50%);
|
|
transition: transform 0.2s ease, filter 0.2s ease;
|
|
}
|
|
.gis-marker-node:hover {
|
|
transform: translate(-50%, -50%) scale(1.15);
|
|
z-index: 1000 !important;
|
|
}
|
|
|
|
.gis-marker-icon {
|
|
width: 32px;
|
|
height: 32px;
|
|
border-radius: 10px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
font-size: 15px;
|
|
border: 2px solid #FFFFFF;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.7);
|
|
}
|
|
.gis-marker-label {
|
|
margin-top: 3px;
|
|
padding: 1px 6px;
|
|
border-radius: 4px;
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
white-space: nowrap;
|
|
background: rgba(15, 23, 42, 0.9);
|
|
border: 1px solid rgba(255, 255, 255, 0.15);
|
|
color: #F8FAFC;
|
|
box-shadow: 0 2px 6px rgba(0,0,0,0.6);
|
|
pointer-events: none;
|
|
}
|
|
|
|
/* Customer Small Pin */
|
|
.gis-cust-pin {
|
|
width: 14px;
|
|
height: 14px;
|
|
border-radius: 9999px;
|
|
border: 2px solid #FFFFFF;
|
|
box-shadow: 0 2px 8px rgba(0,0,0,0.6);
|
|
cursor: pointer;
|
|
transition: transform 0.15s ease;
|
|
}
|
|
.gis-cust-pin:hover {
|
|
transform: scale(1.5);
|
|
z-index: 900 !important;
|
|
}
|
|
|
|
/* Leaflet Popups Dark & Glass */
|
|
.leaflet-popup-content-wrapper {
|
|
background: rgba(15, 23, 42, 0.95) !important;
|
|
backdrop-filter: blur(14px) !important;
|
|
border: 1px solid rgba(255, 255, 255, 0.15) !important;
|
|
border-radius: 14px !important;
|
|
color: #F8FAFC !important;
|
|
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.8) !important;
|
|
padding: 0 !important;
|
|
}
|
|
.leaflet-popup-content {
|
|
margin: 0 !important;
|
|
line-height: 1.4 !important;
|
|
}
|
|
.leaflet-popup-tip {
|
|
background: rgba(15, 23, 42, 0.95) !important;
|
|
border: 1px solid rgba(255, 255, 255, 0.15) !important;
|
|
}
|
|
.leaflet-container a.leaflet-popup-close-button {
|
|
color: #94A3B8 !important;
|
|
padding: 8px 10px 0 0 !important;
|
|
}
|
|
.leaflet-container a.leaflet-popup-close-button:hover {
|
|
color: #FFFFFF !important;
|
|
}
|
|
|
|
/* Mode Pill Buttons */
|
|
.gis-mode-pill-btn {
|
|
padding: 5px 12px;
|
|
border-radius: 8px;
|
|
font-size: 11px;
|
|
font-weight: 700;
|
|
color: #94A3B8;
|
|
background: transparent;
|
|
transition: all 0.2s ease;
|
|
border: 1px solid transparent;
|
|
user-select: none;
|
|
}
|
|
.gis-mode-pill-btn:hover {
|
|
color: #FFFFFF;
|
|
background: rgba(255, 255, 255, 0.08);
|
|
}
|
|
.gis-mode-pill-btn.active {
|
|
color: #FFFFFF;
|
|
background: #0284C7;
|
|
border-color: #38BDF8;
|
|
box-shadow: 0 2px 10px rgba(2, 132, 199, 0.4);
|
|
}
|
|
</style>
|
|
<link rel="stylesheet" href="/vendor/bootstrap-icons/bootstrap-icons.min.css">
|
|
</head>
|
|
|
|
<body class="bg-[#080C15] text-slate-100 flex h-screen overflow-hidden antialiased select-none">
|
|
|
|
<!-- SIDEBAR -->
|
|
<%- include('partials/sidebar', { activePage: 'map', company }) %>
|
|
|
|
<!-- MAIN WRAPPER -->
|
|
<div class="mw min-h-screen flex flex-col flex-1 min-w-0">
|
|
|
|
<!-- TOPBAR -->
|
|
<%- include('partials/topbar', {
|
|
pageIcon: 'ti-map-2',
|
|
pageTitle: 'Peta Geografis (Real GIS Map)',
|
|
pageDesc: 'Visualisasi Geografis Jaringan Fiber Optik, ODC, ODP, HTB P2P & Pelanggan',
|
|
showRouterSelector: false
|
|
}) %>
|
|
|
|
<!-- MAIN PAGE CONTENT -->
|
|
<main class="page p-2 sm:p-4 flex-1 flex flex-col min-w-0 space-y-3 max-w-full w-full mx-auto overflow-hidden">
|
|
|
|
<!-- ALERT BANNER -->
|
|
<% if (msg) { %>
|
|
<div id="autoAlert" class="p-3.5 rounded-xl flex items-center justify-between gap-3 shadow-lg backdrop-blur-xl border transition-all duration-300 <%= msg.type === 'danger' || msg.type === 'error' ? 'bg-rose-500/15 border-rose-500/30 text-rose-300' : (msg.type === 'success' ? 'bg-emerald-500/15 border-emerald-500/30 text-emerald-300' : 'bg-amber-500/15 border-amber-500/30 text-amber-300') %>">
|
|
<div class="flex items-center gap-3">
|
|
<i class="<%= msg.type === 'success' ? 'ti-check' : 'ti-alert-circle' %> text-lg flex-shrink-0"></i>
|
|
<span class="text-xs sm:text-sm font-semibold"><%= msg.text %></span>
|
|
</div>
|
|
<button type="button" onclick="document.getElementById('autoAlert')?.remove()" class="w-6 h-6 rounded-lg hover:bg-white/10 flex items-center justify-center transition flex-shrink-0">
|
|
<i class="bi bi-x-lg text-sm"></i>
|
|
</button>
|
|
</div>
|
|
<% } %>
|
|
|
|
<!-- TOOLBAR CONTROLS -->
|
|
<div class="glass-panel relative z-[1010] p-2.5 sm:p-3 rounded-xl flex flex-wrap items-center justify-between gap-2.5 shadow-md flex-shrink-0">
|
|
|
|
<!-- Left: Search & Quick Navigation -->
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
|
|
<!-- Search Input -->
|
|
<div class="relative w-48 sm:w-64 z-[1020]">
|
|
<i class="bi bi-search absolute left-3 top-1/2 -translate-y-1/2 text-slate-400 text-sm"></i>
|
|
<input
|
|
type="text"
|
|
id="gisSearchInp"
|
|
placeholder="<%= t('admin.gis.ph_search','Cari ODP, ODC, HTB, Pelanggan...') %>"
|
|
oninput="handleGisSearch(this.value)"
|
|
class="w-full bg-slate-900/90 border border-slate-700/70 rounded-lg pl-8 pr-7 py-1.5 text-xs text-white placeholder-slate-400 focus:outline-none focus:border-sky-500 transition"
|
|
>
|
|
<button
|
|
type="button"
|
|
id="clearGisSearchBtn"
|
|
onclick="clearGisSearch()"
|
|
class="hidden absolute right-2 top-1/2 -translate-y-1/2 text-slate-400 hover:text-white"
|
|
>
|
|
<i class="bi bi-x-lg text-xs"></i>
|
|
</button>
|
|
|
|
<!-- Dropdown search suggestions -->
|
|
<div id="gisSearchDropdown" class="hidden absolute left-0 right-0 top-full mt-1.5 bg-slate-900/98 backdrop-blur-2xl border border-slate-700/90 rounded-xl shadow-[0_25px_60px_rgba(0,0,0,0.95)] max-h-60 overflow-y-auto z-[1050] divide-y divide-slate-800 text-xs ring-1 ring-white/10"></div>
|
|
</div>
|
|
|
|
<!-- Layer Toggle Filter Button -->
|
|
<div class="relative z-[1020]">
|
|
<button
|
|
type="button"
|
|
onclick="toggleLayerMenu()"
|
|
id="btnToggleLayers"
|
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 text-slate-300 hover:text-white text-xs font-semibold transition"
|
|
>
|
|
<i class="bi bi-layers text-sm"></i>
|
|
<span><%= t('admin.gis.layers','Layer Peta') %></span>
|
|
<i class="bi bi-chevron-down text-xs"></i>
|
|
</button>
|
|
|
|
<!-- Layer Dropdown Box -->
|
|
<div id="gisLayersMenu" class="hidden absolute left-0 top-full mt-2 w-64 bg-slate-900/98 backdrop-blur-2xl border border-slate-700/90 rounded-xl shadow-[0_25px_60px_rgba(0,0,0,0.95)] p-3.5 z-[1050] space-y-2.5 text-xs max-h-[calc(100vh-200px)] overflow-y-auto ring-1 ring-white/10">
|
|
<div class="text-[10px] font-bold text-slate-400 uppercase tracking-wider mb-1"><%= t('admin.gis.show_elems','Tampilkan Elemen') %></div>
|
|
|
|
<label class="flex items-center justify-between cursor-pointer py-1">
|
|
<span class="flex items-center gap-2 text-slate-300">
|
|
<span class="w-2.5 h-2.5 rounded bg-blue-500"></span>
|
|
<span><%= t('admin.gis.el_server','Server & OLT') %></span>
|
|
</span>
|
|
<input type="checkbox" id="chkLayerServer" checked onchange="toggleLayerVisibility('server')" class="rounded border-slate-700 text-blue-500 focus:ring-0">
|
|
</label>
|
|
|
|
<label class="flex items-center justify-between cursor-pointer py-1">
|
|
<span class="flex items-center gap-2 text-slate-300">
|
|
<span class="w-2.5 h-2.5 rounded bg-amber-500"></span>
|
|
<span><%= t('admin.gis.el_odc','ODC Feeder Hub') %></span>
|
|
</span>
|
|
<input type="checkbox" id="chkLayerOdc" checked onchange="toggleLayerVisibility('odc')" class="rounded border-slate-700 text-amber-500 focus:ring-0">
|
|
</label>
|
|
|
|
<label class="flex items-center justify-between cursor-pointer py-1">
|
|
<span class="flex items-center gap-2 text-slate-300">
|
|
<span class="w-2.5 h-2.5 rounded bg-emerald-500"></span>
|
|
<span><%= t('admin.gis.el_odp','ODP Distribusi') %></span>
|
|
</span>
|
|
<input type="checkbox" id="chkLayerOdp" checked onchange="toggleLayerVisibility('odp')" class="rounded border-slate-700 text-emerald-500 focus:ring-0">
|
|
</label>
|
|
|
|
<label class="flex items-center justify-between cursor-pointer py-1">
|
|
<span class="flex items-center gap-2 text-slate-300">
|
|
<span class="w-2.5 h-2.5 rounded bg-fuchsia-500"></span>
|
|
<span><%= t('admin.gis.el_htb','HTB Media Converter') %></span>
|
|
</span>
|
|
<input type="checkbox" id="chkLayerHtb" checked onchange="toggleLayerVisibility('htb')" class="rounded border-slate-700 text-fuchsia-500 focus:ring-0">
|
|
</label>
|
|
|
|
<label class="flex items-center justify-between cursor-pointer py-1">
|
|
<span class="flex items-center gap-2 text-slate-300">
|
|
<span class="w-2.5 h-2.5 rounded bg-sky-400"></span>
|
|
<span><%= t('common.customer','Pelanggan') %></span>
|
|
</span>
|
|
<input type="checkbox" id="chkLayerCust" checked onchange="toggleLayerVisibility('customer')" class="rounded border-slate-700 text-sky-500 focus:ring-0">
|
|
</label>
|
|
|
|
<div class="border-t border-slate-800 pt-2">
|
|
<label class="flex items-center justify-between cursor-pointer py-1">
|
|
<span class="flex items-center gap-2 text-slate-300">
|
|
<span class="w-3 h-0.5 bg-emerald-400"></span>
|
|
<span><%= t('admin.gis.el_fiber','Jalur Kabel Fiber') %></span>
|
|
</span>
|
|
<input type="checkbox" id="chkLayerCables" checked onchange="toggleLayerVisibility('cables')" class="rounded border-slate-700 text-emerald-500 focus:ring-0">
|
|
</label>
|
|
|
|
<label class="flex items-center justify-between cursor-pointer py-1">
|
|
<span class="flex items-center gap-2 text-slate-300">
|
|
<span class="w-2.5 h-2.5 rounded-full border border-sky-400"></span>
|
|
<span><%= t('admin.gis.el_radius','Radius Sinyal (150m)') %></span>
|
|
</span>
|
|
<input type="checkbox" id="chkLayerRadius" onchange="toggleLayerVisibility('radius')" class="rounded border-slate-700 text-sky-500 focus:ring-0">
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Measurement Tool Toggle -->
|
|
<button
|
|
type="button"
|
|
onclick="toggleRulerTool()"
|
|
id="btnRulerTool"
|
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 text-slate-300 hover:text-white text-xs font-semibold transition"
|
|
title="<%= t('admin.gis.tip_ruler','Ukur Jarak Kabel Fiber di Peta') %>"
|
|
>
|
|
<i class="bi bi-rulers text-sm"></i>
|
|
<span><%= t('admin.gis.ruler','Ukur Jarak') %></span>
|
|
</button>
|
|
|
|
</div>
|
|
|
|
<!-- Right: Mode Switcher & Navigation to <%= t('admin.gis.workflow','Workflow Map') %> -->
|
|
<div class="flex items-center gap-2 flex-wrap">
|
|
|
|
<!-- Recenter Map Button -->
|
|
<button
|
|
type="button"
|
|
onclick="resetMapCenter()"
|
|
class="w-8 h-8 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700/80 text-slate-300 flex items-center justify-center transition"
|
|
title="<%= t('admin.gis.tip_center','Pusatkan Peta ke Jaringan') %>"
|
|
>
|
|
<i class="bi bi-crosshair text-sm"></i>
|
|
</button>
|
|
|
|
<!-- Switch to Visual <%= t('admin.gis.workflow','Workflow Map') %> -->
|
|
<a
|
|
href="/admin/map"
|
|
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-sky-600/20 border border-sky-500/30 hover:bg-sky-600/30 text-sky-300 text-xs font-semibold shadow-sm transition"
|
|
title="<%= t('admin.gis.tip_workflow','Buka Visual Workflow Mapping (n8n Style)') %>"
|
|
>
|
|
<i class="bi bi-diagram-3 text-sm"></i>
|
|
<span><%= t('admin.gis.workflow','Workflow Map') %></span>
|
|
</a>
|
|
|
|
</div>
|
|
</div>
|
|
|
|
<!-- REAL GIS MAP VIEWPORT -->
|
|
<div id="gis-map-viewport" class="flex-1 relative">
|
|
|
|
<!-- FLOATING 3 VISUAL MODES SWITCHER (Top Right) -->
|
|
<div class="absolute top-3 right-3 z-[1000] flex items-center bg-slate-900/90 backdrop-blur-md p-1 rounded-xl border border-slate-700/80 shadow-2xl">
|
|
<button
|
|
type="button"
|
|
id="btnModeVector"
|
|
onclick="setGisVisualMode('vector')"
|
|
class="gis-mode-pill-btn active"
|
|
title="<%= t('admin.gis.tip_dark','Mode Vektor Gelap Berkontras Tinggi') %>"
|
|
>
|
|
Vector Mode
|
|
</button>
|
|
<button
|
|
type="button"
|
|
id="btnModeClean"
|
|
onclick="setGisVisualMode('clean')"
|
|
class="gis-mode-pill-btn"
|
|
title="<%= t('admin.gis.tip_clean','Mode Bersih Jalan & Bangunan Minimalis') %>"
|
|
>
|
|
Clean Mode
|
|
</button>
|
|
<button
|
|
type="button"
|
|
id="btnModeFull"
|
|
onclick="setGisVisualMode('full')"
|
|
class="gis-mode-pill-btn"
|
|
title="<%= t('admin.gis.tip_sat','Mode Satelit Citra Udara Resolusi Tinggi') %>"
|
|
>
|
|
Full Satelit
|
|
</button>
|
|
</div>
|
|
|
|
<!-- FLOATING MEASUREMENT HUD (Top Left, appears when ruler is active) -->
|
|
<div id="rulerHudBox" class="hidden absolute top-3 left-3 z-[1000] bg-slate-900/95 backdrop-blur-md p-3 rounded-xl border border-sky-500/50 shadow-2xl text-xs space-y-1.5 max-w-xs">
|
|
<div class="flex items-center justify-between gap-3">
|
|
<div class="font-bold text-sky-300 flex items-center gap-1.5">
|
|
<i class="bi bi-rulers text-sm"></i>
|
|
<span><%= t('admin.gis.ruler_title','Pengukur Panjang Kabel') %></span>
|
|
</div>
|
|
<button type="button" onclick="clearMeasurement()" class="text-slate-400 hover:text-white text-xs">
|
|
Reset
|
|
</button>
|
|
</div>
|
|
<div class="text-[11px] text-slate-300"><%= t('admin.gis.ruler_desc','Klik titik di peta untuk mengukur rute kabel optik.') %></div>
|
|
<div class="pt-1 flex items-baseline gap-2">
|
|
<span class="text-slate-400"><%= t('admin.gis.total_length','Total Panjang:') %></span>
|
|
<span id="rulerDistanceText" class="font-mono font-bold text-sm text-emerald-400">0 m</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- FLOATING HUD: CABLE & NODE LEGEND (Bottom Right) -->
|
|
<div class="absolute bottom-4 right-4 z-[1000] hidden md:flex items-center gap-3 bg-slate-900/90 backdrop-blur-md px-3.5 py-2 rounded-xl border border-slate-700/80 text-xs shadow-2xl text-slate-300">
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="w-2.5 h-2.5 rounded bg-blue-500"></span>
|
|
<span><%= t('admin.gis.legend_server','Server / OLT') %></span>
|
|
</div>
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="w-2.5 h-2.5 rounded bg-amber-500"></span>
|
|
<span>ODC</span>
|
|
</div>
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="w-2.5 h-2.5 rounded bg-emerald-500"></span>
|
|
<span>ODP</span>
|
|
</div>
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="w-2.5 h-2.5 rounded bg-fuchsia-500"></span>
|
|
<span>HTB P2P</span>
|
|
</div>
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="w-3.5 h-1.5 rounded-full bg-emerald-400"></span>
|
|
<span>Flow</span>
|
|
</div>
|
|
<div class="flex items-center gap-1.5">
|
|
<span class="w-3.5 h-1.5 rounded-full bg-rose-500 shadow-[0_0_6px_#EF4444]"></span>
|
|
<span class="text-rose-300 font-semibold">Offline</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- LEAFLET MAP ELEMENT -->
|
|
<div id="map" class="w-full h-full"></div>
|
|
|
|
</div>
|
|
|
|
</main>
|
|
</div>
|
|
|
|
<!-- Leaflet JS CDN -->
|
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
|
|
|
<!-- ========================================================================= -->
|
|
<!-- CLIENT-SIDE JAVASCRIPT: GIS ENGINE, 3 MODES, ANIMATIONS & RELATIONS -->
|
|
<!-- ========================================================================= -->
|
|
<script>
|
|
// ── INJECTED DATA ────────────────────────────────────────────────────────
|
|
const rawRouters = <%- JSON.stringify(routers || []).split('<').join('\\u003c') %>;
|
|
const rawOlts = <%- JSON.stringify(olts || []).split('<').join('\\u003c') %>;
|
|
const rawOdcs = <%- JSON.stringify(odcs || []).split('<').join('\\u003c') %>;
|
|
const rawOdps = <%- JSON.stringify(odps || []).split('<').join('\\u003c') %>;
|
|
const rawCustomers = <%- JSON.stringify(customers || []).split('<').join('\\u003c') %>;
|
|
|
|
const DEFAULT_MAP_CENTER = [-6.844781, 107.828551];
|
|
const DEFAULT_ZOOM = 15;
|
|
|
|
// ── LEAFLET INSTANCE & 3 VISUAL MODES ────────────────────────────────────
|
|
const map = L.map('map', {
|
|
zoomControl: true,
|
|
attributionControl: false,
|
|
minZoom: 10,
|
|
maxZoom: 20
|
|
}).setView(DEFAULT_MAP_CENTER, DEFAULT_ZOOM);
|
|
|
|
// 1. Vector Mode: CartoDB Dark Matter
|
|
const layerVector = L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/dark_all/{z}/{x}/{y}{r}.png', {
|
|
maxZoom: 20,
|
|
subdomains: 'abcd'
|
|
});
|
|
|
|
// 2. Clean Mode: CartoDB Voyager Clean Minimalist Street
|
|
const layerClean = L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
|
|
maxZoom: 20,
|
|
subdomains: 'abcd'
|
|
});
|
|
|
|
// 3. Full Content Mode: Google Satellite Hybrid (Air Photos + Streets)
|
|
const layerFull = L.tileLayer('https://{s}.google.com/vt/lyrs=y&x={x}&y={y}&z={z}', {
|
|
maxZoom: 20,
|
|
subdomains: ['mt0', 'mt1', 'mt2', 'mt3']
|
|
});
|
|
|
|
let activeVisualMode = localStorage.getItem('gis_visual_mode') || 'vector';
|
|
|
|
function setGisVisualMode(mode) {
|
|
activeVisualMode = mode;
|
|
localStorage.setItem('gis_visual_mode', mode);
|
|
|
|
[layerVector, layerClean, layerFull].forEach(l => {
|
|
if (map.hasLayer(l)) map.removeLayer(l);
|
|
});
|
|
|
|
if (mode === 'clean') {
|
|
layerClean.addTo(map);
|
|
} else if (mode === 'full') {
|
|
layerFull.addTo(map);
|
|
} else {
|
|
layerVector.addTo(map);
|
|
}
|
|
|
|
document.querySelectorAll('.gis-mode-pill-btn').forEach(btn => btn.classList.remove('active'));
|
|
if (mode === 'clean') document.getElementById('btnModeClean')?.classList.add('active');
|
|
else if (mode === 'full') document.getElementById('btnModeFull')?.classList.add('active');
|
|
else document.getElementById('btnModeVector')?.classList.add('active');
|
|
}
|
|
|
|
// Set initial visual mode
|
|
setGisVisualMode(activeVisualMode);
|
|
|
|
// ── LAYER GROUPS ─────────────────────────────────────────────────────────
|
|
const groupServer = L.layerGroup().addTo(map);
|
|
const groupOdc = L.layerGroup().addTo(map);
|
|
const groupOdp = L.layerGroup().addTo(map);
|
|
const groupHtb = L.layerGroup().addTo(map);
|
|
const groupCustomer = L.layerGroup().addTo(map);
|
|
const groupCables = L.layerGroup().addTo(map);
|
|
const groupRadius = L.layerGroup(); // off by default
|
|
|
|
const markerRegistry = {}; // key -> { marker, type, data, latlng }
|
|
const cableRegistry = []; // array of cable polylines
|
|
|
|
// ── POPULATE GEOGRAPHIC NETWORK ──────────────────────────────────────────
|
|
function populateGisNetwork() {
|
|
// Customer statistics per ODP
|
|
const custPerOdp = {};
|
|
const activeCustPerOdp = {};
|
|
rawCustomers.forEach(c => {
|
|
if (c.odp_id) {
|
|
custPerOdp[c.odp_id] = (custPerOdp[c.odp_id] || 0) + 1;
|
|
if (c.status === 'active') {
|
|
activeCustPerOdp[c.odp_id] = (activeCustPerOdp[c.odp_id] || 0) + 1;
|
|
}
|
|
}
|
|
});
|
|
|
|
// 1. ROUTERS / SERVERS (NOC GATEWAY)
|
|
const routerList = rawRouters.length > 0 ? rawRouters : [{ id: 1, name: 'MikroTik Core Gateway', host: '192.168.1.1', is_active: 1 }];
|
|
routerList.forEach(r => {
|
|
const lat = DEFAULT_MAP_CENTER[0];
|
|
const lng = DEFAULT_MAP_CENTER[1];
|
|
|
|
const icon = L.divIcon({
|
|
className: 'gis-pin-wrapper',
|
|
html: `
|
|
<div class="gis-marker-node">
|
|
<div class="gis-marker-icon bg-blue-600 text-white">
|
|
<i class="bi bi-server"></i>
|
|
</div>
|
|
<div class="gis-marker-label">${r.name}</div>
|
|
</div>
|
|
`,
|
|
iconSize: [32, 45],
|
|
iconAnchor: [16, 22]
|
|
});
|
|
|
|
const m = L.marker([lat, lng], { icon: icon }).addTo(groupServer);
|
|
m.bindPopup(buildNodePopupHtml({
|
|
type: 'Server / Core Gateway',
|
|
title: r.name,
|
|
subtitle: `MikroTik Gateway • ${r.host || '192.168.1.1'}`,
|
|
status: r.is_active ? 'online' : 'offline',
|
|
lat: lat,
|
|
lng: lng,
|
|
workflowId: `core-${r.id}`
|
|
}));
|
|
|
|
markerRegistry[`core-${r.id}`] = { marker: m, type: 'core', data: r, latlng: [lat, lng] };
|
|
});
|
|
|
|
// 2. OLT DEVICES
|
|
const oltList = rawOlts.length > 0 ? rawOlts : [{ id: 1, name: 'OLT Utama (EPON/GPON)', host: '192.168.8.100', router_id: 1, is_active: 1 }];
|
|
oltList.forEach(olt => {
|
|
const lat = parseFloat(olt.lat) || (DEFAULT_MAP_CENTER[0] + 0.0003);
|
|
const lng = parseFloat(olt.lng) || (DEFAULT_MAP_CENTER[1] + 0.0003);
|
|
|
|
const icon = L.divIcon({
|
|
className: 'gis-pin-wrapper',
|
|
html: `
|
|
<div class="gis-marker-node">
|
|
<div class="gis-marker-icon bg-cyan-600 text-white">
|
|
<i class="bi bi-display"></i>
|
|
</div>
|
|
<div class="gis-marker-label">${olt.name}</div>
|
|
</div>
|
|
`,
|
|
iconSize: [32, 45],
|
|
iconAnchor: [16, 22]
|
|
});
|
|
|
|
const m = L.marker([lat, lng], { icon: icon }).addTo(groupServer);
|
|
m.bindPopup(buildNodePopupHtml({
|
|
type: 'OLT Hub',
|
|
title: olt.name,
|
|
subtitle: `Optical Line Terminal • ${olt.host || 'GPON Master'}`,
|
|
status: olt.is_active ? 'online' : 'offline',
|
|
lat: lat,
|
|
lng: lng,
|
|
workflowId: `olt-${olt.id}`
|
|
}));
|
|
|
|
markerRegistry[`olt-${olt.id}`] = { marker: m, type: 'olt', data: olt, latlng: [lat, lng] };
|
|
|
|
// Cable Server -> OLT
|
|
const serverKey = `core-${olt.router_id || 1}`;
|
|
if (markerRegistry[serverKey]) {
|
|
drawGisCable(markerRegistry[serverKey].latlng, [lat, lng], {
|
|
type: 'core-olt',
|
|
label: 'Trunk Fiber Core -> OLT',
|
|
color: '#06B6D4',
|
|
status: olt.is_active ? 'online' : 'offline'
|
|
});
|
|
}
|
|
});
|
|
|
|
// 3. ODCs (FEEDER HUBS)
|
|
const odcCoordFallbacks = {
|
|
1: [-6.8455, 107.8282], // ODC Ciherang
|
|
2: [-6.8447, 107.8273], // ODC Pasir SD
|
|
3: [-6.8420, 107.8200], // ODC Pasirbiru
|
|
4: [-6.8390, 107.8170], // ODC WLN
|
|
5: [-6.8465, 107.8290] // ODC Local Server
|
|
};
|
|
|
|
rawOdcs.forEach(odc => {
|
|
const fb = odcCoordFallbacks[odc.id] || DEFAULT_MAP_CENTER;
|
|
const lat = parseFloat(odc.lat) || fb[0];
|
|
const lng = parseFloat(odc.lng) || fb[1];
|
|
|
|
const isCascade = !!odc.parent_odc_id;
|
|
|
|
const icon = L.divIcon({
|
|
className: 'gis-pin-wrapper',
|
|
html: `
|
|
<div class="gis-marker-node">
|
|
<div class="gis-marker-icon bg-amber-500 text-slate-900 font-bold">
|
|
<i class="bi bi-hdd-rack"></i>
|
|
</div>
|
|
<div class="gis-marker-label">${odc.name}</div>
|
|
</div>
|
|
`,
|
|
iconSize: [32, 45],
|
|
iconAnchor: [16, 22]
|
|
});
|
|
|
|
const m = L.marker([lat, lng], { icon: icon }).addTo(groupOdc);
|
|
m.bindPopup(buildNodePopupHtml({
|
|
type: isCascade ? 'ODC Cascade Feeder' : '<%= t('admin.gis.el_odc','ODC Feeder Hub') %>',
|
|
title: odc.name,
|
|
subtitle: `Kapasitas: ${odc.port_capacity || 4} Tray Out • PON ${odc.pon_port || 1}`,
|
|
status: 'online',
|
|
lat: lat,
|
|
lng: lng,
|
|
workflowId: `odc-${odc.id}`
|
|
}));
|
|
|
|
markerRegistry[`odc-${odc.id}`] = { marker: m, type: 'odc', data: odc, latlng: [lat, lng] };
|
|
|
|
// Cable for ODC:
|
|
if (isCascade) {
|
|
const parentKey = `odc-${odc.parent_odc_id}`;
|
|
if (markerRegistry[parentKey]) {
|
|
drawGisCable(markerRegistry[parentKey].latlng, [lat, lng], {
|
|
type: 'odc-cascade',
|
|
label: `Cascade Feeder ODC #${odc.parent_odc_id} -> #${odc.id}`,
|
|
color: '#A855F7',
|
|
status: 'online'
|
|
});
|
|
}
|
|
} else {
|
|
const oltKey = `olt-${odc.olt_id || 1}`;
|
|
if (markerRegistry[oltKey]) {
|
|
drawGisCable(markerRegistry[oltKey].latlng, [lat, lng], {
|
|
type: 'olt-odc',
|
|
label: `Feeder PON ${odc.pon_port || 1} OLT -> ${odc.name}`,
|
|
color: '#F59E0B',
|
|
status: 'online'
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
// 4. ODPs (DISTRIBUTION SPLITTERS) & HTBs (P2P MEDIA CONVERTER)
|
|
rawOdps.forEach(odp => {
|
|
if (!odp.lat || !odp.lng) return;
|
|
const lat = parseFloat(odp.lat);
|
|
const lng = parseFloat(odp.lng);
|
|
|
|
const totalCust = custPerOdp[odp.id] || 0;
|
|
const activeCust = activeCustPerOdp[odp.id] || 0;
|
|
const capacity = Number(odp.port_capacity || (odp.is_htb ? 1 : 16));
|
|
|
|
const isOffline = totalCust > 0 && activeCust === 0;
|
|
const isStandby = totalCust === 0;
|
|
const odpStatus = isOffline ? 'offline' : (isStandby ? 'standby' : 'online');
|
|
|
|
const isHtb = !!odp.is_htb;
|
|
const isCascade = !!odp.parent_odp_id;
|
|
|
|
if (isHtb) {
|
|
// HTB MEDIA CONVERTER (P2P)
|
|
const targetCust = rawCustomers.find(c => Number(c.odp_id) === Number(odp.id));
|
|
const icon = L.divIcon({
|
|
className: 'gis-pin-wrapper',
|
|
html: `
|
|
<div class="gis-marker-node">
|
|
<div class="gis-marker-icon bg-fuchsia-600 text-white ${isOffline ? 'bg-rose-600' : ''}">
|
|
<i class="bi bi-arrow-left-right"></i>
|
|
</div>
|
|
<div class="gis-marker-label">${odp.name}</div>
|
|
</div>
|
|
`,
|
|
iconSize: [32, 45],
|
|
iconAnchor: [16, 22]
|
|
});
|
|
|
|
const m = L.marker([lat, lng], { icon: icon }).addTo(groupHtb);
|
|
m.bindPopup(buildNodePopupHtml({
|
|
type: '<%= t('admin.gis.el_htb','HTB Media Converter') %> (P2P 1:1)',
|
|
title: odp.name,
|
|
subtitle: `P2P Target: ${targetCust?.name || 'Belum Ada'}`,
|
|
status: odpStatus,
|
|
lat: lat,
|
|
lng: lng,
|
|
workflowId: `odp-${odp.id}`
|
|
}));
|
|
|
|
markerRegistry[`odp-${odp.id}`] = { marker: m, type: 'htb', data: odp, latlng: [lat, lng] };
|
|
|
|
// P2P Uplink Cable:
|
|
let sourceKey = null;
|
|
if (odp.parent_customer_id) {
|
|
// Relay from Pelanggan Lama
|
|
const parentCustObj = rawCustomers.find(c => c.id === odp.parent_customer_id);
|
|
if (parentCustObj && parentCustObj.odp_id) {
|
|
sourceKey = `odp-${parentCustObj.odp_id}`;
|
|
}
|
|
} else if (odp.parent_router_id) {
|
|
sourceKey = `core-${odp.parent_router_id}`;
|
|
} else if (odp.odc_id) {
|
|
sourceKey = `odc-${odp.odc_id}`;
|
|
}
|
|
|
|
if (sourceKey && markerRegistry[sourceKey]) {
|
|
drawGisCable(markerRegistry[sourceKey].latlng, [lat, lng], {
|
|
type: 'htb-p2p',
|
|
label: `HTB P2P Link (${odp.name})`,
|
|
color: '#EC4899',
|
|
status: odpStatus
|
|
});
|
|
}
|
|
|
|
} else {
|
|
// STANDARD ODP SPLITTER
|
|
const icon = L.divIcon({
|
|
className: 'gis-pin-wrapper',
|
|
html: `
|
|
<div class="gis-marker-node">
|
|
<div class="gis-marker-icon bg-emerald-600 text-white ${isOffline ? 'bg-rose-600' : (isStandby ? 'bg-slate-700' : '')}">
|
|
<i class="bi bi-box"></i>
|
|
</div>
|
|
<div class="gis-marker-label">${odp.name} (${totalCust}/${capacity})</div>
|
|
</div>
|
|
`,
|
|
iconSize: [32, 45],
|
|
iconAnchor: [16, 22]
|
|
});
|
|
|
|
const m = L.marker([lat, lng], { icon: icon }).addTo(groupOdp);
|
|
m.bindPopup(buildNodePopupHtml({
|
|
type: isCascade ? 'ODP Cascade' : '<%= t('admin.gis.el_odp','ODP Distribusi') %>',
|
|
title: odp.name,
|
|
subtitle: `Port: ${totalCust}/${capacity} (${activeCust} Aktif)`,
|
|
status: odpStatus,
|
|
lat: lat,
|
|
lng: lng,
|
|
workflowId: `odp-${odp.id}`
|
|
}));
|
|
|
|
// Coverage radius circle (150m)
|
|
L.circle([lat, lng], {
|
|
radius: 150,
|
|
color: '#0284C7',
|
|
fillColor: '#38BDF8',
|
|
fillOpacity: 0.08,
|
|
weight: 1
|
|
}).addTo(groupRadius);
|
|
|
|
markerRegistry[`odp-${odp.id}`] = { marker: m, type: 'odp', data: odp, latlng: [lat, lng] };
|
|
|
|
// Cable for ODP:
|
|
if (isCascade) {
|
|
const parentKey = `odp-${odp.parent_odp_id}`;
|
|
if (markerRegistry[parentKey]) {
|
|
drawGisCable(markerRegistry[parentKey].latlng, [lat, lng], {
|
|
type: 'odp-cascade',
|
|
label: `Cascade Drop: ${odp.name}`,
|
|
color: '#38BDF8',
|
|
status: odpStatus
|
|
});
|
|
}
|
|
} else if (odp.odc_id) {
|
|
const odcKey = `odc-${odp.odc_id}`;
|
|
if (markerRegistry[odcKey]) {
|
|
drawGisCable(markerRegistry[odcKey].latlng, [lat, lng], {
|
|
type: 'odc-odp',
|
|
label: `Distribusi: ${odp.name}`,
|
|
color: '#10B981',
|
|
status: odpStatus
|
|
});
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// 5. CUSTOMERS (PELANGGAN)
|
|
rawCustomers.forEach(cust => {
|
|
if (!cust.lat || !cust.lng) return;
|
|
const lat = parseFloat(cust.lat);
|
|
const lng = parseFloat(cust.lng);
|
|
|
|
const isActive = cust.status === 'active';
|
|
const isSuspended = cust.status === 'suspended';
|
|
|
|
let pinColor = '#10B981'; // Green active
|
|
if (!isActive) pinColor = isSuspended ? '#F59E0B' : '#EF4444'; // Orange or Red
|
|
|
|
const icon = L.divIcon({
|
|
className: 'gis-pin-wrapper',
|
|
html: `<div class="gis-cust-pin" style="background-color: ${pinColor};" title="${cust.name} (${cust.status})"></div>`,
|
|
iconSize: [14, 14],
|
|
iconAnchor: [7, 7]
|
|
});
|
|
|
|
const m = L.marker([lat, lng], { icon: icon }).addTo(groupCustomer);
|
|
m.bindPopup(buildCustomerPopupHtml(cust));
|
|
|
|
markerRegistry[`cust-${cust.id}`] = { marker: m, type: 'customer', data: cust, latlng: [lat, lng] };
|
|
|
|
// Cable ODP -> Customer Dropwire
|
|
if (cust.odp_id) {
|
|
const odpKey = `odp-${cust.odp_id}`;
|
|
if (markerRegistry[odpKey]) {
|
|
L.polyline([markerRegistry[odpKey].latlng, [lat, lng]], {
|
|
color: isActive ? '#34D399' : '#EF4444',
|
|
weight: 1.5,
|
|
opacity: 0.65,
|
|
dashArray: isActive ? '3, 4' : 'none'
|
|
}).addTo(groupCables);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── DRAW GIS CABLE: DUAL-LAYER WITH STATUS (OFFLINE = STATIS MERAH) ───────
|
|
function drawGisCable(coord1, coord2, opts) {
|
|
const isOffline = opts.status === 'offline';
|
|
const isStandby = opts.status === 'standby';
|
|
|
|
if (isOffline) {
|
|
// OFFLINE: STATIS MERAH TEBAL
|
|
const line = L.polyline([coord1, coord2], {
|
|
color: '#EF4444',
|
|
weight: 3.5,
|
|
opacity: 0.95,
|
|
className: 'gis-cable-offline'
|
|
}).addTo(groupCables);
|
|
|
|
line.bindTooltip(`${opts.label} • OFFLINE (Statis Merah)`, { sticky: true });
|
|
cableRegistry.push(line);
|
|
|
|
} else if (isStandby) {
|
|
// STANDBY: GRAY DASHED
|
|
const line = L.polyline([coord1, coord2], {
|
|
color: '#64748B',
|
|
weight: 2,
|
|
opacity: 0.6,
|
|
className: 'gis-cable-standby'
|
|
}).addTo(groupCables);
|
|
|
|
line.bindTooltip(`${opts.label} • Standby (Kosong)`, { sticky: true });
|
|
cableRegistry.push(line);
|
|
|
|
} else {
|
|
// ONLINE: GLOW BASE + LASER FLOW PATH
|
|
const glowLine = L.polyline([coord1, coord2], {
|
|
color: opts.color || '#38BDF8',
|
|
weight: 5,
|
|
opacity: 0.35
|
|
}).addTo(groupCables);
|
|
|
|
const flowLine = L.polyline([coord1, coord2], {
|
|
color: opts.color || '#38BDF8',
|
|
weight: 2.5,
|
|
opacity: 0.95,
|
|
className: 'gis-cable-flow'
|
|
}).addTo(groupCables);
|
|
|
|
flowLine.bindTooltip(`${opts.label} • Online Aktif`, { sticky: true });
|
|
cableRegistry.push(glowLine);
|
|
cableRegistry.push(flowLine);
|
|
}
|
|
}
|
|
|
|
// ── POPUP BUILDERS ───────────────────────────────────────────────────────
|
|
function buildNodePopupHtml(d) {
|
|
const isOffline = d.status === 'offline';
|
|
return `
|
|
<div class="p-3.5 space-y-2.5 text-xs font-sans max-w-xs">
|
|
<div class="flex items-center justify-between gap-2 border-b border-slate-700/80 pb-2">
|
|
<div>
|
|
<div class="text-[10px] font-bold text-slate-400 uppercase tracking-wider">${d.type}</div>
|
|
<div class="font-bold text-white text-sm">${d.title}</div>
|
|
</div>
|
|
<span class="px-2 py-0.5 rounded text-[10px] font-bold uppercase ${isOffline ? 'bg-rose-500/20 text-rose-300 border border-rose-500/30' : 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/30'}">
|
|
${d.status || 'Active'}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="text-[11px] text-slate-300">${d.subtitle}</div>
|
|
|
|
<div class="bg-slate-950/70 p-2 rounded-lg border border-slate-800 text-[10px] font-mono text-slate-400 flex items-center justify-between">
|
|
<span><%= t('admin.gis.coord','Koordinat:') %></span>
|
|
<span class="text-white">${d.lat ? d.lat.toFixed(6) : '-'}, ${d.lng ? d.lng.toFixed(6) : '-'}</span>
|
|
</div>
|
|
|
|
<div class="pt-1 flex items-center justify-between gap-2">
|
|
<a
|
|
href="https://www.google.com/maps?q=${d.lat},${d.lng}"
|
|
target="_blank"
|
|
class="text-sky-400 hover:text-sky-300 font-semibold flex items-center gap-1 text-[11px]"
|
|
>
|
|
<i class="bi bi-geo-alt text-xs"></i>
|
|
<span>Google Maps</span>
|
|
</a>
|
|
<a
|
|
href="/admin/map?focus=${d.workflowId}"
|
|
class="px-2.5 py-1 rounded bg-sky-600 hover:bg-sky-500 text-white font-semibold text-[10px] shadow transition"
|
|
>
|
|
Workflow Node
|
|
</a>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
function buildCustomerPopupHtml(c) {
|
|
return `
|
|
<div class="p-3.5 space-y-2 text-xs font-sans max-w-xs">
|
|
<div class="flex items-center justify-between gap-2 border-b border-slate-700/80 pb-2">
|
|
<div>
|
|
<div class="text-[10px] font-bold text-slate-400 uppercase tracking-wider"><%= t('common.customer','Pelanggan') %></div>
|
|
<div class="font-bold text-white text-sm">${c.name}</div>
|
|
</div>
|
|
<span class="px-2 py-0.5 rounded text-[10px] font-bold uppercase ${c.status === 'active' ? 'bg-emerald-500/20 text-emerald-300' : 'bg-rose-500/20 text-rose-300'}">
|
|
${c.status}
|
|
</span>
|
|
</div>
|
|
|
|
<div class="space-y-1 text-[11px] text-slate-300">
|
|
<div><span class="text-slate-400">PPPoE User:</span> <span class="font-mono text-white">${c.pppoe_username || '-'}</span></div>
|
|
<div><span class="text-slate-400">Paket:</span> ${c.package_id || 'Standar'}</div>
|
|
<div><span class="text-slate-400">Alamat:</span> ${c.address || '-'}</div>
|
|
</div>
|
|
|
|
<div class="pt-1 flex items-center justify-between border-t border-slate-800 text-[10px]">
|
|
<a href="/admin/customers?search=${encodeURIComponent(c.name)}" class="text-sky-400 hover:text-sky-300 font-semibold">
|
|
Detail Pelanggan
|
|
</a>
|
|
<a href="https://www.google.com/maps?q=${c.lat},${c.lng}" target="_blank" class="text-emerald-400 hover:text-emerald-300 font-semibold">
|
|
Navigasi GPS
|
|
</a>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// ── LAYER TOGGLE HANDLER ─────────────────────────────────────────────────
|
|
function toggleLayerMenu() {
|
|
document.getElementById('gisLayersMenu').classList.toggle('hidden');
|
|
}
|
|
|
|
// Close layer menu on outside click
|
|
document.addEventListener('click', (e) => {
|
|
if (!e.target.closest('#btnToggleLayers') && !e.target.closest('#gisLayersMenu')) {
|
|
document.getElementById('gisLayersMenu')?.classList.add('hidden');
|
|
}
|
|
});
|
|
|
|
function toggleLayerVisibility(layerKey) {
|
|
if (layerKey === 'server') {
|
|
const chk = document.getElementById('chkLayerServer').checked;
|
|
chk ? groupServer.addTo(map) : map.removeLayer(groupServer);
|
|
} else if (layerKey === 'odc') {
|
|
const chk = document.getElementById('chkLayerOdc').checked;
|
|
chk ? groupOdc.addTo(map) : map.removeLayer(groupOdc);
|
|
} else if (layerKey === 'odp') {
|
|
const chk = document.getElementById('chkLayerOdp').checked;
|
|
chk ? groupOdp.addTo(map) : map.removeLayer(groupOdp);
|
|
} else if (layerKey === 'htb') {
|
|
const chk = document.getElementById('chkLayerHtb').checked;
|
|
chk ? groupHtb.addTo(map) : map.removeLayer(groupHtb);
|
|
} else if (layerKey === 'customer') {
|
|
const chk = document.getElementById('chkLayerCust').checked;
|
|
chk ? groupCustomer.addTo(map) : map.removeLayer(groupCustomer);
|
|
} else if (layerKey === 'cables') {
|
|
const chk = document.getElementById('chkLayerCables').checked;
|
|
chk ? groupCables.addTo(map) : map.removeLayer(groupCables);
|
|
} else if (layerKey === 'radius') {
|
|
const chk = document.getElementById('chkLayerRadius').checked;
|
|
chk ? groupRadius.addTo(map) : map.removeLayer(groupRadius);
|
|
}
|
|
}
|
|
|
|
// ── SEARCH & ZOOM TO TARGET ──────────────────────────────────────────────
|
|
function handleGisSearch(val) {
|
|
const q = val.toLowerCase().trim();
|
|
const dropdown = document.getElementById('gisSearchDropdown');
|
|
const clearBtn = document.getElementById('clearGisSearchBtn');
|
|
clearBtn.classList.toggle('hidden', q.length === 0);
|
|
|
|
if (q.length < 2) {
|
|
dropdown.classList.add('hidden');
|
|
dropdown.innerHTML = '';
|
|
return;
|
|
}
|
|
|
|
const results = [];
|
|
Object.keys(markerRegistry).forEach(key => {
|
|
const item = markerRegistry[key];
|
|
const name = (item.data.name || item.data.title || '').toLowerCase();
|
|
const extra = (item.data.pppoe_username || item.data.host || item.data.description || '').toLowerCase();
|
|
if (name.includes(q) || extra.includes(q)) {
|
|
results.push(item);
|
|
}
|
|
});
|
|
|
|
if (results.length === 0) {
|
|
dropdown.innerHTML = `<div class="p-3 text-center text-slate-500 italic">Tidak ditemukan titik "${val}"</div>`;
|
|
dropdown.classList.remove('hidden');
|
|
return;
|
|
}
|
|
|
|
dropdown.innerHTML = '';
|
|
results.slice(0, 8).forEach(res => {
|
|
const div = document.createElement('div');
|
|
div.className = 'p-2.5 hover:bg-slate-800/80 cursor-pointer flex items-center justify-between transition';
|
|
div.innerHTML = `
|
|
<div>
|
|
<div class="font-bold text-white">${res.data.name || res.data.title}</div>
|
|
<div class="text-[10px] text-slate-400 capitalize">${res.type} • ${res.data.host || res.data.pppoe_username || ''}</div>
|
|
</div>
|
|
<i class="bi bi-arrow-right text-slate-500 text-xs"></i>
|
|
`;
|
|
div.onclick = () => {
|
|
dropdown.classList.add('hidden');
|
|
map.flyTo(res.latlng, 18, { animate: true, duration: 1.2 });
|
|
setTimeout(() => { res.marker.openPopup(); }, 1300);
|
|
};
|
|
dropdown.appendChild(div);
|
|
});
|
|
|
|
dropdown.classList.remove('hidden');
|
|
}
|
|
|
|
function clearGisSearch() {
|
|
const inp = document.getElementById('gisSearchInp');
|
|
inp.value = '';
|
|
document.getElementById('gisSearchDropdown').classList.add('hidden');
|
|
document.getElementById('clearGisSearchBtn').classList.add('hidden');
|
|
}
|
|
|
|
function resetMapCenter() {
|
|
map.flyTo(DEFAULT_MAP_CENTER, DEFAULT_ZOOM, { animate: true, duration: 1.0 });
|
|
}
|
|
|
|
// ── MEASUREMENT RULER TOOL ───────────────────────────────────────────────
|
|
let isRulerActive = false;
|
|
let rulerPoints = [];
|
|
let rulerLine = null;
|
|
let rulerMarkers = [];
|
|
|
|
function toggleRulerTool() {
|
|
isRulerActive = !isRulerActive;
|
|
const btn = document.getElementById('btnRulerTool');
|
|
const hud = document.getElementById('rulerHudBox');
|
|
|
|
if (isRulerActive) {
|
|
btn.className = 'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-sky-600 text-white border border-sky-500 text-xs font-semibold shadow-md transition';
|
|
hud.classList.remove('hidden');
|
|
map.getContainer().style.cursor = 'crosshair';
|
|
clearMeasurement();
|
|
} else {
|
|
btn.className = 'inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-slate-800 hover:bg-slate-700 border border-slate-700 text-slate-300 hover:text-white text-xs font-semibold transition';
|
|
hud.classList.add('hidden');
|
|
map.getContainer().style.cursor = '';
|
|
clearMeasurement();
|
|
}
|
|
}
|
|
|
|
map.on('click', function(e) {
|
|
if (!isRulerActive) return;
|
|
|
|
rulerPoints.push(e.latlng);
|
|
|
|
const m = L.circleMarker(e.latlng, {
|
|
radius: 5,
|
|
color: '#38BDF8',
|
|
fillColor: '#FFFFFF',
|
|
fillOpacity: 1
|
|
}).addTo(map);
|
|
rulerMarkers.push(m);
|
|
|
|
if (rulerPoints.length > 1) {
|
|
if (rulerLine) map.removeLayer(rulerLine);
|
|
rulerLine = L.polyline(rulerPoints, {
|
|
color: '#38BDF8',
|
|
weight: 3,
|
|
dashArray: '6, 6'
|
|
}).addTo(map);
|
|
|
|
// Compute total distance
|
|
let totalMeters = 0;
|
|
for (let i = 0; i < rulerPoints.length - 1; i++) {
|
|
totalMeters += rulerPoints[i].distanceTo(rulerPoints[i + 1]);
|
|
}
|
|
|
|
const distText = totalMeters >= 1000
|
|
? `${(totalMeters / 1000).toFixed(2)} km`
|
|
: `${Math.round(totalMeters)} meter`;
|
|
document.getElementById('rulerDistanceText').textContent = distText;
|
|
}
|
|
});
|
|
|
|
function clearMeasurement() {
|
|
rulerPoints = [];
|
|
if (rulerLine) {
|
|
map.removeLayer(rulerLine);
|
|
rulerLine = null;
|
|
}
|
|
rulerMarkers.forEach(m => map.removeLayer(m));
|
|
rulerMarkers = [];
|
|
document.getElementById('rulerDistanceText').textContent = '0 m';
|
|
}
|
|
|
|
// ── INITIALIZE ON LOAD ───────────────────────────────────────────────────
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
populateGisNetwork();
|
|
|
|
// Check query params (e.g. ?focus=odp&id=2)
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const focusType = urlParams.get('focus');
|
|
const focusId = urlParams.get('id');
|
|
|
|
if (focusType && focusId) {
|
|
const key = `${focusType}-${focusId}`;
|
|
if (markerRegistry[key]) {
|
|
setTimeout(() => {
|
|
map.flyTo(markerRegistry[key].latlng, 18, { animate: true, duration: 1.2 });
|
|
setTimeout(() => { markerRegistry[key].marker.openPopup(); }, 1300);
|
|
}, 300);
|
|
}
|
|
}
|
|
});
|
|
</script>
|
|
|
|
</body>
|
|
</html>
|