PageSourceSearch

https://zerowaste.org/assets/controllers/map_controller-b2615603.js

js zerowaste.org collected 2026-09-26 00:00:17 UTC 22,752 bytes, 547 lines download raw bytes

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.