PageSourceSearch

https://crashcount.nyc/js/crashfinder-search-core.5cf74fcfae200999…ee385165495d9b77005d1425336dec7f4c734ae0.js

js crashcount.nyc collected 2026-09-25 03:16:41 UTC 37,894 bytes, 1,100 lines download raw bytes

1(() => {
2  if (window.CrashCountSearchCore) return;
3
4  const DEFAULT_MAPBOX_BBOX = '-74.25909,40.477399,-73.700272,40.916178';
5  const DEFAULT_MAPBOX_PROXIMITY = '-73.98,40.75';
6
7  const GROUP_ORDER = [
8    'geographies',
9    'coordinates',
10    'crashes',
11    'addresses',
12    'schools',
13    'streets',
14    'bike_lanes',
15    'intersections',
16    'public_events'
17  ];
18
19  const GROUP_LABELS = {
20    geographies: 'Geographies',
21    addresses: 'Addresses',
22    schools: 'Schools',
23    streets: 'Streets',
24    bike_lanes: 'Bike Lanes',
25    intersections: 'Intersections',
26    coordinates: 'Coordinates',
27    crashes: 'Crashes',
28    public_events: 'Fatality Pages'
29  };
30
31  const GROUP_LIMITS = {
32    geographies: 8,
33    coordinates: 2,
34    crashes: 3,
35    public_events: 5,
36    addresses: 3,
37    schools: 5,
38    streets: 5,
39    bike_lanes: 5,
40    intersections: 3
41  };
42  const EMPTY_FATALITY_PAGES = [];
43
44  const GEO_LAYER_PREFIX = {
45    borough: 'borough',
46    council: 'council-district',
47    community: 'community-board',
48    precinct: 'police-precinct',
49    nta: 'neighborhood',
50    assembly: 'assembly-district',
51    senate: 'senate-district',
52    citywide: 'citywide'
53  };
54
55  const GEO_LAYER_LABELS = {
56    borough: 'Borough',
57    council: 'Council District',
58    community: 'Community Board',
59    precinct: 'Police Precinct',
60    nta: 'Neighborhood',
61    assembly: 'Assembly District',
62    senate: 'Senate District',
63    citywide: 'Citywide'
64  };
65
66  const GEO_LAYER_SHORTHAND = {
67    council: 'CD',
68    senate: 'SD',
69    assembly: 'AD',
70    community: 'CB'
71  };
72
73  const BOROUGH_ORDER = {
74    Manhattan: 0,
75    Brooklyn: 1,
76    Queens: 2,
77    Bronx: 3,
78    'Staten Island': 4
79  };
80
81  const BIKE_LANE_FACILITY_LABELS = {
82    protected: 'Protected',
83    unprotected: 'Unprotected',
84    sharrow: 'Sharrow/Shared',
85    off_street: 'Off-street',
86    link: 'Link',
87    other: 'Other',
88    unknown: 'Unknown'
89  };
90
91  const NYC_PLACES = new Set([
92    'New York',
93    'Brooklyn',
94    'Queens',
95    'Bronx',
96    'Staten Island',
97    'Manhattan'
98  ]);
99
100  function normalizeText(value) {
101    if (value === null || value === undefined) return '';
102    return String(value).trim();
103  }
104
105  function coalesce(...values) {
106    for (const value of values) {
107      const text = normalizeText(value);
108      if (text) return text;
109    }
110    return '';
111  }
112
113  function resolveConfigValue(value, fallback) {
114    let resolved = value;
115    if (typeof resolved === 'function') {
116      try {
117        resolved = resolved();
118      } catch (err) {
119        resolved = undefined;
120      }
121    }
122    if (resolved === undefined || resolved === null) return fallback;
123    if (typeof resolved === 'string' && resolved === '') return fallback;
124    return resolved;
125  }
126
127  function safeNumber(value) {
128    const num = Number(value);
129    return Number.isFinite(num) ? num : null;
130  }
131
132  function normalizeSearchText(value) {
133    return String(value || '')
134      .toLowerCase()
135      .replace(/[^a-z0-9]+/g, ' ')
136      .trim();
137  }
138
139  function normalizeSchoolText(value) {
140    return String(value || '')
141      .toLowerCase()
142      .replace(/\bp\.?\s*s\.?\b/g, 'ps')
143      .replace(/\bi\.?\s*s\.?\b/g, 'is')
144      .replace(/\bj\.?\s*h\.?\s*s\.?\b/g, 'jhs')
145      .replace(/\bm\.?\s*s\.?\b/g, 'ms')
146      .replace(/\bh\.?\s*s\.?\b/g, 'hs')
147      .replace(/([a-z])(\d)/g, '$1 $2')
148      .replace(/(\d)([a-z])/g, '$1 $2')
149      .replace(/[^a-z0-9]+/g, ' ')
150      .trim();
151  }
152
153  function slugify(value) {
154    return String(value || '')
155      .toLowerCase()
156      .replace(/[^a-z0-9]+/g, '-')
157      .replace(/^-+|-+$/g, '');
158  }
159
160  function parseIntersectionQuery(query) {
161    const parts = String(query || '')
162      .split(/\s*(?:&|\/|@|\+|\b(?:and|at)\b)\s*/i)
163      .map(item => item.trim())
164      .filter(Boolean);
165    if (parts.length < 2) return null;
166    return [parts[0], parts[1]];
167  }
168
169  function extractBorough(query) {
170    const q = ` ${String(query || '').toLowerCase()} `;
171    if (q.includes(' manhattan ') || q.includes(' mn ')) return 'Manhattan';
172    if (q.includes(' brooklyn ') || q.includes(' bk ')) return 'Brooklyn';
173    if (q.includes(' queens ') || q.includes(' qn ') || q.includes(' quee')) return 'Queens';
174    if (q.includes(' bronx ') || q.includes(' bx ')) return 'Bronx';
175    if (q.includes(' staten island ') || q.includes(' staten ') || q.includes(' si ')) return 'Staten Island';
176    return null;
177  }
178
179  function parseLatLonQuery(query) {
180    const match = String(query || '').trim().match(/(-?\d+(?:\.\d+)?)\s*(?:,|\s)\s*(-?\d+(?:\.\d+)?)/);
181    if (!match) return null;
182    let lat = Number(match[1]);
183    let lon = Number(match[2]);
184    if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
185    if (Math.abs(lat) > 90 || Math.abs(lon) > 180) {
186      if (Math.abs(lon) <= 90 && Math.abs(lat) <= 180) {
187        const swap = lat;
188        lat = lon;
189        lon = swap;
190      } else {
191        return null;
192      }
193    }
194    if (Math.abs(lat) > 90 || Math.abs(lon) > 180) return null;
195    return { lat, lon };
196  }
197
198  function formatCoordinateLabel(lat, lon) {
199    return `Coordinates ${lat.toFixed(5)}, ${lon.toFixed(5)}`;
200  }
201
202  function parseCrashIdQuery(query) {
203    const value = String(query || '').trim();
204    if (!value) return null;
205    const explicit = value.match(/^(?:crash|collision|crash id|crashid|id|#)\s*[:#]?\s*(\d{6,})$/i);
206    if (explicit) return explicit[1];
207    if (/^\d{6,}$/.test(value)) return value;
208    return null;
209  }
210
211  function isNycFeature(feature) {
212    if (!feature) return false;
213    const context = Array.isArray(feature.context) ? feature.context : [];
214    const place = context.find(item => item.id && item.id.startsWith('place.'));
215    if (place && place.text) return NYC_PLACES.has(place.text);
216    if (place) return false;
217    const label = feature.place_name || '';
218    if (label.includes('New Jersey')) return false;
219    return [
220      'Brooklyn',
221      'Queens',
222      'Bronx',
223      'Staten Island',
224      'Manhattan',
225      'New York, NY',
226      'New York, New York'
227    ].some(token => label.includes(token));
228  }
229
230  function boroughRank(name) {
231    return BOROUGH_ORDER[name] ?? 99;
232  }
233
234  function streetNameFromLabel(label) {
235    if (!label) return '';
236    const parts = String(label).split(' - ');
237    return parts[0] || label;
238  }
239
240  function buildGeoPath(layer, id) {
241    const prefix = GEO_LAYER_PREFIX[layer];
242    if (!prefix) return '';
243    const safeId = layer === 'borough' ? slugify(id) : String(id || '').toLowerCase();
244    if (!safeId) return '';
245    return `/${prefix}/${safeId}/`;
246  }
247
248  function buildGeoUrl(layer, id, options = {}) {
249    const path = buildGeoPath(layer, id);
250    if (!path) return '';
251    const baseUrl = resolveConfigValue(options.baseUrl, '');
252    const absoluteGeoUrls = !!resolveConfigValue(options.absoluteGeoUrls, false);
253    if (!absoluteGeoUrls || !baseUrl) return path;
254    try {
255      return new URL(path, baseUrl).toString();
256    } catch (err) {
257      return path;
258    }
259  }
260
261  function parseGeoQuery(query) {
262    const normalized = normalizeSearchText(query);
263    if (!normalized) return null;
264    const patterns = [
265      { layer: 'council', re: /^(?:cd|council|council district)\s*(\d{1,3})$/ },
266      { layer: 'senate', re: /^(?:sd|senate|senate district)\s*(\d{1,3})$/ },
267      { layer: 'assembly', re: /^(?:ad|assembly|assembly district)\s*(\d{1,3})$/ },
268      { layer: 'community', re: /^(?:cb|community|community board)\s*(\d{1,2})$/ },
269      { layer: 'precinct', re: /^(?:pp|precinct|police precinct)\s*(\d{1,3})$/ }
270    ];
271    for (const pattern of patterns) {
272      const match = normalized.match(pattern.re);
273      if (!match) continue;
274      const id = match[1];
275      if (pattern.layer === 'community') return { layer: pattern.layer, board: Number(id) };
276      return { layer: pattern.layer, id };
277    }
278    return null;
279  }
280
281  function buildGeoSearchEntries(data) {
282    if (!Array.isArray(data)) return [];
283    return data
284      .map(entry => {
285        const layer = entry.layer;
286        const id = String(entry.id || '').trim();
287        const label = entry.label || entry.short_label || '';
288        const shortLabel = entry.short_label || '';
289        const politician = entry.politician || '';
290        const shorthand = GEO_LAYER_SHORTHAND[layer];
291        const tokens = [label, shortLabel, politician];
292        if (layer === 'borough' && normalizeSearchText(label) === 'bronx') {
293          tokens.push('the bronx');
294        }
295        if (shorthand && id) tokens.push(`${shorthand} ${id}`);
296        if (layer && id) tokens.push(`${GEO_LAYER_LABELS[layer] || layer} ${id}`);
297        const cbNumber = layer === 'community' ? Number(id) % 100 : null;
298        if (layer === 'community' && Number.isFinite(cbNumber)) {
299          tokens.push(`CB ${cbNumber}`);
300          tokens.push(`Community Board ${cbNumber}`);
301        }
302        return {
303          layer,
304          id,
305          label: label || `${GEO_LAYER_LABELS[layer] || 'District'} ${id}`,
306          shortLabel,
307          politician,
308          cbNumber,
309          searchText: normalizeSearchText(tokens.join(' '))
310        };
311      })
312      .filter(entry => entry.layer && entry.id);
313  }
314
315  function buildSchoolSearchEntries(data) {
316    if (!Array.isArray(data)) return [];
317    return data
318      .map(entry => {
319        const label = entry.label || entry.name || '';
320        const shortLabel = entry.short_label || entry.shortLabel || '';
321        const aliases = Array.isArray(entry.aliases) ? entry.aliases : [];
322        const locationCode = entry.location_code || '';
323        const grades = entry.grades || '';
324        const borough = entry.borough || '';
325        const schoolType = entry.school_type || entry.schoolType || '';
326        const managedBy = entry.managed_by || entry.managedBy || '';
327        const lat = Number(entry.latitude);
328        const lon = Number(entry.longitude);
329        const codeDigits = String(locationCode).replace(/[^0-9]/g, '');
330        const schoolNumber = codeDigits.replace(/^0+/, '');
331        const derivedAliases = [];
332        if (schoolNumber) {
333          const gradeTokens = String(grades)
334            .split(/[,\s]+/)
335            .map(token => token.trim().toLowerCase())
336            .filter(Boolean);
337          const hasLowerGrades = gradeTokens.some(token => {
338            if (token === 'pk' || token === '0k' || token === 'k') return true;
339            if (/^\d+$/.test(token)) return Number(token) <= 5;
340            return false;
341          });
342          const hasMiddleGrades = gradeTokens.some(token => {
343            if (/^\d+$/.test(token)) {
344              const num = Number(token);
345              return num >= 6 && num <= 8;
346            }
347            return false;
348          });
349          if (hasLowerGrades) derivedAliases.push(`ps ${schoolNumber}`);
350          if (hasMiddleGrades) derivedAliases.push(`is ${schoolNumber}`);
351        }
352        const tokens = [label, shortLabel, locationCode, borough, schoolType, managedBy, ...aliases, ...derivedAliases];
353        if (schoolNumber) tokens.push(schoolNumber);
354        const normalized = normalizeSchoolText(tokens.join(' '));
355        if (!label || !Number.isFinite(lat) || !Number.isFinite(lon) || !normalized) return null;
356        const compact = normalized.replace(/\s+/g, '');
357        const searchTokens = normalized.split(' ').filter(Boolean);
358        return {
359          id: entry.id,
360          label,
361          shortLabel,
362          borough,
363          schoolType,
364          managedBy,
365          lat,
366          lon,
367          searchText: `${normalized} ${compact}`.trim(),
368          searchNormalized: normalized,
369          searchCompact: compact,
370          searchTokens
371        };
372      })
373      .filter(Boolean);
374  }
375
376  function findGeoMatches(query, geoEntries, options = {}) {
377    const normalized = normalizeSearchText(query);
378    if (normalized.length < 2) return [];
379
380    const boroughMatch = geoEntries.find(
381      entry => entry.layer === 'borough' && normalizeSearchText(entry.label) === normalized
382    );
383
384    if (boroughMatch) {
385      return [
386        {
387          key: `geo:${boroughMatch.layer}:${boroughMatch.id}`,
388          label: boroughMatch.label,
389          meta: boroughMatch.politician || '',
390          group: 'geographies',
391          url: buildGeoUrl(boroughMatch.layer, boroughMatch.id, options),
392          params: {
393            layer: boroughMatch.layer,
394            id: boroughMatch.id,
395            label: boroughMatch.label,
396            politician: boroughMatch.politician || ''
397          }
398        }
399      ];
400    }
401
402    const parsed = parseGeoQuery(normalized);
403    let matches = [];
404
405    if (parsed && parsed.layer === 'community' && Number.isFinite(parsed.board)) {
406      matches = geoEntries.filter(entry => entry.layer === 'community' && entry.cbNumber === parsed.board);
407    } else if (parsed && parsed.layer && parsed.id) {
408      matches = geoEntries.filter(entry => entry.layer === parsed.layer && entry.id === parsed.id);
409    } else {
410      const tokens = normalized.split(' ').filter(Boolean);
411      matches = geoEntries.filter(entry => tokens.every(token => entry.searchText.includes(token)));
412      matches.sort((a, b) => {
413        const aStarts = a.searchText.startsWith(normalized);
414        const bStarts = b.searchText.startsWith(normalized);
415        if (aStarts === bStarts) return a.searchText.length - b.searchText.length;
416        return aStarts ? -1 : 1;
417      });
418    }
419
420    return matches.slice(0, 8).map(entry => ({
421      key: `geo:${entry.layer}:${entry.id}`,
422      label: entry.label,
423      meta: entry.politician || '',
424      group: 'geographies',
425      url: buildGeoUrl(entry.layer, entry.id, options),
426      params: {
427        layer: entry.layer,
428        id: entry.id,
429        label: entry.label,
430        politician: entry.politician || ''
431      }
432    }));
433  }
434
435  function tokenMatchesSchool(token, entry) {
436    if (!token || !entry || !Array.isArray(entry.searchTokens)) return false;
437    if (/^\d+$/.test(token)) return entry.searchTokens.includes(token);
438    return entry.searchTokens.some(entryToken => entryToken.startsWith(token));
439  }
440
441  function isUpkSchool(entry) {
442    const schoolType = String((entry && entry.schoolType) || '')
443      .trim()
444      .toLowerCase();
445    return schoolType === 'upk';
446  }
447
448  function buildSchoolMeta(entry) {
449    const parts = [];
450    if (entry.schoolType) parts.push(entry.schoolType);
451    else if (entry.managedBy) parts.push(entry.managedBy);
452    if (entry.borough) parts.push(entry.borough);
453    return parts.join(' · ');
454  }
455
456  function findSchoolMatches(query, schoolEntries, options = {}) {
457    const normalized = normalizeSchoolText(query);
458    if (normalized.length < 2) return [];
459    const tokens = normalized.split(' ').filter(Boolean);
460    if (!tokens.length) return [];
461    const compact = normalized.replace(/\s+/g, '');
462    const matches = schoolEntries.filter(entry => tokens.every(token => tokenMatchesSchool(token, entry)));
463    matches.sort((a, b) => {
464      const aStarts = a.searchNormalized.startsWith(normalized) || (compact && a.searchCompact.startsWith(compact));
465      const bStarts = b.searchNormalized.startsWith(normalized) || (compact && b.searchCompact.startsWith(compact));
466      if (aStarts !== bStarts) return aStarts ? -1 : 1;
467      const aUpk = isUpkSchool(a);
468      const bUpk = isUpkSchool(b);
469      if (aUpk !== bUpk) return aUpk ? 1 : -1;
470      return (a.label || '').length - (b.label || '').length;
471    });
472
473    const schoolLimit = Number(resolveConfigValue(options.schoolLimit, 6));
474
475    return matches.slice(0, schoolLimit).map(entry => ({
476      key: `school:${entry.id || entry.label}:${entry.lat}:${entry.lon}`,
477      label: entry.label,
478      meta: buildSchoolMeta(entry),
479      group: 'schools',
480      params: {
481        layer: 'school',
482        label: entry.label,
483        lat: entry.lat,
484        lon: entry.lon
485      }
486    }));
487  }
488
489  function buildIntersectionSuggestions(leftStreets, rightStreets) {
490    if (!leftStreets.length || !rightStreets.length) return [];
491    const leftByBorough = new Map();
492    const rightByBorough = new Map();
493
494    leftStreets.forEach(item => {
495      const borough = item.properties && item.properties.borough;
496      if (!borough || leftByBorough.has(borough)) return;
497      leftByBorough.set(borough, item);
498    });
499
500    rightStreets.forEach(item => {
501      const borough = item.properties && item.properties.borough;
502      if (!borough || rightByBorough.has(borough)) return;
503      rightByBorough.set(borough, item);
504    });
505
506    const boroughs = Array.from(leftByBorough.keys())
507      .filter(borough => rightByBorough.has(borough))
508      .sort((a, b) => boroughRank(a) - boroughRank(b));
509
510    const features = [];
511
512    for (const borough of boroughs) {
513      const left = leftByBorough.get(borough);
514      const right = rightByBorough.get(borough);
515      if (!left || !right) continue;
516
517      const leftName = streetNameFromLabel(left.properties.label);
518      const rightName = streetNameFromLabel(right.properties.label);
519      const centerA = left.geometry && left.geometry.coordinates;
520      const centerB = right.geometry && right.geometry.coordinates;
521
522      let center = null;
523      if (centerA && centerB) {
524        center = [(centerA[0] + centerB[0]) / 2, (centerA[1] + centerB[1]) / 2];
525      } else {
526        center = centerA || centerB;
527      }
528
529      features.push({
530        type: 'Feature',
531        geometry: center ? { type: 'Point', coordinates: center } : null,
532        properties: {
533          layer: 'intersection',
534          label: `${leftName} & ${rightName} · ${borough}`,
535          street_id_a: left.properties.street_id,
536          street_id_b: right.properties.street_id,
537          borough
538        }
539      });
540
541      if (features.length >= 3) break;
542    }
543
544    return features;
545  }
546
547  function toAddressItems(features, limit = 6) {
548    const items = [];
549    const seen = new Set();
550    const max = Number.isFinite(Number(limit)) ? Number(limit) : 6;
551
552    features.forEach(feature => {
553      if (!feature || !feature.address) return;
554      const center = Array.isArray(feature.center) ? feature.center : [];
555      const label = feature.place_name || feature.text || 'Address';
556      const key = feature.place_name || feature.id || feature.text || label;
557      if (seen.has(key)) return;
558      seen.add(key);
559
560      items.push({
561        key: `address:${feature.id || label}`,
562        label,
563        group: 'addresses',
564        params: {
565          layer: 'address',
566          label,
567          lat: center.length === 2 ? center[1] : '',
568          lon: center.length === 2 ? center[0] : ''
569        }
570      });
571    });
572
573    return items.slice(0, Math.max(1, max));
574  }
575
576  function toStreetItems(features) {
577    return features.map(item => {
578      const props = item.properties || {};
579      const coords = item.geometry && item.geometry.coordinates ? item.geometry.coordinates : [];
580      return {
581        key: `street:${props.street_id || props.label || ''}`,
582        label: props.label || 'Street',
583        group: 'streets',
584        params: {
585          layer: 'street',
586          label: props.label || 'Street',
587          street_id: props.street_id || '',
588          lat: coords.length === 2 ? coords[1] : '',
589          lon: coords.length === 2 ? coords[0] : ''
590        }
591      };
592    });
593  }
594
595  function toBikeLaneItems(features) {
596    return features.map(item => {
597      const props = item.properties || {};
598      const coords = item.geometry && item.geometry.coordinates ? item.geometry.coordinates : [];
599      return {
600        key: `bike_lane:${props.bike_lane_id || props.label || ''}`,
601        label: props.label || 'Bike lane',
602        meta: props.meta || props.borough || '',
603        group: 'bike_lanes',
604        params: {
605          layer: 'bike_lane',
606          label: props.label || 'Bike lane',
607          bike_lane_id: props.bike_lane_id || '',
608          lat: coords.length === 2 ? coords[1] : '',
609          lon: coords.length === 2 ? coords[0] : ''
610        }
611      };
612    });
613  }
614
615  function toIntersectionItems(features) {
616    return features.map(item => {
617      const props = item.properties || {};
618      const coords = item.geometry && item.geometry.coordinates ? item.geometry.coordinates : [];
619      return {
620        key: `intersection:${props.street_id_a || ''}:${props.street_id_b || ''}`,
621        label: props.label || 'Intersection',
622        group: 'intersections',
623        params: {
624          layer: 'intersection',
625          label: props.label || 'Intersection',
626          street_a: props.street_id_a || '',
627          street_b: props.street_id_b || '',
628          lat: coords.length === 2 ? coords[1] : '',
629          lon: coords.length === 2 ? coords[0] : ''
630        }
631      };
632    });
633  }
634
635  function dedupeSuggestions(items, max = 0) {
636    const out = [];
637    const seen = new Set();
638    items.forEach(item => {
639      if (!item || !item.key || seen.has(item.key)) return;
640      seen.add(item.key);
641      out.push(item);
642    });
643    if (max > 0) return out.slice(0, max);
644    return out;
645  }
646
647  function groupSuggestions(items, groupOrder = GROUP_ORDER) {
648    const grouped = new Map();
649    groupOrder.forEach(group => grouped.set(group, []));
650    items.forEach(item => {
651      if (!item || !item.group || !grouped.has(item.group)) return;
652      grouped.get(item.group).push(item);
653    });
654    return grouped;
655  }
656
657  function limitGroupedSuggestions(grouped, groupLimits = GROUP_LIMITS) {
658    const limited = new Map();
659    grouped.forEach((items, group) => {
660      const limit = groupLimits[group] || items.length;
661      limited.set(group, items.slice(0, limit));
662    });
663    return limited;
664  }
665
666  function isStreetOnlyQuery(query) {
667    const value = normalizeSearchText(query);
668    if (!value) return false;
669    if (parseIntersectionQuery(value)) return false;
670    if (value.includes(',')) return false;
671
672    const suffixRe = /\b(?:street|st|avenue|ave|road|rd|boulevard|blvd|drive|dr|lane|ln|place|pl|court|ct|terrace|ter|way|parkway|pkwy)\b/;
673    if (!suffixRe.test(value)) return false;
674
675    const tokens = value.split(' ').filter(Boolean);
676    if (!tokens.length) return false;
677
678    const startsWithHouseNumber = /^\d+$/.test(tokens[0]);
679    if (startsWithHouseNumber && tokens.length >= 3) return false;
680
681    return true;
682  }
683
684  function shouldIncludeAddresses(query, intersectionParts, options = {}) {
685    if (!resolveConfigValue(options.enableAddressResults, true)) return false;
686    if (intersectionParts) return false;
687    if (isStreetOnlyQuery(query)) return false;
688    return true;
689  }
690
691  async function defaultFetchMapbox(query, options = {}) {
692    const token = resolveConfigValue(options.mapboxToken, '');
693    if (!token) return [];
694
695    const fetchImpl = options.fetchImpl || window.fetch;
696    if (typeof fetchImpl !== 'function') return [];
697
698    const params = new URLSearchParams({
699      access_token: token,
700      autocomplete: 'true',
701      limit: String(options.limit || 12),
702      types: options.types || 'address',
703      country: 'US',
704      bbox: resolveConfigValue(options.mapboxBBox, DEFAULT_MAPBOX_BBOX),
705      proximity: resolveConfigValue(options.mapboxProximity, DEFAULT_MAPBOX_PROXIMITY)
706    });
707
708    const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(query)}.json?${params}`;
709    const res = await fetchImpl(url);
710    if (!res.ok) throw new Error(`mapbox ${res.status}`);
711    const data = await res.json();
712    return Array.isArray(data.features) ? data.features.filter(isNycFeature) : [];
713  }
714
715  async function defaultFetchStreetSuggestions(query, options = {}) {
716    const apiBase = resolveConfigValue(options.apiBase, '');
717    if (!apiBase) return [];
718
719    const fetchImpl = options.fetchImpl || window.fetch;
720    if (typeof fetchImpl !== 'function') return [];
721
722    const version = resolveConfigValue(options.version, 'latest');
723    const url = `${apiBase}/search?q=${encodeURIComponent(query)}&version=${encodeURIComponent(version)}`;
724    const res = await fetchImpl(url);
725    if (!res.ok) throw new Error(`street search ${res.status}`);
726
727    const data = await res.json();
728    const streets = Array.isArray(data.streets) ? data.streets : [];
729
730    return streets.map(street => {
731      const coords = Array.isArray(street.center) ? street.center : null;
732      return {
733        type: 'Feature',
734        geometry: coords ? { type: 'Point', coordinates: coords } : null,
735        properties: {
736          layer: 'street',
737          label: street.label || street.name || 'Street',
738          street_id: street.street_id,
739          borough: street.borough
740        }
741      };
742    });
743  }
744
745  async function defaultFetchBikeLaneSuggestions(query, options = {}) {
746    const apiBase = resolveConfigValue(options.apiBase, '');
747    if (!apiBase) return [];
748
749    const fetchImpl = options.fetchImpl || window.fetch;
750    if (typeof fetchImpl !== 'function') return [];
751
752    const version = resolveConfigValue(options.version, 'latest');
753    const url = `${apiBase}/bike-lanes/search?q=${encodeURIComponent(query)}&version=${encodeURIComponent(version)}`;
754    const res = await fetchImpl(url);
755    if (!res.ok) throw new Error(`bike lane search ${res.status}`);
756
757    const data = await res.json();
758    const lanes = Array.isArray(data.bike_lanes) ? data.bike_lanes : [];
759
760    return lanes.map(lane => {
761      const coords = Array.isArray(lane.center) ? lane.center : null;
762      const metaParts = [];
763      if (lane.facility_group) {
764        metaParts.push(BIKE_LANE_FACILITY_LABELS[lane.facility_group] || lane.facility_group);
765      }
766      if (lane.borough) metaParts.push(lane.borough);
767
768      return {
769        type: 'Feature',
770        geometry: coords ? { type: 'Point', coordinates: coords } : null,
771        properties: {
772          layer: 'bike_lane',
773          label: lane.label || lane.name || 'Bike lane',
774          bike_lane_id: lane.bike_lane_id,
775          borough: lane.borough,
776          meta: metaParts.filter(Boolean).join(' · ')
777        }
778      };
779    });
780  }
781
782  function buildFatalitySearchEntries(data) {
783    if (!Array.isArray(data)) return [];
784    return data
785      .map(entry => {
786        const url = normalizeText(entry.url);
787        if (!url) return null;
788        const title = normalizeText(entry.title) || 'Fatality page';
789        const location = normalizeText(entry.location);
790        const date = normalizeText(entry.date);
791        const borough = normalizeText(entry.borough);
792        const pageMode = normalizeText(entry.page_mode);
793        const officialStatus = normalizeText(entry.official_record_status);
794        const officialLabel = normalizeText(entry.official_record_label);
795        const collisionId = normalizeText(entry.collision_id);
796        const eventId = normalizeText(entry.event_id);
797        const summary = normalizeText(entry.summary);
798        const filterTags = Array.isArray(entry.filter_tags) ? entry.filter_tags : [];
799        const tokens = [
800          title,
801          summary,
802          location,
803          date,
804          borough,
805          pageMode,
806          officialStatus,
807          officialLabel,
808          collisionId,
809          eventId,
810          url,
811          ...filterTags
812        ];
813        return {
814          url,
815          title,
816          summary,
817          location,
818          date,
819          borough,
820          pageMode,
821          officialStatus,
822          officialLabel,
823          collisionId,
824          eventId,
825          fatalities: entry.fatalities,
826          searchText: normalizeSearchText(tokens.join(' ')),
827          compactSearchText: normalizeSearchText(tokens.join(' ')).replace(/\s+/g, '')
828        };
829      })
830      .filter(entry => entry && entry.searchText);
831  }
832
833  function publicEventStatusLabel(entry) {
834    if (entry.pageMode === 'provisional_press' || entry.officialStatus === 'unmatched' || !entry.collisionId) {
835      return 'Press-reported';
836    }
837    return 'Fatality page';
838  }
839
840  function publicEventMeta(entry) {
841    const parts = [
842      entry.date,
843      entry.borough,
844      publicEventStatusLabel(entry)
845    ].filter(Boolean);
846    return parts.join(' · ');
847  }
848
849  function fatalityMatchScore(entry, queryText, compactQuery) {
850    if (!entry || !queryText) return null;
851    if (entry.searchText === queryText || entry.compactSearchText === compactQuery) return 0;
852    const titleText = normalizeSearchText(entry.title);
853    const locationText = normalizeSearchText(entry.location);
854    if (titleText.startsWith(queryText) || locationText.startsWith(queryText)) return 1;
855    if (entry.searchText.includes(queryText)) return 2;
856    if (compactQuery && entry.compactSearchText.includes(compactQuery)) return 3;
857    const tokens = queryText.split(' ').filter(Boolean);
858    if (tokens.length > 1 && tokens.every(token => entry.searchText.includes(token))) return 4;
859    return null;
860  }
861
862  function findFatalityMatches(query, entries, options = {}) {
863    const queryText = normalizeSearchText(query);
864    if (queryText.length < 3) return [];
865    const compactQuery = queryText.replace(/\s+/g, '');
866    const limit = Number(resolveConfigValue(options.limit, GROUP_LIMITS.public_events || 5));
867    return entries
868      .map(entry => ({ entry, score: fatalityMatchScore(entry, queryText, compactQuery) }))
869      .filter(match => match.score !== null)
870      .sort((left, right) => {
871        if (left.score !== right.score) return left.score - right.score;
872        return String(right.entry.date || '').localeCompare(String(left.entry.date || ''));
873      })
874      .slice(0, Math.max(1, limit))
875      .map(({ entry }) => ({
876        key: `public_event:${entry.eventId || entry.url}`,
877        label: entry.title || 'Fatality page',
878        meta: publicEventMeta(entry),
879        group: 'public_events',
880        url: entry.url,
881        params: {
882          layer: 'public_event',
883          label: entry.title || 'Fatality page',
884          public_event_id: entry.eventId || '',
885          page_mode: entry.pageMode || '',
886          official_record_status: entry.officialStatus || '',
887          fatality_page_url: entry.url,
888          url: entry.url,
889          collision_id: entry.collisionId || ''
890        }
891      }));
892  }
893
894  function createEngine(config = {}) {
895    let preparedIndex = null;
896    let geoRef = null;
897    let schoolRef = null;
898    let fatalityRef = null;
899
900    const getSearchData =
901      typeof config.getSearchData === 'function'
902        ? config.getSearchData
903        : () => window.CrashCountSearchIndex || {};
904
905    const ensureSearchIndexLoaded =
906      typeof config.ensureSearchIndexLoaded === 'function'
907        ? config.ensureSearchIndexLoaded
908        : async () => {};
909
910    const fetchMapboxSuggestions =
911      typeof config.fetchMapboxSuggestions === 'function'
912        ? config.fetchMapboxSuggestions
913        : (query, options) => defaultFetchMapbox(query, options);
914
915    const fetchStreetSuggestions =
916      typeof config.fetchStreetSuggestions === 'function'
917        ? config.fetchStreetSuggestions
918        : (query, options) => defaultFetchStreetSuggestions(query, options);
919
920    const fetchBikeLaneSuggestions =
921      typeof config.fetchBikeLaneSuggestions === 'function'
922        ? config.fetchBikeLaneSuggestions
923        : (query, options) => defaultFetchBikeLaneSuggestions(query, options);
924
925    function ensureIndex() {
926      const source = getSearchData() || {};
927      const geo = Array.isArray(source.geo) ? source.geo : [];
928      const schools = Array.isArray(source.schools) ? source.schools : [];
929      const fatalityPages = Array.isArray(source.fatality_pages)
930        ? source.fatality_pages
931        : EMPTY_FATALITY_PAGES;
932
933      if (!preparedIndex || geo !== geoRef || schools !== schoolRef || fatalityPages !== fatalityRef) {
934        preparedIndex = {
935          geoEntries: buildGeoSearchEntries(geo),
936          schoolEntries: buildSchoolSearchEntries(schools),
937          fatalityEntries: buildFatalitySearchEntries(fatalityPages)
938        };
939        geoRef = geo;
940        schoolRef = schools;
941        fatalityRef = fatalityPages;
942      }
943
944      return preparedIndex;
945    }
946
947    async function search(query, runtime = {}) {
948      const value = normalizeText(query);
949      const coordMatch = parseLatLonQuery(value);
950      const crashId = parseCrashIdQuery(value);
951      const minLength = Number(resolveConfigValue(runtime.minLength, 2));
952
953      if (!value || (value.length < minLength && !coordMatch && !crashId)) {
954        return [];
955      }
956
957      await Promise.resolve(ensureSearchIndexLoaded());
958      const { geoEntries, schoolEntries, fatalityEntries } = ensureIndex();
959
960      const includeGeographies = resolveConfigValue(runtime.includeGeographies, true);
961      const includeSchools = resolveConfigValue(runtime.includeSchools, true);
962      const includeBikeLanes = resolveConfigValue(
963        runtime.enableBikeLaneResults,
964        resolveConfigValue(config.enableBikeLaneResults, true)
965      );
966      const includePublicEvents = resolveConfigValue(
967        runtime.enablePublicEventResults,
968        resolveConfigValue(config.enablePublicEventResults, false)
969      );
970      const includeAddressResults = resolveConfigValue(runtime.enableAddressResults, true);
971
972      const baseUrl = resolveConfigValue(runtime.baseUrl, resolveConfigValue(config.baseUrl, ''));
973      const absoluteGeoUrls = resolveConfigValue(runtime.absoluteGeoUrls, resolveConfigValue(config.absoluteGeoUrls, false));
974
975      const geoResults = includeGeographies
976        ? findGeoMatches(value, geoEntries, { baseUrl, absoluteGeoUrls })
977        : [];
978      const schoolResults = includeSchools
979        ? findSchoolMatches(value, schoolEntries, {
980            schoolLimit: resolveConfigValue(runtime.schoolLimit, resolveConfigValue(config.schoolResultLimit, 6))
981          })
982        : [];
983
984      const custom = [];
985
986      if (coordMatch) {
987        custom.push({
988          key: `coordinate:${coordMatch.lat}:${coordMatch.lon}`,
989          label: formatCoordinateLabel(coordMatch.lat, coordMatch.lon),
990          meta: '',
991          group: 'coordinates',
992          params: {
993            layer: 'coordinate',
994            label: formatCoordinateLabel(coordMatch.lat, coordMatch.lon),
995            lat: coordMatch.lat,
996            lon: coordMatch.lon
997          }
998        });
999      }
1000
1001      if (crashId) {
1002        custom.push({
1003          key: `crash:${crashId}`,
1004          label: `Crash #${crashId}`,
1005          meta: 'Crash ID',
1006          group: 'crashes',
1007          params: {
1008            layer: 'crash',
1009            label: `Crash #${crashId}`,
1010            crash_id: crashId
1011          }
1012        });
1013      }
1014
1015      const intersectionParts = parseIntersectionQuery(value);
1016      const borough = extractBorough(value);
1017      const includeAddresses = shouldIncludeAddresses(value, intersectionParts, {
1018        enableAddressResults: includeAddressResults
1019      });
1020
1021      const requestOptions = {
1022        fetchImpl: config.fetchImpl || window.fetch,
1023        apiBase: resolveConfigValue(runtime.apiBase, resolveConfigValue(config.apiBase, '')),
1024        version: resolveConfigValue(runtime.version, resolveConfigValue(config.version, 'latest')),
1025        mapboxToken: resolveConfigValue(runtime.mapboxToken, resolveConfigValue(config.mapboxToken, '')),
1026        mapboxBBox: resolveConfigValue(runtime.mapboxBBox, resolveConfigValue(config.mapboxBBox, DEFAULT_MAPBOX_BBOX)),
1027        mapboxProximity: resolveConfigValue(runtime.mapboxProximity, resolveConfigValue(config.mapboxProximity, DEFAULT_MAPBOX_PROXIMITY))
1028      };
1029
1030      const requests = [
1031        includeAddresses ? fetchMapboxSuggestions(value, { ...requestOptions, limit: 12, types: 'address' }) : Promise.resolve([]),
1032        fetchStreetSuggestions(value, requestOptions),
1033        includeBikeLanes ? fetchBikeLaneSuggestions(value, requestOptions) : Promise.resolve([])
1034      ];
1035
1036      if (intersectionParts) {
1037        const [left, right] = intersectionParts;
1038        const leftQuery = borough ? `${left} ${borough}` : left;
1039        const rightQuery = borough ? `${right} ${borough}` : right;
1040        requests.push(fetchStreetSuggestions(leftQuery, requestOptions));
1041        requests.push(fetchStreetSuggestions(rightQuery, requestOptions));
1042      }
1043
1044      const settled = await Promise.allSettled(requests);
1045
1046      const mapbox = settled[0].status === 'fulfilled' ? settled[0].value : [];
1047      const streets = settled[1].status === 'fulfilled' ? settled[1].value : [];
1048      const bikeLanes = settled[2].status === 'fulfilled' ? settled[2].value : [];
1049      const publicEvents = includePublicEvents
1050        ? findFatalityMatches(value, fatalityEntries, {
1051            limit: resolveConfigValue(runtime.publicEventResultLimit, resolveConfigValue(config.publicEventResultLimit, GROUP_LIMITS.public_events))
1052          })
1053        : [];
1054
1055      let intersections = [];
1056      if (intersectionParts) {
1057        const leftStreets = settled[3] && settled[3].status === 'fulfilled' ? settled[3].value : [];
1058        const rightStreets = settled[4] && settled[4].status === 'fulfilled' ? settled[4].value : [];
1059        intersections = buildIntersectionSuggestions(leftStreets, rightStreets);
1060      }
1061
1062      const addressLimit = Number(
1063        resolveConfigValue(runtime.addressResultLimit, resolveConfigValue(config.addressResultLimit, 6))
1064      );
1065
1066      return dedupeSuggestions([
1067        ...geoResults,
1068        ...toAddressItems(mapbox, addressLimit),
1069        ...schoolResults,
1070        ...toStreetItems(streets),
1071        ...(includeBikeLanes ? toBikeLaneItems(bikeLanes) : []),
1072        ...(includePublicEvents ? publicEvents : []),
1073        ...toIntersectionItems(intersections),
1074        ...custom
1075      ]);
1076    }
1077
1078    return {
1079      ensureIndex,
1080      search
1081    };
1082  }
1083
1084  window.CrashCountSearchCore = {
1085    GROUP_ORDER,
1086    GROUP_LABELS,
1087    GROUP_LIMITS,
1088    normalizeText,
1089    normalizeSearchText,
1090    normalizeSchoolText,
1091    parseIntersectionQuery,
1092    parseLatLonQuery,
1093    parseCrashIdQuery,
1094    formatCoordinateLabel,
1095    groupSuggestions,
1096    limitGroupedSuggestions,
1097    dedupeSuggestions,
1098    createEngine
1099  };
1100})();

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.