|
| 1 | +/* global L, mapData */ |
| 2 | +;(function () { |
| 3 | + 'use strict' |
| 4 | + |
| 5 | + if (typeof mapData === 'undefined') { |
| 6 | + console.error('mapData not found. Make sure data.js is loaded first.') |
| 7 | + return |
| 8 | + } |
| 9 | + |
| 10 | + var floatingTooltip = document.getElementById('floatingTooltip') |
| 11 | + var escapeDiv = document.createElement('div') |
| 12 | + |
| 13 | + function escapeHtml(str) { |
| 14 | + escapeDiv.textContent = str |
| 15 | + return escapeDiv.innerHTML |
| 16 | + } |
| 17 | + |
| 18 | + function formatSenders(messages) { |
| 19 | + var uniqueSenders = [] |
| 20 | + var seen = {} |
| 21 | + for (var i = 0; i < messages.length; i++) { |
| 22 | + var name = messages[i].sender.split(' ')[0] |
| 23 | + if (!seen[name]) { |
| 24 | + seen[name] = true |
| 25 | + uniqueSenders.push(name) |
| 26 | + } |
| 27 | + } |
| 28 | + var label = uniqueSenders.length === 1 ? 'Sender' : 'Senders' |
| 29 | + var display = |
| 30 | + uniqueSenders.length <= 2 |
| 31 | + ? uniqueSenders.join(', ') |
| 32 | + : uniqueSenders.slice(0, 2).join(', ') + ', and ' + (uniqueSenders.length - 2) + ' more' |
| 33 | + return { label: label, display: display } |
| 34 | + } |
| 35 | + |
| 36 | + function buildTooltipHtml(messages) { |
| 37 | + return messages |
| 38 | + .map(function (m) { |
| 39 | + var senderName = m.sender.split(' ')[0] |
| 40 | + return ( |
| 41 | + '<div class="msg-tooltip-item">' + |
| 42 | + '<div class="msg-tooltip-header">' + |
| 43 | + escapeHtml(senderName) + |
| 44 | + ' · ' + |
| 45 | + m.date + |
| 46 | + '</div>' + |
| 47 | + '<div class="msg-tooltip-text">' + |
| 48 | + escapeHtml(m.message) + |
| 49 | + '</div></div>' |
| 50 | + ) |
| 51 | + }) |
| 52 | + .join('') |
| 53 | + } |
| 54 | + |
| 55 | + function showTooltip(trigger, messages) { |
| 56 | + floatingTooltip.innerHTML = buildTooltipHtml(messages) |
| 57 | + var rect = trigger.getBoundingClientRect() |
| 58 | + var left = rect.left |
| 59 | + if (left + 300 > window.innerWidth) left = window.innerWidth - 310 |
| 60 | + if (left < 10) left = 10 |
| 61 | + floatingTooltip.style.left = left + 'px' |
| 62 | + floatingTooltip.style.bottom = window.innerHeight - rect.top + 10 + 'px' |
| 63 | + floatingTooltip.style.top = 'auto' |
| 64 | + floatingTooltip.style.display = 'block' |
| 65 | + } |
| 66 | + |
| 67 | + function hideTooltip() { |
| 68 | + floatingTooltip.style.display = 'none' |
| 69 | + } |
| 70 | + |
| 71 | + // Initialize map |
| 72 | + var map = L.map('map').setView([mapData.center.lat, mapData.center.lng], mapData.zoom) |
| 73 | + |
| 74 | + L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { |
| 75 | + attribution: '© OpenStreetMap contributors' |
| 76 | + }).addTo(map) |
| 77 | + |
| 78 | + var markersLayer = mapData.clusterMarkers |
| 79 | + ? L.markerClusterGroup({ |
| 80 | + maxClusterRadius: 50, |
| 81 | + spiderfyOnMaxZoom: true, |
| 82 | + showCoverageOnHover: false |
| 83 | + }) |
| 84 | + : L.layerGroup() |
| 85 | + |
| 86 | + // Add markers |
| 87 | + mapData.points.forEach(function (p) { |
| 88 | + var messagesEncoded = encodeURIComponent(JSON.stringify(p.messages)).replace(/'/g, '%27') |
| 89 | + var senderDisplay = formatSenders(p.messages) |
| 90 | + var mentionCount = p.messages.length |
| 91 | + var mentionText = mentionCount > 1 ? ' (' + mentionCount + ' mentions)' : '' |
| 92 | + |
| 93 | + var imageHtml = p.imagePath |
| 94 | + ? '<img src="' + |
| 95 | + escapeHtml(p.imagePath) + |
| 96 | + '" style="display:block;width:100%;max-width:200px;border-radius:4px;margin-bottom:8px;" />' |
| 97 | + : '' |
| 98 | + |
| 99 | + var mapsUrl = p.placeId |
| 100 | + ? 'https://www.google.com/maps/search/?api=1&query=' + |
| 101 | + encodeURIComponent(p.activity) + |
| 102 | + '&query_place_id=' + |
| 103 | + p.placeId |
| 104 | + : null |
| 105 | + |
| 106 | + var popupContent = |
| 107 | + '<div style="max-width:240px;">' + |
| 108 | + imageHtml + |
| 109 | + '<strong>' + |
| 110 | + escapeHtml(p.activity) + |
| 111 | + '</strong><br>' + |
| 112 | + '<small>' + |
| 113 | + p.date + |
| 114 | + ' · <span class="sender-trigger" data-messages="' + |
| 115 | + messagesEncoded + |
| 116 | + '">' + |
| 117 | + senderDisplay.label + |
| 118 | + ': ' + |
| 119 | + senderDisplay.display + |
| 120 | + mentionText + |
| 121 | + '</span></small><br>' + |
| 122 | + (p.location ? '<em>' + escapeHtml(p.location) + '</em><br>' : '') + |
| 123 | + (mapsUrl ? '<a href="' + mapsUrl + '" target="_blank">View on Google Maps</a><br>' : '') + |
| 124 | + (p.url ? '<a href="' + escapeHtml(p.url) + '" target="_blank">Source Link</a>' : '') + |
| 125 | + '</div>' |
| 126 | + |
| 127 | + var marker = L.marker([p.lat, p.lng], { |
| 128 | + icon: L.divIcon({ |
| 129 | + className: 'custom-marker', |
| 130 | + html: |
| 131 | + '<div style="background-color:' + |
| 132 | + p.color + |
| 133 | + ';width:12px;height:12px;border-radius:50%;border:2px solid white;"></div>', |
| 134 | + iconSize: [16, 16], |
| 135 | + iconAnchor: [8, 8] |
| 136 | + }) |
| 137 | + }) |
| 138 | + .addTo(markersLayer) |
| 139 | + .bindPopup(popupContent) |
| 140 | + |
| 141 | + var closeTimeout = null |
| 142 | + marker.on('mouseover', function () { |
| 143 | + if (closeTimeout) { |
| 144 | + clearTimeout(closeTimeout) |
| 145 | + closeTimeout = null |
| 146 | + } |
| 147 | + this.openPopup() |
| 148 | + }) |
| 149 | + marker.on('mouseout', function () { |
| 150 | + var self = this |
| 151 | + closeTimeout = setTimeout(function () { |
| 152 | + self.closePopup() |
| 153 | + }, 100) |
| 154 | + }) |
| 155 | + marker.on('popupopen', function () { |
| 156 | + var popup = this.getPopup() |
| 157 | + var popupEl = popup.getElement() |
| 158 | + if (popupEl) { |
| 159 | + popupEl.addEventListener('mouseenter', function () { |
| 160 | + if (closeTimeout) { |
| 161 | + clearTimeout(closeTimeout) |
| 162 | + closeTimeout = null |
| 163 | + } |
| 164 | + }) |
| 165 | + popupEl.addEventListener('mouseleave', function () { |
| 166 | + marker.closePopup() |
| 167 | + }) |
| 168 | + } |
| 169 | + }) |
| 170 | + }) |
| 171 | + |
| 172 | + map.addLayer(markersLayer) |
| 173 | + |
| 174 | + if (markersLayer.getLayers().length > 0) { |
| 175 | + map.fitBounds(markersLayer.getBounds(), { padding: [50, 50] }) |
| 176 | + } |
| 177 | + |
| 178 | + // Popup tooltip handlers |
| 179 | + map.on('popupopen', function () { |
| 180 | + var triggers = document.querySelectorAll('.leaflet-popup .sender-trigger[data-messages]') |
| 181 | + triggers.forEach(function (trigger) { |
| 182 | + trigger.addEventListener('mouseenter', function () { |
| 183 | + var messages = JSON.parse(decodeURIComponent(trigger.getAttribute('data-messages') || '')) |
| 184 | + if (!messages.length) return |
| 185 | + showTooltip(trigger, messages) |
| 186 | + }) |
| 187 | + trigger.addEventListener('mouseleave', hideTooltip) |
| 188 | + }) |
| 189 | + }) |
| 190 | + |
| 191 | + // Render info box |
| 192 | + document.getElementById('infoTitle').textContent = mapData.title |
| 193 | + document.getElementById('infoCount').textContent = mapData.points.length |
| 194 | + |
| 195 | + // Render legend |
| 196 | + var legendHtml = Object.entries(mapData.senderColors) |
| 197 | + .sort(function (a, b) { |
| 198 | + return a[0].localeCompare(b[0]) |
| 199 | + }) |
| 200 | + .map(function (entry) { |
| 201 | + var sender = entry[0] |
| 202 | + var color = entry[1] |
| 203 | + var firstName = sender.split(' ')[0] |
| 204 | + return ( |
| 205 | + '<div class="legend-item"><div class="legend-dot" style="background-color:' + |
| 206 | + color + |
| 207 | + ';"></div>' + |
| 208 | + '<span>' + |
| 209 | + escapeHtml(firstName) + |
| 210 | + "'s suggestions</span></div>" |
| 211 | + ) |
| 212 | + }) |
| 213 | + .join('') |
| 214 | + document.getElementById('legend').innerHTML = legendHtml |
| 215 | + |
| 216 | + // Activity list |
| 217 | + var activities = mapData.points.map(function (p) { |
| 218 | + return { |
| 219 | + id: p.activityId, |
| 220 | + activity: p.activity, |
| 221 | + sender: p.sender.split(' ')[0], |
| 222 | + location: p.location, |
| 223 | + date: p.date, |
| 224 | + score: p.score, |
| 225 | + imagePath: p.imagePath, |
| 226 | + placeId: p.placeId, |
| 227 | + url: p.url, |
| 228 | + messages: p.messages |
| 229 | + } |
| 230 | + }) |
| 231 | + |
| 232 | + function renderActivityList(sorted) { |
| 233 | + var html = sorted |
| 234 | + .map(function (a, idx) { |
| 235 | + var mapsUrl = a.placeId |
| 236 | + ? 'https://www.google.com/maps/search/?api=1&query=' + |
| 237 | + encodeURIComponent(a.activity) + |
| 238 | + '&query_place_id=' + |
| 239 | + a.placeId |
| 240 | + : null |
| 241 | + var thumb = a.imagePath |
| 242 | + ? '<img src="' + a.imagePath + '" class="activity-thumb" alt="" />' |
| 243 | + : '<div class="activity-thumb-placeholder"></div>' |
| 244 | + var links = |
| 245 | + (mapsUrl ? '<a href="' + mapsUrl + '" target="_blank">Google Maps</a>' : '') + |
| 246 | + (a.url ? '<a href="' + a.url + '" target="_blank">Source</a>' : '') |
| 247 | + var senderInfo = formatSenders(a.messages) |
| 248 | + var senderHtml = |
| 249 | + '<span class="sender-trigger" data-idx="' + |
| 250 | + idx + |
| 251 | + '">' + |
| 252 | + senderInfo.label + |
| 253 | + ': ' + |
| 254 | + senderInfo.display + |
| 255 | + '</span>' |
| 256 | + return ( |
| 257 | + '<div class="activity-row">' + |
| 258 | + thumb + |
| 259 | + '<div class="activity-content">' + |
| 260 | + '<div class="activity-title">' + |
| 261 | + escapeHtml(a.activity) + |
| 262 | + '</div>' + |
| 263 | + '<div class="activity-meta">' + |
| 264 | + (a.location ? '<span class="activity-location">' + escapeHtml(a.location) + '</span> · ' : '') + |
| 265 | + senderHtml + |
| 266 | + ' · ' + |
| 267 | + a.date + |
| 268 | + '</div>' + |
| 269 | + '<div class="activity-links">' + |
| 270 | + links + |
| 271 | + '</div>' + |
| 272 | + '</div></div>' |
| 273 | + ) |
| 274 | + }) |
| 275 | + .join('') |
| 276 | + document.getElementById('activityListBody').innerHTML = html |
| 277 | + attachTooltipHandlers(sorted) |
| 278 | + } |
| 279 | + |
| 280 | + function attachTooltipHandlers(sorted) { |
| 281 | + var triggers = document.querySelectorAll('.sender-trigger[data-idx]') |
| 282 | + triggers.forEach(function (trigger) { |
| 283 | + trigger.addEventListener('mouseenter', function () { |
| 284 | + var idx = parseInt(trigger.getAttribute('data-idx'), 10) |
| 285 | + var a = sorted[idx] |
| 286 | + if (!a) return |
| 287 | + showTooltip(trigger, a.messages) |
| 288 | + }) |
| 289 | + trigger.addEventListener('mouseleave', hideTooltip) |
| 290 | + }) |
| 291 | + } |
| 292 | + |
| 293 | + window.sortActivities = function (sortBy) { |
| 294 | + var sorted = activities.slice() |
| 295 | + if (sortBy === 'score') |
| 296 | + sorted.sort(function (a, b) { |
| 297 | + return b.score - a.score |
| 298 | + }) |
| 299 | + else if (sortBy === 'oldest') |
| 300 | + sorted.sort(function (a, b) { |
| 301 | + return a.date.localeCompare(b.date) |
| 302 | + }) |
| 303 | + else if (sortBy === 'newest') |
| 304 | + sorted.sort(function (a, b) { |
| 305 | + return b.date.localeCompare(a.date) |
| 306 | + }) |
| 307 | + renderActivityList(sorted) |
| 308 | + } |
| 309 | + |
| 310 | + window.openModal = function () { |
| 311 | + document.getElementById('activityModal').classList.add('open') |
| 312 | + document.body.style.overflow = 'hidden' |
| 313 | + window.sortActivities(document.getElementById('sortSelect').value) |
| 314 | + } |
| 315 | + |
| 316 | + window.closeModal = function (e) { |
| 317 | + if (!e || e.target === e.currentTarget) { |
| 318 | + document.getElementById('activityModal').classList.remove('open') |
| 319 | + document.body.style.overflow = '' |
| 320 | + } |
| 321 | + } |
| 322 | + |
| 323 | + document.addEventListener('keydown', function (e) { |
| 324 | + if (e.key === 'Escape') window.closeModal() |
| 325 | + }) |
| 326 | +})() |
0 commit comments