Skip to content

Architecture: should we refactor explorer.qmd before the next big feature? (analysis + options) #249

Closed

Description

@rdhyee

The question

With several substantial features recently landed or in flight in explorer.qmd
— heatmap mode (#241, #242, #246), the collection facet (#244), cross-filter
facets (#156), URL-state contract (#163/#164) — is the explorer codebase messy
enough that we should refactor before adding the next big feature, or keep
shipping incrementally?

This issue is the analysis to decide that, not a proposal to rewrite.

Diagnosis (measured)

explorer.qmd is ~4,580 lines across 9 OJS cells. The complexity is not
uniform — it's concentrated in one cell:

Block ~Lines Health
~40 helper functions (facetFilterSQL, readHash/buildHash, card renderers, bbox math, …) 730 ✅ small, single-purpose
viewer cell (Cesium setup + click/hover handlers) 270 ✅ cohesive
facetFilters cell 105 ✅ fine
tableView cell 455 ⚠️ large but cohesive
zoomWatcher cell ~2,200 ❌ god-closure
perfPanel cell 95 ✅ fine

~48% of the file is a single closure. zoomWatcher co-mingles: H3 cluster
loading (loadRes), point-mode sample loading (loadViewportSamples), mode
transitions (enterPointMode/exitPointMode/applyLayerVisibility), the
heatmap subsystem (refreshHeatmap/renderHeatmap/clearHeatmap + imagery
layer), camera (moveEnd/camera.changed) handlers, facet-count refresh, and
several stale-request guards. Recent features (heatmap, collection facet) mostly
landed in or adjacent to this cell.

Why it's monolithic (it's the platform, not just neglect)

Quarto OJS has no ergonomic way to share mutable state across cells, so
stateful, tightly-coupled logic tends to collapse into one closure (the shared
loading, requestId, loadResGen, heatmapReqId, currentRes, etc.).
EXPLORER_STATE.md already documents the escape hatch — hoisting state onto
viewer._globeState / viewer._heatmap* so logic can be split across cells.

The risk / tension

explorer.qmd is dense with hard-won, issue-specific fixes. A non-exhaustive
list visible in the comments: #185 (terrain depth-test bypass), #221 (viewport
padding so map/table/count agree), #233 (heatmap mutual-exclusivity + self-
refresh race), #156 (cross-filter pid IN (...) predicate), _suppressHashWrite
(camera↔hash echo prevention), loadResGen/requestId/freshSelectionToken
(stale-result guards), #127 (TOC overlap intercepting clicks).

A big-bang rewrite is precisely the move that silently drops one of these guards
and reintroduces a solved bug. The cost of a regression here is high (the page is
the project's flagship demo).

What we already have for safe decomposition

Two of the three prerequisites for a low-risk refactor are already in place:

  • Test net: ~24 test files under tests/ (Playwright + pytest), plus a
    search-perf baseline.
  • State contract: EXPLORER_STATE.md defines URL params, hash channel,
    DOM-as-state, and viewer.* widget state — the seams along which logic can be
    separated.
  • Module-extraction precedent: the explorer already imports an ES module at
    runtime — await import('assets/js/source-palette.js'). Pure logic can move
    into assets/js/*.js the same way and become independently unit-testable.

Options

A — Refactor-first (restructure explorer.qmd before the next feature).
Cleaner base for future work. Cons: high regression risk against the dense
fix-set above; a large, hard-to-review diff; delays features; and it collides
with any concurrent work in this same file. Not recommended now.

B — Don't refactor; keep shipping. Fastest short term, lowest immediate
risk. Cons: zoomWatcher keeps accreting; each subsequent change gets slower and
riskier. Defers the problem rather than addressing it.

C — Strangler / refactor-as-you-go (recommended). Decompose incrementally
behind the existing test net:

  1. Strengthen tests around whatever the next feature touches (cheap, compounding).
  2. Extract pure logic into assets/js/*.js ES modules — SQL builders
    (facetFilterSQL, sourceFilterSQL, textSearchWhere/Score), the hash
    codec (readHash/buildHash), bbox math (paddedViewportBounds/
    viewerBboxSQL), card HTML renderers, escapers. Pure functions → easy unit
    tests, ~500 lines out of the qmd, near-zero risk.
  3. Later, last, one seam at a time: carve the heatmap subsystem out of
    zoomWatcher into its own cell/module (its state is already namespaced
    viewer._heatmap* / heatmapImageryLayer). Highest-risk seam — only behind
    green tests.

Recommendation

Don't refactor-first; adopt C. The codebase hasn't crossed the
"stop-and-rewrite" line — it's one bloated closure plus otherwise-clean code,
with a real test net and a state contract. Highest-leverage, lowest-risk path is
opportunistic module extraction that lets zoomWatcher shrink across a few small
PRs, rather than blocking features on a big restructure.

Proposed next step

Before committing to any option, produce a refactor map: enumerate every
extractable seam in zoomWatcher (and the pure helpers), risk-rated and in
dependency order, with the target module/cell for each. Low commitment; doubles
as the execution plan if we pick C.

Open questions for the team

  1. Is the single-file Quarto-OJS approach a long-term fit, or should the explorer
    eventually become a bundled app (e.g. a Vite entry embedded in the page) with
    real modules + a test harness? (Bigger fork; out of scope for C but worth
    naming.)
  2. How do we coordinate refactor PRs with concurrent feature work in
    explorer.qmd to avoid merge churn? (Refactor windows? Feature freeze on the
    touched seam?)
  3. Is EXPLORER_STATE.md the right home to also document the module boundaries
    as they're extracted?

Analysis generated while landing #244 / #246; line counts are against main at
the time of writing.

Activity

  1. rdhyee commented on Jun 5, 2026

    @rdhyee
    ContributorAuthor

    2026-06-05 update: recent Eric/Andrea issues strengthen the case for a refactor window

    I agree with the updated read: this is probably a good time to schedule the Explorer refactor early next week, before the next round of feature work lands in explorer.qmd.

    The original recommendation in this issue was intentionally conservative: avoid a big-bang rewrite, use a strangler/refactor-as-you-go path, and extract safe modules behind the existing behavior. I still think that is the right method. What has changed is the timing. The recent issue cluster from Eric and Andrea is symptomatic of the same architectural pressure points rather than a handful of isolated bugs.

    Recent user-facing symptoms:

    Issue Reporter Symptom Architectural smell it points at
    #260 Eric Sample popup shows the wrong material category for a specific PID Detail-card material/category query and parquet-field contract are hard to inspect and reason about in the current page-level code.
    #265 Andrea Material facet values look wrong, including values like material Facet data provenance, label resolution, and facet-rendering semantics are not cleanly separated.
    #266 Andrea Duplicate search bars are confusing There is one logical search state mirrored through multiple DOM inputs; the UI duplication reflects hidden state coupling.
    #267 Andrea Map view does not update with facet selection This is the core filter-coherence problem: facet state, map dots, table, counts, and mode transitions are still too distributed across event handlers.
    #268 Eric Wants to know where the SQL lives and which parquet files are queried The current implementation makes the query layer difficult for collaborators to find, audit, and explain. That is a maintainability problem, not just a docs gap.

    Related existing threads show the same pattern:

    Local code shape has also moved since this issue was opened: explorer.qmd is now about 5.4k lines, and the largest OJS/runtime cell runs from the zoomWatcher block through search, facets, heatmap, URL/hash handling, and boot hydration. That cell is acting as controller, state store, query layer, event bus, and renderer. OJS is still useful as the Quarto integration surface, but it is being pushed past what it is good at for this page.

    Revised recommendation

    Keep the no-big-bang rule, but create a short refactor window before new feature work:

    1. Lock characterization tests for the current user-visible contracts: deep links, facet toggles, search, duplicate search-box mirroring, material/detail card display, heatmap on/off, and back/forward state.
    2. Extract the query/data contract first: parquet URL registry, SQL builders, facet/detail/search query functions, and plain-English query documentation that can directly answer Where's the SQL and what parquet files are getting queried for the Interactive Explorer? #268.
    3. Extract URL/state codecs next: search params, hash state, selected sample/cluster state, heatmap state, and source/facet selections.
    4. Then split controllers: map mode/rendering, facet panel, samples table, search panel, and heatmap. Keep Quarto/OJS as the host page initially; do not require a framework migration as the first step.
    5. Only after those pieces are isolated decide whether the Explorer should become a Vite/TypeScript app embedded in Quarto, or whether ES modules are sufficient.

    This turns #260/#265/#266/#267/#268 into test cases and module-boundary requirements, instead of patching each symptom deeper into the same closure.

    Non-goals for the refactor window

    • Do not redesign the Explorer UI from scratch.
    • Do not change the data substrate unless a bug is proven in the parquet generation path.
    • Do not replace Quarto as the site shell.
    • Do not rewrite all of explorer.qmd in one PR.

    The practical target is a sequence of small PRs that make the next bug fix easier to explain, test, and review than the last one.

  2. added
    explorerInteractive Explorer features
    needs-discussionRequires team input before implementing
    on Jun 5, 2026
  3. added 6 commits that reference this issue on Jun 14, 2026
  4. rdhyee commented on Jun 19, 2026

    @rdhyee
    ContributorAuthor

    Closing as resolved. The refactor groundwork shipped in #287: CI Playwright smoke gate, characterization tests pinning current behavior, and extraction of pure functions out of the OJS closure into ES modules (sql-builders.js, explorer-utils.js) with unit tests. Subsequent PRs (#288/#289/#301) continued the agreed refactor-as-you-go pattern, so the 'should we refactor before the next feature' question is settled in the affirmative and executed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    explorerInteractive Explorer featuresneeds-discussionRequires team input before implementing

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions