// ─── Scelta con ricerca: la logica, senza DOM ─────────────────────────────── // // Le proposte per quello che si scrive in un campo di scelta (territorio, // proclamatore). Funzioni pure, provate da tests/js/scelta.test.mjs. // // Un'opzione è { valore, etichetta, dettaglio?, chiavi: [stringhe], motivo?, // speciale? }: // chiavi i campi in cui cercare, in ordine di importanza (cognome prima // del nome, numero prima della zona) // motivo l'opzione c'è ma non si può scegliere ("già assegnato a …"): si // mostra solo cercando, mai nella tendina completa // speciale sempre in cima e mai filtrata ("— nessun territorio —") // // Punteggio di ogni parola scritta, più basso = più vicino: // 0 uguale · 1 inizia con · 2 contiene · 3 a una lettera di distanza // ("forse cercavi"), più 0.1 per ogni posizione della chiave. // Devono trovare posto tutte le parole scritte, in qualsiasi ordine // ("mario ros" trova Rossi Mario). export function normalizza(testo) { return (testo ?? '') .toString() .normalize('NFD') .replace(/[̀-ͯ]/g, '') .toLowerCase() .trim(); } /** Distanza di modifica tra due parole, fermandosi appena supera `limite`. */ export function distanza(a, b, limite = 2) { if (Math.abs(a.length - b.length) > limite) return limite + 1; let precedente = Array.from({ length: b.length + 1 }, (_, j) => j); for (let i = 1; i <= a.length; i++) { const riga = [i]; let minimo = i; for (let j = 1; j <= b.length; j++) { const costo = a[i - 1] === b[j - 1] ? 0 : 1; riga[j] = Math.min(precedente[j] + 1, riga[j - 1] + 1, precedente[j - 1] + costo); minimo = Math.min(minimo, riga[j]); } if (minimo > limite) return limite + 1; precedente = riga; } return precedente[b.length]; } const PERFETTO = 0, INIZIA = 1, CONTIENE = 2, VICINO = 3; function punteggioParola(parola, chiavi) { let migliore = Infinity; chiavi.forEach((chiave, posizione) => { if (chiave === '') return; let p = Infinity; if (chiave === parola) p = PERFETTO; else if (chiave.startsWith(parola) || chiave.split(/\s+/).some((w) => w.startsWith(parola))) p = INIZIA; else if (chiave.includes(parola)) p = CONTIENE; else if (parola.length >= 4) { // Errori di battitura: una lettera (due dalle 7 lettere in su), su // parole intere. Mai sui numeri brevi: "35" non deve trovare "36". const tolleranza = parola.length >= 7 ? 2 : 1; if (chiave.split(/\s+/).some((w) => distanza(w, parola, tolleranza) <= tolleranza)) p = VICINO; } if (p !== Infinity) migliore = Math.min(migliore, p + posizione * 0.1); }); return migliore; } /** * Le opzioni da proporre per `testo`. * Campo vuoto: la tendina completa (speciali e sceglibili, nell'ordine dato). * Altrimenti: le opzioni in cui trovano posto tutte le parole, dalla più * vicina; `forse` segna quelle trovate solo per un errore di battitura. */ export function proponi(opzioni, testo, massimo = 50) { const speciali = opzioni.filter((o) => o.speciale); const normali = opzioni.filter((o) => !o.speciale); const parole = normalizza(testo).split(/\s+/).filter(Boolean); if (parole.length === 0) { return [...speciali, ...normali.filter((o) => !o.motivo)].map((o) => ({ ...o, forse: false })); } const trovate = []; normali.forEach((opzione, ordine) => { const chiavi = (opzione.chiavi ?? [opzione.etichetta]).map(normalizza); let totale = 0; let forse = false; for (const parola of parole) { const p = punteggioParola(parola, chiavi); if (p === Infinity) return; if (p >= VICINO) forse = true; totale += p; } trovate.push({ opzione, totale, ordine, forse }); }); trovate.sort((a, b) => a.totale - b.totale || a.ordine - b.ordine); return [...speciali.map((o) => ({ ...o, forse: false })), ...trovate.slice(0, massimo).map((t) => ({ ...t.opzione, forse: t.forse }))]; }