1// Combobox con búsqueda integrada + paginación (5 por página) 2// Búsqueda insensible a acentos y con tolerancia leve (edit distance <= 1) 3window.filialesSelector = { 4 // Datos 5 all: [], // [{ id, title, json: [ {image_url, title, description}, ... ] }] 6 search: '', // texto tipeado 7 selectedId: 0, // id de la filial seleccionada 8 9 // Estado del combobox 10 open: false, 11 activeIndex: 0, // Ãndice de la opción resaltada dentro de "visible" 12 13 // Paginación del dropdown 14 pageSize: 5, 15 visibleCount: 5, // cuántos mostrar de "filtered" 16 17 // ========================== 18 // Init 19 // ========================== 20 init(filiales) { 21 this.all = Array.isArray(filiales) ? filiales : []; 22 this.resetPagination(); 23 // Si querés preseleccionar la primera: 24 // if (this.all.length) this.applySelection(this.all[0]); 25 }, 26 27 // ========================== 28 // Normalización y helpers 29 // ========================== 30 _norm(s) { 31 return (s || '') 32 .toString() 33 .toLowerCase() 34 // quitar diacrÃticos (acentos) 35 .normalize('NFD').replace(/[\u0300-\u036f]/g, '') 36 // reemplazar cualquier separador raro por espacio 37 .replace(/[^a-z0-9ñ\s]/g, ' ') 38 // colapsar espacios 39 .replace(/\s+/g, ' ') 40 .trim(); 41 }, 42 43 _tokens(s) { 44 const n = this._norm(s); 45 return n ? n.split(' ') : []; 46 }, 47 48 // Distancia de edición simple (Levenshtein) 49 // Usamos umbral 1 para tolerar un pequeño typo. 50 _editDistance(a, b) { 51 const m = a.length, n = b.length; 52 if (!m) return n; 53 if (!n) return m; 54 const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); 55 for (let i = 0; i <= m; i++) dp[i][0] = i; 56 for (let j = 0; j <= n; j++) dp[0][j] = j; 57 for (let i = 1; i <= m; i++) { 58 for (let j = 1; j <= n; j++) { 59 const cost = a[i - 1] === b[j - 1] ? 0 : 1; 60 dp[i][j] = Math.min( 61 dp[i - 1][j] + 1, // eliminación 62 dp[i][j - 1] + 1, // inserción 63 dp[i - 1][j - 1] + cost // sustitución 64 ); 65 } 66 } 67 return dp[m][n]; 68 }, 69 70 // Score de coincidencia entre tÃtulo y query (sin acentos) 71 // Regresa 0 si no matchea lo suficiente, o un número > 0 si matchea (mayor = mejor) 72 _matchScore(title, q) { 73 const t = this._norm(title); 74 const toks = this._tokens(q); 75 if (!toks.length) return 1; // sin query: todos pasan con score mÃnimo 76 77 const words = t.split(' '); 78 79 let present = 0; 80 let score = 0; 81 82 for (const tok of toks) { 83 if (!tok) continue; 84 85 // coincidencia directa: incluye 86 if (t.includes(tok)) { 87 present++; 88 // bonus por palabra completa o comienzo de palabra 89 if (new RegExp(`\\b${tok}\\b`).test(t)) score += 12; 90 else if (new RegExp(`\\b${tok}`).test(t)) score += 10; 91 else score += 8; 92 continue; 93 } 94 95 // tolerancia a error: si alguna palabra del tÃtulo está a distancia 1 del token 96 // (solo aplicamos si el token tiene 4+ para evitar ruido) 97 if (tok.length >= 4) { 98 let near = false; 99 for (const w of words) { 100 if (Math.abs(w.length - tok.length) > 1) continue; // poda rápida 101 if (this._editDistance(w, tok) <= 1) { near = true; break; } 102 } 103 if (near) { present++; score += 6; continue; } 104 } 105 106 // si no matchea este token (ni tolerante), penalizamos 107 score -= 4; 108 } 109 110 // Requerimos al menos que todos los tokens tengan alguna coincidencia (débil) 111 if (present < toks.length) return 0; 112 113 // Bonus por inicio de cadena con el primer token 114 if (t.startsWith(toks[0])) score += 4; 115 116 return Math.max(score, 1); 117 }, 118 119 // ========================== 120 // Filtrado y orden 121 // ========================== 122 get filtered() { 123 const q = this.search || ''; 124 const scored = this.all.map(f => ({ 125 item: f, 126 score: this._matchScore(f.title, q) 127 })).filter(x => x.score > 0); 128 129 // ordenar por score desc y luego por tÃtulo asc 130 scored.sort((a, b) => (b.score - a.score) || String(a.item.title).localeCompare(b.item.title)); 131 return scored.map(x => x.item); 132 }, 133 134 // Subconjunto visible (paginado) 135 get visible() { 136 return this.filtered.slice(0, this.visibleCount); 137 }, 138 139 // Opción activa (dentro de lo visible) 140 get active() { 141 return this.visible[this.activeIndex] || null; 142 }, 143 144 // Para aria-activedescendant 145 get activeDesc() { 146 const a = this.active; 147 return a ? `filial-opt-${a.id}` : ''; 148 }, 149
150 // Filial seleccionada por selectedId 151 get current() { 152 const id = Number(this.selectedId) || 0; 153 if (!id) return null; 154 return this.all.find(f => Number(f.id) === id) || null; 155 }, 156 157 // ========================== 158 // Navegación / selección 159 // ========================== 160 move(delta) { 161 if (!this.open) this.open = true; 162 const len = this.visible.length; 163 if (!len) return; 164 this.activeIndex = (this.activeIndex + delta + len) % len; 165 }, 166 167 selectActive() { 168 if (!this.active) return; 169 this.applySelection(this.active); 170 }, 171 172 selectByIndex(i) { 173 const item = this.visible[i]; 174 if (!item) return; 175 this.applySelection(item); 176 }, 177 178 selectById(id) { 179 const item = this.all.find(f => Number(f.id) === Number(id)); 180 if (!item) return; 181 this.applySelection(item); 182 }, 183 184 applySelection(item) { 185 this.selectedId = Number(item.id); 186 this.search = item.title || ''; 187 this.open = false; 188 this.activeIndex = 0; 189 }, 190 191 clearSelection() { 192 this.selectedId = 0; 193 this.search = ''; 194 this.open = false; 195 this.activeIndex = 0; 196 this.resetPagination(); 197 }, 198 199 // ========================== 200 // Paginación 201 // ========================== 202 resetPagination() { 203 this.visibleCount = this.pageSize; 204 this.activeIndex = 0; 205 }, 206 207 showMore() { 208 const total = this.filtered.length; 209 this.visibleCount = Math.min(this.visibleCount + this.pageSize, total); 210 if (this.activeIndex >= this.visible.length) { 211 this.activeIndex = Math.max(0, this.visible.length - 1); 212 } 213 }, 214 215 // ========================== 216 // Util 217 // ========================== 218 prettyJSON(obj) { 219 try { return JSON.stringify(obj, null, 2); } 220 catch { return ''; } 221 } 222};
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.