update
This commit is contained in:
@ -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} · ${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} · ${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} · ${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;
|
||||
|
||||
Reference in New Issue
Block a user