update revenue dashboard
This commit is contained in:
@ -1,26 +1,52 @@
|
|||||||
import { useEffect, useRef, useState, useMemo } from 'react';
|
import { useEffect, useRef, useState, useMemo } from 'react';
|
||||||
import L from 'leaflet';
|
import L from 'leaflet';
|
||||||
import 'leaflet/dist/leaflet.css';
|
import 'leaflet/dist/leaflet.css';
|
||||||
import { sitePoints, SitePoint } from '../sitepoints';
|
import { sitePoints, SitePoint } from '../sitePoints';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// --- TAMBAHAN DATA MOCK POLYGON UNTUK AREA KLASTER ---
|
||||||
// Satellite map (Leaflet + Esri World Imagery, no API key required) with a
|
// Dilengkapi data placeholder untuk kebutuhan Dialog Popup Klaster
|
||||||
// pin for every Cell/Sector row from the uploaded Excel
|
const CLUSTER_AREAS = [
|
||||||
// (Sites_Coordinates_untuk_CC.xlsm -> "site coordinates 4G").
|
{
|
||||||
//
|
id: 'area-surabaya-pusat',
|
||||||
// No clustering — every site is shown as its own blue pin, directly at its
|
name: 'Tunjungan & Pusat',
|
||||||
// real coordinates (previously multi-sector sites used a marker cluster
|
color: '#be123c', // Warna merah muda transparan seperti di screenshot
|
||||||
// plugin, which rendered green/yellow/orange group bubbles; that's removed).
|
regionalName: 'JATIM',
|
||||||
//
|
siteId: 'SBY095',
|
||||||
// SETUP REQUIRED:
|
neId: 'SBY095ML',
|
||||||
// npm install leaflet
|
siteName: 'N_SBY095ML_WESTIN',
|
||||||
// npm install --save-dev @types/leaflet
|
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 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 groupByCoordinate = (points: SitePoint[]) => {
|
||||||
const map = new Map<string, SitePoint[]>();
|
const map = new Map<string, SitePoint[]>();
|
||||||
points.forEach((p) => {
|
points.forEach((p) => {
|
||||||
@ -31,26 +57,13 @@ const groupByCoordinate = (points: SitePoint[]) => {
|
|||||||
return map;
|
return map;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Signal-tower badge icon: blue circle background + white "broadcast tower"
|
|
||||||
// glyph. Same size/style for every site pin.
|
|
||||||
const pinIcon = () => {
|
const pinIcon = () => {
|
||||||
const size = 28;
|
const size = 28;
|
||||||
const bg = '#2563eb';
|
const bg = '#2563eb';
|
||||||
|
|
||||||
return L.divIcon({
|
return L.divIcon({
|
||||||
className: '',
|
className: '',
|
||||||
html: `
|
html: `
|
||||||
<div style="
|
<div style="width:${size}px;height:${size}px;border-radius:50%;background:${bg};border:2px solid white;box-shadow:0 1px 3px rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;">
|
||||||
width:${size}px;
|
|
||||||
height:${size}px;
|
|
||||||
border-radius:50%;
|
|
||||||
background:${bg};
|
|
||||||
border:2px solid white;
|
|
||||||
box-shadow:0 1px 3px rgba(0,0,0,0.5);
|
|
||||||
display:flex;
|
|
||||||
align-items:center;
|
|
||||||
justify-content:center;
|
|
||||||
">
|
|
||||||
<svg width="${size * 0.62}" height="${size * 0.62}" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="${size * 0.62}" height="${size * 0.62}" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
<line x1="12" y1="21" x2="12" y2="10"></line>
|
<line x1="12" y1="21" x2="12" y2="10"></line>
|
||||||
<circle cx="12" cy="7" r="2" fill="white" stroke="none"></circle>
|
<circle cx="12" cy="7" r="2" fill="white" stroke="none"></circle>
|
||||||
@ -64,36 +77,77 @@ const pinIcon = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// --- TEMPLATE TAB DIALOG UNTUK AREA KLASTER (SESUAI SCREENSHOT) ---
|
||||||
// "Signal detail" popup — tabbed Signal / RAN 2G / RAN 3G / Alarm dialog,
|
const buildClusterPopupHtml = (area: typeof CLUSTER_AREAS[0]) => {
|
||||||
// shown for every pin.
|
return `
|
||||||
// ---------------------------------------------------------------------------
|
<div class="cluster-popup" style="width:320px; font-family: Arial, sans-serif; color: #333;">
|
||||||
|
<div style="display:flex; border-bottom: 2px solid #e5e7eb; margin-bottom: 12px; font-size: 12px; font-weight: bold;">
|
||||||
|
<div data-cluster-tab="general" class="cluster-tab active" style="padding: 6px 12px; cursor: pointer; color: #2563eb; border-bottom: 2px solid #2563eb; margin-bottom: -2px;">GENERAL</div>
|
||||||
|
<div data-cluster-tab="revenue" class="cluster-tab" style="padding: 6px 12px; cursor: pointer; color: #64748b;">REVENUE</div>
|
||||||
|
<div data-cluster-tab="network" class="cluster-tab" style="padding: 6px 12px; cursor: pointer; color: #64748b;">NETWORK</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-cluster-content="general" class="cluster-content" style="display: block;">
|
||||||
|
<table style="width: 100%; border-collapse: collapse; font-size: 12px;">
|
||||||
|
<tr style="background: #f8fafc;"><td style="padding: 6px 8px; font-weight: 600; color: #64748b;">Regional Name</td><td style="padding: 6px 8px;">${area.regionalName}</td></tr>
|
||||||
|
<tr><td style="padding: 6px 8px; font-weight: 600; color: #64748b;">Site ID</td><td style="padding: 6px 8px;">${area.siteId}</td></tr>
|
||||||
|
<tr style="background: #f8fafc;"><td style="padding: 6px 8px; font-weight: 600; color: #64748b;">NeId</td><td style="padding: 6px 8px;">${area.neId}</td></tr>
|
||||||
|
<tr><td style="padding: 6px 8px; font-weight: 600; color: #64748b;">Site Name</td><td style="padding: 6px 8px; font-weight: 600;">${area.siteName}</td></tr>
|
||||||
|
<tr style="background: #f8fafc;"><td style="padding: 6px 8px; font-weight: 600; color: #64748b;">BSCRNC Name</td><td style="padding: 6px 8px;">${area.bscrncName}</td></tr>
|
||||||
|
<tr><td style="padding: 6px 8px; font-weight: 600; color: #64748b;">LAC - CI</td><td style="padding: 6px 8px;">${area.lacCi}</td></tr>
|
||||||
|
<tr style="background: #f8fafc;"><td style="padding: 6px 8px; font-weight: 600; color: #64748b;">Node</td><td style="padding: 6px 8px;">${area.node}</td></tr>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-cluster-content="revenue" class="cluster-content" style="display: none; padding: 10px 8px; font-size: 12px; color: #64748b;">
|
||||||
|
Informasi tren revenue klaster ${area.name} di sini.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-cluster-content="network" class="cluster-content" style="display: none; padding: 10px 8px; font-size: 12px; color: #64748b;">
|
||||||
|
Informasi performa network klaster ${area.name} di sini.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Wires up tab switching untuk Popup Area Klaster
|
||||||
|
const wireClusterPopup = (container: HTMLElement) => {
|
||||||
|
const tabs = container.querySelectorAll<HTMLElement>('.cluster-tab');
|
||||||
|
const contents = container.querySelectorAll<HTMLElement>('.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'];
|
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) => {
|
const placeholderQuality = (seed: string, offset: number) => {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
|
for (let i = 0; i < seed.length; i++) hash = (hash * 31 + seed.charCodeAt(i)) >>> 0;
|
||||||
const level = (hash + offset) % QUALITY_LEVELS.length;
|
const level = (hash + offset) % QUALITY_LEVELS.length;
|
||||||
return { level: level + 1, label: QUALITY_LEVELS[level] };
|
return { level: level + 1, label: QUALITY_LEVELS[level] };
|
||||||
};
|
};
|
||||||
|
|
||||||
const signalBarsHtml = (level: number) => {
|
const signalBarsHtml = (level: number) => {
|
||||||
const heights = [5, 9, 13, 17];
|
const heights = [5, 9, 13, 17];
|
||||||
return `<div style="display:flex;align-items:flex-end;gap:2px;height:17px;">
|
return `<div style="display:flex;align-items:flex-end;gap:2px;height:17px;">
|
||||||
${heights
|
${heights.map((h, i) => `<div style="width:4px;height:${h}px;border-radius:1px;background:${i < level ? '#2563eb' : '#dbeafe'};"></div>`).join('')}
|
||||||
.map(
|
|
||||||
(h, i) =>
|
|
||||||
`<div style="width:4px;height:${h}px;border-radius:1px;background:${
|
|
||||||
i < level ? '#2563eb' : '#dbeafe'
|
|
||||||
};"></div>`
|
|
||||||
)
|
|
||||||
.join('')}
|
|
||||||
</div>`;
|
</div>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const signalCardHtml = (title: string, desc: string, seed: string, offset: number) => {
|
const signalCardHtml = (title: string, desc: string, seed: string, offset: number) => {
|
||||||
const q = placeholderQuality(seed, offset);
|
const q = placeholderQuality(seed, offset);
|
||||||
return `
|
return `
|
||||||
@ -104,24 +158,14 @@ const signalCardHtml = (title: string, desc: string, seed: string, offset: numbe
|
|||||||
<div style="font-size:10px;color:#64748b;line-height:1.3;">${desc}</div>
|
<div style="font-size:10px;color:#64748b;line-height:1.3;">${desc}</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const cellListHtml = (points: SitePoint[]) => {
|
const cellListHtml = (points: SitePoint[]) => {
|
||||||
if (points.length === 0) {
|
if (points.length === 0) return `<div style="padding:10px 0;text-align:center;font-size:12px;color:#94a3b8;">No cells in this band.</div>`;
|
||||||
return `<div style="padding:10px 0;text-align:center;font-size:12px;color:#94a3b8;">No cells in this band.</div>`;
|
const rows = points.map((p) => `<tr><td style="padding:2px 8px 2px 0;">${p.cell}</td><td>${p.sector}</td></tr>`).join('');
|
||||||
}
|
|
||||||
const rows = points
|
|
||||||
.map((p) => `<tr><td style="padding:2px 8px 2px 0;">${p.cell}</td><td>${p.sector}</td></tr>`)
|
|
||||||
.join('');
|
|
||||||
return `<table style="width:100%;border-collapse:collapse;font-size:12px;margin-top:6px;">${rows}</table>`;
|
return `<table style="width:100%;border-collapse:collapse;font-size:12px;margin-top:6px;">${rows}</table>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildSignalPopupHtml = (points: SitePoint[], lat: number, lng: number) => {
|
const buildSignalPopupHtml = (points: SitePoint[], lat: number, lng: number) => {
|
||||||
const site = points[0].site;
|
const site = points[0].site;
|
||||||
const location = points[0].location;
|
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 ran2g = points.filter((p) => p.cell.trim().toUpperCase().endsWith('E'));
|
||||||
const ran3g = points.filter((p) => p.cell.trim().toUpperCase().endsWith('F'));
|
const ran3g = points.filter((p) => p.cell.trim().toUpperCase().endsWith('F'));
|
||||||
|
|
||||||
@ -131,81 +175,29 @@ const buildSignalPopupHtml = (points: SitePoint[], lat: number, lng: number) =>
|
|||||||
<div style="font-weight:700;font-size:14px;color:#0f172a;">${site}</div>
|
<div style="font-weight:700;font-size:14px;color:#0f172a;">${site}</div>
|
||||||
<div style="font-size:11px;color:#64748b;">${location} · ${lat.toFixed(5)}, ${lng.toFixed(5)}</div>
|
<div style="font-size:11px;color:#64748b;">${location} · ${lat.toFixed(5)}, ${lng.toFixed(5)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="signal-tabs" style="display:flex;gap:4px;margin-bottom:10px;">
|
<div class="signal-tabs" style="display:flex;gap:4px;margin-bottom:10px;">
|
||||||
<button data-tab="signal" class="signal-tab signal-tab-active" style="${tabBtnStyle(true)}">Signal</button>
|
<button data-tab="signal" class="signal-tab signal-tab-active" style="flex:1;padding:5px 0;font-size:11px;font-weight:600;border-radius:6px;border:none;cursor:pointer;background:#2563eb;color:#ffffff;">Signal</button>
|
||||||
<button data-tab="ran2g" class="signal-tab" style="${tabBtnStyle(false)}">RAN 2G</button>
|
<button data-tab="ran2g" class="signal-tab" style="flex:1;padding:5px 0;font-size:11px;font-weight:600;border-radius:6px;border:none;cursor:pointer;background:#f1f5f9;color:#475569;">RAN 2G</button>
|
||||||
<button data-tab="ran3g" class="signal-tab" style="${tabBtnStyle(false)}">RAN 3G</button>
|
<button data-tab="ran3g" class="signal-tab" style="flex:1;padding:5px 0;font-size:11px;font-weight:600;border-radius:6px;border:none;cursor:pointer;background:#f1f5f9;color:#475569;">RAN 3G</button>
|
||||||
<button data-tab="alarm" class="signal-tab" style="${tabBtnStyle(false)}">Alarm</button>
|
<button data-tab="alarm" class="signal-tab" style="flex:1;padding:5px 0;font-size:11px;font-weight:600;border-radius:6px;border:none;cursor:pointer;background:#f1f5f9;color:#475569;">Alarm</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div data-content="signal" class="signal-content">
|
<div data-content="signal" class="signal-content">
|
||||||
<div style="display:flex;gap:6px;">
|
<div style="display:flex;gap:6px;">
|
||||||
${signalCardHtml('3G', 'Browsing, email, video call', site, 0)}
|
${signalCardHtml('4G', 'Browsing, email, video call', site, 0)}
|
||||||
${signalCardHtml('Mobile BB', 'Laptop browsing & downloads', site, 1)}
|
${signalCardHtml('3G', 'Laptop browsing & downloads', site, 1)}
|
||||||
${signalCardHtml('2G', 'Calling and texting', site, 2)}
|
${signalCardHtml('2G', 'Calling and texting', site, 2)}
|
||||||
</div>
|
</div>
|
||||||
<div style="display:flex;gap:8px;margin-top:10px;">
|
|
||||||
<button data-action="suggest" style="${actionBtnStyle('#2563eb', '#ffffff')}">Suggest</button>
|
|
||||||
<button data-action="complaint" style="${actionBtnStyle('#ffffff', '#2563eb')}">Complaint</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div data-content="ran2g" class="signal-content" style="display:none;">
|
|
||||||
<div style="font-size:11px;color:#64748b;">Cell / Sector on 2G band</div>
|
|
||||||
${cellListHtml(ran2g)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div data-content="ran3g" class="signal-content" style="display:none;">
|
|
||||||
<div style="font-size:11px;color:#64748b;">Cell / Sector on 3G band</div>
|
|
||||||
${cellListHtml(ran3g)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div data-content="alarm" class="signal-content" style="display:none;">
|
|
||||||
<div style="padding:14px 0;text-align:center;font-size:12px;color:#94a3b8;">No active alarms.</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
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<HTMLButtonElement>('.signal-tab');
|
|
||||||
const contents = container.querySelectorAll<HTMLElement>('.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<HTMLButtonElement>('[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 SitesSatelliteMap = () => {
|
||||||
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
const mapContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const mapRef = useRef<L.Map | null>(null);
|
const mapRef = useRef<L.Map | null>(null);
|
||||||
|
|
||||||
|
const markerLayerGroupRef = useRef<L.LayerGroup>(L.layerGroup());
|
||||||
|
const polygonLayerGroupRef = useRef<L.LayerGroup>(L.layerGroup());
|
||||||
|
|
||||||
const markerByKeyRef = useRef<Map<string, L.Marker>>(new Map());
|
const markerByKeyRef = useRef<Map<string, L.Marker>>(new Map());
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [selectedCount, setSelectedCount] = useState(sitePoints.length);
|
const [selectedCount, setSelectedCount] = useState(sitePoints.length);
|
||||||
@ -222,43 +214,72 @@ const SitesSatelliteMap = () => {
|
|||||||
});
|
});
|
||||||
mapRef.current = map;
|
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}', {
|
||||||
L.tileLayer(
|
attribution: 'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics',
|
||||||
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
maxZoom: 19
|
||||||
{
|
}).addTo(map);
|
||||||
attribution:
|
|
||||||
'Tiles © Esri — Source: Esri, Maxar, Earthstar Geographics, and the GIS User Community',
|
|
||||||
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}', {
|
||||||
L.tileLayer(
|
maxZoom: 19,
|
||||||
'https://server.arcgisonline.com/ArcGIS/rest/services/Reference/World_Boundaries_and_Places/MapServer/tile/{z}/{y}/{x}',
|
opacity: 0.9
|
||||||
{ maxZoom: 19, opacity: 0.9 }
|
}).addTo(map);
|
||||||
).addTo(map);
|
|
||||||
|
|
||||||
// Every site gets its own blue pin placed directly on the map — no
|
// Keduanya langsung dimasukkan secara permanen ke peta utama
|
||||||
// clustering, no group bubbles.
|
markerLayerGroupRef.current.addTo(map);
|
||||||
|
polygonLayerGroupRef.current.addTo(map);
|
||||||
|
|
||||||
|
// 1. Render Seluruh Marker Pin Biru
|
||||||
grouped.forEach((points, key) => {
|
grouped.forEach((points, key) => {
|
||||||
const [lat, lng] = key.split(',').map(Number);
|
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.bindPopup(buildSignalPopupHtml(points, lat, lng), { maxWidth: 320, minWidth: 290 });
|
||||||
|
marker.addTo(markerLayerGroupRef.current);
|
||||||
markerByKeyRef.current.set(key, marker);
|
markerByKeyRef.current.set(key, marker);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wire up the rich popup's interactive bits (tabs + buttons) every time
|
// 2. Render Batas Klaster / Polygon (Selalu Menetap di Peta)
|
||||||
// one is opened, since Leaflet re-renders the DOM content on each open.
|
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 onPopupOpen = (e: L.PopupEvent) => {
|
||||||
const el = e.popup.getElement();
|
const el = e.popup.getElement();
|
||||||
const content = el?.querySelector<HTMLElement>('.signal-popup');
|
|
||||||
if (!content) return;
|
// Kasus A: Jika yang dibuka adalah Dialog Klaster Area
|
||||||
const siteName = content.querySelector('div')?.textContent ?? '';
|
const clusterContent = el?.querySelector<HTMLElement>('.cluster-popup');
|
||||||
wireSignalPopup(content, siteName);
|
if (clusterContent) {
|
||||||
|
wireClusterPopup(clusterContent);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
map.on('popupopen', onPopupOpen);
|
map.on('popupopen', onPopupOpen);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
map.off('zoomend', handleZoomVisibility);
|
||||||
map.off('popupopen', onPopupOpen);
|
map.off('popupopen', onPopupOpen);
|
||||||
map.remove();
|
map.remove();
|
||||||
mapRef.current = null;
|
mapRef.current = null;
|
||||||
@ -266,8 +287,6 @@ const SitesSatelliteMap = () => {
|
|||||||
};
|
};
|
||||||
}, [grouped]);
|
}, [grouped]);
|
||||||
|
|
||||||
// Simple search: pans/zooms to a matching cell, sector, or site name and
|
|
||||||
// opens its popup.
|
|
||||||
const handleSearch = (value: string) => {
|
const handleSearch = (value: string) => {
|
||||||
setSearch(value);
|
setSearch(value);
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@ -276,17 +295,14 @@ const SitesSatelliteMap = () => {
|
|||||||
}
|
}
|
||||||
const q = value.toLowerCase();
|
const q = value.toLowerCase();
|
||||||
const matches = sitePoints.filter(
|
const matches = sitePoints.filter(
|
||||||
(p) =>
|
(p) => p.cell.toLowerCase().includes(q) || p.sector.toLowerCase().includes(q) || p.site.toLowerCase().includes(q)
|
||||||
p.cell.toLowerCase().includes(q) ||
|
|
||||||
p.sector.toLowerCase().includes(q) ||
|
|
||||||
p.site.toLowerCase().includes(q)
|
|
||||||
);
|
);
|
||||||
setSelectedCount(matches.length);
|
setSelectedCount(matches.length);
|
||||||
|
|
||||||
if (matches.length > 0 && mapRef.current) {
|
if (matches.length > 0 && mapRef.current) {
|
||||||
const first = matches[0];
|
const first = matches[0];
|
||||||
const key = `${first.lat.toFixed(6)},${first.lng.toFixed(6)}`;
|
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);
|
const marker = markerByKeyRef.current.get(key);
|
||||||
if (marker) marker.openPopup();
|
if (marker) marker.openPopup();
|
||||||
}
|
}
|
||||||
@ -296,8 +312,7 @@ const SitesSatelliteMap = () => {
|
|||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<div className="text-sm text-gray-600">
|
<div className="text-sm text-gray-600">
|
||||||
Showing <span className="font-semibold">{selectedCount}</span> from {sitePoints.length} points
|
Showing <span className="font-semibold">{selectedCount}</span> from {sitePoints.length} points (Cell/Sector)
|
||||||
(Cell/Sector)
|
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
Reference in New Issue
Block a user