From 52ceabab71c91d710388e5280ae05cdb235e6066 Mon Sep 17 00:00:00 2001
From: "Yifeng \"Evan\" Wang" <7312949+doodlewind@users.noreply.github.com>
Date: Sun, 6 Sep 2026 11:19:38 -0700
Subject: [PATCH 01/13] feat(map): add paired 3DS maps and saved places
---
.github/workflows/check.yml | 19 ++++
.gitignore | 5 +
.gitmodules | 4 +
AGENTS.md | 9 ++
LICENSE | 21 ++++
README.md | 116 +++++++++++++++++++++-
app/geo.ts | 18 ++++
app/main.tsx | 3 +
app/map-pad.svg | 1 +
app/map-pin.svg | 1 +
app/model.ts | 185 ++++++++++++++++++++++++++++++++++++
app/saved.ts | 69 ++++++++++++++
app/shift-lock.svg | 1 +
app/shift.svg | 1 +
app/ui.tsx | 177 ++++++++++++++++++++++++++++++++++
docs/ARCHITECTURE.md | 129 +++++++++++++++++++++++++
docs/VALIDATION.md | 50 ++++++++++
docs/images/delete.png | Bin 0 -> 21664 bytes
docs/images/keyboard.png | Bin 0 -> 194838 bytes
docs/images/map.png | Bin 0 -> 194343 bytes
docs/images/saved.png | Bin 0 -> 22532 bytes
docs/images/search.png | Bin 0 -> 62827 bytes
host/bookmarks.ts | 54 +++++++++++
host/cache.ts | 69 ++++++++++++++
host/provider.ts | 106 +++++++++++++++++++++
host/serve.ts | 10 ++
host/worker.ts | 8 ++
package.json | 9 ++
pocket.config.ts | 2 +
pocket.json | 6 ++
runtime | 1 +
scripts/build.ts | 13 +++
scripts/deploy.ts | 28 ++++++
scripts/quickjs-smoke.js | 47 +++++++++
scripts/setup.ts | 9 ++
scripts/sim.ts | 147 ++++++++++++++++++++++++++++
shared/types.ts | 7 ++
test/bookmarks.test.ts | 30 ++++++
test/geo.test.ts | 11 +++
test/model.test.ts | 23 +++++
test/provider.test.ts | 68 +++++++++++++
tsconfig.json | 4 +
42 files changed, 1460 insertions(+), 1 deletion(-)
create mode 100644 .github/workflows/check.yml
create mode 100644 .gitignore
create mode 100644 .gitmodules
create mode 100644 AGENTS.md
create mode 100644 LICENSE
create mode 100644 app/geo.ts
create mode 100644 app/main.tsx
create mode 100644 app/map-pad.svg
create mode 100644 app/map-pin.svg
create mode 100644 app/model.ts
create mode 100644 app/saved.ts
create mode 100644 app/shift-lock.svg
create mode 100644 app/shift.svg
create mode 100644 app/ui.tsx
create mode 100644 docs/ARCHITECTURE.md
create mode 100644 docs/VALIDATION.md
create mode 100644 docs/images/delete.png
create mode 100644 docs/images/keyboard.png
create mode 100644 docs/images/map.png
create mode 100644 docs/images/saved.png
create mode 100644 docs/images/search.png
create mode 100644 host/bookmarks.ts
create mode 100644 host/cache.ts
create mode 100644 host/provider.ts
create mode 100644 host/serve.ts
create mode 100644 host/worker.ts
create mode 100644 package.json
create mode 100644 pocket.config.ts
create mode 100644 pocket.json
create mode 160000 runtime
create mode 100644 scripts/build.ts
create mode 100644 scripts/deploy.ts
create mode 100644 scripts/quickjs-smoke.js
create mode 100644 scripts/setup.ts
create mode 100644 scripts/sim.ts
create mode 100644 shared/types.ts
create mode 100644 test/bookmarks.test.ts
create mode 100644 test/geo.test.ts
create mode 100644 test/model.test.ts
create mode 100644 test/provider.test.ts
create mode 100644 tsconfig.json
diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml
new file mode 100644
index 0000000..d62aef3
--- /dev/null
+++ b/.github/workflows/check.yml
@@ -0,0 +1,19 @@
+name: Map provider and model
+on: [push, pull_request]
+permissions:
+ contents: read
+jobs:
+ check:
+ runs-on: ubuntu-latest
+ timeout-minutes: 5
+ steps:
+ - uses: actions/checkout@v7
+ with:
+ submodules: recursive
+ - uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: 1.3.14
+ - run: bun install --frozen-lockfile
+ working-directory: runtime
+ - run: bun run setup
+ - run: bun run check
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..33a0b38
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+dist/
+.local/
+.DS_Store
+*.log
diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..33f6800
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,4 @@
+[submodule "runtime"]
+ branch = main
+ path = runtime
+ url = https://github.com/pocket-stack/pocketjs.git
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..1839902
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,9 @@
+# Pocket Map
+
+- Framework transport, resource lifecycle and tile geometry belong in PocketJS.
+- Keep network requests, SQLite, image decoding and rasterization in the Mac worker.
+- Bound guest tile demand, pending responses, native image staging and GPU residency.
+- Import PocketJS APIs from `@pocketjs/framework/*`, Solid primitives from `solid-js`.
+- Keep pairing keys, cached tiles, user searches and raw replay captures out of Git. Curated public demo images belong in docs/images/.
+- Run the app's explicit tests; do not discover tests through the runtime submodule.
+- Publish validated changes as a Draft PR with a Conventional Commits title.
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..c025cd0
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Pocket Stack contributors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 91b79b1..bf5e1f9 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,117 @@
# Pocket Map
-A Nintendo 3DS map app built with PocketJS, with a paired Mac providing map tiles and place search.
+A map browser for Nintendo 3DS, built with [PocketJS](https://github.com/pocket-stack/pocketjs) and SolidJS 1.9. Search for places, pan with the resistive touch screen, and zoom the map on the upper display. A paired Mac handles network requests, SQLite caching, PNG decoding, label rasterization and saved places.
+
+

+
+These are **compiled guest + Wasm captures using live OpenStreetMap DE and Photon data**, at the 3DS's 400×240 / 320×240 logical resolutions. They are not photographs of the console. Map data © [OpenStreetMap contributors](https://www.openstreetmap.org/copyright); tiles provided by [FOSSGIS / OpenStreetMap DE](https://openstreetmap.de/germanstyle/).
+
+## Try it
+
+Requirements: Bun 1.3.14, Docker with the PocketJS 3DS toolchain (or a supported native devkitPro install), a Mac and a 3DS on the same LAN, Homebrew Launcher and ftpd. The runtime submodule pins the image-resource support used by this app.
+
+```sh
+git clone --recurse-submodules https://github.com/pocket-stack/pocket-map.git
+cd pocket-map
+cd runtime && bun install --frozen-lockfile && cd ..
+bun run setup
+bun run 3ds
+# Open ftpd on the console first. Substitute its LAN IP.
+bun run deploy 192.168.8.102
+bun run host 192.168.8.102
+```
+
+Exit ftpd and open **Pocket Map** in HBL. The deployment script installs only this app and its pairing key, then reads both back for verification. Keys, saved places and the provider's cache stay under ignored `.local/`. `L + R + START` returns to HBL.
+
+| Control | Action |
+| --- | --- |
+| Bottom touchpad | Drag to pan; flick for inertia |
+| Circle Pad / D-pad | Pan the upper map |
+| `+` / `-` buttons | Animated zoom around the map center |
+| Search / Y | Open the local keyboard; Find submits the query |
+| Search results touchpad | Slide to choose a result, tap to open |
+| D-pad up/down + A | Choose and open a search result or menu item |
+| X / Back to pin | Return to the selected place |
+| Hold L | Search, saved places, save map center, return to pin, or San Francisco |
+| Hold R | Zoom, clear pin, retry tiles, or show controls |
+| Save view / Save place | Name and save the center or selected search result on the Mac |
+| Saved | Browse, rename, delete with confirmation, or return to a saved location |
+| Saved page: Prev / Next or D-pad left/right | Turn five-place pages |
+| B | Dismiss results/menu; backspace while typing |
+| Shift | Tap for one uppercase letter; double-tap for caps lock |
+
+Typing, dragging, inertia and zoom transitions update locally. Missing tiles show a fallback; an already loaded previous zoom level stays visible during replacement. A disconnected Mac leaves resident tiles navigable. Search, new tiles and uncached labels require the Mac.
+
+## Saved places
+
+Use **Save view** to store the map center, or **Save place** on a search result.
+The local keyboard names it before Save sends a command to the Mac. Saved opens
+five-place pages, with Rename, Delete and Go to place actions. Deletion uses an
+animated confirmation sheet; B / Keep place cancels it.
+
+The Mac stores user data in `.local/places.sqlite`, separate from the disposable
+`.local/cache.sqlite`. Coordinates, zoom and names survive daemon restarts.
+Mutations commit with an operation receipt: Retry after a missing acknowledgement
+uses the same operation, so it cannot create a second copy or resurrect a deleted
+place. Cancel after an unconfirmed command does not undo a Mac write; reopen
+Saved to check its outcome. A connected Mac is needed for writes. The guest
+retains up to four previously viewed pages during a disconnection.
+
+## Provider configuration
+
+The default small-demo configuration uses `https://tile.openstreetmap.de/{z}/{x}/{y}.png` and `https://photon.komoot.io/api/`, both verified reachable from the development Mac. The main `tile.openstreetmap.org` endpoint failed to connect in that network. No API keys are required by the selected demo endpoints.
+
+Override settings in `.local/provider.json`, then restart the host:
+
+```json
+{
+ "tileURL": "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
+ "searchURL": "https://photon.komoot.io/api/",
+ "name": "OpenStreetMap",
+ "attribution": "OpenStreetMap contributors",
+ "maxZoom": 18
+}
+```
+
+The tile endpoint must return 256px PNGs; the search endpoint must implement the Photon response shape. HTTPS and loopback HTTP development endpoints are supported. The device sends typed tile coordinates, not arbitrary URLs. Tile identities include a hash of the source URL and rendition version.
+
+Public services have limited capacity: [OSM DE's terms](https://openstreetmap.de/germanstyle/#nutzungsbedingungen), [tile usage policy](https://operations.osmfoundation.org/policies/tiles/), and [Photon's demo policy](https://github.com/komoot/photon#demo-server) apply. **The current viewport has priority; at most four nearby tiles provide short-range look-ahead.** The margin is 64 screen pixels and movement prediction is capped at 128 pixels; there is no area downloader or multi-level pre-seeding. Searches require an explicit submit and are limited to one per 1.1 seconds on this provider. The Mac honors HTTP cache headers and conditional requests, using seven days for tiles without freshness headers. Use an appropriate hosted or self-hosted service before scaling beyond this personal demo. Endpoint configuration allows that switch without rebuilding the 3DS app.
+
+## Architecture and resource pattern
+
+```text
+3DS UI thread 3DS network worker Mac provider worker
+camera + controls authenticated TCP HTTPS + SQLite cache
+visible tile demand -> bounded request queue -> fetch / PNG decode
+ResourceImage fallback <- native image tickets <- binary R5G6B5 pixels
+one image upload/frame eight staging slots bounded tile/label output
+```
+
+The app uses `createOffloadImageCollection` to declare the tile identity and rendition once. View owners declare demand; reads subscribe by key. The runtime merges demands, schedules requests, retries failed reads, uploads within the frame budget and frees textures on eviction. Cancelling a response before upload returns its native staging slot.
+
+```ts
+const tiles = createOffloadImageCollection(runtime, io, {
+ key: (tile: TileInput) => `${tile.source}/${tile.z}/${tile.x}/${tile.y}`,
+ method: "map.tile", payload: JSON.stringify,
+ width: 256, height: 256, maxEntries: 40,
+ maxViews: 2, maxDemandsPerView: 16,
+});
+const view = createResourceView(tiles, { demand: visibleTileDemand });
+// view.state(tile)} fallback={() => } />
+```
+
+Map-specific behavior remains in [app/model.ts](app/model.ts) and [app/geo.ts](app/geo.ts): Mercator projection, longitude wrapping, visible priorities, selection and search. [host/provider.ts](host/provider.ts) owns source grants and image processing; [host/cache.ts](host/cache.ts) owns persistent HTTP caching. [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) records budgets, lifecycle and tradeoffs.
+
+## Validation
+
+```sh
+bun run check
+runtime/node_modules/.bin/tsc --noEmit
+bun run sim # compiled guest, synthetic provider, interaction replay
+bun run sim --live # current live viewport + one explicit place search
+qjs --std --stack-size 131072 scripts/quickjs-smoke.js
+```
+
+Generated captures and receipts go to `dist/qa/`. Replay covers keyboard input, selecting places, drag/inertia, zoom, sustained movement while replies wait, reconnect, date-line navigation and bounded resource ownership. Replay also covers saving, a lost acknowledgement and safe retry, renaming, paging, delete confirmation and entering a prefetched tile. Stress tests use synthetic tiles, not automated public-service downloads.
+
+Native build, native transport, visual replay and physical interaction are separate checks. **No measured 60 fps claim is made yet.** Per-frame IO and image limits prevent network waits on the UI thread; JS, allocation, GPU uploads and presentation still need hardware measurement. See [docs/VALIDATION.md](docs/VALIDATION.md).
diff --git a/app/geo.ts b/app/geo.ts
new file mode 100644
index 0000000..a51f9b2
--- /dev/null
+++ b/app/geo.ts
@@ -0,0 +1,18 @@
+export const MAX_LATITUDE = 85.0511287798066;
+export function project(lat: number, lon: number) {
+ if (!Number.isFinite(lat) || !Number.isFinite(lon)) throw new Error("Invalid coordinate");
+ lat = Math.max(-MAX_LATITUDE, Math.min(MAX_LATITUDE, lat));
+ const sine = Math.sin(lat * Math.PI / 180);
+ return { x: (((lon + 180) % 360 + 360) % 360) / 360 * 256,
+ y: (0.5 - Math.log((1 + sine) / (1 - sine)) / (4 * Math.PI)) * 256 };
+}
+export function unproject(x: number, y: number) {
+ return { lon: x / 256 * 360 - 180, lat: Math.atan(Math.sinh(Math.PI * (1 - 2 * y / 256))) * 180 / Math.PI };
+}
+export function wrapTile(x: number, z: number) { const n = 2 ** z; return ((x % n) + n) % n; }
+export function scaleBar(lat: number, zoom: number) {
+ const metersPerPixel = 156543.03392804097 * Math.cos(lat * Math.PI / 180) / 2 ** zoom;
+ const target = metersPerPixel * 70, base = 10 ** Math.floor(Math.log10(target));
+ const meters = [5, 2, 1].map(n => n * base).find(n => n <= target) ?? base / 2;
+ return { pixels: meters / metersPerPixel, label: meters >= 1000 ? `${meters / 1000} km` : `${meters} m` };
+}
diff --git a/app/main.tsx b/app/main.tsx
new file mode 100644
index 0000000..cd09b1d
--- /dev/null
+++ b/app/main.tsx
@@ -0,0 +1,3 @@
+import { mount } from "@pocketjs/framework/solid";
+import MapApp from "./ui.tsx";
+mount(() => );
diff --git a/app/map-pad.svg b/app/map-pad.svg
new file mode 100644
index 0000000..686a96e
--- /dev/null
+++ b/app/map-pad.svg
@@ -0,0 +1 @@
+
diff --git a/app/map-pin.svg b/app/map-pin.svg
new file mode 100644
index 0000000..9c6b712
--- /dev/null
+++ b/app/map-pin.svg
@@ -0,0 +1 @@
+
diff --git a/app/model.ts b/app/model.ts
new file mode 100644
index 0000000..261bd3f
--- /dev/null
+++ b/app/model.ts
@@ -0,0 +1,185 @@
+import { createSignal, createMemo } from "solid-js";
+import { createResourceRuntime, createResourceView } from "@pocketjs/framework/resource-view";
+import { createOffloadImageCollection, offloadResource } from "@pocketjs/framework/resource-offload";
+import { createTileCamera, planTileWindow } from "@pocketjs/framework/tile-viewport";
+import { offload } from "@pocketjs/framework/offload";
+import { analogX, analogY, onFrame, onButtonPress } from "@pocketjs/framework/lifecycle";
+import { BTN } from "@pocketjs/framework/input";
+import { simulationHz, virtualNow } from "@pocketjs/framework/clock";
+import { project, unproject, wrapTile } from "./geo.ts";
+import { createSavedPlaces, validPlaces, type MapMode } from "./saved.ts";
+import { HOME, type TileInput, type MapInfo, type SearchInput, type Place } from "../shared/types.ts";
+
+export interface DrawTile { input: TileInput; column: number; row: number; priority: number }
+export interface Layer { level: number; tiles: DrawTile[]; originX: number; originY: number }
+export const MENU = {
+ places: ["Search places", "Saved places", "Save map center", "Back to pin", "San Francisco"],
+ map: ["Zoom in", "Zoom out", "Clear pin", "Retry tiles", "About & controls"],
+};
+export function createMap(io = offload()) {
+ const [info, setInfo] = createSignal();
+ const [online, setOnline] = createSignal(false), [status, setStatus] = createSignal("Waiting for paired Mac");
+ const [mode, setMode] = createSignal("map");
+ const [query, setQuery] = createSignal(""), [submitted, setSubmitted] = createSignal();
+ const [selection, setSelection] = createSignal(0), [pin, setPin] = createSignal();
+ const [menu, setMenu] = createSignal<"places" | "map">(), [menuIndex, setMenuIndex] = createSignal(0);
+ const [shift, setShift] = createSignal<"off" | "once" | "locked">("off"), [symbols, setSymbols] = createSignal(false);
+ const [front, setFront] = createSignal(), [back, setBack] = createSignal();
+ const p = project(HOME.lat, HOME.lon);
+ const camera = createTileCamera({ width: 400, height: 240, x: p.x, y: p.y, zoom: HOME.zoom, minZoom: 1, maxZoom: 18, bounds: { width: 256, height: 256, wrapX: true } });
+ const runtime = createResourceRuntime({ maxConcurrent: 3, startsPerFrame: 1, completionsPerFrame: 1, maxCollections: 4, available: () => io.connected() && !!info() && io.pending() < 3 });
+ const tiles = createOffloadImageCollection(runtime, io, { key: (i: TileInput) => `${i.source}/${i.z}/${i.x}/${i.y}`, method: "map.tile", payload: JSON.stringify,
+ width: 256, height: 256, maxEntries: 40, maxViews: 2, maxDemandsPerView: 16, retry: { attempts: 3, delayFrames: 90, maxDelayFrames: 360 } });
+ const labels = createOffloadImageCollection(runtime, io, { key: (i: Place) => `${i.name}/${i.detail}`, method: "map.label", payload: i => JSON.stringify({ name: i.name, detail: i.detail }),
+ width: 256, height: 32, maxEntries: 5, maxViews: 5, maxDemandsPerView: 1 });
+ const [lookAhead, setLookAhead] = createSignal([]);
+ const frontView = createResourceView(tiles, { demand: () => [...(front()?.tiles.map(t => ({ input: t.input, priority: t.priority, pin: true })) ?? []), ...lookAhead().map(t => ({ input: t.input, priority: 1000 + t.priority, pin: false }))] });
+ const backView = createResourceView(tiles, { demand: () => back()?.tiles.map(t => ({ input: t.input, priority: 100, pin: true })) ?? [] });
+ const searches = runtime.createCollection({ key: (i: SearchInput) => JSON.stringify(i), maxEntries: 4, maxViews: 1, maxDemandsPerView: 1,
+ maxCost: 4 * 8192, cost: () => 8192, maxResponseBytes: 5000, retry: { attempts: 1, delayFrames: 60, maxDelayFrames: 60 },
+ load: offloadResource(io, "map.search", JSON.stringify), materialize(raw: string): Place[] {
+ const rows = JSON.parse(raw);
+ if (!validPlaces(rows)) throw new Error("Invalid places response");
+ return rows;
+ },
+ });
+ const results = createResourceView(searches, { demand: () => submitted() ? [{ input: submitted()!, priority: -10, pin: true }] : [] });
+ const places = createMemo(() => submitted() ? results.value(submitted()!) ?? [] : []);
+ const saved = createSavedPlaces(io, runtime, mode, setMode);
+ const typing = () => mode() === "search" || mode() === "name";
+ const listing = () => mode() === "results" || mode() === "saved";
+ const rows = () => mode() === "saved" ? saved.page()?.items ?? [] : places();
+ const selectedIndex = () => mode() === "saved" ? saved.selection() : selection();
+ function select(index: number) { const n = Math.max(0, Math.min(rows().length - 1, index)); if (mode() === "saved") saved.setSelection(n); else setSelection(n); }
+ let previousView = camera.view();
+ let frame = 0, previousSession = 0, infoRequest = 0, retryAt = 0, shiftAt = -10, levelAge = 0, candidateLevel = HOME.zoom;
+ let confirmed = false;
+ function search() {
+ const text = query().trim(); if (!text) return;
+ const pos = unproject(camera.view().x, camera.view().y);
+ const next = { query: text, lat: Math.round(pos.lat * 10) / 10, lon: Math.round(pos.lon * 10) / 10 };
+ searches.invalidate(i => JSON.stringify(i) === JSON.stringify(next));
+ setSubmitted(next); setSelection(0); setMode("results"); camera.stop();
+ }
+ function openSearch() { if (saved.busy() || saved.modal() || mode() === "name") return; camera.stop(); setMode("search"); setMenu(undefined); }
+ function go(place = rows()[selectedIndex()]) {
+ if (!place) return;
+ const pos = project(place.lat, place.lon); camera.jump(pos.x, pos.y, Math.min(info()?.maxZoom ?? 18, place.zoom));
+ setPin(place); setMode("map"); setMenu(undefined);
+ }
+ function home(name: string, lat: number, lon: number) { go({ id: name, name, detail: "", lat, lon, zoom: 14 }); }
+ function zoom(delta: number) {
+ if (delta > 0 && camera.view().zoom >= (info()?.maxZoom ?? 18)) return;
+ camera.zoomBy(delta);
+ }
+ function key(value: string) {
+ if (!typing() || saved.busy()) return;
+ const change = (fn: (value: string) => string) => mode() === "name" ? saved.changeName(fn(saved.name())) : setQuery(fn);
+ if (value === "DEL") change(q => q.slice(0, -1));
+ else if (value === "SPACE") change(q => q.length < 80 ? q + " " : q);
+ else if (value === "GO") mode() === "name" ? saved.submit() : search();
+ else if (value === "SHIFT") {
+ const now = virtualNow(); setShift(s => s === "locked" ? "off" : now - shiftAt < 0.35 ? "locked" : s === "off" ? "once" : "off"); shiftAt = now;
+ } else if (value === "123") setSymbols(s => !s);
+ else if (value.length === 1) { change(q => q.length < 80 ? q + (shift() !== "off" ? value.toUpperCase() : value) : q); if (shift() === "once") setShift("off"); }
+ }
+ function saveCurrent() {
+ camera.stop();
+ if (mode() === "results") { const selected = places()[selection()]; if (selected) { const p = project(selected.lat, selected.lon); camera.jump(p.x, p.y, selected.zoom); setPin(selected); saved.begin(selected); } return; }
+ const view = camera.view(), pos = unproject(view.x, view.y), selected = pin();
+ const target = selected && project(selected.lat, selected.lon);
+ let dx = target ? target.x - view.x : Infinity; dx -= Math.round(dx / 256) * 256;
+ saved.begin(target && Math.hypot(dx, target.y - view.y) * view.scale < 12 ? selected! : { id: "center", name: "Map center", detail: `${pos.lat.toFixed(5)}, ${pos.lon.toFixed(5)}`, ...pos, zoom: Math.round(view.zoom) });
+ }
+ function dismiss() { if (saved.busy()) return; if (mode() === "name") { saved.cancelName(); return; } setMenu(undefined); setMode("map"); camera.stop(); }
+ function runMenu() {
+ const i = menuIndex(), bank = menu(); setMenu(undefined); confirmed = true;
+ if (bank === "places") {
+ if (i === 0) openSearch(); else if (i === 1) saved.open(); else if (i === 2) saveCurrent(); else if (i === 3) go(pin()); else home("San Francisco", HOME.lat, HOME.lon);
+ } else if (bank === "map") {
+ if (i < 2) zoom(i === 0 ? 1 : -1); else if (i === 2) setPin(undefined); else if (i === 3) tiles.invalidate(); else setMode("about");
+ }
+ }
+ onButtonPress(BTN.CIRCLE, () => { if (saved.modal() || saved.busy()) return; if (menu()) runMenu(); else if (typing()) key("GO"); else if (listing()) go(); });
+ onButtonPress(BTN.CROSS, () => { if (saved.modal()) { saved.cancelDelete(); return; } if (saved.busy()) return; if (menu()) { confirmed = true; setMenu(undefined); } else if (typing()) key("DEL"); else dismiss(); });
+ onButtonPress(BTN.TRIANGLE, openSearch);
+ onButtonPress(BTN.SQUARE, () => { if (mode() === "map") go(pin()); });
+ for (const [button, delta] of [[BTN.UP, -1], [BTN.DOWN, 1]] as const) onButtonPress(button, () => {
+ if (saved.modal() || saved.busy()) return;
+ if (menu()) setMenuIndex(i => Math.max(0, Math.min(4, i + delta)));
+ else if (listing()) select(selectedIndex() + delta);
+ });
+ onButtonPress(BTN.LEFT, () => { if (mode() === "saved" && !menu() && !saved.modal()) saved.turnPage(-1); });
+ onButtonPress(BTN.RIGHT, () => { if (mode() === "saved" && !menu() && !saved.modal()) saved.turnPage(1); });
+ let previousButtons = 0;
+ onFrame(buttons => {
+ frame++; const session = io.session(); setOnline(session > 0);
+ if (session !== previousSession) {
+ if (infoRequest) { io.cancel(infoRequest); infoRequest = 0; }
+ runtime.cancel(); retryAt = 0;
+ if (session > 0) { tiles.invalidate(); searches.invalidate(); labels.invalidate(); saved.refresh(); setStatus("Connecting map service"); }
+ else setStatus("Mac disconnected - cached map");
+ previousSession = session;
+ }
+ if (session > 0 && !infoRequest && frame >= retryAt) {
+ infoRequest = io.request("map.info", "{}", result => {
+ infoRequest = 0; retryAt = frame + 3600;
+ if (!result.ok) { setStatus(result.error); retryAt = frame + 120; return; }
+ try {
+ const value: MapInfo = JSON.parse(result.value);
+ if (typeof value.source !== "string" || !/^[a-f0-9]{16}$/.test(value.source) || typeof value.name !== "string" || typeof value.attribution !== "string" || !Number.isInteger(value.maxZoom) || value.maxZoom < 1 || value.maxZoom > 18) throw new Error("Invalid map provider");
+ if (info()?.source !== value.source) { tiles.clear(); setFront(undefined); setBack(undefined); setLookAhead([]); }
+ setInfo(value); setStatus("Map ready");
+ } catch { setStatus("Unsupported map provider"); retryAt = frame + 120; }
+ });
+ }
+ const shoulders = buttons & (BTN.LTRIGGER | BTN.RTRIGGER);
+ if (!shoulders) { confirmed = false; if (menu()) setMenu(undefined); }
+ else if (!saved.modal() && !saved.busy() && mode() !== "name" && !confirmed && !menu() && shoulders !== (previousButtons & (BTN.LTRIGGER | BTN.RTRIGGER))) {
+ setMenu(shoulders & BTN.LTRIGGER ? "places" : "map"); setMenuIndex(0); camera.stop();
+ }
+ const canPan = mode() === "map" && !shoulders && !saved.modal();
+ const axisX = canPan ? analogX() : 0, axisY = canPan ? analogY() : 0;
+ const dx = canPan ? (buttons & BTN.LEFT ? 1 : 0) - (buttons & BTN.RIGHT ? 1 : 0) : 0;
+ const dy = canPan ? (buttons & BTN.UP ? 1 : 0) - (buttons & BTN.DOWN ? 1 : 0) : 0;
+ camera.step(1 / simulationHz(), dx ? dx * 180 : -axisX * 320, dy ? dy * 180 : -axisY * 320);
+ previousButtons = buttons;
+ if (!info()) return;
+ const view = camera.view(), next = Math.min(info()!.maxZoom, Math.max(1, Math.round(view.zoom)));
+ levelAge = next === candidateLevel ? levelAge + 1 : 0; candidateLevel = next;
+ let level = front()?.level ?? next;
+ if (next !== level && (levelAge >= 8 || Math.abs(next - level) > 1)) {
+ const previous = front();
+ setBack(previous ? { ...previous, tiles: previous.tiles.filter(t => frontView.state(t.input).status === "ready") } : undefined);
+ level = next;
+ }
+ let leadX = view.x - previousView.x; leadX -= Math.round(leadX / 256) * 256;
+ const leadY = view.y - previousView.y;
+ const moving = view.moving && Math.hypot(leadX, leadY) * view.scale < 24 && Math.abs(view.zoom - level) < 0.02;
+ const planOptions = { ...view, level, width: 400, height: 240, maxTiles: 12, margin: 64,
+ leadX: moving ? Math.max(-128, Math.min(128, leadX * view.scale * 18)) : 0,
+ leadY: moving ? Math.max(-128, Math.min(128, leadY * view.scale * 18)) : 0, maxExtra: Math.abs(view.zoom - level) < 0.02 ? 4 : 0 };
+ let window;
+ try { window = planTileWindow(planOptions); }
+ catch { level = next; window = planTileWindow({ ...planOptions, level, maxExtra: 0 }); }
+ previousView = view;
+ const address = (list: typeof window.visible) => list.filter(t => t.row >= 0 && t.row < 2 ** level).map(t => ({ ...t, input: { source: info()!.source, z: level, x: wrapTile(t.column, level), y: t.row } }));
+ const draw = address(window.visible), ahead = address(window.lookAhead);
+ if (ahead.length !== lookAhead().length || ahead.some((t, i) => { const o = lookAhead()[i]; return !o || t.input.z !== o.input.z || t.input.x !== o.input.x || t.input.y !== o.input.y; })) setLookAhead(ahead);
+ const previous = front();
+ if (!previous || previous.level !== level || previous.tiles.length !== draw.length || draw.some(t => !previous.tiles.some(o => o.column === t.column && o.row === t.row))) {
+ // Solid's For keys by object identity. Keep overlapping tile instances;
+ // a one-column pan must not remount every resource/fallback on the screen.
+ const retained = draw.map(t => previous?.level === level
+ ? previous.tiles.find(o => o.column === t.column && o.row === t.row && o.input.source === t.input.source) ?? t : t);
+ setFront({ level, tiles: retained, originX: Math.min(...draw.map(t => t.column)) * 256, originY: Math.min(...draw.map(t => t.row)) * 256 });
+ }
+ if (back() && front()?.tiles.every(t => frontView.state(t.input).status === "ready")) setBack(undefined);
+ });
+ return { io, runtime, tiles, labels, frontView, backView, info, online, status, mode, setMode, query, setQuery, submitted, results, places, selection, setSelection, pin, menu, menuIndex,
+ shift, symbols, front, back, camera, saved, typing, listing, rows, selectedIndex, select, saveCurrent, lookAhead, search, openSearch, go, zoom, key, dismiss, runMenu,
+ clearBack: () => setBack(undefined),
+ diagnostics: () => ({ frame, pending: io.pending(), resources: runtime.stats(), tiles: tiles.stats(), camera: camera.view() }),
+ };
+}
+export type MapModel = ReturnType;
diff --git a/app/saved.ts b/app/saved.ts
new file mode 100644
index 0000000..c1d4e37
--- /dev/null
+++ b/app/saved.ts
@@ -0,0 +1,69 @@
+import { createSignal, createMemo, onCleanup } from "solid-js";
+import { createResourceRuntime, createResourceView } from "@pocketjs/framework/resource-view";
+import { offloadResource } from "@pocketjs/framework/resource-offload";
+import type { offload } from "@pocketjs/framework/offload";
+import type { Place, BookmarkPage, BookmarkCommand } from "../shared/types.ts";
+
+export type MapMode = "map" | "search" | "results" | "about" | "saved" | "name";
+export function validPlaces(rows: unknown): rows is Place[] {
+ return Array.isArray(rows) && rows.length <= 5 && rows.every(p => p && typeof p.id === "string" && p.id.length <= 80 && typeof p.name === "string" && p.name.length <= 36 && typeof p.detail === "string" && p.detail.length <= 60 && Number.isFinite(p.lat) && Math.abs(p.lat) <= 90 && Number.isFinite(p.lon) && Math.abs(p.lon) <= 180 && Number.isInteger(p.zoom) && p.zoom >= 1 && p.zoom <= 18);
+}
+export function createSavedPlaces(io: ReturnType, runtime: ReturnType, mode: () => MapMode, setMode: (mode: MapMode) => void) {
+ const [offset, setOffset] = createSignal(0), [selection, setSelection] = createSignal(0);
+ const [name, setName] = createSignal(""), [editing, setEditing] = createSignal();
+ const [busy, setBusy] = createSignal(false), [error, setError] = createSignal("");
+ const [deleting, setDeleting] = createSignal(), [modal, setModal] = createSignal(false);
+ let target: Place | undefined, returnMode: MapMode = "map", request = 0, last: BookmarkCommand | undefined;
+ const collection = runtime.createCollection({ key: (offset: number) => String(offset), maxEntries: 4, maxViews: 1, maxDemandsPerView: 1,
+ maxCost: 4 * 8192, cost: () => 8192, maxResponseBytes: 5000, retry: { attempts: 2, delayFrames: 60, maxDelayFrames: 120 },
+ load: offloadResource(io, "bookmarks.list", offset => JSON.stringify({ offset })), materialize(raw: string): BookmarkPage {
+ const page = JSON.parse(raw);
+ if (!page || !validPlaces(page.items) || !Number.isSafeInteger(page.offset) || page.offset < 0 || page.offset > 995 || page.offset % 5 || !Number.isSafeInteger(page.total) || page.total < 0 || page.total > 1000 || page.items.length !== Math.min(5, Math.max(0, page.total - page.offset))) throw new Error("Invalid saved places response");
+ return page;
+ } });
+ const view = createResourceView(collection, { demand: () => mode() === "saved" || mode() === "name" ? [{ input: offset(), priority: -10, pin: true }] : [] });
+ const page = createMemo(() => view.value(offset()));
+ const selected = () => page()?.items[Math.min(selection(), (page()?.items.length ?? 1) - 1)];
+ function open() { if (busy()) return; collection.invalidate(); setSelection(0); setOffset(0); setMode("saved"); }
+ function begin(place: Place, rename = false) {
+ if (busy()) return;
+ target = place; returnMode = mode(); setEditing(rename ? place : undefined); setName(place.name); setError(""); last = undefined; setMode("name");
+ }
+ function changeName(value: string) { if (busy()) return; setName(value.slice(0, 36)); last = undefined; setError(""); }
+ function perform(command: BookmarkCommand) {
+ if (busy()) return;
+ last = command; setBusy(true); setError("");
+ request = io.request("bookmarks.command", JSON.stringify(command), result => {
+ request = 0; setBusy(false);
+ if (!result.ok) { setError("No confirmation. Retry is safe."); return; }
+ try { const reply = JSON.parse(result.value); if (!/^b_[a-f0-9]{24}$/.test(reply.id)) throw new Error(); }
+ catch { setError("No confirmation. Retry is safe."); return; }
+ collection.invalidate(undefined, true); last = undefined; setDeleting(undefined);
+ if (command.kind === "save") { setOffset(0); setSelection(0); }
+ else if (command.kind === "remove") setSelection(i => Math.max(0, i - 1));
+ setMode("saved");
+ });
+ if (!request) { setBusy(false); setError("Mac unavailable. Tap Retry."); }
+ }
+ const operation = () => `m_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}_${Math.random().toString(36).slice(2)}`;
+ function submit() {
+ if (!target || !name().trim() || busy()) return;
+ if (last) { perform(last); return; }
+ perform(editing() ? { op: operation(), kind: "rename", id: editing()!.id, name: name().trim() }
+ : { op: operation(), kind: "save", place: { ...target, name: name().trim() } });
+ }
+ function remove() { const p = deleting(); if (p) perform(last?.kind === "remove" ? last : { op: operation(), kind: "remove", id: p.id }); }
+ function askRemove() { const p = selected(); if (p && !busy()) { setError(""); last = undefined; setDeleting(p); } }
+ function cancelDelete() { if (!busy()) { setDeleting(undefined); setError(""); last = undefined; collection.invalidate(); } }
+ function cancelName() { if (busy()) return; collection.invalidate(); setMode(returnMode); setError(""); last = undefined; }
+ function turnPage(delta: number) {
+ const p = page(); if (!p || busy()) return;
+ const next = p.offset + delta * 5;
+ if (next >= 0 && next < p.total) { setOffset(next); setSelection(0); }
+ }
+ onCleanup(() => { if (request) io.cancel(request); });
+ return { page, selected, selection, setSelection, name, changeName, editing, busy, error, deleting, modal, setModal,
+ open, begin, submit, askRemove, remove, cancelDelete, cancelName, turnPage, refresh: () => collection.invalidate(),
+ state: () => view.state(offset()),
+ };
+}
diff --git a/app/shift-lock.svg b/app/shift-lock.svg
new file mode 100644
index 0000000..fbc5c08
--- /dev/null
+++ b/app/shift-lock.svg
@@ -0,0 +1 @@
+
diff --git a/app/shift.svg b/app/shift.svg
new file mode 100644
index 0000000..cebacd2
--- /dev/null
+++ b/app/shift.svg
@@ -0,0 +1 @@
+
diff --git a/app/ui.tsx b/app/ui.tsx
new file mode 100644
index 0000000..a73c368
--- /dev/null
+++ b/app/ui.tsx
@@ -0,0 +1,177 @@
+import { createSignal, For, Show } from "solid-js";
+import { AuxiliarySurface, Image, Text, View, type NodeMirror } from "@pocketjs/framework/components";
+import { ClassicButton, ClassicFace, ClassicPanel, ClassicSheet } from "@pocketjs/framework/classic";
+import { ResourceImage } from "@pocketjs/framework/resource";
+import { createResourceView } from "@pocketjs/framework/resource-view";
+import { createGesture, createDragFilter } from "@pocketjs/framework/gesture";
+import { simulationHz } from "@pocketjs/framework/clock";
+import { onFrame } from "@pocketjs/framework/lifecycle";
+import * as hot from "@pocketjs/framework/hot";
+import { createMap, MENU, type MapModel, type Layer } from "./model.ts";
+import { project, unproject, scaleBar } from "./geo.ts";
+import type { Place } from "../shared/types.ts";
+const box = (x: number, y: number, w: number, h: number) => ({ posType: 1, insetL: x, insetT: y, width: w, height: h });
+
+function TileLayer(p: { s: MapModel; back?: boolean }) {
+ let world: NodeMirror | undefined;
+ const layer = () => (p.back ? p.s.back() : p.s.front());
+ const resources = p.back ? p.s.backView : p.s.frontView;
+ onFrame(() => {
+ const l = layer(); if (!l || !world) return;
+ const view = p.s.camera.view(), scale = 2 ** (view.zoom - l.level);
+ const worldWidth = 256 * 2 ** l.level;
+ let offsetX = l.originX - view.x * 2 ** l.level;
+ // Rebase at the viewport, including the nearest copy across the date line.
+ offsetX -= Math.round(offsetX / worldWidth) * worldWidth;
+ hot.prop(world, "translateX", 200 + offsetX * scale);
+ hot.prop(world, "translateY", 120 + (l.originY - view.y * 2 ** l.level) * scale);
+ hot.prop(world, "scaleX", scale); hot.prop(world, "scaleY", scale);
+ });
+ return
+ {tile => resources.state(tile.input)} fallback={() => p.back || p.s.back() ? :
+
+
+ } errorFallback={() => Tile unavailable} />}
+ ;
+}
+function PlaceRow(p: { s: MapModel; place: Place; index: number }) {
+ const unicode = /[^\x20-\x7e]/.test(p.place.name + p.place.detail);
+ const image = createResourceView(p.s.labels, { demand: () => unicode && p.s.listing() ? [{ input: p.place, priority: -5, pin: true }] : [] });
+ return
+ {p.index + 1}
+
+ {p.place.name.slice(0, 36)}
+ {p.place.detail.slice(0, 43)}
+ >}> image.state(p.place)} fallback={() => } />
+ ;
+}
+function SearchResults(p: { s: MapModel }) {
+ return
+ {p.s.mode() === "saved" ? `Saved places${p.s.saved.page() ? " (" + p.s.saved.page()!.total + ")" : ""}` : `Places: ${p.s.query().slice(0, 32)}`}
+ {(place, i) => }
+
+ {p.s.mode() === "saved" ? p.s.saved.state().status === "pending" ? "Loading saved places from your Mac..." : p.s.saved.state().status === "error" ? "Saved places unavailable. Reopen to retry." : "No saved places yet. Use Save view." : p.s.submitted() && p.s.results.state(p.s.submitted()!).status === "pending" ? "Searching with your Mac..." : p.s.submitted() && p.s.results.state(p.s.submitted()!).status === "error" ? "Search unavailable. Edit and retry." : "No places found. Try another name."}
+
+ ;
+}
+function Context(p: { s: MapModel }) {
+ return {bank =>
+
+ {bank() === "places" ? "Places" : "Map controls"}
+ {(name, i) =>
+ {name}
+ }
+ D-pad: choose A: open B: cancel
+
+ };
+}
+const LETTERS = ["q w e r t y u i o p", "a s d f g h j k l DEL", "SHIFT z x c v b n m .", "123 - , SPACE"];
+const SYMBOLS = ["1 2 3 4 5 6 7 8 9 0", "@ # / ( ) : ; ' ? DEL", "SHIFT ! $ % & * + = .", "123 - , SPACE"];
+function Keyboard(p: { s: MapModel }) {
+ const [pressed, setPressed] = createSignal(""); let root: NodeMirror | undefined;
+ const keys = () => (p.s.symbols() ? SYMBOLS : LETTERS).flatMap((line, row) => {
+ let x = 6; return line.split(" ").map((value, col) => { const w = row === 3 ? [36, 28, 28, 216][col] : 30.8;
+ const key = { value, x, y: 41 + row * 22, w: w - 2 }; x += w; return key; });
+ });
+ createGesture({ surface: "auxiliary", region: { node: () => root }, onDown(c) {
+ if (!p.s.typing() || p.s.menu() || p.s.saved.busy()) return;
+ const k = keys().find(k => c.x >= k.x && c.x < k.x + k.w && c.y >= k.y && c.y < k.y + 20);
+ if (k) { setPressed(k.value); p.s.key(k.value); }
+ }, onUp: () => setPressed(""), onCancel: () => setPressed("") });
+ return
+ {k =>
+ {k.value === "123" && p.s.symbols() ? "ABC" : k.value.length === 1 && p.s.shift() !== "off" ? k.value.toUpperCase() : k.value === "SPACE" ? "space" : k.value}}>
+
+
+ }
+ ;
+}
+function Deck(p: { s: MapModel }) {
+ let pad: NodeMirror | undefined; let dy = 0;
+ const filter = createDragFilter();
+ const [touching, setTouching] = createSignal(false);
+ const typing = p.s.typing, results = p.s.listing, saved = () => p.s.mode() === "saved", naming = () => p.s.mode() === "name";
+ const blocked = () => !!p.s.menu() || p.s.saved.modal() || p.s.saved.busy();
+ const top = () => typing() ? 136 : 40, height = () => 194 - top();
+ createGesture({ surface: "auxiliary", region: { node: () => pad }, panSlop: 2,
+ onDown() { if (blocked() || naming()) return; setTouching(true); dy = 0; filter.reset(); p.s.camera.beginDrag(); },
+ onPanMove(c) {
+ if (blocked() || naming()) return;
+ if (results()) { dy += c.fdy; const step = Math.trunc(dy / 20); if (step) { p.s.select(p.s.selectedIndex() + step); dy -= step * 20; } }
+ else { const delta = filter.update(c.dx, c.dy, 1 / simulationHz()); p.s.camera.drag(delta.dx * 1.45, delta.dy * 1.45); }
+ },
+ onPanEnd() { setTouching(false); if (blocked() || naming()) return; const v = filter.velocity(); p.s.camera.endDrag(results() ? 0 : v.x * 1.45, results() ? 0 : v.y * 1.45); },
+ onTap() { setTouching(false); if (blocked() || naming()) return; p.s.camera.endDrag(0, 0); if (results()) p.s.go(); },
+ onCancel() { setTouching(false); p.s.camera.stop(); },
+ });
+ // Build fixed controls outside the outer wrapper's mount stack on QuickJS.
+ const keyboard = ;
+ const search = typing() || saved() || p.s.mode() === "about" ? p.s.dismiss() : p.s.openSearch()} />;
+ const padView =
+ {naming() ? p.s.saved.editing() ? "Rename saved place" : "Save place to your Mac" : results() ? "Choose a place" : touching() ? "Moving map" : "Map touchpad"}
+
+
+ {results() ? "Slide to choose; tap to open" : "Drag to pan. Flick to glide."}
+ {saved() ? `Page ${Math.floor((p.s.saved.page()?.offset ?? 0) / 5) + 1} of ${Math.max(1, Math.ceil((p.s.saved.page()?.total ?? 0) / 5))} Left / Right: turn page` : results() ? "Or use D-pad and A" : "The upper screen follows your stylus"}
+
+ {naming() ? p.s.saved.busy() ? "Saving on your Mac..." : p.s.saved.error() || "Name this location, then tap Save" : "Drag here to move the map"}
+ ;
+ const plus = saved() ? p.s.saved.turnPage(-1) : p.s.zoom(1)} />;
+ const minus = = p.s.saved.page()!.total : results())} onPress={() => saved() ? p.s.saved.turnPage(1) : p.s.zoom(-1)} />;
+ const left = naming() ? p.s.saved.changeName("") : typing() ? p.s.setQuery("") : saved() ? p.s.saved.begin(p.s.saved.selected()!, true) : results() ? p.s.dismiss() : p.s.go(p.s.pin())} />;
+ const middle = saved() ? p.s.saved.askRemove() : p.s.saveCurrent()} />;
+ const right = typing() ? p.s.key("GO") : results() ? p.s.go() : p.s.saved.open()} />;
+ const sheet = ;
+ return
+
+ {search}
+
+ {typing() ? `${(naming() ? p.s.saved.name() : p.s.query()).slice(-31)}|` : saved() ? "Saved on your paired Mac" : results() ? p.s.query().slice(0, 32) : p.s.online() ? "Explore with your Nintendo 3DS" : "Waiting for paired Mac"}
+
+ {keyboard}{padView}{plus}{minus}{left}{middle}{right}{sheet}
+ ;
+}
+
+export default function MapApp() {
+ const s = createMap(); (globalThis as unknown as { __map: MapModel }).__map = s;
+ let marker: NodeMirror | undefined, bar: NodeMirror | undefined, scaleText: NodeMirror | undefined, zoomText: NodeMirror | undefined;
+ onFrame(() => {
+ const v = s.camera.view(), pin = s.pin();
+ hot.text(zoomText, `z${v.zoom.toFixed(1)}`);
+ if (pin) { const pos = project(pin.lat, pin.lon); let dx = pos.x - v.x; dx -= Math.round(dx / 256) * 256;
+ hot.prop(marker, "translateX", 200 + dx * v.scale - 8); hot.prop(marker, "translateY", 120 + (pos.y - v.y) * v.scale - 22); }
+ const scale = scaleBar(unproject(v.x, v.y).lat, v.zoom); hot.prop(bar, "scaleX", scale.pixels / 70); hot.text(scaleText, scale.label);
+ });
+ const deck = ;
+ const map =
+
+
+ ;
+ return <>
+ {map}
+
+ Pocket Map
+
+ z14.0
+
+
+ 500 m
+
+
+
+ {s.status().slice(0, 35)}
+
+
+
+
+
+
+ Pocket Map
+ {"Stylus / Circle Pad / D-pad: pan\n+ and -: zoom around the map center\nY: search X: return to selected place\nHold L: places Hold R: map controls\n\nMaps: OpenStreetMap contributors\nosm.org/copyright | Tiles: OSM DE\nPlace search: Photon by komoot"}
+
+
+ (c) OpenStreetMap contributors
+ {deck}>;
+}
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
new file mode 100644
index 0000000..14008be
--- /dev/null
+++ b/docs/ARCHITECTURE.md
@@ -0,0 +1,129 @@
+# Pocket Map architecture
+
+## Ownership
+
+**The 3DS guest has one JavaScript thread and no network or filesystem calls.**
+Its state consists of a camera, two tile-layer descriptions, a bounded search
+page, local keyboard text, saved-place pages and a selected place. Solid owns components and
+subscriptions. Continuous camera movement writes paint transforms through
+PocketJS `hot` APIs; a tile window change updates the component tree.
+
+The native offload worker owns sockets, the SD pairing-key read and binary
+reception. The paired Mac's worker owns HTTPS, SQLite, PNG decoding, optional
+Unicode label rasterization and R5G6B5 packing. Provider socket callbacks pass
+bounded requests and replies; they do not execute these capabilities.
+
+## Working set
+
+| Budget | Limit |
+| --- | --- |
+| Active resource requests | 3 |
+| Resource starts / materializations | 1 / 1 per frame |
+| Tile collection | 40 entries, two view owners |
+| Tile demand per view | 16 addresses (up to 12 visible + 4 look-ahead) |
+| Native image staging | 8 × 131,088 bytes plus metadata |
+| Native image envelope | 16–256px power-of-two sides, two bytes per pixel |
+| Native image uploads | 1 per frame |
+| Places per search | 5 |
+| Saved places | 1,000 on Mac; four five-place pages cached on device |
+| Saved-place name | 36 UTF-16 code units |
+| Query text | 80 UTF-16 code units |
+| Unicode label collection | 5 × 256×32 images |
+| Mac concurrent tile decodes | 3 |
+| Mac decoded-image cache | 64 entries, honoring HTTP expiry |
+| Mac HTTP cache | 2,048 bounded responses in SQLite |
+
+The image collection reserves 18 bytes per pixel plus 512 bytes per entry:
+native staging, old-plus-new core/GPU storage, the tiled upload scratch buffer
+and the serialized ticket. Forty steady resident 256px tiles account for
+about 15 MiB of core R5G6B5 plus GPU RGBA8 pixels; native staging, upload scratch,
+fonts, UI nodes and JavaScript use separate memory. This is an admission upper bound, not a claim that
+every tile allocates that amount. JSON responses retain the 4,096-byte record
+and 2,500-code-unit payload limits.
+
+## One tile's lifecycle
+
+1. The camera's current viewport produces near-first tile coordinates and
+ up to four extra neighbors using a 64px margin and up to 128px directional
+ lead (0.3 seconds of recent movement). Visible entries are pinned; extras
+ have lower priority and are unpinned. The map
+ wraps longitude, clips polar rows and attaches the provider source identity.
+2. `createResourceView` declares desired keys. The collection merges both zoom
+ layers' demand and reserves entry cost before starting an offload read.
+3. The Mac resolves the named tile capability to its configured URL. It reads
+ a fresh cached PNG or fetches one with HTTP validators, checks its 256px
+ envelope, decodes it and packs opaque R5G6B5 pixels.
+4. The 3DS worker receives binary pixels into a free native slot and publishes
+ a small ticket. Socket reads pause when staging credit is exhausted.
+5. The scheduler materializes one completed resource per frame. The native
+ upload consumes no JS pixel array; `ResourceImage` replaces its fallback.
+6. `releaseResponse` returns staging credit. Texture disposal waits until
+ cache eviction or invalidation with value removal. A cancelled or late
+ response also returns staging credit without an upload.
+
+**Rendering a resource does not request it.** Demand, subscriptions and
+ownership have different roles. An old zoom layer retains loaded resources
+while the new layer demands current tiles. It never requests an unseen parent
+pyramid or downloads a city in advance.
+
+## Motion and recovery
+
+`createTileCamera` stores level-zero coordinates and log2 zoom. Dragging applies
+screen-space deltas at the current scale. `createDragFilter` filters cumulative
+touch travel with a 1px hysteresis and up to 3px additional lag. Fast motion
+reduces filtering; stationary frames settle release velocity. The 1.45 touchpad
+gain maps that 4px input-space bound to 5.8px on the map. Circle Pad movement approaches a
+velocity; release follows exponential inertia. Zoom runs an anchored 180ms
+transition. The integrator gives matching held-input/fling distances at 30 and
+60 Hz. Mercator projection is application code.
+
+Tile positions are rebased near the viewport before reaching native float
+transforms, including the nearest world copy at the date line. Tile enumeration
+has a hard maximum; a mismatched old level cannot create an unbounded loop.
+
+On connection-generation changes, pending reads are cancelled and current
+demand is revalidated. Resident tiles remain visible. An explicit source change
+clears tiles, because the old source is a different identity. Exhausted tile
+retries have a visible error fallback and an R-menu retry action. Search errors
+and empty results have distinct messages; editing and submitting retries a
+query. Local camera and query changes never wait for responses.
+
+## Framework additions
+
+PocketJS receives binary image responses, native staging ownership, the
+`releaseResponse` cache hook, `createOffloadImageCollection`, and the camera /
+visible-window / look-ahead functions in `tile-viewport`, and `createDragFilter`
+in `gesture`. There is no OSM, Photon, Mercator,
+search-history or map-service policy inside the framework. These APIs can
+support image pages, photo renditions or diagram tiles using different keys
+and demand plans.
+
+The image extension retains offload's authenticated LAN connection and JSON
+control frames. It does not turn the older companion SVC socket API into a
+worker API. Other native hosts still need to implement optional image staging
+operations; an in-process iPhone provider is not part of this implementation.
+
+## Saved-place commands
+
+A fourth resource collection owns four five-place SQLite pages. Selection,
+name editing, pagination and delete confirmation belong to the app controller.
+**Writes bypass the resource cache.** `bookmarks.command` carries a bounded
+operation ID and typed save / rename / remove payload. The Mac commits the
+change and its receipt in one SQLite transaction. Repeating that operation ID
+returns the stored result; using it with another payload is rejected. Receipts
+remain after deletion, so delayed save retries cannot recreate deleted data.
+
+The UI waits for confirmation before changing its saved list. Missing replies
+leave a retry action which retains the operation ID. Cancellation of that UI
+cannot roll back a completed Mac write. Successful writes invalidate saved
+pages; deleting the last row of a page makes the provider return the preceding
+populated page. User data lives in `places.sqlite`; HTTP-cache eviction never
+touches it. The Mac performs all SQL work in its provider worker.
+
+## Scope
+
+This app implements map browsing, place search and saved places. It has no GPS location,
+turn-by-turn routing, satellite layer or offline-area downloader. Cached views
+are a latency optimization. The public demo services are replaceable through
+host configuration and have no availability guarantee. Network isolation and
+bounded work do not establish a hardware frame-time guarantee.
diff --git a/docs/VALIDATION.md b/docs/VALIDATION.md
new file mode 100644
index 0000000..a4b0af5
--- /dev/null
+++ b/docs/VALIDATION.md
@@ -0,0 +1,50 @@
+# Validation
+
+## Local execution
+
+- Eight app tests cover Mercator wrapping, immediate controller motion while IO
+ waits, keyboard submission, menu ownership, HTTP validators, cache limits,
+ duplicate requests, pixel order, invalid image dimensions and bounded search
+ results, including a real Bun HTTP 304 cache revalidation. Saved-place tests reopen SQLite, replay committed writes, reject
+ operation-ID reuse, paginate, rename and delete. TypeScript checks pass.
+- The compiled guest ran 1,591 simulated frames against a synthetic provider.
+ Replay covers real auxiliary hit testing, keyboard input, result selection,
+ placing a pin, touch inertia, animated zoom, 180 frames with replies withheld,
+ disconnect/reconnect, date-line navigation and menu presentation.
+- Observed replay maxima: three pending guest requests, 40 resident tile
+ entries and eight native-image staging tickets. Staging drains to zero after
+ navigation settles. Generated receipts are in `dist/qa/replay.json`.
+- Live OSM DE tiles and Photon search passed through the same compiled guest
+ and resource API. Captures in this README are from that Wasm replay. The live
+ check requests one viewport and one submitted query; it performs no pan scan.
+- QuickJS passed 1,084 frames with a 128 KiB stack, including offline mount,
+ resource reveal, sustained panning, both zoom directions, local typing,
+ search, place navigation and reconnect. Texture tickets drained to zero.
+
+## Native boundaries
+
+The 3DS package builds with the pinned PocketJS ARM toolchain. Framework tests
+exercise production `offload.c` through POSIX socket/thread shims, compare all
+bytes of a 256×256 image, verify staging release and reconnect after realm
+reset. ASan/UBSan cover 4,000 concurrent images with bounded credit, malformed
+envelopes and token reuse. This verifies transport code; it does not emulate
+libctru or PICA200 timing.
+
+The production `.3dsx` and an app-specific pairing key were uploaded through
+ftpd and read back for equality. The install receipt, package digest and target
+path are recorded in ignored `dist/qa/deploy.json`. The slot is
+`c7771f0167312c63`, derived from `dev.pocket-stack.map`.
+
+**The physical device has connected and is returning frame telemetry.** A
+stable interval reported 119–120 frames per two-second sample. The earlier
+session recorded CPU frames above 16,667 microseconds and a lifetime maximum
+of 263,387 microseconds. These counters do not identify which input caused a
+frame or prove continuous 60 fps. The first device build remounted overlapping
+tile components at window changes; the revised model retains their identities.
+
+The user physically confirmed the first build was usable, then requested
+more caching, drag smoothing, layout changes and saved places. The revised
+replay covers those flows, including lost-acknowledgement retries and a resident
+prefetched tile becoming visible. Physical gesture and frame-time validation
+of this revision remain pending. Build, native transport, Wasm visuals, device liveness and physical
+input remain separate evidence.
diff --git a/docs/images/delete.png b/docs/images/delete.png
new file mode 100644
index 0000000000000000000000000000000000000000..1160479d005c91e4ada9d0bc4de44486e3b96c48
GIT binary patch
literal 21664
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
sZVUe3&kOo6g*qX_3hfawE0?>=(oAE6JMTL1t6
literal 0
HcmV?d00001
diff --git a/docs/images/keyboard.png b/docs/images/keyboard.png
new file mode 100644
index 0000000000000000000000000000000000000000..d8cf726328daa334aecaa236f89d81f5dddd57a6
GIT binary patch
literal 194838
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