diff --git a/src/pages/dashboards/home/blocks/SitesateliteMap.tsx b/src/pages/dashboards/home/blocks/SitesateliteMap.tsx index 0d74ed0..2ab516f 100644 --- a/src/pages/dashboards/home/blocks/SitesateliteMap.tsx +++ b/src/pages/dashboards/home/blocks/SitesateliteMap.tsx @@ -1,26 +1,52 @@ import { useEffect, useRef, useState, useMemo } from 'react'; import L from 'leaflet'; import 'leaflet/dist/leaflet.css'; -import { sitePoints, SitePoint } from '../sitepoints'; +import { sitePoints, SitePoint } from '../sitePoints'; -// --------------------------------------------------------------------------- -// Satellite map (Leaflet + Esri World Imagery, no API key required) with a -// pin for every Cell/Sector row from the uploaded Excel -// (Sites_Coordinates_untuk_CC.xlsm -> "site coordinates 4G"). -// -// No clustering — every site is shown as its own blue pin, directly at its -// real coordinates (previously multi-sector sites used a marker cluster -// plugin, which rendered green/yellow/orange group bubbles; that's removed). -// -// SETUP REQUIRED: -// npm install leaflet -// npm install --save-dev @types/leaflet -// --------------------------------------------------------------------------- +// --- TAMBAHAN DATA MOCK POLYGON UNTUK AREA KLASTER --- +// Dilengkapi data placeholder untuk kebutuhan Dialog Popup Klaster +const CLUSTER_AREAS = [ + { + id: 'area-surabaya-pusat', + name: 'Tunjungan & Pusat', + color: '#be123c', // Warna merah muda transparan seperti di screenshot + regionalName: 'JATIM', + siteId: 'SBY095', + neId: 'SBY095ML', + siteName: 'N_SBY095ML_WESTIN', + bscrncName: '-', + lacCi: '232095 - 11', + node: '4G', + coords: [ + [-8.5550, 125.5650], + [-8.5550, 125.5800], + [-8.5650, 125.5850], + [-8.5680, 125.5700], + ] as [number, number][] + }, + { + id: 'area-surabaya-timur', + name: 'Gubeng & Manyar', + color: '#be123c', + regionalName: 'JATIM', + siteId: 'SBY102', + neId: 'SBY102ML', + siteName: 'N_SBY102ML_GUBENG', + bscrncName: '-', + lacCi: '232102 - 12', + node: '4G', + coords: [ + [-8.5500, 125.5850], + [-8.5480, 125.5950], + [-8.5580, 125.5980], + [-8.5600, 125.5880], + ] as [number, number][] + } +]; const DILI_CENTER: [number, number] = [-8.5586, 125.5736]; +const ZOOM_THRESHOLD = 14; // Batas tingkat zoom untuk menyembunyikan/menampilkan pin biru -// Groups points that share the exact same coordinates so a single pin can -// list every cell/sector at that physical site. const groupByCoordinate = (points: SitePoint[]) => { const map = new Map(); points.forEach((p) => { @@ -31,26 +57,13 @@ const groupByCoordinate = (points: SitePoint[]) => { return map; }; -// Signal-tower badge icon: blue circle background + white "broadcast tower" -// glyph. Same size/style for every site pin. const pinIcon = () => { const size = 28; const bg = '#2563eb'; - return L.divIcon({ className: '', html: ` -
+
@@ -64,36 +77,77 @@ const pinIcon = () => { }); }; -// --------------------------------------------------------------------------- -// "Signal detail" popup — tabbed Signal / RAN 2G / RAN 3G / Alarm dialog, -// shown for every pin. -// --------------------------------------------------------------------------- +// --- TEMPLATE TAB DIALOG UNTUK AREA KLASTER (SESUAI SCREENSHOT) --- +const buildClusterPopupHtml = (area: typeof CLUSTER_AREAS[0]) => { + return ` +
+
+
GENERAL
+
REVENUE
+
NETWORK
+
+
+ + + + + + + + +
Regional Name${area.regionalName}
Site ID${area.siteId}
NeId${area.neId}
Site Name${area.siteName}
BSCRNC Name${area.bscrncName}
LAC - CI${area.lacCi}
Node${area.node}
+
+ + + + +
+ `; +}; + +// Wires up tab switching untuk Popup Area Klaster +const wireClusterPopup = (container: HTMLElement) => { + const tabs = container.querySelectorAll('.cluster-tab'); + const contents = container.querySelectorAll('.cluster-content'); + + tabs.forEach((btn) => { + btn.addEventListener('click', () => { + const target = btn.dataset.clusterTab; + tabs.forEach((t) => { + if (t === btn) { + t.style.color = '#2563eb'; + t.style.borderBottom = '2px solid #2563eb'; + } else { + t.style.color = '#64748b'; + t.style.borderBottom = 'none'; + } + }); + contents.forEach((c) => { + c.style.display = c.dataset.clusterContent === target ? 'block' : 'none'; + }); + }); + }); +}; + +// --- POPUP PIN BIRU INDIVIDUAL (Bawaan Sebelumnya) --- const QUALITY_LEVELS = ['Low', 'Moderate', 'Good', 'Very Good', 'Excellent']; - -// Deterministic PLACEHOLDER quality derived from the site name — replace -// with a real lookup (RSRP/RSSI, KPI feed, etc.) once that data exists. const placeholderQuality = (seed: string, offset: number) => { let hash = 0; for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) >>> 0; const level = (hash + offset) % QUALITY_LEVELS.length; return { level: level + 1, label: QUALITY_LEVELS[level] }; }; - const signalBarsHtml = (level: number) => { const heights = [5, 9, 13, 17]; return `
- ${heights - .map( - (h, i) => - `
` - ) - .join('')} + ${heights.map((h, i) => `
`).join('')}
`; }; - const signalCardHtml = (title: string, desc: string, seed: string, offset: number) => { const q = placeholderQuality(seed, offset); return ` @@ -104,24 +158,14 @@ const signalCardHtml = (title: string, desc: string, seed: string, offset: numbe
${desc}
`; }; - const cellListHtml = (points: SitePoint[]) => { - if (points.length === 0) { - return `
No cells in this band.
`; - } - const rows = points - .map((p) => `${p.cell}${p.sector}`) - .join(''); + if (points.length === 0) return `
No cells in this band.
`; + const rows = points.map((p) => `${p.cell}${p.sector}`).join(''); return `${rows}
`; }; - const buildSignalPopupHtml = (points: SitePoint[], lat: number, lng: number) => { const site = points[0].site; const location = points[0].location; - - // Placeholder split: cells ending in "E" shown under RAN 2G, cells ending - // in "F" under RAN 3G. The source data doesn't label technology - // explicitly — adjust this rule once the real mapping is known. const ran2g = points.filter((p) => p.cell.trim().toUpperCase().endsWith('E')); const ran3g = points.filter((p) => p.cell.trim().toUpperCase().endsWith('F')); @@ -131,81 +175,29 @@ const buildSignalPopupHtml = (points: SitePoint[], lat: number, lng: number) =>
${site}
${location} · ${lat.toFixed(5)}, ${lng.toFixed(5)}
-
- - - - + + + +
-
- ${signalCardHtml('3G', 'Browsing, email, video call', site, 0)} - ${signalCardHtml('Mobile BB', 'Laptop browsing & downloads', site, 1)} + ${signalCardHtml('4G', 'Browsing, email, video call', site, 0)} + ${signalCardHtml('3G', 'Laptop browsing & downloads', site, 1)} ${signalCardHtml('2G', 'Calling and texting', site, 2)}
-
- - -
-
- - - - - - `; }; -const tabBtnStyle = (active: boolean) => - `flex:1;padding:5px 0;font-size:11px;font-weight:600;border-radius:6px;border:none;cursor:pointer;` + - (active ? 'background:#2563eb;color:#ffffff;' : 'background:#f1f5f9;color:#475569;'); - -const actionBtnStyle = (bg: string, color: string) => - `flex:1;padding:6px 0;font-size:12px;font-weight:600;border-radius:6px;cursor:pointer;` + - `background:${bg};color:${color};border:1px solid #2563eb;`; - -// Wires up tab-switching and Suggest/Complaint clicks for a just-opened -// signal popup. Leaflet popups are plain HTML, so this is done imperatively -// rather than through React state. -const wireSignalPopup = (container: HTMLElement, site: string) => { - const tabs = container.querySelectorAll('.signal-tab'); - const contents = container.querySelectorAll('.signal-content'); - - tabs.forEach((btn) => { - btn.addEventListener('click', () => { - const target = btn.dataset.tab; - tabs.forEach((b) => { - const isActive = b === btn; - b.setAttribute('style', tabBtnStyle(isActive)); - }); - contents.forEach((c) => { - c.style.display = c.dataset.content === target ? 'block' : 'none'; - }); - }); - }); - - container.querySelectorAll('[data-action]').forEach((btn) => { - btn.addEventListener('click', () => { - // TODO: wire these up to real endpoints once available. - console.log(`[SitesSatelliteMap] "${btn.dataset.action}" clicked for site "${site}"`); - }); - }); -}; - const SitesSatelliteMap = () => { const mapContainerRef = useRef(null); const mapRef = useRef(null); + + const markerLayerGroupRef = useRef(L.layerGroup()); + const polygonLayerGroupRef = useRef(L.layerGroup()); + const markerByKeyRef = useRef>(new Map()); const [search, setSearch] = useState(''); const [selectedCount, setSelectedCount] = useState(sitePoints.length); @@ -222,43 +214,72 @@ const SitesSatelliteMap = () => { }); mapRef.current = map; - // Esri World Imagery — free satellite tiles, no API key required. - L.tileLayer( - 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', - { - attribution: - 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community', - maxZoom: 19 - } - ).addTo(map); + L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}', { + attribution: 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics', + maxZoom: 19 + }).addTo(map); - // Optional labels/roads overlay on top of the satellite imagery. - L.tileLayer( - 'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}', - { maxZoom: 19, opacity: 0.9 } - ).addTo(map); + L.tileLayer('https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}', { + maxZoom: 19, + opacity: 0.9 + }).addTo(map); - // Every site gets its own blue pin placed directly on the map — no - // clustering, no group bubbles. + // Keduanya langsung dimasukkan secara permanen ke peta utama + markerLayerGroupRef.current.addTo(map); + polygonLayerGroupRef.current.addTo(map); + + // 1. Render Seluruh Marker Pin Biru grouped.forEach((points, key) => { const [lat, lng] = key.split(',').map(Number); - const marker = L.marker([lat, lng], { icon: pinIcon() }).addTo(map); + const marker = L.marker([lat, lng], { icon: pinIcon() }); marker.bindPopup(buildSignalPopupHtml(points, lat, lng), { maxWidth: 320, minWidth: 290 }); + marker.addTo(markerLayerGroupRef.current); markerByKeyRef.current.set(key, marker); }); - // Wire up the rich popup's interactive bits (tabs + buttons) every time - // one is opened, since Leaflet re-renders the DOM content on each open. + // 2. Render Batas Klaster / Polygon (Selalu Menetap di Peta) + CLUSTER_AREAS.forEach((area) => { + const polygon = L.polygon(area.coords, { + color: '#b91c1c', // Warna merah tua garis batas luar + weight: 2, + fillColor: area.color, // Warna kemerahan transparan sesuai request gambar + fillOpacity: 0.25 + }); + + // Bind dialog tab analisis ke area klaster ketika diklik + polygon.bindPopup(buildClusterPopupHtml(area), { maxWidth: 360, minWidth: 310 }); + polygon.addTo(polygonLayerGroupRef.current); + }); + + // Logika pengontrol visibilitas Pin Biru berdasarkan tingkatan Zoom + const handleZoomVisibility = () => { + const currentZoom = map.getZoom(); + if (currentZoom >= ZOOM_THRESHOLD) { + // Mode Zoom In: Tampilkan Pin Biru Individual di atas Polygon + if (!map.hasLayer(markerLayerGroupRef.current)) map.addLayer(markerLayerGroupRef.current); + } else { + // Mode Zoom Out: Sembunyikan Pin Biru, biarkan Area Klaster saja + if (map.hasLayer(markerLayerGroupRef.current)) map.removeLayer(markerLayerGroupRef.current); + } + }; + + handleZoomVisibility(); + map.on('zoomend', handleZoomVisibility); + + // Delegasi inisialisasi Event Listener interaktif Tab saat popup dibuka const onPopupOpen = (e: L.PopupEvent) => { const el = e.popup.getElement(); - const content = el?.querySelector('.signal-popup'); - if (!content) return; - const siteName = content.querySelector('div')?.textContent ?? ''; - wireSignalPopup(content, siteName); + + // Kasus A: Jika yang dibuka adalah Dialog Klaster Area + const clusterContent = el?.querySelector('.cluster-popup'); + if (clusterContent) { + wireClusterPopup(clusterContent); + } }; map.on('popupopen', onPopupOpen); return () => { + map.off('zoomend', handleZoomVisibility); map.off('popupopen', onPopupOpen); map.remove(); mapRef.current = null; @@ -266,8 +287,6 @@ const SitesSatelliteMap = () => { }; }, [grouped]); - // Simple search: pans/zooms to a matching cell, sector, or site name and - // opens its popup. const handleSearch = (value: string) => { setSearch(value); if (!value) { @@ -276,17 +295,14 @@ const SitesSatelliteMap = () => { } const q = value.toLowerCase(); const matches = sitePoints.filter( - (p) => - p.cell.toLowerCase().includes(q) || - p.sector.toLowerCase().includes(q) || - p.site.toLowerCase().includes(q) + (p) => p.cell.toLowerCase().includes(q) || p.sector.toLowerCase().includes(q) || p.site.toLowerCase().includes(q) ); setSelectedCount(matches.length); if (matches.length > 0 && mapRef.current) { const first = matches[0]; const key = `${first.lat.toFixed(6)},${first.lng.toFixed(6)}`; - mapRef.current.setView([first.lat, first.lng], 17); + mapRef.current.setView([first.lat, first.lng], ZOOM_THRESHOLD + 2); const marker = markerByKeyRef.current.get(key); if (marker) marker.openPopup(); } @@ -296,8 +312,7 @@ const SitesSatelliteMap = () => {
- Showing {selectedCount} from {sitePoints.length} points - (Cell/Sector) + Showing {selectedCount} from {sitePoints.length} points (Cell/Sector)