Skip to content

Commit f423971

Browse files
committed
improve tooltip ux
1 parent b66108b commit f423971

2 files changed

Lines changed: 138 additions & 20 deletions

File tree

css/map.css

Lines changed: 42 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -57,17 +57,48 @@ body { display: flex; flex-direction: column; height: 100dvh; overflow: hidden;
5757
display: inline-block; padding: 1px 8px; margin-left: 5px; border-radius: 3px;
5858
color: #fff; font-size: 12px; font-weight: 600;
5959
}
60-
.popup-section { margin-top: 10px; }
61-
.popup-section-title {
62-
font-size: 11px; color: #999; text-transform: uppercase;
63-
letter-spacing: 0.5px; margin-bottom: 2px; font-weight: 600;
64-
}
60+
.popup-section { margin-top: 8px; border-top: 1px solid #e5e7eb; padding-top: 6px; }
6561
.popup-section-hint { font-weight: 400; opacity: 0.7; }
6662

63+
.popup-toggle {
64+
font-size: 11px; color: #999; text-transform: uppercase; letter-spacing: 0.5px;
65+
font-weight: 600; cursor: pointer; user-select: none;
66+
display: flex; align-items: center; gap: 6px; padding: 2px 0;
67+
}
68+
.popup-toggle::before {
69+
content: '\25B8'; font-size: 10px; display: inline-block; width: 10px; flex-shrink: 0;
70+
transition: transform 0.15s ease;
71+
}
72+
.popup-toggle-open::before { transform: rotate(90deg); }
73+
.popup-toggle:hover { color: #666; }
74+
.popup-section-body { padding-top: 4px; }
75+
.popup-meta { display: block; font-size: 11px; color: #888; margin-top: 2px; }
76+
.popup-signal-count {
77+
display: inline-block; background: #f3f4f6; color: #6b7280;
78+
font-size: 10px; font-weight: 600; padding: 0 5px; border-radius: 8px;
79+
line-height: 1.6; text-transform: none; letter-spacing: 0;
80+
}
81+
.popup-empty { color: #999; font-size: 12px; }
82+
6783
.dns-table { border-collapse: collapse; }
6884
.dns-table td { padding: 1px 8px 1px 0; vertical-align: baseline; font-size: 12px; white-space: nowrap; }
6985
.dns-table .dn { font-weight: 600; }
7086
.dns-table .host { color: #999; font-size: 11px; font-family: ui-monospace, monospace; max-width: 200px; overflow: hidden; text-overflow: ellipsis; }
87+
.signal-list { display: flex; flex-direction: column; gap: 1px; }
88+
.signal-item {
89+
display: flex; align-items: baseline; gap: 6px;
90+
padding: 2px 0; font-size: 12px; line-height: 1.5; min-width: 0;
91+
}
92+
.signal-kind {
93+
display: inline-block; background: #f3f4f6; color: #374151;
94+
font-size: 10px; font-weight: 600; padding: 0 5px; border-radius: 3px;
95+
line-height: 1.6; white-space: nowrap; flex-shrink: 0;
96+
}
97+
.signal-text {
98+
color: #555; font-size: 11px; font-family: ui-monospace, monospace;
99+
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0;
100+
}
101+
.signal-dup { color: #9ca3af; font-size: 10px; flex-shrink: 0; }
71102

72103
.cat-badge {
73104
display: inline-block; padding: 0 5px; border-radius: 3px;
@@ -91,4 +122,10 @@ body { display: flex; flex-direction: column; height: 100dvh; overflow: hidden;
91122
.legend-toggle { display: block; }
92123
.legend.legend-collapsed .legend-content { display: none; }
93124
.legend.legend-collapsed { padding: 8px 12px; }
125+
.popup-toggle { padding: 6px 0; min-height: 44px; }
126+
.popup-section-body { overflow-x: hidden; }
127+
.dns-table .dn { max-width: 72px; overflow: hidden; text-overflow: ellipsis; }
128+
.dns-table .host { max-width: 100px; }
129+
.signal-text { max-width: 140px; }
130+
.signal-item { gap: 4px; padding-left: 4px; }
94131
}

index.html

Lines changed: 96 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -282,8 +282,80 @@ <h3>Open source &amp; open data</h3>
282282
return `<table class="dns-table">${rows}</table>`;
283283
}
284284

285+
const SIGNAL_LABELS = {
286+
mx: 'MX', spf: 'SPF', dkim: 'DKIM', asn: 'ASN',
287+
tenant: 'Tenant', spf_ip: 'SPF IP', txt_verification: 'TXT',
288+
autodiscover: 'Autodisc.', dmarc: 'DMARC', cname_chain: 'CNAME', smtp: 'SMTP'
289+
};
290+
291+
function deduplicateSignals(signals) {
292+
const seen = new Map();
293+
for (const s of signals) {
294+
const key = s.kind + '|' + s.detail;
295+
if (seen.has(key)) {
296+
seen.get(key).count++;
297+
} else {
298+
seen.set(key, { kind: s.kind, provider: s.provider, weight: s.weight, detail: s.detail, count: 1 });
299+
}
300+
}
301+
return Array.from(seen.values()).sort((a, b) => b.weight - a.weight);
302+
}
303+
304+
function condenseDetail(kind, detail) {
305+
var m;
306+
switch (kind) {
307+
case 'mx':
308+
m = detail.match(/^MX\s+(\S+)\s+matches\s+\S+$/);
309+
return m ? m[1] : detail;
310+
case 'spf':
311+
m = detail.match(/^SPF\s+include:(\S+)\s+matches\s+\S+$/);
312+
return m ? m[1] : detail;
313+
case 'dkim':
314+
m = detail.match(/^DKIM\s+(selector\d+)\._domainkey\.\S+\s+CNAME\s+\s+\S+\._domainkey\.(\S+)$/);
315+
return m ? m[1] + ' \u2192 ' + m[2] : detail;
316+
case 'asn':
317+
m = detail.match(/^ASN\s+(\d+)\s+matches\s+\S+$/);
318+
if (m) return 'AS' + m[1];
319+
m = detail.match(/^ASN\s+(\d+)\s+is Swiss ISP:\s+(.+)$/);
320+
if (m) return 'AS' + m[1] + ' (' + m[2] + ')';
321+
return detail;
322+
case 'tenant':
323+
m = detail.match(/^MS365 tenant detected:\s+(.+)$/);
324+
return m ? m[1] : detail;
325+
case 'autodiscover':
326+
m = detail.match(/^autodiscover\s+(CNAME|SRV)\s+\s+(.+)$/);
327+
if (m) return (m[1] === 'CNAME' ? '\u2192 ' : m[1] + ' \u2192 ') + m[2];
328+
return detail;
329+
case 'txt_verification':
330+
m = detail.match(/^TXT verification matches\s+(\S+)$/);
331+
if (m) return m[1];
332+
if (detail.includes('AWS SES')) return 'AWS SES';
333+
return detail;
334+
case 'spf_ip':
335+
m = detail.match(/ASN\s+(\d+)\s+is Swiss ISP:\s+(.+)$/);
336+
if (m) return 'AS' + m[1] + ' (' + m[2] + ')';
337+
m = detail.match(/ASN\s+(\d+)\s+matches\s+(\S+)$/);
338+
if (m) return 'AS' + m[1];
339+
return detail;
340+
case 'dmarc':
341+
m = detail.match(/^DMARC record\s+(.+)$/);
342+
return m ? m[1] : detail;
343+
default:
344+
return detail;
345+
}
346+
}
347+
348+
285349
const map = initMap('map');
286350
setupInfoBar(map);
351+
const isMobile = window.innerWidth <= 600;
352+
353+
// Toggle collapsible popup sections (inline onclick, bypasses Leaflet event interception)
354+
window.toggleSection = function(el) {
355+
el.classList.toggle('popup-toggle-open');
356+
var body = el.nextElementSibling;
357+
body.style.display = body.style.display === 'none' ? '' : 'none';
358+
};
287359

288360
var muni = {};
289361
var muniLayer;
@@ -398,51 +470,60 @@ <h3>Open source &amp; open data</h3>
398470
const textColor = (level === 'high' && cat) ? '#fff' : '#333';
399471
const label = LABELS[m.provider] || m.provider;
400472

473+
const showBody = isMobile ? 'display:none' : '';
474+
401475
let mxSection = '';
402476
if (m.mx && m.mx.length > 0) {
403-
mxSection = `<div class="popup-section"><div class="popup-section-title">Incoming mail handled by <span class="popup-section-hint">(MX)</span></div>${renderTable(m.mx)}</div>`;
477+
mxSection = `<div class="popup-section"><div class="popup-toggle${isMobile ? '' : ' popup-toggle-open'}" onclick="window.toggleSection(this)">Incoming mail handled by <span class="popup-section-hint">(MX)</span></div><div class="popup-section-body" style="${showBody}">${renderTable(m.mx)}</div></div>`;
404478
} else {
405-
mxSection = `<div class="popup-section"><div class="popup-section-title">Incoming mail handled by <span class="popup-section-hint">(MX)</span></div><span style="color:#999;font-size:12px">No MX records found</span></div>`;
479+
mxSection = `<div class="popup-section"><div class="popup-toggle${isMobile ? '' : ' popup-toggle-open'}" onclick="window.toggleSection(this)">Incoming mail handled by <span class="popup-section-hint">(MX)</span></div><div class="popup-section-body" style="${showBody}"><span class="popup-empty">No MX records found</span></div></div>`;
406480
}
407481

408482
const spfDelegations = parseSpfDelegations(m.spf);
409483
let spfSection = '';
410484
if (spfDelegations.length > 0) {
411-
spfSection = `<div class="popup-section"><div class="popup-section-title">Authorized to send mail <span class="popup-section-hint">(SPF)</span></div>${renderTable(spfDelegations)}</div>`;
485+
spfSection = `<div class="popup-section"><div class="popup-toggle${isMobile ? '' : ' popup-toggle-open'}" onclick="window.toggleSection(this)">Authorized to send mail <span class="popup-section-hint">(SPF)</span></div><div class="popup-section-body" style="${showBody}">${renderTable(spfDelegations)}</div></div>`;
412486
} else if (m.spf) {
413-
spfSection = `<div class="popup-section"><div class="popup-section-title">Authorized to send mail <span class="popup-section-hint">(SPF)</span></div><span style="color:#999;font-size:12px">${escapeHtml(m.spf)}</span></div>`;
487+
spfSection = `<div class="popup-section"><div class="popup-toggle${isMobile ? '' : ' popup-toggle-open'}" onclick="window.toggleSection(this)">Authorized to send mail <span class="popup-section-hint">(SPF)</span></div><div class="popup-section-body" style="${showBody}"><span class="popup-empty">${escapeHtml(m.spf)}</span></div></div>`;
414488
} else {
415-
spfSection = `<div class="popup-section"><div class="popup-section-title">Authorized to send mail <span class="popup-section-hint">(SPF)</span></div><span style="color:#999;font-size:12px">No SPF record found</span></div>`;
489+
spfSection = `<div class="popup-section"><div class="popup-toggle${isMobile ? '' : ' popup-toggle-open'}" onclick="window.toggleSection(this)">Authorized to send mail <span class="popup-section-hint">(SPF)</span></div><div class="popup-section-body" style="${showBody}"><span class="popup-empty">No SPF record found</span></div></div>`;
416490
}
417491

418492
const confidence = m.classification_confidence != null ? m.classification_confidence : 0;
419493
const confPct = Math.round(confidence) + '%';
420494
let signalsSection = '';
421495
if (m.classification_signals && m.classification_signals.length > 0) {
422-
const signalRows = m.classification_signals.map(s =>
423-
`<tr><td style="font-weight:600;white-space:nowrap">${escapeHtml(s.kind)}</td><td style="color:#666;font-size:11px">${escapeHtml(s.detail)}</td></tr>`
424-
).join('');
425-
signalsSection = `<div class="popup-section"><div class="popup-section-title">Classification signals</div><table class="dns-table">${signalRows}</table></div>`;
496+
const deduped = deduplicateSignals(m.classification_signals);
497+
const uniqueCount = deduped.length;
498+
const signalItems = deduped.map(s => {
499+
const kindLabel = SIGNAL_LABELS[s.kind] || s.kind;
500+
const condensed = condenseDetail(s.kind, s.detail);
501+
const dup = s.count > 1 ? `<span class="signal-dup">\u00d7${s.count}</span>` : '';
502+
return `<div class="signal-item" title="${escapeHtml(s.detail).replace(/"/g, '&quot;')}"><span class="signal-kind">${escapeHtml(kindLabel)}</span><span class="signal-text">${escapeHtml(condensed)}</span>${dup}</div>`;
503+
}).join('');
504+
signalsSection = `<div class="popup-section"><div class="popup-toggle" onclick="window.toggleSection(this)">Classification signals <span class="popup-signal-count" title="DNS and network evidence used to identify the email provider">${uniqueCount}</span></div><div class="popup-section-body" style="display:none"><div class="signal-list">${signalItems}</div></div></div>`;
426505
} else {
427-
signalsSection = `<div class="popup-section"><div class="popup-section-title">Classification signals</div><span style="color:#999;font-size:12px">No classification signals</span></div>`;
506+
signalsSection = `<div class="popup-section"><div class="popup-toggle" onclick="window.toggleSection(this)">Classification signals</div><div class="popup-section-body" style="display:none"><span class="popup-empty">No classification signals</span></div></div>`;
428507
}
429508

430509
const eName = escapeHtml(m.name);
431510
const cantonCode = CANTON_CODES[m.canton] || '';
432511
const nameDisplay = cantonCode ? `${eName} (${cantonCode})` : eName;
433512
const eDomain = escapeHtml(m.domain || 'unknown');
434513
const eLabel = escapeHtml(label);
435-
const confLine = `<br><span style="font-size:11px;color:#888">Confidence: ${confPct}</span>`;
436-
const gatewayLine = m.gateway ? `<br><span style="font-size:11px;color:#888">Gateway: ${escapeHtml(m.gateway)}</span>` : '';
437514
const badge = `<span class="provider-badge" style="background:${color};color:${textColor}">${eLabel}</span>`;
515+
const metaParts = [`Confidence: ${confPct}`];
516+
if (m.gateway) metaParts.push(`Gateway: ${escapeHtml(m.gateway)}`);
517+
const metaLine = `<div class="popup-meta">${metaParts.join(' &middot; ')}</div>`;
438518

439519
layer.bindPopup(
440520
`<div class="info-popup">` +
441521
`<strong>${nameDisplay}</strong><br>` +
442-
`${eDomain} ${badge}${confLine}${gatewayLine}` +
443-
signalsSection + mxSection + spfSection +
522+
`${eDomain} ${badge}` +
523+
metaLine +
524+
mxSection + spfSection + signalsSection +
444525
`</div>`,
445-
{ maxWidth: 450 }
526+
{ maxWidth: isMobile ? 300 : 450 }
446527
);
447528
layer.on('mouseover', function () { this.setStyle({ weight: 2, color: '#333' }); });
448529
layer.on('mouseout', function () { this.setStyle({ weight: 0.5, color: '#666' }); });

0 commit comments

Comments
 (0)