URL errors and fuzzy atlas search #60

Closed
opened 2026-04-06 17:45:58 +00:00 by maxtkc · 0 comments
Owner

Summary

Two improvements to the Atlas/URL load flow (#36): (1) replace the opaque "Need Help?" toast action on URL load failures with a "More Info" button that opens a modal showing the full error, the attempted URL, and a direct link to open it in a new tab; (2) swap the atlas search's substring filter for uFuzzy so multi-word and out-of-order queries work naturally.

Both changes are self-contained and can be done in either order. The guiding principle is transparency — show the user exactly what failed and give them a direct path to recover.

Relevant Context

Error handling:

  • loadGTFSFromURL(url) in src/modules/ui.ts:363 — the catch block at line 394–418 currently calls notifications.showError(errorMessage, { actions: [{ id: 'help', label: 'Need Help?', handler: ... }] }). The handler tries to click a help tab; this is the dead code we're replacing.
  • showModal in src/modules/modal-utils.ts — takes { title, body (HTML string), actions, onMount? }. Body is injected as innerHTML so we can include links. This is the right primitive for the error detail modal.
  • The url variable is in scope in the catch block, so it's easy to close over.

Fuzzy search:

  • filterAndRender(query, feeds, resultsEl, onSelect) in src/modules/atlas-search.ts:36 — the filter logic is lines 43–51, a simple includes() check. This is the only thing changing.
  • uFuzzy: npm install ufuzzy. The API is: new uFuzzy()uf.search(haystack, needle) returns [idxs, info, order]. idxs is the ranked list of matched indices into the haystack array. We build a parallel string array (one string per feed) for uFuzzy to search, then map results back to feeds.
  • The 50-result cap and DOM rendering stay unchanged.

Phase 1: URL load error modal

Replace the useless "Need Help?" action with a "More Info" button that opens a transparent error modal.

Steps:

  • In src/modules/ui.ts, in the loadGTFSFromURL catch block (lines 394–418), change the notifications.showError call:
    • Keep autoHide: false (errors should persist until dismissed — add this if not already set)
    • Replace the actions array with a single action: { id: 'more-info', label: 'More Info', handler: () => showURLErrorModal(url, error as Error) }
  • Add a new function showURLErrorModal(url: string, error: Error) in ui.ts (or a small helper at the top of the file — keep it local, no new file needed):
    • Calls showModal with:
      • title: 'Failed to load feed'
      • body: HTML string containing:
        1. A <p> with the full error.message in a <code> block (monospace, wrapped) so nothing is hidden
        2. A <p> with the attempted URL as a clickable <a href="${url}" target="_blank" rel="noopener"> link
        3. A <p class="text-sm text-base-content/60"> with the note: "Some feeds block direct browser requests (CORS). You can try opening the link above to download the file, then upload it directly using Load → Upload."
      • actions: [{ label: 'Close', onClick: () => {} }]
    • Make sure to escapeHtml the error message before injecting into innerHTML (the URL goes in href so it needs no escaping, but error.message is untrusted text)
  • Add a small local escapeHtml helper in ui.ts (or reuse if one already exists — grep first)
  • Import showModal from modal-utils.ts in ui.ts if not already imported
  • While were at it, lets change from Search Atlas to From TransitLand Atlas

Gotchas:

  • The url variable is captured in the catch block closure — confirm it's in scope before the try (it's a parameter of loadGTFSFromURL, so it is).
  • showURLErrorModal should be a plain function, not async, since showModal returns a Promise we don't need to await here (fire-and-forget from the notification handler is fine).
  • Don't remove the console.error on line 395 — keep that for debuggability.

Phase 2: Fuzzy atlas search with uFuzzy

Replace the includes() filter in filterAndRender with uFuzzy for ranked, multi-token, order-independent matching.

Steps:

  • npm install ufuzzy and npm install --save-dev @types/ufuzzy (check if types are bundled — uFuzzy ships its own .d.ts, so @types/ may not exist; if not, skip the types install)
  • In src/modules/atlas-search.ts, import uFuzzy: import uFuzzy from '@leeoniya/ufuzzy' (check actual package name after install — it may be ufuzzy or @leeoniya/ufuzzy)
  • Build a haystack once when feeds are loaded (not on every keystroke): construct a haystack: string[] where each entry is the concatenated searchable fields for that feed: `${feed.name} ${feed.operator_name} ${feed.location} ${feed.url}`. Store it alongside cachedFeeds.
  • Instantiate uFuzzy once (module-level): const uf = new uFuzzy({ intraIns: 1 })intraIns: 1 allows one insertion per term, giving basic typo tolerance without getting too loose
  • In filterAndRender, replace the filter logic:
    • If q is empty: filtered = feeds (unchanged)
    • If q is non-empty: call uf.search(haystack, q) → destructure [idxs]. If idxs is null/empty, filtered = []. Otherwise filtered = idxs.map(i => feeds[i]) (already in ranked order)
    • The rest of the function (slice to 50, render rows, overflow note) is unchanged
  • Pass haystack into filterAndRender as a new parameter, or close over it from showAtlasSearchModal — the latter is simpler since haystack is built once in the .then() callback

Notes: Package is @leeoniya/ufuzzy (not ufuzzy). Types are bundled in dist/uFuzzy.d.ts — no @types/ package needed. Haystack stored in module-level cachedHaystack alongside cachedFeeds, built in loadAtlasFeeds(). Closed over in .then() callback via const haystack = cachedHaystack!.

Gotchas:

  • uFuzzy's search() can return null for idxs when there are zero matches — guard with if (!idxs || idxs.length === 0).
  • The haystack indices must stay in sync with feeds array indices — build them together and never sort/mutate either array after construction.
  • uFuzzy is case-insensitive by default; no need to .toLowerCase() the query anymore.
  • If the package name turns out to be different from what's documented, console.log the import to confirm it loaded.
## Summary Two improvements to the Atlas/URL load flow (#36): (1) replace the opaque "Need Help?" toast action on URL load failures with a "More Info" button that opens a modal showing the full error, the attempted URL, and a direct link to open it in a new tab; (2) swap the atlas search's substring filter for uFuzzy so multi-word and out-of-order queries work naturally. Both changes are self-contained and can be done in either order. The guiding principle is transparency — show the user exactly what failed and give them a direct path to recover. ## Relevant Context **Error handling:** - `loadGTFSFromURL(url)` in `src/modules/ui.ts:363` — the `catch` block at line 394–418 currently calls `notifications.showError(errorMessage, { actions: [{ id: 'help', label: 'Need Help?', handler: ... }] })`. The handler tries to click a help tab; this is the dead code we're replacing. - `showModal` in `src/modules/modal-utils.ts` — takes `{ title, body (HTML string), actions, onMount? }`. Body is injected as innerHTML so we can include links. This is the right primitive for the error detail modal. - The `url` variable is in scope in the `catch` block, so it's easy to close over. **Fuzzy search:** - `filterAndRender(query, feeds, resultsEl, onSelect)` in `src/modules/atlas-search.ts:36` — the filter logic is lines 43–51, a simple `includes()` check. This is the only thing changing. - uFuzzy: `npm install ufuzzy`. The API is: `new uFuzzy()` → `uf.search(haystack, needle)` returns `[idxs, info, order]`. `idxs` is the ranked list of matched indices into the haystack array. We build a parallel string array (one string per feed) for uFuzzy to search, then map results back to feeds. - The 50-result cap and DOM rendering stay unchanged. --- ## Phase 1: URL load error modal Replace the useless "Need Help?" action with a "More Info" button that opens a transparent error modal. **Steps:** - [x] In `src/modules/ui.ts`, in the `loadGTFSFromURL` catch block (lines 394–418), change the `notifications.showError` call: - Keep `autoHide: false` (errors should persist until dismissed — add this if not already set) - Replace the `actions` array with a single action: `{ id: 'more-info', label: 'More Info', handler: () => showURLErrorModal(url, error as Error) }` - [x] Add a new function `showURLErrorModal(url: string, error: Error)` in `ui.ts` (or a small helper at the top of the file — keep it local, no new file needed): - Calls `showModal` with: - `title`: `'Failed to load feed'` - `body`: HTML string containing: 1. A `<p>` with the full `error.message` in a `<code>` block (monospace, wrapped) so nothing is hidden 2. A `<p>` with the attempted URL as a clickable `<a href="${url}" target="_blank" rel="noopener">` link 3. A `<p class="text-sm text-base-content/60">` with the note: `"Some feeds block direct browser requests (CORS). You can try opening the link above to download the file, then upload it directly using Load → Upload."` - `actions`: `[{ label: 'Close', onClick: () => {} }]` - Make sure to `escapeHtml` the error message before injecting into innerHTML (the URL goes in `href` so it needs no escaping, but `error.message` is untrusted text) - [x] Add a small local `escapeHtml` helper in `ui.ts` (or reuse if one already exists — grep first) - [x] Import `showModal` from `modal-utils.ts` in `ui.ts` if not already imported - [x] While were at it, lets change from `Search Atlas` to `From TransitLand Atlas` **Gotchas:** - The `url` variable is captured in the `catch` block closure — confirm it's in scope before the `try` (it's a parameter of `loadGTFSFromURL`, so it is). - `showURLErrorModal` should be a plain function, not `async`, since `showModal` returns a Promise we don't need to await here (fire-and-forget from the notification handler is fine). - Don't remove the `console.error` on line 395 — keep that for debuggability. --- ## Phase 2: Fuzzy atlas search with uFuzzy Replace the `includes()` filter in `filterAndRender` with uFuzzy for ranked, multi-token, order-independent matching. **Steps:** - [x] `npm install ufuzzy` and `npm install --save-dev @types/ufuzzy` (check if types are bundled — uFuzzy ships its own `.d.ts`, so `@types/` may not exist; if not, skip the types install) - [x] In `src/modules/atlas-search.ts`, import uFuzzy: `import uFuzzy from '@leeoniya/ufuzzy'` (check actual package name after install — it may be `ufuzzy` or `@leeoniya/ufuzzy`) - [x] Build a haystack once when feeds are loaded (not on every keystroke): construct a `haystack: string[]` where each entry is the concatenated searchable fields for that feed: `` `${feed.name} ${feed.operator_name} ${feed.location} ${feed.url}` ``. Store it alongside `cachedFeeds`. - [x] Instantiate uFuzzy once (module-level): `const uf = new uFuzzy({ intraIns: 1 })` — `intraIns: 1` allows one insertion per term, giving basic typo tolerance without getting too loose - [x] In `filterAndRender`, replace the filter logic: - If `q` is empty: `filtered = feeds` (unchanged) - If `q` is non-empty: call `uf.search(haystack, q)` → destructure `[idxs]`. If `idxs` is null/empty, `filtered = []`. Otherwise `filtered = idxs.map(i => feeds[i])` (already in ranked order) - The rest of the function (slice to 50, render rows, overflow note) is unchanged - [x] Pass `haystack` into `filterAndRender` as a new parameter, or close over it from `showAtlasSearchModal` — the latter is simpler since haystack is built once in the `.then()` callback **Notes:** Package is `@leeoniya/ufuzzy` (not `ufuzzy`). Types are bundled in `dist/uFuzzy.d.ts` — no `@types/` package needed. Haystack stored in module-level `cachedHaystack` alongside `cachedFeeds`, built in `loadAtlasFeeds()`. Closed over in `.then()` callback via `const haystack = cachedHaystack!`. **Gotchas:** - uFuzzy's `search()` can return `null` for `idxs` when there are zero matches — guard with `if (!idxs || idxs.length === 0)`. - The haystack indices must stay in sync with `feeds` array indices — build them together and never sort/mutate either array after construction. - uFuzzy is case-insensitive by default; no need to `.toLowerCase()` the query anymore. - If the package name turns out to be different from what's documented, `console.log` the import to confirm it loaded.
maxtkc self-assigned this 2026-04-06 17:45:58 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
gtfs.zone/coloring-book#60
No description provided.