Skip to content

Search this area over the map, place search near the view, and a speed pass - #3

Open
coinmandeer wants to merge 11 commits into
mainfrom
claude/stoic-hopper-jutqgy
Open

coinmandeer wants to merge 11 commits into
mainfrom
claude/stoic-hopper-jutqgy

Conversation

@coinmandeer

Copy link
Copy Markdown
Owner

Change

A floating Search this area after a map move, place search biased to the view, and one pass over what made the live site slow (/v2/themes median 3.8 s, untuned PostgreSQL without statistics, a 2.4 MB startup bundle, a 367 kB icon font, cold Discover endpoints after each restart).

Search this area

  • After a long pan the pin layers deliberately wait to be asked, but the only button that asked was inside the Map status popover, so the map seemed not to load. The button now floats over the map (above a phone's sheet, stacked with Discover's chip, hidden behind a full sheet or the map picker) whenever the view waits for it.
  • It counts only layers that wait for it. Loading, partial and failed layers have their own indicators; counting them, and every expensive layer during the 300 ms debounce, made it flicker.
  • Pressing it refreshes only out-of-date layers (LayerEngine.searchHere); layers already loaded or loading for the view cost nothing, fresh data is not refetched and tiles are not reattached.
  • With the assistant open, the last place search is offered again once the reader moved the map (drag, wheel, keys — MapLibre's originalEvent), and pressing it asks that question for the new view. The answer's own camera fit is not a move.
  • A search result, My location or a country asked for data right after starting the flight, so the view being left loaded and the destination waited for the button. The request now waits for the camera to land.
  • Settings: the unused "Search this area" switch becomes "Load only on Search this area" (manualRefresh, off): the reader's own moves then load nothing until asked; programmatic jumps still load.
  • Place search sends the view centre as near (two decimals, from region zoom); /geocode passes it on as a bias, never a filter (Mapy preferNear, Nominatim viewbox + bounded=0, distance tie-break in the offline gazetteer).
  • Removed three unmounted components (Places panel, activity indicator, layer notices).

Backend

  • /v2/themes: coverage is shared per zoom band (the zooms where the dataset choice changes) and per view snapped outward to that band's grid, for 15 minutes, with concurrent requests on one query; the join reads only theme datasets; the answer is cacheable for five minutes. The client asks with the same key (statCoverageRequest in @mapos/adapter-sdk), so an ordinary pan asks nothing.
  • Theme inventory cached for two minutes; Discover boundary coverage kept per published revision; both catalogues are computed once after the API starts listening (first visitor after a restart waited ~8 s).
  • PostgreSQL: JIT off, 512 MB shared buffers, 32 MB work_mem, 256 MB maintenance memory, SSD page cost, 256 MB /dev/shm — sized for the shared 8 GiB host, overridable with POSTGRES_*. The API also sets JIT off and work_mem per connection.
  • Imports analyse the tables they rewrote; the deploy analyses tables never analysed or changed by more than a tenth.
  • Vector tiles are gzipped by the API (MVT was on neither the plugin's nor the edge proxy's list); the web image precompresses its assets for nginx gzip_static.
  • Deploy housekeeping after a healthy rollout: three newest releases and backups (never the live or previous one), six rollback image tags per service, dangling images and week-old build cache. Never fails the release.

Frontend

  • Icon font 367 kB → 64 kB: the subset now keeps exactly the 229 icons' glyphs (layout closure had pulled in all 6,186) and the script fails if any name stops forming its ligature. Rendered icons match the old font within anti-aliasing.
  • Startup JS one 2.42 MB chunk (706 kB gzip) → four chunks, 2.26 MB (651 kB gzip): React Query removed (unused), @mapos/layer-sdk marked side-effect free (drops the table parser and fflate), satellite.js loaded with its layer, MapLibre's CSP build (its worker already loads separately), and MapLibre, React and the UI kit in their own chunks that stay cached across releases.
  • App CSS 225 kB → 194 kB: rules for ~110 classes nothing renders.
  • Tile watchers no longer serialise the whole style on each sourcedata event (hundreds per pan); a click reads the layer order once instead of the style three times; pixel ratio capped at 2; store snapshot hooks keep one subscription; repeated loading flags, empty layers and source reports don't notify; a partial answer with a scheduled retry is not asked again.
  • The legacy service-worker cleanup runs once per browser.

Measured on vite build: icon font 375,564 → 65,536 bytes; startup JS 2,420,314 bytes in one chunk (706 kB gzip) → index 858 kB + maplibre 970 kB + ui-kit 243 kB + react 194 kB (651 kB gzip); app CSS 295 kB (incl. MapLibre CSS) → 194 kB + 70 kB MapLibre CSS.

Evidence

  • Relevant unit and contract tests pass — npm test (layer-sdk 111, adapter-sdk 85, map-runtime 4, API 852, web 590; skips are the existing environment-gated ones), test:contracts, test:privacy (218), test:migrations (37), test:load, test:secrets, test:css-tokens. New unit tests: engine waiting/manual/searchHere, area-query slot, near parsing and bias, TtlCache, coverage snapping, compressible types, tile watcher style reads, store no-op notifications.
  • Type, lint and formatting checks pass — npm run typecheck, npm run lint, npm run format:check.
  • Integration/E2E, visual, accessibility or load evidence — local runs with the preinstalled Chromium: default suite 220 passed, 1 skipped (the existing MAPOS_LONG_PERF gate), 1 failed before the change dropped below (that spec now 18/18); profiles, run after it, offline 5/5, security 1/1 (production bundle under CSP, so MapLibre's CSP build and worker work), accessibility 8/8, visual 18/18, runtime-starter 2/2, performance 2/2. New e2e: the chip floats without opening anything; a long drag waits and one press loads the new view; a place picked in search loads at the destination without asking; manual mode loads nothing until asked; with the assistant open, a drag offers the last place search and pressing it re-asks it with the new bbox. Walk-through at 1440 px and iPhone 14 Pro size (long pan → chip → load, search "Brno", settings): no page errors.
  • No test was presented as run when it was skipped. One change was dropped after the full run: loading a newly enabled layer without the 300 ms debounce fired its first request before the camera settled at startup, so it was cancelled and viewportRefresh.spec.ts:174 failed; with the debounce back the spec passes 18/18.

Layer or provider integration (if applicable)

  • Versioned manifest, query/100-feature budget and cancellation — no new layer or provider; searchHere reuses the engine's per-layer cancellation and query keys.
  • Loading, empty, partial, error and provider-outage states — unchanged indicators; the chip deliberately no longer reacts to partial/error states, which have their own alert and retry.
  • Filters, legend, provider-neutral detail/actions and accessible non-map alternative — the chip is a labelled button with a busy state and a title naming the question it repeats.
  • Permissions, private-data boundary, AI exposure and entitlement policy — near is the view centre rounded to ~1 km, sent to our API and on to the geocoder only as a ranking bias; no device location is involved. Re-asking the assistant happens only on an explicit press.
  • Fixture/contract tests, health/SLO owner and disable/rollback path — offline gazetteer honours near; manual mode is a setting; caches are in-process and bounded.
  • No provider secret or untrusted extension JavaScript reaches the client.

Release safety

  • Migration: none. The deploy recreates PostgreSQL once when its settings change (inside the rollback boundary; a failed rollout restores the previous settings). Expect a few seconds of database restart during that deploy.
  • Feature flags: none. Rollback of the database settings is removing the command/shm_size block or overriding POSTGRES_*; the chip's manual mode is a user setting.
  • Security, privacy, dependency and bundle impact — one runtime dependency removed (@tanstack/react-query); harfbuzzjs and wawoff2 declared as dev dependencies (already installed via subset-font); bundle numbers above.
  • Docs: docs/environment.md (PostgreSQL sizing), docs/operations/backup-restore.md (restart, ANALYZE, pruning, restored databases have no statistics), infra/.env.production.example.

Not in this PR: lazy loading of the AJV manifest validator (it backs the synchronous assertLayerManifestV2 contract used by the layer registry — worth its own change), and the PWA service worker (separate PR as agreed).

🤖 Generated with Claude Code

https://claude.ai/code/session_01J3QFugXbaTxPuuP3q3rR16


Generated by Claude Code

…er notices

None of the three is imported anywhere since the shell redesign; PlacesPanel was the only reader of the old search-here pending flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J3QFugXbaTxPuuP3q3rR16
A search for "Náměstí Míru" over Brno found Prague's square first, because the geocoder never learned where the reader was looking. Search now sends the centre of the view as `near` (two decimals, only from region zoom), and `/geocode` passes it on as a bias, never a filter: Mapy `preferNear` for geocode and suggest, a Nominatim `viewbox` with `bounded=0`, and distance as the tie-breaker in the offline gazetteer. Planning stop search sends the same bias.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J3QFugXbaTxPuuP3q3rR16
After a long pan the pin layers deliberately wait to be asked, but the only button that asked lived inside the Map status popover, so the map simply seemed not to load. The button now floats over the map (above a phone's sheet, stacked with Discover's chip) whenever the view is waiting for it, and it means one thing: layers that wait for the button. Loading, partial and failed layers keep their own indicators; counting them, and every expensive layer during the debounce before an automatic load, made it flicker.

- Pressing it brings only out-of-date layers up to date for the view (`LayerEngine.searchHere`); layers already loaded or loading for it cost nothing, fresh data is not refetched and tiles are not reattached.
- With the assistant open, the last place search is offered again once the reader has moved the map (drag, wheel, keys — MapLibre's `originalEvent`), and pressing it asks that question for the new view. The answer's own camera fit does not count as a move.
- Jumps that ask for data right after starting a flight (a search result, My location, a country) waited for nothing and loaded the view being left, so the destination then waited for the button. The request now waits for the camera to land.
- Settings: the unused "Search this area" switch becomes "Load only on Search this area" (`manualRefresh`, off by default): the reader's own moves then load nothing until asked, while programmatic jumps still load. A move whose refresh waits for the basemap's idle keeps that flag.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J3QFugXbaTxPuuP3q3rR16
`/v2/themes` ran a PostGIS join over every territory for each exact bbox, cached for 60 s, and the drawer asks on every pan: the live median was 3.8 s. Coverage is a property of the imports, so requests now share one computation per zoom band (the zooms at which the dataset choice changes) and per view snapped outward to that band's grid, for 15 minutes, with concurrent requests for one key waiting on a single query. The join reads only theme datasets. The list answer is marked cacheable for five minutes.

The theme inventory (two queries per detail request) is cached for two minutes, and the Discover boundary manifest keeps its coverage for as long as the published revision is unchanged; only the cheap manifest lookup repeats each minute. The API computes both catalogues once after it starts listening, so the first visitor after a restart no longer waits ~8 s.

A small `TtlCache` (bounded, LRU on hit, in-flight sharing, failures not cached) backs the new caches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J3QFugXbaTxPuuP3q3rR16
The image defaults (128 MB shared buffers, 4 MB work_mem, JIT on) and tables that were bulk-imported but never analysed made the heavy catalogue queries plan, compile and spill more than they read: JIT alone cost 1.8 s of a 4.2 s coverage query, and the planner guessed 139 rows for a 242 000-row join.

- Compose starts PostgreSQL with JIT off, 512 MB shared buffers, 32 MB work_mem, 256 MB maintenance memory, SSD page costs and a 256 MB /dev/shm, each sized for the shared 8 GiB host and overridable per host. The API also sets JIT off and work_mem on its own connections, which helps before the server settings apply.
- The deploy recreates PostgreSQL only when those settings change, inside the rollback boundary; a failed rollout brings the previous settings back.
- Imports (statistics, boundaries, LAU correspondences, staged boundary publication) analyse the tables they rewrote; a failed ANALYZE never fails the import. After a healthy rollout the deploy analyses tables that were never analysed or changed by more than a tenth.
- After a healthy rollout the deploy keeps the three newest releases and backups (never the live or previous release), six rollback image tags per service, and prunes dangling images and week-old build cache. None of it can fail the release.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J3QFugXbaTxPuuP3q3rR16
Vector tiles left the API uncompressed: MVT is not on `@fastify/compress`'s list nor on the edge proxy's, although protobuf gzips to about a third. The API now compresses MVT and protobuf responses (event streams still never). The web image gzips its scripts, styles, HTML, SVG and JSON at build time and nginx serves those files with `gzip_static`, so the busy host no longer compresses the same bundle for every visitor.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J3QFugXbaTxPuuP3q3rR16
The subset kept 6,186 glyphs (367 kB): passing the icon names as text makes the subsetter's layout closure follow every ligature those letters can spell, which is every icon in Material Symbols. The script now shapes each name with HarfBuzz to find its glyph and private-use codepoint, keeps exactly those codepoints plus the letters with layout closure off, and fails unless every name still turns into a single icon glyph in the result. 229 icons, FILL and weight axes kept: 64 kB. A render of all icons at both axis ends matches the previous font within anti-aliasing.

`harfbuzzjs` and `wawoff2`, already installed with `subset-font`, are declared because the script now imports them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J3QFugXbaTxPuuP3q3rR16
- React Query was installed and wrapped around the app, but nothing queried through it.
- `@mapos/layer-sdk` is side-effect free, so the table parser the browser never calls (and `fflate` with it) no longer ships.
- SGP4 (`satellite.js`, ~100 kB) arrives with the satellites layer's first update instead of with the map.
- MapLibre resolves to its CSP build: its worker already loads from its own file, so the default build's inlined copy was dead weight.
- MapLibre, React and the UI kit get their own chunks, which stay cached across releases while the app chunk changes.
- The cleanup of an earlier app's service workers and caches runs once per browser instead of on every start.

Startup JavaScript: one 2.42 MB chunk (706 kB gzip) → 2.26 MB in four chunks (651 kB gzip).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J3QFugXbaTxPuuP3q3rR16
- Tile watchers read the whole style (`getStyle()` serialises every basemap layer) on each `sourcedata` event, hundreds per pan and mostly for the basemap. They now filter by source name and keep the list of their sources until the style changes.
- A map click read the style three times to find cluster, weather and theme layers; it now reads the layer order once.
- The map renders at most at pixel ratio 2 (a 3× phone drew 2.25× the pixels for no visible gain), following density changes within that cap.
- The store snapshot hooks passed React a new subscribe function every render, which unsubscribed and resubscribed every component each time; loading flags, empty layers staying empty and repeated source reports no longer notify at all.
- A partial answer whose retry is already scheduled is not asked again for the same request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J3QFugXbaTxPuuP3q3rR16
The statistics store asked `/v2/themes` with the exact view after every pan. The coverage snapping (zoom band + outward grid) moves to `@mapos/adapter-sdk` as `statCoverageRequest`, used by the API's cache key and by the client, which now asks only when that key changes; a pan back is served by the browser cache the API now allows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J3QFugXbaTxPuuP3q3rR16
About 110 classes in the stylesheets had no element left in the app (the removed activity indicator and layer notices, the old mega menu, planner sections the redesigned planning panel no longer has, unused motion helpers). Rules that only targeted them are removed; selector lists keep their live members. Found by matching every class selector against the TypeScript sources, including class names built from a prefix. App CSS 225 → 194 kB.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01J3QFugXbaTxPuuP3q3rR16

Copy link
Copy Markdown
Owner Author

The deploy check here is the Deploy VPS workflow, triggered manually (workflow_dispatch) at the repository owner's request to deploy this branch — it is not part of this PR's merge gate; check and e2e are green and the head is mergeable.

It failed immediately, before touching the VPS:

##[error]Set the VPS_SSH_KEY or VPS_PASSWORD repository secret.

Neither secret is set on the repository (Settings → Secrets and variables → Actions). This isn't fixable from the PR — no code change addresses a missing secret. Once the owner adds VPS_SSH_KEY (preferred) or VPS_PASSWORD, re-running the workflow against claude/stoic-hopper-jutqgy will deploy it.


Generated by Claude Code

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants