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.