1import { Controller } from "@hotwired/stimulus" 2 3const LEAFLET_CSS = "/vendor/leaflet/1.9.4/leaflet.css" 4const CLUSTER_CSS = "/vendor/leaflet-markercluster/1.5.3/MarkerCluster.css" 5const CLUSTER_DEFAULT_CSS = "/vendor/leaflet-markercluster/1.5.3/MarkerCluster.Default.css" 6const CLUSTER_JS = "/vendor/leaflet-markercluster/1.5.3/leaflet.markercluster.js" 7 8// Where a focused pin lands: close enough to read the street, far enough to keep 9// the surrounding blocks on screen. 10const FOCUS_ZOOM = 16 11 12function loadCss(href) { 13 if (document.querySelector(`link[href="${href}"]`)) return 14 const link = document.createElement("link") 15 link.rel = "stylesheet" 16 link.href = href 17 link.crossOrigin = "" 18 document.head.appendChild(link) 19} 20 21function loadScript(src) { 22 return new Promise((resolve, reject) => { 23 if (document.querySelector(`script[src="${src}"]`)) { resolve(); return } 24 const script = document.createElement("script") 25 script.src = src 26 script.crossOrigin = "" 27 script.onload = resolve 28 script.onerror = reject 29 document.head.appendChild(script) 30 }) 31} 32 33function loadLeafletCss() { 34 loadCss(LEAFLET_CSS) 35} 36 37// Every marker arrives with a `color` resolved by MapHelper, which is the single source of 38// truth. This controller used to carry its own copy of the whole category table described 39// as a "mirror" of the Ruby one, which meant the two drifted the moment either changed â 40// so only the fallback remains, for a marker that somehow arrives without a color. 41// --zw-ink-3. 42const FALLBACK_PIN_COLOR = "#566676" 43 44export default class extends Controller { 45 static values = { 46 latitude: { type: Number, default: 39.8283 }, 47 longitude: { type: Number, default: -98.5795 }, 48 zoom: { type: Number, default: 12 }, 49 markers: { type: Array, default: [] }, 50 pinStyle: { type: String, default: "dot" }, 51 zoomPosition: { type: String, default: "topleft" }, 52 autoFit: { type: Boolean, default: false }, 53 maxFitZoom: { type: Number, default: 15 }, 54 fitPadding: { type: Array, default: [40, 40] }, 55 cluster: { type: Boolean, default: false }, 56 clusterRadius: { type: Number, default: 50 }, 57 // 3.0 hands the wheel back to the page: scrolling over the map scrolls past it, and 58 // zooming needs the meta key. Opt-in, because the move-out and program maps share 59 // this controller and expect Leaflet's default. 60 requireMetaToZoom: { type: Boolean, default: false } 61 } 62 63 connect() { 64 this.initMap() 65 } 66 67 async initMap() { 68 loadLeafletCss() 69 if (this.clusterValue) { 70 loadCss(CLUSTER_CSS) 71 loadCss(CLUSTER_DEFAULT_CSS) 72 } 73 74 let L 75 if (this.clusterValue) { 76 // markercluster plugin patches a global L; use a single shared copy. 77 if (!window.L || !window.L.markerClusterGroup) { 78 const leafletModule = await import("leaflet") 79 window.L = Object.assign({}, leafletModule) 80 await loadScript(CLUSTER_JS) 81 } 82 L = window.L 83 } else { 84 L = await import("leaflet") 85 } 86 87 delete L.Icon.Default.prototype._getIconUrl 88 L.Icon.Default.mergeOptions({ 89 iconRetinaUrl: "/vendor/leaflet/1.9.4/images/marker-icon-2x.png", 90 iconUrl: "/vendor/leaflet/1.9.4/images/marker-icon.png", 91 shadowUrl: "/vendor/leaflet/1.9.4/images/marker-shadow.png" 92 }) 93 94 this.L = L 95 96 this.map = L.map(this.element, { 97 zoomControl: false, 98 scrollWheelZoom: !this.requireMetaToZoomValue, 99 // 3.0 zooms in fractional steps rather than Leaflet's whole levels, which is what 100 // makes meta-scroll feel like zooming rather than jumping. 101 ...(this.requireMetaToZoomValue ? { zoomSnap: 0, zoomDelta: 0.5 } : {}) 102 }).setView( 103 [this.latitudeValue, this.longitudeValue], 104 this.zoomValue 105 ) 106 L.control.zoom({ position: this.zoomPositionValue }).addTo(this.map) 107 108 const { addBasemap } = await import("maps/basemap") 109 addBasemap(L, this.map, { maxZoom: 20 }) 110 111 this.layerGroups = {} 112 this.markersById = {} 113 this.addMarkers() 114 if (this.requireMetaToZoomValue) this.attachWheel() 115 116 if (this.autoFitValue && this.markersValue.length > 0) { 117 const points = this.markersValue 118 .filter(m => m.latitude != null && m.longitude != null) 119 .map(m => [m.latitude, m.longitude]) 120 if (points.length > 0) { 121 this.map.fitBounds(points, { maxZoom: this.maxFitZoomValue, padding: this.fitPaddingValue }) 122 } 123 } 124 } 125 126 focus(event) {
127 const id = event?.detail?.id 128 if (!id || !this.markersById) return 129 const marker = this.markersById[String(id)] 130 if (!marker) return 131 132 if (this.pinStyleValue === "disc") { 133 const previous = this.markersById[this.selectedId] 134 if (previous && previous !== marker) this.setDiscActive(previous, false) 135 this.selectedId = String(id) 136 this.setDiscActive(marker, true) 137 } 138 if (this.clusterValue && this.clusterGroup) { 139 this.flyToClustered(marker) 140 } else { 141 this.map.flyTo(marker.getLatLng(), this.focusZoom(), { duration: 0.6 }) 142 marker.openPopup() 143 } 144 } 145 146 // Never zoom out on a focus: someone who has already zoomed past FOCUS_ZOOM 147 // chose that, and pulling them back out would undo it. 148 focusZoom() { 149 return Math.max(this.map.getZoom(), FOCUS_ZOOM) 150 } 151 152 // markercluster's zoomToShowLayer zooms until the pin is no longer clustered, 153 // however deep that is. Two places on one block put the viewer on the rooftop 154 // with every landmark off screen â the opposite of what picking a place off a 155 // neighbourhood list is for. 156 // 157 // So cap the flight at FOCUS_ZOOM and deal with the consequence: the pin is 158 // often still inside a cluster when we arrive. Spiderfying that cluster is 159 // what keeps it reachable, otherwise the click ends on a cluster bubble with 160 // no way to tell which place was picked. 161 flyToClustered(marker) { 162 const destination = marker.getLatLng() 163 const zoom = this.focusZoom() 164 165 const reveal = () => { 166 const parent = this.clusterGroup.getVisibleParent(marker) 167 if (parent && parent !== marker && typeof parent.spiderfy === "function") { 168 // Spiderfy animates the marker out to its leg, so the popup has to wait 169 // for that to finish or it anchors where the pin used to be. 170 this.clusterGroup.once("spiderfied", () => marker.openPopup()) 171 parent.spiderfy() 172 } else { 173 marker.openPopup() 174 } 175 } 176 177 // markercluster refuses to spiderfy while a zoom animation is running, and 178 // it refuses *silently* â no error, no event, no popup. moveend fires too 179 // early to be that moment: the group is still at _inZoomAnimation 1 there 180 // and only reaches 0 by animationend. So re-check on animationend rather 181 // than guessing at a delay. The plugin version is pinned in CLUSTER_JS 182 // above, so this private flag cannot shift under us without that URL 183 // changing. 184 const revealWhenSettled = () => { 185 if (this.clusterGroup._inZoomAnimation) { 186 this.clusterGroup.once("animationend", revealWhenSettled) 187 return 188 } 189 reveal() 190 } 191 192 // flyTo is a no-op when the map already sits at that centre and zoom, and a 193 // no-op fires no moveend â which would leave the popup permanently unopened 194 // on a second click of the same row. 195 const stationary = 196 this.map.getZoom() === zoom && this.map.getCenter().distanceTo(destination) < 1 197 if (stationary) { 198 revealWhenSettled() 199 return 200 } 201 202 this.map.once("moveend", revealWhenSettled) 203 this.map.flyTo(destination, zoom, { duration: 0.6 }) 204 } 205 206 // Show only markers in the given category. Pass "all" (or no arg) to show 207 // everything. No-op if markers haven't been added yet. 208 filterByCategory(category) { 209 if (!this.map) return 210 const openOnly = category === "__open_now__" 211 const showAll = !category || category === "all" 212 213 if (this.clusterValue && this.clusterGroup) { 214 this.clusterGroup.clearLayers() 215 Object.entries(this.markersByCategory || {}).forEach(([cat, markers]) => { 216 markers.forEach(m => { 217 const match = showAll || (openOnly ? m.options.openNow : cat === category) 218 if (match) this.clusterGroup.addLayer(m) 219 }) 220 }) 221 return 222 } 223 224 // "Open now" cuts across categories, so there is no layer group to switch off: 225 // it has to be decided marker by marker. 226 if (openOnly) { 227 Object.values(this.markersByCategory || {}).forEach(markers => { 228 markers.forEach(m => this.setMarkerVisible(m, !!m.options.openNow)) 229 }) 230 return 231 } 232 233 Object.entries(this.layerGroups || {}
233).forEach(([cat, group]) => { 234 const shouldShow = showAll || cat === category 235 // A per-marker filter may have left holes in this group, so put every marker 236 // back rather than assuming the group still holds all of them. 237 if (shouldShow) { 238 (this.markersByCategory?.[cat] || []).forEach(m => this.setMarkerVisible(m, true)) 239 if (!this.map.hasLayer(group)) group.addTo(this.map) 240 } else if (this.map.hasLayer(group)) { 241 this.map.removeLayer(group) 242 } 243 }) 244 } 245 246 // Add or remove one marker from the layer group it belongs to, so a filter that 247 // cuts across categories can hide a single pin without disturbing the group. 248 setMarkerVisible(marker, visible) { 249 const group = this.layerGroups?.[marker.options.mapCategory] 250 if (!group) return 251 if (visible && !group.hasLayer(marker)) group.addLayer(marker) 252 if (!visible && group.hasLayer(marker)) group.removeLayer(marker) 253 } 254 255 // Show only the given categories (multi-select). Used by the map-filter pills. 256 // Works in both clustered and non-clustered modes. No-op if markers haven't 257 // been added yet. 258 applyCategoryFilter(categories) { 259 if (!this.map) return 260 const active = new Set(categories) 261 262 if (this.clusterValue && this.clusterGroup) { 263 this.clusterGroup.clearLayers() 264 Object.entries(this.markersByCategory || {}).forEach(([cat, markers]) => { 265 if (active.has(cat)) markers.forEach(m => this.clusterGroup.addLayer(m)) 266 }) 267 return 268 } 269 270 if (!this.layerGroups) return 271 Object.entries(this.layerGroups).forEach(([cat, group]) => { 272 const shouldShow = active.has(cat) 273 const onMap = this.map.hasLayer(group) 274 if (shouldShow && !onMap) group.addTo(this.map) 275 if (!shouldShow && onMap) this.map.removeLayer(group) 276 }) 277 } 278 279 // Show only the given marker ids. /map filters on more than category â a saved-only 280 // toggle crosses it â so the list works out what is visible and names it, rather than 281 // this controller re-deriving the same answer from a different input. 282 showOnly(ids) { 283 if (!this.map || !this.markersById) return 284 const visible = new Set(ids) 285 286 if (this.clusterValue && this.clusterGroup) { 287 this.clusterGroup.clearLayers() 288 Object.entries(this.markersById).forEach(([id, marker]) => { 289 if (visible.has(id)) this.clusterGroup.addLayer(marker) 290 }) 291 return 292 } 293 294 Object.entries(this.markersById).forEach(([id, marker]) => { 295 const onMap = this.map.hasLayer(marker) 296 if (visible.has(id) && !onMap) marker.addTo(this.map) 297 if (!visible.has(id) && onMap) this.map.removeLayer(marker) 298 }) 299 } 300 301 addMarkers() { 302 const L = this.L 303 this.markersByCategory = {} 304 305 if (this.clusterValue) { 306 this.clusterGroup = L.markerClusterGroup({ 307 maxClusterRadius: this.clusterRadiusValue, 308 showCoverageOnHover: false, 309 spiderfyOnMaxZoom: true, 310 iconCreateFunction: (cluster) => this.clusterIcon(L, cluster) 311 }).addTo(this.map) 312 } 313 314 this.markersValue.forEach(marker => { 315 const category = marker.category || "Other" 316 const color = marker.color || FALLBACK_PIN_COLOR 317 318 const icon = this.pinStyleValue === "disc" ? this.discIcon(L, color, marker.glyph) 319 : this.pinStyleValue === "pin" ? (marker.found ? this.checkIcon(L, color) : this.pinIcon(L, color)) 320 : this.dotIcon(L, color) 321 322 const leafletMarker = L.marker([marker.latitude, marker.longitude], { 323 icon, openNow: !!marker.open_now, mapCategory: category 324 }).bindPopup(this.popupContent(marker)) 325 326 if (this.clusterValue) { 327 this.clusterGroup.addLayer(leafletMarker) 328 } else { 329 if (!this.layerGroups[category]) { 330 this.layerGroups[category] = L.layerGroup().addTo(this.map) 331 } 332 leafletMarker.addTo(this.layerGroups[category]) 333 } 334 335 if (!this.markersByCategory[category]) this.markersByCategory[category] = [] 336 this.markersByCategory[category].push(leafletMarker) 337 338 if (marker.id != null) { 339 this.markersById[String(marker.id)] = leafletMarker 340 341 // Announce the click so a list beside the map can select the matching row. 342 // Additive: this controller is shared with the move-out map and the program 343 // map, which simply have nothing listening. 344 leafletMarker.on("click", () => { 345 this.dispatch("marker-selected", { detail: { id: marker.id }, prefix: "map" }) 346 }) 347 348 if (this.pinStyleValue === "disc") { 349 leafletMarker.on("mouseover", () => this.setDiscActive(leafletMarker, true)) 350 leafletMarker.on("mouseout", () => { 351 if (this.selectedId !== String(marker.id)) this.setDiscActive(leafletMarker, false) 352 }) 353 } 354 } 355 }) 356 } 357 358 clusterIcon(L, cluster) { 359 const count = cluster.getChildCount() 360 const size = count < 10 ? 34 : count < 50 ? 40 : 46 361 362 // 3.0's cluster is the inverse of its pins: white with dark text, ringed in white. 363 // A dark blob competes with the discs it stands for. 364 if (this.pinStyleValue === "disc") { 365 return L.divIcon({ 366 className: "", 367 iconSize: [size, size], 368 iconAnchor: [size / 2, size / 2], 369 html: `<div class="zw-map-cluster" style="width:${size}px;height:${size}px">${count}</div>` 370 }) 371 } 372 373 return L.divIcon({ 374 className: "leaflet-marker-custom", 375 html: `<div style="width:${size}px;height:${size}px;border-radius:50%;background:#0f1a26;color:#fff;display:flex;align-items:center;justify-content:center;font-weight:700;font-size:${size > 38 ? 15 : 13}px;border:3px solid #fff;box-shadow:0 2px 6px rgba(0,0,0,0.3);">${count}</div>`, 376 iconSize: [size, size], 377 iconAnchor: [size / 2, size / 2] 378 }) 379 } 380 381 // 3.0's marker: a filled circle carrying a white glyph, ringed in white â the same
382 // object the list row shows beside each place, so a row and its pin read as one thing. 383 // The glyph arrives rendered from the server, where IconHelper already owns the Lucide 384 // set and the legacy name aliases. 385 discIcon(L, color, glyph, active = false) { 386 const d = active ? 34 : 28 387 const ring = active ? 3 : 2 388 const shadow = active 389 ? "0 5px 12px rgba(28,28,30,0.26)" 390 : "0 2px 6px rgba(28,28,30,0.18)" 391 392 return L.divIcon({ 393 className: "", 394 iconSize: [d, d], 395 iconAnchor: [d / 2, d / 2], 396 popupAnchor: [0, -(d / 2) - 4], 397 html: `<div class="zw-map-disc" style="width:${d}px;height:${d}px;background:${color};box-shadow:${shadow};outline:${ring}px solid #fff">${glyph || ""}</div>` 398 }) 399 } 400 401 setDiscActive(marker, active) { 402 const el = marker.getElement()?.firstElementChild 403 if (!el) return 404 el.style.width = `${active ? 34 : 28}px` 405 el.style.height = `${active ? 34 : 28}px` 406 el.style.outlineWidth = `${active ? 3 : 2}px` 407 el.style.boxShadow = active 408 ? "0 5px 12px rgba(28,28,30,0.26)" 409 : "0 2px 6px rgba(28,28,30,0.18)" 410 marker.setZIndexOffset(active ? 1000 : 0) 411 } 412 413 // 3.0 hands the wheel back to the page. Scrolling over the map scrolls past it, and a 414 // hint appears explaining how to zoom â a full-height map that swallowed the wheel 415 // would trap anyone trying to reach the rest of the page. 416 attachWheel() { 417 this.element.addEventListener("wheel", (event) => { 418 if (event.metaKey || event.ctrlKey) { 419 event.preventDefault() 420 event.stopPropagation() 421 422 const px = event.deltaMode === 1 ? event.deltaY * 16 423 : event.deltaMode === 2 ? event.deltaY * 400 424 : event.deltaY 425 const delta = Math.max(-1.5, Math.min(1.5, -px / 120)) * 0.6 426 const rect = this.element.getBoundingClientRect() 427 const point = this.L.point(event.clientX - rect.left, event.clientY - rect.top) 428 const zoom = Math.max( 429 this.map.getMinZoom() || 0, 430 Math.min(this.map.getMaxZoom() || 19, this.map.getZoom() + delta) 431 ) 432 this.map.setZoomAround(this.map.containerPointToLatLng(point), zoom, { animate: false }) 433 this.hideZoomHint() 434 } else { 435 this.showZoomHint() 436 } 437 }, { passive: false }) 438 } 439 440 showZoomHint() { 441 this.dispatch("zoom-hint", { detail: { visible: true }, prefix: "map" }) 442 clearTimeout(this.hintTimer) 443 this.hintTimer = setTimeout(() => this.hideZoomHint(), 1400) 444 } 445 446 hideZoomHint() { 447 clearTimeout(this.hintTimer) 448 this.dispatch("zoom-hint", { detail: { visible: false }, prefix: "map" }) 449 } 450 451 dotIcon(L, color) { 452 return L.divIcon({ 453 className: "leaflet-marker-custom", 454 html: `<div style="background-color: ${color}; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; box-shadow: 0 1px 3px rgba(0,0,0,0.3);"></div>`, 455 iconSize: [16, 16], 456 iconAnchor: [8, 8], 457 popupAnchor: [0, -10] 458 }) 459 } 460 461 pinIcon(L, color) { 462 // A color-coded teardrop map-pin (Heroicons v2 solid map-pin, 24Ã24 463 // viewBox). The path's even-odd inner circle punches a hole through the 464 // colored body; a white circle behind it fills that hole so the pin reads 465 // as a classic hollow-center marker. 466 const size = 28 467 const svg = ` 468 <svg data-shape="pin" width="${size}" height="${size}" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 469 <circle cx="12" cy="10" r="5.5" fill="white"/> 470 <path fill="${color}" fill-rule="evenodd" clip-rule="evenodd" d="M11.54 22.351l.07.04.028.016a.76.76 0 00.723 0l.028-.015.071-.041a16.975 16.975 0 001.144-.742 19.58 19.58 0 002.683-2.282c1.944-1.99 3.963-4.98 3.963-8.827a8.25 8.25 0 00-16.5 0c0 3.846 2.02 6.837 3.963 8.827a19.58 19.58 0 002.682 2.282 16.975 16.975 0 001.145.742zM12 13a3 3 0 100-6 3 3 0 000 6z"/> 471 </svg> 472 ` 473 return L.divIcon({ 474 className: "leaflet-marker-custom", 475 html: `<div style="position:relative;width:${size}px;height:${size}px;filter:drop-shadow(0 3px 4px rgba(0,0,0,0.35));">${svg}</div>`, 476 iconSize: [size, size], 477 iconAnchor: [size / 2, size - 2], 478 popupAnchor: [0, -size + 6] 479 }) 480 } 481 482 // A found stop on the pin style: a disc in the stop's colour, ringed in 483 // white, carrying a white check (Lucide check). It sits ON the spot rather 484 // than hanging from a tip, so it anchors at its centre. 485 checkIcon(L, color) { 486 const size = 26 487 const svg = ` 488 <svg data-shape="check" width="${size}" height="${size}
488" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"> 489 <circle cx="12" cy="12" r="10.5" fill="${color}" stroke="white" stroke-width="2"/> 490 <path d="M17 8.5 10.1 15.5 7 12.4" fill="none" stroke="white" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"/> 491 </svg> 492 ` 493 return L.divIcon({ 494 className: "leaflet-marker-custom", 495 html: `<div style="width:${size}px;height:${size}px;filter:drop-shadow(0 2px 3px rgba(0,0,0,0.3));">${svg}</div>`, 496 iconSize: [size, size], 497 iconAnchor: [size / 2, size / 2], 498 popupAnchor: [0, -size / 2 - 2] 499 }) 500 } 501 502 popupContent(marker) { 503 let html = `<strong>${this.escapeHtml(marker.name)}</strong>` 504 if (marker.edit_url) { 505 html += ` <a href="${this.escapeHtml(marker.edit_url)}" title="Edit" data-turbo-frame="_top" style="display:inline-block;vertical-align:middle;color:#78716c;margin-left:4px;">` + 506 `<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M15.232 5.232l3.536 3.536m-2.036-5.036a2.5 2.5 0 113.536 3.536L6.5 21.036H3v-3.572L16.732 3.732z"/></svg></a>` 507 } 508 if (marker.event_date) html += `<br><span style="color: #2563eb; font-size: 0.85em; font-weight: 500;">${this.escapeHtml(marker.event_date)}</span>` 509 if (marker.recurrence) html += `<br><span style="color: #7c3aed; font-size: 0.85em;">${this.escapeHtml(marker.recurrence)}</span>` 510 if (marker.schedule) html += `<br><span style="color: #059669; font-size: 0.85em; font-weight: 500;">${this.escapeHtml(marker.schedule)}</span>` 511 if (marker.description) html += `<br><span style="color: #6b7280; font-size: 0.85em;">${this.escapeHtml(marker.description)}</span>` 512 if (marker.address) html += `<br><span style="color: #6b7280; font-size: 0.85em;">${this.escapeHtml(marker.address)}</span>` 513 if (marker.phone) html += `<br><span style="color: #6b7280; font-size: 0.85em;">${this.escapeHtml(marker.phone)}</span>` 514 515 const actions = [] 516 if (marker.website) { 517 // Our own events link to their event page rather than an external site, so the label 518 // is overridable â "Visit website" would be wrong for an internal link. 519 const label = marker.link_label || "Visit website" 520 // Internal links stay in this tab and go through Turbo; external ones open away. 521 const external = !marker.link_label 522 const target = external ? ' target="_blank" rel="noopener"' : "" 523 actions.push(`<a href="${this.escapeHtml(marker.website)}"${target} style="display:inline-flex;align-items:center;justify-content:center;padding:7px 12px;background:#336699;color:#fff;border-radius:8px;font-size:12.5px;font-weight:600;text-decoration:none;flex:1;">${this.escapeHtml(label)}</a>`) 524 } 525 if (marker.latitude != null && marker.longitude != null) { 526 const dest = `${marker.latitude},${marker.longitude}` 527 actions.push(`<a href="https://www.google.com/maps/dir/?api=1&destination=${encodeURIComponent(dest)}" target="_blank" rel="noopener" style="display:inline-flex;align-items:center;justify-content:center;padding:7px 12px;background:#fff;color:#336699;border:1.5px solid #336699;border-radius:8px;font-size:12.5px;font-weight:600;text-decoration:none;flex:1;">Get directions â</a>`) 528 } 529 if (actions.length) { 530 html += `<div style="display:flex;gap:8px;margin-top:12px;">${actions.join("")}</div>` 531 } 532 return html 533 } 534 535 escapeHtml(text) { 536 const div = document.createElement("div") 537 div.textContent = text 538 return div.innerHTML 539 } 540 541 disconnect() { 542 if (this.map) { 543 this.map.remove() 544 this.map = null 545 } 546 } 547}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.