1/** 2 * KASAMUKA Search Engine v1.1.0 3 * Semantic search for KASAMUKA PRO map (p.n / p.a / p.l point format) 4 * 5 * Supports 23 European countries: 6 * PL DE FR IT ES PT NL BE AT HU LU GB CZ SK SE RO HR CH SI LT LV EE BG 7 * 8 * Features: 9 * - Intent detection: LOCKER_CODE_EXACT / CITY_POSTAL / POSTAL_ONLY / 10 * CITY_STREET / CITY_ONLY / STREET_ONLY / FALLBACK 11 * - Per-country postal code formats (PL 12-345, DE 12345, NL 1234AB, 12 * GB SW1A2AA, SE 123 45, CH 1234, PT 1234-567, BE 1000, HU 1234 ...) 13 * - Per-country street prefix noise words (ul./rue/str./via/calle ...) 14 * - Full EU diacritics normalization (Latin only â no Cyrillic) 15 * - Scoring: postal+400, city+300, street+200, lockerId+500 16 * 17 * API: 18 * window.KasamukaSearchEngine.search(points, rawQuery, {countryCode, limit}) 19 * â { results: [...], meta: { type, intent, conflict } } 20 */ 21;(function (window) { 22 'use strict'; 23 24 var SearchEngine = {}; 25 26 // ââ EU diacritics map (Latin only â 23 countries) âââââââââââââââââââââââââ 27 var DIACRITICS_MAP = { 28 // Polish 29 Ä :'a', Ä:'c', Ä:'e', Å:'l', Å:'n', ó:'o', Å:'s', ź:'z', ż:'z', 30 // German / Austrian / Swiss 31 ä:'a', ö:'o', ü:'u', Ã:'ss', 32 // French / Belgian / Luxembourg 33 à :'a', â:'a', é:'e', è:'e', ê:'e', ë:'e', ï:'i', î:'i', 34 ô:'o', ù:'u', û:'u', ü:'u', ÿ:'y', ç:'c', Å:'oe', æ:'ae', 35 // Spanish / Portuguese 36 á:'a', Ã:'i', ú:'u', ñ:'n', ã:'a', õ:'o', 37 // Czech / Slovak 38 Å:'r', Å¡:'s', Ä:'c', ž:'z', ý:'y', Ä:'d', Å¥:'t', Å:'n', Ä:'e', ů:'u', 39 // Hungarian 40 Å:'o', ű:'u', 41 // Romanian 42 È:'s', È:'t', Å:'s', Å£:'t', 43 // Croatian / Slovenian 44 // (Ä, Å¡, ž already covered above) 45 // Swedish / Finnish 46 Ã¥:'a', 47 // Lithuanian 48 Å«:'u', Ä:'e', į:'i', 49 // Latvian 50 Ä:'a', Ä:'e', Ä«:'i', Ä£:'g', Ä·:'k', ļ:'l', Å:'n', Å:'r', 51 // Estonian 52 // (ä, ö, ü already covered above) 53 // GB / IE â no diacritics needed 54 }; 55 56 // ââ Per-country street-prefix noise words âââââââââââââââââââââââââââââââââ 57 // These are stripped from queries/point data before token matching. 58 var NOISE_WORDS_COMMON = { 59 // PL 60 ul:1, 'ul.':1, ulica:1, al:1, 'al.':1, aleja:1, pl:1, plac:1, os:1, 'os.':1, 61 // DE / AT / CH 62 str:1, 'str.':1, strasse:1, straÃe:1, gasse:1, weg:1, platz:1, ring:1, 63 // FR / BE / LU 64 rue:1, avenue:1, 'av.':1, boulevard:1, 'bd.':1, impasse:1, place:1, 65 // IT 66 via:1, viale:1, corso:1, piazza:1, vicolo:1, 67 // ES / PT 68 calle:1, 'c/':1, avenida:1, paseo:1, rua:1, largo:1, travessa:1, 69 // NL / BE (nl) 70 straat:1, laan:1, weg:1, plein:1, kade:1, gracht:1, 71 // SE 72 gatan:1, vagen:1, vägen:1, torget:1, 73 // CZ / SK 74 namesti:1, námÄstÃ:1, trida:1, tÅÃda:1, 75 // HU 76 utca:1, ut:1, út:1, ter:1, tér:1, 77 // GB 78 street:1, road:1, avenue:1, lane:1, drive:1, close:1, court:1, way:1, 79 // RO / HR / SI 80 strada:1, bulevardul:1, calea:1, ulica:1, 81 // Generic 82 nr:1, 'nr.':1, no:1, 'no.':1, 83 }; 84 85 // ââ Per-country postal code patterns ââââââââââââââââââââââââââââââââââââââ 86 // Ordered by specificity (most specific first). 87 // Each entry: { cc: ['XX',...], pattern: RegExp, normalize: fn } 88 var POSTAL_PATTERNS = [ 89 // PL: 12-345 or 12 345 or 12345 (with or without separator) 90 { cc: ['PL'], re: /\b(\d{2})[-\s]?(\d{3})\b(?!\d)/, norm: function(m) { return m[1]+m[2]; } }, 91 // GB: SW1A 2AA, EC1A 1BB, W1A 0AX etc. 92 { cc: ['GB'], re: /\b([A-Z]{1,2}\d{1,2}[A-Z]?)\s?(\d[A-Z]{2})\b/i, norm: function(m) { return (m[1]+m[2]).toUpperCase(); } }, 93 // NL: 1234 AB (Dutch format â letters suffix; BE does NOT use this format) 94 { cc: ['NL'], re: /\b(\d{4})\s?([A-Z]{2})\b/i, norm: function(m) { return (m[1]+m[2]).toUpperCase(); } }, 95 // PT: 1234-567 96 { cc: ['PT'], re: /\b(\d{4})[-\s](\d{3})\b/, norm: function(m) { return m[1]+m[2]; } }, 97 // SE: 123 45 98 { cc: ['SE'], re: /\b(\d{3})\s(\d{2})\b/, norm: function(m) { return m[1]+m[2]; } }, 99 // CH: 4 digits (1000-9999) 100 { cc: ['CH'], re: /\b([1-9]\d{3})\b/, norm: function(m) { return m[1]; } }, 101 // HU/AT/LU/BE: 4 digits (1000-9999) 102 // AT: 1010-9992, LU: 1000-9999, BE: 1000-9999 â all 4-digit, no letter suffix 103 { cc: ['HU','AT','LU','BE'], re: /\b([1-9]\d{3})\b/, norm: function(m) { return m[1]; } }, 104 // LT/LV/EE: LT-12345 or 5 digits 105 { cc: ['LT','LV','EE'], re: /\b(?:[A-Z]{2}-)?(\d{5})\b/i, norm: function(m) { return m[1]; } }, 106 // SI/HR: 4 digits (1000-9999) 107 { cc: ['SI','HR'], re: /\b([1-9]\d{3})\b/, norm: function(m) { return m[1]; } }, 108 // DE/FR/IT/ES/CZ/SK/RO/BG: 5 digits 109 { cc: ['DE','FR','IT','ES','CZ','SK','RO','BG'], re: /\b(\d{5})\b/, norm: function(m) { return m[1]; } }, 110 // Fallback: 5 digits 111 { cc: null, re: /\b(\d{5})\b/, norm: function(m) { return m[1]; } }, 112 // Fallback: 4 digits 113 { cc: null, re: /\b(\d{4})\b/, norm: function(m) { return m[1]; } }, 114 ]; 115 116 // ââ Normalization âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 117 118 function normalizeText(text) { 119 if (!text) return ''; 120 return String(text).toLowerCase().replace(/\s+/g, ' ').trim(); 121 } 122 123 function normalizeDiacritics(text) { 124 if (!text) return '';
125 return String(text).replace(/[^\u0000-\u007E]/g, function (c) { 126 var lc = c.toLowerCase(); 127 return DIACRITICS_MAP[lc] || DIACRITICS_MAP[c] || c; 128 }); 129 } 130 131 function sanitizeQuery(raw) { 132 if (!raw) return ''; 133 var q = String(raw) 134 .replace(/[\r\n\t]+/g, ' ') 135 .replace(/['"\\<>]+/g, '') 136 .replace(/[,;|\s]+$/g, '') 137 .replace(/\s+/g, ' ').trim(); 138 return q; 139 } 140 141 // ââ Postal code detection â country-aware âââââââââââââââââââââââââââââââââ 142 143 function detectPostal(raw, countryCode) { 144 var cc = countryCode ? String(countryCode).toUpperCase() : null; 145 // 'UK' is the country key used in anunaki-countries.js; postal patterns use 'GB'. 146 if (cc === 'UK') cc = 'GB'; 147 for (var i = 0; i < POSTAL_PATTERNS.length; i++) { 148 var pp = POSTAL_PATTERNS[i]; 149 if (pp.cc && cc && pp.cc.indexOf(cc) === -1) continue; 150 var m = raw.match(pp.re); 151 if (m) return { original: m[0], normalized: pp.norm(m) }; 152 } 153 return null; 154 } 155 156 function stripPostal(raw) { 157 // Strip any known postal pattern 158 return raw 159 .replace(/\b[A-Z]{1,2}\d{1,2}[A-Z]?\s?\d[A-Z]{2}\b/gi, ' ') // GB 160 .replace(/\b\d{4}\s?[A-Z]{2}\b/gi, ' ') // NL 161 .replace(/\b[A-Z]{2}-\d{5}\b/gi, ' ') // LT/LV/EE prefix 162 .replace(/\b\d{2}[-\s]\d{3}\b/, ' ') // PL 163 .replace(/\b\d{4}[-\s]\d{3}\b/, ' ') // PT 164 .replace(/\b\d{3}\s\d{2}\b/, ' ') // SE 165 .replace(/\b\d{5}\b/, ' ') // 5-digit 166 .replace(/\b\d{4}\b/, ' ') // 4-digit 167 .replace(/\s+/g, ' ').trim(); 168 } 169 170 function detectLockerId(raw) { 171 if (!raw) return null; 172 // Require at least one digit â prevents city names like "WARS" matching as locker IDs. 173 // Real locker IDs are always alphanumeric with digits: KRA01A, WAW04B, ZOL01, etc. 174 var re = /^(?=.*\d)[A-Z0-9]{4,10}$/; 175 var compact = raw.replace(/\s+/g, '').toUpperCase(); 176 if (re.test(compact)) return compact; 177 var parts = raw.split(/\s+/); 178 for (var i = 0; i < parts.length; i++) { 179 var t = parts[i].toUpperCase(); 180 if (re.test(t)) return t; 181 } 182 return null; 183 } 184 185 // ââ Tokenization âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 186 187 function splitByCityAndStreet(raw) { 188 // Explicit separator: comma, pipe, semicolon 189 var sepMatch = raw.match(/(.*?)(,|\||;)(.*)/); 190 if (sepMatch) return { cityPart: sepMatch[1], streetPart: sepMatch[3] }; 191 // Street prefix keywords (multi-language). 192 // Use (?=\W|$) lookahead instead of \b â dotted abbreviations like "ul." end with 193 // a non-word char, so \b never fires after them (space after dot = \W+\W, no boundary). 194 // Alternatives ordered longestâshortest to prevent "ul" swallowing "ulica". 195 var ulMatch = raw.match( 196 /(.*?)\b(ulica|ul\.|ul|aleja|al\.|al|plac|pl\.|os\.|rue|via|calle|strasse|stra\u00dfe|str\.|str|straat|gatan|utca|strada|rua)(?=\W|$)(.*)/i 197 ); 198 if (ulMatch) return { cityPart: ulMatch[1], streetPart: ulMatch[3] }; 199 return { cityPart: raw, streetPart: '' }; 200 } 201 202 function tokensFrom(text) { 203 if (!text) return []; 204 return normalizeDiacritics(normalizeText(text)) 205 .split(' ') 206 .filter(function (t) { return t.length > 0; }); 207 } 208 209 function filterNoiseTokens(tokens) { 210 if (!tokens || !tokens.length) return []; 211 return tokens.filter(function (t) { return !NOISE_WORDS_COMMON[t]; }); 212 } 213 214 function parseQuery(raw, countryCode) { 215 var sanitized = sanitizeQuery(raw); 216 var normalized = normalizeText(sanitized); 217 var ascii = normalizeDiacritics(normalized); 218 var postal = detectPostal(normalized, countryCode) || detectPostal(ascii, countryCode); 219 var withoutPostal = postal ? stripPostal(normalized) : normalized; 220 var lockerId = detectLockerId(normalized); 221 var parts = splitByCityAndStreet(withoutPostal); 222 var cityTokens = filterNoiseTokens(tokensFrom(parts.cityPart).slice(0, 3)); 223 var streetTokens = filterNoiseTokens(tokensFrom(parts.streetPart)); 224 var buildingNumber = null; 225 if (streetTokens.length > 0) { 226 var last = streetTokens[streetTokens.length - 1]; 227 if (/^\d+[a-zA-Z]?$/.test(last)) { 228 buildingNumber = last; 229 streetTokens.pop(); 230 } 231 } 232 return { 233 raw: raw, sanitized: sanitized, normalized: normalized, ascii: ascii, 234 lockerId: lockerId, postal: postal, 235 cityTokens: cityTokens, streetTokens: streetTokens, buildingNumber: buildingNumber 236 }; 237 } 238 239 // ââ KASAMUKA point field accessors (p.n / p.a / p.l format) ââââââââââââââ 240 241 function pointCountry(point) { 242 if (!point) return null; 243 var c = point.country ? String(point.country).toUpperCase() : null; 244 // Normalize 'UK' alias â 'GB' so filterByCountry and detectPostal are consistent 245 return c === 'UK' ? 'GB' : c; 246 } 247 248 function pointPostal(point, countryCode) { 249 if (point._postal !== undefined) return point._postal; 250 var addr = point.a || ''; 251 var r = detectPostal(addr, countryCode || pointCountry(point)); 252 return r ? r.normalized : ''; 253 } 254 255 /** 256 * Extract CITY tokens from point address. 257 * p.l is always a description (or null) â city is in the LAST comma-segment 258 * of p.a after stripping the postal code. 259 * 260 * Examples across all countries: 261 * PL "KoÅciuszki 27, 21-412 Adamów" â last seg "21-412 Adamów" â "Adamów" 262 * DE "Baustr. 21a, 17438 Wolgast" â last seg "17438 Wolgast" â "Wolgast" 263 * FR "RUE DU GENERAL DE GAULLE, 76240 BELBEUF" â last seg "76240 BELBEUF" â "BELBEUF" 264 * UK "Oxford St, SW1A 2AA London" â last seg "SW1A 2AA London" â "London" 265 * IT "Via Montello 5, 67051 Avezzano" â last seg "67051 Avezzano" â "Avezzano" 266 * ES "CARRER DE BINEFAR 3, 08020 BARCELONA" â last seg "08020 BARCELONA" â "BARCELONA" 267 * NL "51 Raadstede, 3431 HA Nieuwegein" â last seg "3431 HA Nieuwegein" â "Nieuwegein" 268 */ 269 function pointCityTokens(point) { 270 if (point._cityTokens) return point._cityTokens; 271 var addr = point.a || ''; 272 var segs = addr.split(','); 273 var last = (segs[segs.length - 1] || '').trim(); 274 var city = stripPostal(last).trim();
275 // If stripping ate the entire segment, try second-to-last 276 if (!city && segs.length > 1) { 277 city = stripPostal((segs[segs.length - 2] || '').trim()).trim(); 278 } 279 return tokensFrom(city); 280 } 281 282 /** 283 * Full address tokens â ALL segments of p.a after stripping postal code. 284 * Broader than pointCityTokens â includes street words too. 285 * Used as fallback when city extraction from last segment fails 286 * (unusual address formats, missing commas, etc.). 287 */ 288 function pointAddrTokens(point) { 289 if (point._addrTokens) return point._addrTokens; 290 var raw = stripPostal(normalizeText(point.a || '')); 291 return filterNoiseTokens(tokensFrom(raw)); 292 } 293 294 function pointStreetTokens(point) { 295 if (point._streetTokens) return point._streetTokens; 296 // p.a = full address; street is the first comma-segment (before postal+city) 297 var raw = point.a || ''; 298 raw = stripPostal(raw).split(',')[0].trim(); 299 return filterNoiseTokens(tokensFrom(raw)); 300 } 301 302 // ââ Matching helpers ââââââââââââââââââââââââââââââââââââââââââââââââââââââ 303 304 function tokensMatchAll(queryTokens, targetTokens) { 305 if (!queryTokens.length || !targetTokens.length) return false; 306 return queryTokens.every(function (qt) { 307 return targetTokens.some(function (tt) { return tt.indexOf(qt) !== -1; }); 308 }); 309 } 310 311 function tokensMatchAny(queryTokens, targetTokens) { 312 if (!queryTokens.length || !targetTokens.length) return false; 313 return queryTokens.some(function (qt) { 314 return targetTokens.some(function (tt) { return tt.indexOf(qt) !== -1; }); 315 }); 316 } 317 318 /** 319 * Exact whole-word city match â each query token must equal a target token exactly. 320 * Used for city filtering so "WrocÅaw" doesn't match "InowrocÅaw" or "WrocÅawskie". 321 * Substring matching is kept as fallback for partial/incomplete queries. 322 */ 323 function tokensMatchAllExact(queryTokens, targetTokens) { 324 if (!queryTokens.length || !targetTokens.length) return false; 325 return queryTokens.every(function (qt) { 326 return targetTokens.some(function (tt) { return tt === qt; }); 327 }); 328 } 329 330 function filterByCountry(points, countryCode) { 331 if (!countryCode) return points.slice(); 332 var cc = String(countryCode).toUpperCase(); 333 if (cc === 'UK') cc = 'GB'; // normalize alias â pointCountry() also normalizes UKâGB 334 return points.filter(function (p) { 335 var pc = pointCountry(p); 336 return !pc || pc === cc; 337 }); 338 } 339 340 // ââ Intent evaluation âââââââââââââââââââââââââââââââââââââââââââââââââââââ 341
342 function evaluateIntent(parsed, points, countryCode) { 343 var inCountry = filterByCountry(points, countryCode); 344 345 var lockerPoint = null; 346 if (parsed.lockerId) { 347 var lid = parsed.lockerId; 348 lockerPoint = inCountry.filter(function (p) { 349 return p.n && String(p.n).toUpperCase().replace(/\s+/g, '') === lid; 350 })[0] || null; 351 } 352 353 var postalPoints = []; 354 if (parsed.postal) { 355 var pnorm = parsed.postal.normalized; 356 postalPoints = inCountry.filter(function (p) { 357 return pointPostal(p, countryCode) === pnorm; 358 }); 359 } 360 361 var cityPoints = []; 362 if (parsed.cityTokens.length > 0) { 363 // Exact whole-word match first: "WrocÅaw" must not match "InowrocÅaw" or "WrocÅawskie" 364 // pointCityTokens extracts city from last comma-segment of p.a (all countries). 365 // pointAddrTokens is broader fallback â all address tokens (handles unusual formats). 366 cityPoints = inCountry.filter(function (p) { 367 return tokensMatchAllExact(parsed.cityTokens, pointCityTokens(p)) || 368 tokensMatchAllExact(parsed.cityTokens, pointAddrTokens(p)); 369 }); 370 // If no exact matches (partial query like "Wroc"), fall back to substring 371 if (cityPoints.length === 0) { 372 cityPoints = inCountry.filter(function (p) { 373 return tokensMatchAll(parsed.cityTokens, pointCityTokens(p)) || 374 tokensMatchAll(parsed.cityTokens, pointAddrTokens(p)); 375 }); 376 } 377 } 378 379 var streetPoints = []; 380 if (parsed.streetTokens.length > 0 && cityPoints.length > 0) { 381 streetPoints = cityPoints.filter(function (p) { 382 return tokensMatchAll(parsed.streetTokens, pointStreetTokens(p)); 383 }); 384 } 385 386 var hasLocker = !!lockerPoint; 387 var hasPostal = postalPoints.length > 0; 388 var hasCity = cityPoints.length > 0; 389 var hasStreet = streetPoints.length > 0; 390 391 var type = 'FALLBACK'; 392 if (hasLocker) type = 'LOCKER_CODE_EXACT'; 393 else if (hasPostal && hasCity) type = 'CITY_POSTAL'; 394 else if (hasPostal) type = 'POSTAL_ONLY'; 395 else if (hasCity && hasStreet) type = 'CITY_STREET'; 396 else if (hasCity) type = 'CITY_ONLY'; 397 else if (parsed.streetTokens.length > 0) type = 'STREET_ONLY'; 398 399 var cityMatchesPostal = true; 400 if (parsed.postal && hasPostal && hasCity) { 401 var postalCities = postalPoints 402 .map(function (p) { return pointCityTokens(p).join(' '); }) 403 .filter(function (x) { return x.length > 0; }); 404 var citySet = {}; 405 postalCities.forEach(function (c) { citySet[c] = true; }); 406 var parsedCityAscii = normalizeDiacritics(parsed.cityTokens.join(' ')); 407 cityMatchesPostal = Object.keys(citySet).some(function (c) { 408 return normalizeDiacritics(c).indexOf(parsedCityAscii) !== -1; 409 }); 410 } 411 412 var streetExistsInCityOrPostal = true; 413 if (parsed.streetTokens.length > 0) { 414 var base = hasCity ? cityPoints : postalPoints; 415 if (base.length > 0) { 416 streetExistsInCityOrPostal = base.some(function (p) { 417 return tokensMatchAll(parsed.streetTokens, pointStreetTokens(p)); 418 }); 419 } 420 } 421 422 return { 423 inCountry: inCountry, 424 lockerPoint: lockerPoint, 425 postalPoints: postalPoints, 426 cityPoints: cityPoints, 427 streetPoints: streetPoints, 428 type: type, 429 consistency: { 430 cityMatchesPostal: cityMatchesPostal, 431 streetExistsInCityOrPostal: streetExistsInCityOrPostal 432 } 433 }; 434 } 435 436 // ââ Relevance scoring âââââââââââââââââââââââââââââââââââââââââââââââââââââ 437 438 function sortByRelevance(points, parsed, countryCode) { 439 var cityTokens = parsed.cityTokens; 440 var streetTokens = parsed.streetTokens; 441 var postalNorm = parsed.postal ? parsed.postal.normalized : null; 442 var queryTokens = tokensFrom(parsed.normalized); 443 444 function scorePoint(p) { 445 var s = 0; 446 var name = normalizeDiacritics(normalizeText(p.n || '')); 447 var cityT = pointCityTokens(p); 448 var streetT = pointStreetTokens(p); 449 var postal = pointPostal(p, countryCode); 450 451 if (postalNorm && postal === postalNorm) s += 400; 452 if (cityTokens.length > 0 && tokensMatchAll(cityTokens, cityT)) s += 300; 453 if (streetTokens.length > 0 && tokensMatchAll(streetTokens, streetT)) s += 200; 454 if (streetTokens.length > 0 && tokensMatchAny(streetTokens, streetT)) s += 50; 455 if (parsed.lockerId && name.indexOf(parsed.lockerId.toLowerCase()) !== -1) s += 500; 456 if (tokensMatchAny(queryTokens, cityT)) s += 250; 457 458 // Penalize test/dummy entries (contain "TEST" or are purely sequential numbers) 459 if (/\btest\b/i.test(p.n || '')) { s -= 1000; } 460 461 // Penalize points that don't match the searched street when street was specified 462 // Makes "Warszawa ul. PowstaÅców ÅlÄ skich" not show unrelated street points first 463 if (streetTokens.length > 0 && !tokensMatchAny(streetTokens, streetT)) { s -= 350; } 464 465 return s; 466 } 467 468 return points 469 .map(function (p) { return { p: p, s: scorePoint(p) }; }) 470 .sort(function (a, b) { return b.s - a.s; }) 471 .map(function (x) { return x.p; }); 472 } 473 474 function pointHasBuildingNumber(point, buildingNumber) { 475 if (!buildingNumber) return false;
476 return (point.a || '').toLowerCase().indexOf(String(buildingNumber).toLowerCase()) !== -1; 477 } 478 479 // ââ Fallback broad filter âââââââââââââââââââââââââââââââââââââââââââââââââ 480 481 var NOISE_SHORT_NUM = /^\d{1,2}$/; 482 483 function buildFallbackFilter(points, countryCode, parsed) { 484 var inCountry = filterByCountry(points, countryCode); 485 var qTokens = tokensFrom(parsed.normalized); 486 var cityQ = qTokens.filter(function (t) { 487 return t && !NOISE_SHORT_NUM.test(t) && !NOISE_WORDS_COMMON[t] && t.length >= 3; 488 }); 489 return inCountry.filter(function (p) { 490 var name = tokensFrom(normalizeDiacritics(normalizeText(p.n || ''))); 491 var ct = pointCityTokens(p); 492 var at = pointAddrTokens(p); // full address â city may be here for UK/LT etc. 493 var st = pointStreetTokens(p); 494 // City filter uses prefix/exact matching (NOT substring) to prevent 495 // "gora" matching "bilgoraj" or "nowa" matching "nowy" etc. 496 // Also check full address tokens for countries where p.l doesn't have city. 497 if (cityQ.length) { 498 var _allCityT = ct.concat(at); 499 var _cityOk = cityQ.every(function(qt) { 500 return _allCityT.some(function(tt) { return tt === qt || tt.indexOf(qt) === 0; }); 501 }); 502 if (!_cityOk) return false; 503 } 504 return tokensMatchAny(qTokens, name) || tokensMatchAny(qTokens, ct) || tokensMatchAny(qTokens, st) || tokensMatchAny(qTokens, at); 505 }); 506 } 507 508 // ââ Main search âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ 509 510 function search(points, rawQuery, options) { 511 var opts = options || {};
512 var countryCode = opts.countryCode || null; 513 var sanitized = sanitizeQuery(rawQuery); 514 515 if (!sanitized) { 516 return { results: [], meta: { type: 'EMPTY', intent: null, conflict: false } }; 517 } 518 519 var parsed = parseQuery(sanitized, countryCode); 520 var compact = parsed.normalized.replace(/\s+/g, ''); 521 522 // Reject pure-numeric strings too short to be a postal code 523 if ( 524 compact && /^\d+$/.test(compact) && 525 (!parsed.postal || !parsed.postal.normalized) && 526 compact.length < 4 && 527 parsed.cityTokens.length === 0 && 528 parsed.streetTokens.length === 0 && 529 !parsed.lockerId 530 ) { 531 return { results: [], meta: { type: 'NUMERIC_TOO_SHORT', intent: null, conflict: false } }; 532 } 533 534 var intent = evaluateIntent(parsed, points, countryCode); 535 var results = []; 536 var searchType = intent.type; 537 538 if (intent.type === 'LOCKER_CODE_EXACT' && intent.lockerPoint) { 539 // Locker ID gets priority slot #1, then fill with context-based results. 540 // This allows queries like "Warszawa KRA01B" to return the exact locker 541 // FIRST (with yellow pulse in UI) + nearby Warsaw points after it. 542 var _otherParsed = { 543 normalized: parsed.normalized, 544 cityTokens: parsed.cityTokens, 545 streetTokens: parsed.streetTokens, 546 postal: parsed.postal, 547 lockerId: null, 548 buildingNumber: parsed.buildingNumber 549 }; 550 var _others = sortByRelevance( 551 buildFallbackFilter(points, countryCode, _otherParsed).filter(function(p) { 552 return p !== intent.lockerPoint; 553 }), 554 _otherParsed, countryCode 555 ); 556 results = [intent.lockerPoint].concat(_others); 557 } else if (intent.type === 'CITY_POSTAL') { 558 // PERF: Set lookup O(1) per element instead of indexOf O(M) â critical for large cities 559 var _citySet = new Set(intent.cityPoints); 560 var intersection = intent.postalPoints.filter(function (p) { 561 return _citySet.has(p); 562 }); 563 results = sortByRelevance( 564 intersection.length > 0 ? intersection : intent.postalPoints, 565 parsed, countryCode 566 ); 567 } else if (intent.type === 'POSTAL_ONLY') { 568 results = sortByRelevance(intent.postalPoints, parsed, countryCode); 569 } else if (intent.type === 'CITY_STREET') { 570 var base = intent.streetPoints.length > 0 ? intent.streetPoints : intent.cityPoints; 571 if (parsed.buildingNumber && base.length > 0) { 572 var narrowed = base.filter(function (p) { 573 return pointHasBuildingNumber(p, parsed.buildingNumber); 574 }); 575 if (narrowed.length > 0) base = narrowed; 576 } 577 results = sortByRelevance(base, parsed, countryCode); 578 } else if (intent.type === 'CITY_ONLY') { 579 results = sortByRelevance(intent.cityPoints, parsed, countryCode); 580 } else if (intent.type === 'STREET_ONLY') { 581 var inC = filterByCountry(points, countryCode); 582 results = sortByRelevance( 583 inC.filter(function (p) { return tokensMatchAll(parsed.streetTokens, pointStreetTokens(p)); }), 584 parsed, countryCode 585 ); 586 } else { 587 results = sortByRelevance(buildFallbackFilter(points, countryCode, parsed), parsed, countryCode); 588 searchType = 'FALLBACK'; 589 } 590 591 // Second-pass fallback if nothing found 592 if (!results.length) { 593 results = sortByRelevance(buildFallbackFilter(points, countryCode, parsed), parsed, countryCode); 594 searchType = 'FALLBACK'; 595 } 596 597 var maxResults = typeof opts.limit === 'number' ? opts.limit : 500; 598 599 // ââ Carrier interleaving for city/street searches âââââââââââââââââââââââââ 600 // Problem: a single carrier (e.g. Orlen) may dominate results for a city, 601 // pushing all other carriers far down. Interleave by carrier so results 602 // show variety: InPost, DPD, Orlen, Pocztex, ⦠mixed together. 603 // 604 // Only apply to multi-carrier situations. Top N results (street-specific 605 // or first best match) are preserved; the remaining are interleaved. 606 if (searchType === 'CITY_ONLY' || searchType === 'CITY_STREET' || searchType === 'CITY_POSTAL') { 607 var _seenCarriers = {};
608 results.forEach(function(p) { _seenCarriers[p.carrier || 'inpost'] = true; }); 609 var _numCarriers = Object.keys(_seenCarriers).length; 610 if (_numCarriers >= 2) { 611 // All results go through carrier interleaving (no top-tier bypass) 612 var _topTier = []; 613 var _restTier = results; 614 // Group by carrier 615 var _grp = {}, _gOrd = []; 616 _restTier.forEach(function(p) { 617 var c = p.carrier || 'inpost'; 618 if (!_grp[c]) { _grp[c] = []; _gOrd.push(c); } 619 _grp[c].push(p); 620 }); 621 // Round-robin 622 var _il = []; 623 var _mL = 0; 624 _gOrd.forEach(function(c) { _mL = Math.max(_mL, _grp[c].length); }); 625 for (var _ri = 0; _ri < _mL; _ri++) { 626 _gOrd.forEach(function(c) { 627 if (_grp[c][_ri]) { _il.push(_grp[c][_ri]); } 628 }); 629 } 630 results = _topTier.concat(_il); 631 } 632 } 633 634 if (results.length > maxResults) results = results.slice(0, maxResults); 635 636 var conflict = intent.consistency && ( 637 !intent.consistency.cityMatchesPostal || 638 !intent.consistency.streetExistsInCityOrPostal 639 ); 640 641 return { 642 results: results, 643 meta: { type: searchType, intent: intent, conflict: conflict } 644 }; 645 } 646 647 // PERF: pre-compute expensive per-point fields once after data loads. 648 // Chunked via requestIdleCallback to avoid blocking main thread on 100k+ points. 649 // Country switch: each load builds a FRESH points array (anunaki-popup.js allPoints), so a 650 // stale _postal never rides on new objects. The cancellation token (_pcToken) stops a pending 651 // idle-chain from a previous country from continuing to write into an abandoned array. 652 SearchEngine.precompute = function(points, countryCode) { 653 var index = 0; 654 var CHUNK = 1000; 655 var myToken = (SearchEngine._pcToken = (SearchEngine._pcToken || 0) + 1); 656 657 function processChunk(deadline) { 658 if (myToken !== SearchEngine._pcToken) { return; } // superseded by a newer precompute (country switch) 659 var timeOk = !deadline || deadline.timeRemaining() > 2; 660 while (index < points.length && timeOk) { 661 var p = points[index++]; 662 p._postal = pointPostal(p, countryCode); 663 p._cityTokens = pointCityTokens(p); 664 p._addrTokens = pointAddrTokens(p); 665 p._streetTokens = pointStreetTokens(p); 666 if (deadline && index % CHUNK === 0) { 667 timeOk = deadline.timeRemaining() > 2; 668 } 669 } 670 if (index < points.length) { 671 if (typeof requestIdleCallback !== 'undefined') { 672 requestIdleCallback(processChunk, { timeout: 500 }); 673 } else { 674 setTimeout(function() { processChunk(null); }, 0); 675 } 676 } 677 } 678 679 if (typeof requestIdleCallback !== 'undefined') { 680 requestIdleCallback(processChunk, { timeout: 500 }); 681 } else { 682 setTimeout(function() { processChunk(null); }, 0); 683 } 684 }; 685 686 SearchEngine.search = search; 687 SearchEngine.parseQuery = parseQuery; 688 SearchEngine.evaluateIntent = evaluateIntent; 689 SearchEngine.VERSION = 2; 690 691 window.KasamukaSearchEngine = SearchEngine; 692 693})(window);
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.