This commit is contained in:
wayanrivan
2026-07-02 16:42:28 +07:00
parent 3f9452cf8e
commit fb11249d3f

View File

@ -64,6 +64,145 @@ const pinIcon = (multi: boolean) => {
});
};
// ---------------------------------------------------------------------------
// "Signal detail" popup (blue pins only) — mimics the tabbed
// Signal / RAN 2G / RAN 3G / Alarm dialog from the reference screenshot.
// ---------------------------------------------------------------------------
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 `<div style="display:flex;align-items:flex-end;gap:2px;height:17px;">
${heights
.map(
(h, i) =>
`<div style="width:4px;height:${h}px;border-radius:1px;background:${
i < level ? '#2563eb' : '#dbeafe'
};"></div>`
)
.join('')}
</div>`;
};
const signalCardHtml = (title: string, desc: string, seed: string, offset: number) => {
const q = placeholderQuality(seed, offset);
return `
<div style="flex:1;min-width:0;text-align:center;padding:8px 4px;border:1px solid #e5e7eb;border-radius:8px;">
<div style="font-weight:700;font-size:12px;color:#1e293b;margin-bottom:4px;">${title}</div>
<div style="display:flex;justify-content:center;margin-bottom:4px;">${signalBarsHtml(q.level)}</div>
<div style="font-size:11px;font-weight:600;color:#2563eb;margin-bottom:4px;">${q.label}</div>
<div style="font-size:10px;color:#64748b;line-height:1.3;">${desc}</div>
</div>`;
};
const cellListHtml = (points: SitePoint[]) => {
if (points.length === 0) {
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('');
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 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'));
return `
<div class="signal-popup" style="width:290px;font-family:Arial, sans-serif;">
<div style="margin-bottom:8px;">
<div style="font-weight:700;font-size:14px;color:#0f172a;">${site}</div>
<div style="font-size:11px;color:#64748b;">${location} &middot; ${lat.toFixed(5)}, ${lng.toFixed(5)}</div>
</div>
<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="ran2g" class="signal-tab" style="${tabBtnStyle(false)}">RAN 2G</button>
<button data-tab="ran3g" class="signal-tab" style="${tabBtnStyle(false)}">RAN 3G</button>
<button data-tab="alarm" class="signal-tab" style="${tabBtnStyle(false)}">Alarm</button>
</div>
<div data-content="signal" class="signal-content">
<div style="display:flex;gap:6px;">
${signalCardHtml('3G', 'Browsing, email, video call', site, 0)}
${signalCardHtml('Mobile BB', 'Laptop browsing & downloads', site, 1)}
${signalCardHtml('2G', 'Calling and texting', site, 2)}
</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>`;
};
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 mapContainerRef = useRef<HTMLDivElement | null>(null);
const mapRef = useRef<L.Map | null>(null);
@ -105,21 +244,27 @@ const SitesSatelliteMap = () => {
grouped.forEach((points, key) => {
const [lat, lng] = key.split(',').map(Number);
const marker = L.marker([lat, lng], { icon: pinIcon(points.length > 1) });
const isMulti = points.length > 1;
const marker = L.marker([lat, lng], { icon: pinIcon(isMulti) });
const site = points[0].site;
const location = points[0].location;
const rows = points
.map((p) => `<tr><td style="padding-right:8px;">${p.cell}</td><td>${p.sector}</td></tr>`)
.join('');
marker.bindPopup(`
<div style="font-family: Arial, sans-serif; font-size: 13px; max-width: 240px;">
<div style="font-weight:600; margin-bottom:2px;">${site}</div>
<div style="color:#666; margin-bottom:6px;">${location} &middot; ${lat.toFixed(5)}, ${lng.toFixed(5)}</div>
<table style="width:100%; border-collapse:collapse;">${rows}</table>
</div>
`);
if (isMulti) {
// Blue pin -> rich "Signal" dialog.
marker.bindPopup(buildSignalPopupHtml(points, lat, lng), { maxWidth: 320, minWidth: 290 });
} else {
// Red pin -> simple cell/sector info (unchanged).
const site = points[0].site;
const location = points[0].location;
const rows = points
.map((p) => `<tr><td style="padding-right:8px;">${p.cell}</td><td>${p.sector}</td></tr>`)
.join('');
marker.bindPopup(`
<div style="font-family: Arial, sans-serif; font-size: 13px; max-width: 240px;">
<div style="font-weight:600; margin-bottom:2px;">${site}</div>
<div style="color:#666; margin-bottom:6px;">${location} &middot; ${lat.toFixed(5)}, ${lng.toFixed(5)}</div>
<table style="width:100%; border-collapse:collapse;">${rows}</table>
</div>
`);
}
markerByKeyRef.current.set(key, marker);
cluster.addLayer(marker);
@ -127,7 +272,19 @@ const SitesSatelliteMap = () => {
map.addLayer(cluster);
// 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.
const onPopupOpen = (e: L.PopupEvent) => {
const el = e.popup.getElement();
const content = el?.querySelector<HTMLElement>('.signal-popup');
if (!content) return;
const siteName = content.querySelector('div')?.textContent ?? '';
wireSignalPopup(content, siteName);
};
map.on('popupopen', onPopupOpen);
return () => {
map.off('popupopen', onPopupOpen);
map.remove();
mapRef.current = null;
clusterRef.current = null;