clone

an offline map explorer where each world, from a game map to a city's open data, ships as one .atlas file

feat: mint Atlas volumes from Earth

Dami committed ce13336
- add an optional native minting capability to the desktop application\n- turn the live camera or a drawn geographic zone into an app-managed manifest\n- capture, compile, validate, install, and automatically open the minted Atlas\n- keep U.S. public-source coverage separate from globally valid selections
commit
ce13336a32da1df3c3dd02d18f06c4ebab6a48b3
parent
87db9b59f7074e198130dfed00ef221937140e2e
author
Dami <dami.etoile@gmail.com>
23 files changed, +1340 -40
Mdocs/app.md50+++++++-
Mdocs/authoring.md12++
Minternal/app/app.go11+-
Minternal/app/app_test.go137+++++++++++++++++++
Minternal/app/assets/assets.go1+
Ainternal/app/assets/css/mint.css218+++++++++++++++++++++++++++++
Ainternal/app/mint.go120++++++++++++++++
Ainternal/app/templates/mint.tmpl84+++++++++++
Minternal/app/templates/shell.tmpl1+
Minternal/app/templates/templates.go1+
Minternal/app/templates/topbar.tmpl3+
Minternal/app/view.go22+++
Ainternal/authoring/minter.go222++++++++++++++++++++++++++++++
Ainternal/authoring/minter_test.go95+++++++++++++
Minternal/authoring/us_profile.go63++++----
Minternal/authoring/us_profile_test.go33++++-
Ainternal/minting/minting.go60++++++++
Minternal/workbench/operations_test.go6+-
Mmain.go11+-
Mrender/boot.ts3+
Mrender/chart/element.ts26++++
Arender/mint.ts178++++++++++++++++++++++++
Arender/test/mint.test.ts23+++
M docs/app.md +49 -1
@@ -37,6 +37,13 @@ by three different hosts:
3737 | **`atlas serve`** | built | Plain HTTP, no window. `PickFile` refuses with `ErrNotAvailable`. The dev loop, CI, and the browser suite (`tests/e2e`). |3838 | **WASM service worker** | not scheduled | Go compiled to `js/wasm`; the stores back onto OPFS. Nothing is built for it; the discipline is what keeps it reachable. |3939 40+Native authoring is a separate, optional capability rather than a fourth41+filesystem method on `Hostenv`. The desktop supplies `minting.Minter`; the42+headless and future WASM hosts omit it. When it is absent the creator and its43+routes are absent together. When it is present, the application still sees44+only a compact request, a bounded preview, build events, and the installed45+volume identity—never a path, cache, or network client.46+4047 The rule is enforced mechanically: `tools/depcheck`'s `hostenv` analyzer4148 fails any import of `os`, `os/exec`, `path/filepath`, `syscall`, or a window4249 toolkit from `internal/app` outside `internal/app/hostenv`. The OS
@@ -121,6 +128,13 @@ moves the library alone, which is what a development run points at a freshly
121128 composed `dist/bundles`. The headless host reads the same two variables122129 (`cmd/atlas/serve.go`).123130 131+The desktop also owns `recipes/` and `cache/` beneath that data directory.132+Earth authoring writes exactly one portable `.atlas-project` manifest per133+minted volume into `recipes/`, shares captured evidence through `cache/`, and134+publishes the validated result into `bundles/`. Those paths never cross into135+the handler. Workbench remains an optional advanced manifest/receipt inspector;136+it is not required for the first-run creator.137+124138 **The included Earth volume.** The shell embeds the committed bundle at125139 `included/` (`//go:embed included/*.atlas`) and installs it into the library126140 before the host is constructed, so the first scan already serves Earth and a
@@ -223,6 +237,8 @@ happening by accident.
223237 | `POST /session/{concern}` | The partial set for the regions that concern touches (§4). |224238 | `POST /session/reset` | Deletes this volume's record and answers `204` with `HX-Redirect` back into the volume (§4.3). The one concern whose answer is a page. |225239 | `POST /bundles/import` | One progress row, streamed through its states (§4.1); picks, installs, rescans, announces. |240+| `POST /mint/preview` | The exact bounded plan for the creator's current WGS84 zone, sources, detail and presentation. Desktop capability only. |241+| `POST /mint` | Persists the app-managed manifest, captures/builds/validates/installs, rescans, then redirects into the minted volume. Desktop capability only. |226242 | `GET /events?volume=` | The SSE stream (§5). |227243 | `GET /assets/{app.css,htmx.js}` | The application's own chrome, out of the binary. |228244 | `GET /static/{path...}` | Whatever static tree the host mounted; `404` when it mounted none. |
@@ -230,6 +246,37 @@ happening by accident.
230246 Every world lives at a real URL: it can be bookmarked, reloaded, and linked231247 to. `/` is a doorway, not a second name for the explorer.232248 249+### 2.3 Earth is the authoring surface250+251+The included Earth volume offers **Create Atlas** only when two facts are true:252+the world declares a spherical surface and the host supplied the native253+minter. The drawer is a complete server-rendered form. Its title, WGS84 bounds,254+detail, public source pack, presentation choices, cost preview,255+and submit all work without the render seam. The seam progressively enhances256+that same form with two camera facts: **Use current view** and a rectangular257+**Draw zone** gesture over the existing map. It does not own a recipe, fetch a258+source, or compile anything.259+260+Minting follows the ordinary authoring path:261+262+```text263+Earth camera / drawn zone264+ ↓265+app-managed single manifest266+ ↓267+plan → capture → observe → assemble → native compile → validate → install268+ ↓269+rescan library → open the new Atlas270+```271+272+The zone itself may be any finite, non-antimeridian Web Mercator rectangle;273+it is not fenced to today's source coverage. The default pack is USGS Topo274+plus TIGER roads, water, and county context and is labelled as U.S. coverage,275+so an out-of-coverage layer may simply be empty. Planning enforces the same276+pixel, request, evidence, feature, geometry and raster refusal budgets as the277+CLI. A failed build leaves its recipe and cache evidence available for278+diagnosis but never installs a partial Atlas.279+233280 A `GET` of an explorer page writes the session's `world` and the last-volume234281 pointer. That is deliberate: arriving at a URL is a choice whether it was235282 clicked, typed, or restored by the browser, and the session follows the
@@ -357,7 +404,7 @@ reader should have to diagnose a field at a time.
357404 358405 ### 4.1 The regions359406 360-Eleven regions, one template file each (`internal/app/templates/<region>.tmpl`),407+Twelve regions, one template file each (`internal/app/templates/<region>.tmpl`),361408 mirroring the one-file-per-region stylesheet system in362409 `internal/app/assets/css`. A region's template renders that region's own363410 container, so a first paint and a swap produce the same bytes.
@@ -375,6 +422,7 @@ container, so a first paint and a swap produce the same bytes.
375422 | `island` | `#atlas-session-island` | `outerHTML` | the session record the seam reads, as JSON |376423 | `empty-state` | `#atlas-shell` | `innerMorph` | the library card |377424 | `import` | `#atlas-import` | `outerMorph` | the import that is happening, as one row |425+| `mint` | `#atlas-mint` / `#atlas-mint-preview` | ordinary form / `outerHTML` | Earth zone, source and presentation authoring plus its bounded preview |378426 379427 **Nearly every region is an *outer* morph**, and that is the reading that380428 matches what the templates render: the element is morphed onto itself, which
M docs/authoring.md +12 -0
M internal/app/app.go +10 -1
@@ -31,6 +31,7 @@ import (
3131 "sync"3232 3333 "github.com/FelineStateMachine/atlas/internal/app/hostenv"34+ "github.com/FelineStateMachine/atlas/internal/minting"3435 )3536 3637 // App is the application. It is an http.Handler and holds no state of its own
@@ -49,6 +50,7 @@ type App struct {
4950 // over one host still answer the same, only slower.5051 worlds *worldCache5152 semantic *semanticCache53+ minter minting.Minter5254 5355 // writing serializes the read-modify-write of one volume's record. Two5456 // interactions can be in flight at once -- a reader holds a key down, a
@@ -69,12 +71,17 @@ type Options struct {
6971 // which is what a build without the seam looks like -- the application7072 // is required to work without it (issue #5 §3.2).7173 Static fs.FS74+75+ // Minter is the optional native authoring capability. A desktop host76+ // supplies it; read-only and headless hosts leave it nil and the authoring77+ // controls and routes disappear together.78+ Minter minting.Minter7279 }7380 7481 // New wires the application over a host.7582 func New(env hostenv.Hostenv, opts Options) *App {7683 a := &App{77- env: env, static: opts.Static, events: newHub(), mux: http.NewServeMux(),84+ env: env, static: opts.Static, minter: opts.Minter, events: newHub(), mux: http.NewServeMux(),7885 worlds: newWorldCache(), semantic: newSemanticCache(),7986 }8087 a.routes()
@@ -98,6 +105,8 @@ func (a *App) routes() {
98105 a.mux.HandleFunc("GET /fragments/detail/{id}", a.handleDetail)99106 a.mux.HandleFunc("POST /session/{concern}", a.handleSession)100107 a.mux.HandleFunc("POST /bundles/import", a.handleImport)108+ a.mux.HandleFunc("POST /mint/preview", a.handleMintPreview)109+ a.mux.HandleFunc("POST /mint", a.handleMint)101110 a.mux.HandleFunc("GET /events", a.handleEvents)102111 a.mux.HandleFunc("GET /static/{path...}", a.handleStatic)103112
M internal/app/app_test.go +137 -0
@@ -20,6 +20,7 @@ import (
2020 "github.com/FelineStateMachine/atlas/format/vnext"2121 "github.com/FelineStateMachine/atlas/internal/app"2222 "github.com/FelineStateMachine/atlas/internal/app/hostenv"23+ "github.com/FelineStateMachine/atlas/internal/minting"2324 )2425 2526 // The handler is tested through a host that is entirely in memory, which is
@@ -249,6 +250,32 @@ func newApp(t *testing.T, volumes ...hostenv.Volume) (*app.App, *fakeHost) {
249250 return app.New(host, app.Options{}), host250251 }251252 253+type fakeMinter struct {254+ request minting.Request255+ preview minting.Preview256+ result minting.Result257+ err error258+ onMint func()259+}260+261+func (m *fakeMinter) Default() minting.Request { return m.request }262+263+func (m *fakeMinter) Preview(request minting.Request) (minting.Preview, error) {264+ m.request = request265+ return m.preview, m.err266+}267+268+func (m *fakeMinter) Mint(_ context.Context, request minting.Request, emit func(minting.Event)) (minting.Result, error) {269+ m.request = request270+ if emit != nil {271+ emit(minting.Event{Stage: "publish", Message: "native build installed"})272+ }273+ if m.onMint != nil {274+ m.onMint()275+ }276+ return m.result, m.err277+}278+252279 func get(t *testing.T, handler http.Handler, path string, headers map[string]string) *httptest.ResponseRecorder {253280 t.Helper()254281 request := httptest.NewRequest(http.MethodGet, path, nil)
@@ -1103,6 +1130,116 @@ func TestSessionRefusals(t *testing.T) {
11031130 }11041131 }11051132 1133+func TestEarthOffersAProgressivelyEnhancedMintFlow(t *testing.T) {1134+ earth := volume("earth", "Earth", tunicStamp)1135+ earth.entries["worlds/overworld.json"] = []byte(`{"attrs":{"atlas.geometry.surface":"sphere","atlas.geometry.projection":"web-mercator"},"lenses":[],"collections":[]}`)1136+ host := &fakeHost{1137+ volumes: &fakeVolumes{volumes: []hostenv.Volume{earth}, location: "/library"},1138+ sessions: hostenv.NewMemorySessions(),1139+ }1140+ minter := &fakeMinter{1141+ request: minting.Request{1142+ Title: "My Atlas", Bounds: [4]float64{-105.1, 39.6, -104.9, 39.8}, DetailZoom: 12,1143+ Topo: true, Roads: true, Hydro: true, Counties: true,1144+ },1145+ preview: minting.Preview{Pixels: 2048, RasterTiles: 85, Requests: 7, EstimatedBytes: 54 << 20},1146+ }1147+ handler := app.New(host, app.Options{Minter: minter})1148+1149+ page := get(t, handler, "/v/earth/overworld", nil)1150+ if page.Code != http.StatusOK {1151+ t.Fatalf("Earth answered %d: %s", page.Code, page.Body)1152+ }1153+ body := page.Body.String()1154+ for _, want := range []string{1155+ `id="atlas-create"`, `href="#atlas-mint"`, `id="atlas-mint"`,1156+ `action="/mint"`, `name="west"`, `name="south"`, `name="east"`, `name="north"`,1157+ `name="topo"`, `name="roads"`, `name="hydro"`, `name="counties"`,1158+ `data-mint-current-view`, `data-mint-draw`, `Mint, install &amp; open`,1159+ `change delay:250ms from:#atlas-mint-form`, `atlas:mint-area from:window`,1160+ `hx-config='{"timeout":600000}'`, `hx-sync="this:drop"`,1161+ } {1162+ if !strings.Contains(body, want) {1163+ t.Errorf("embedded mint flow is missing %s:\n%s", want, body)1164+ }1165+ }1166+1167+ plain, _ := newApp(t, volume("tunic", "TUNIC", marsStamp))1168+ if body := get(t, plain, "/v/tunic/overworld", nil).Body.String(); strings.Contains(body, `id="atlas-create"`) || strings.Contains(body, `id="atlas-mint"`) {1169+ t.Errorf("a plane world offers Earth minting:\n%s", body)1170+ }1171+}1172+1173+func TestMintPreviewAndPublishStayInsideAtlas(t *testing.T) {1174+ earth := volume("earth", "Earth", tunicStamp)1175+ earth.entries["worlds/overworld.json"] = []byte(`{"attrs":{"atlas.geometry.surface":"sphere"},"lenses":[],"collections":[]}`)1176+ minted := volume("my-region", "My Region", marsStamp, bundle.WorldEntry{Slug: "my-region", Title: "My Region", UpdatedAt: "2026-01-01T00:00:00Z"})1177+ minted.entries["worlds/my-region.json"] = []byte(`{"lenses":[],"collections":[]}`)1178+ minted.entries["worlds/my-region.bin"] = bundle.PackLocations(nil)1179+ minted.entries["worlds/my-region.text"] = []byte(`{}`)1180+ host := &fakeHost{1181+ volumes: &fakeVolumes{volumes: []hostenv.Volume{earth}, location: "/library"},1182+ sessions: hostenv.NewMemorySessions(),1183+ }1184+ minter := &fakeMinter{1185+ preview: minting.Preview{Pixels: 1024, RasterTiles: 21, Requests: 7, EstimatedBytes: 12 << 20},1186+ result: minting.Result{Slug: "my-region", World: "my-region", Title: "My Region", Stamp: marsStamp},1187+ }1188+ minter.onMint = func() { host.volumes.volumes = append(host.volumes.volumes, minted) }1189+ handler := app.New(host, app.Options{Minter: minter})1190+ form := url.Values{1191+ "title": {"My Region"}, "west": {"-105.1"}, "south": {"39.6"},1192+ "east": {"-104.9"}, "north": {"39.8"}, "detail": {"12"},1193+ "topo": {"true"}, "roads": {"true"}, "hydro": {"true"}, "counties": {"true"},1194+ }1195+1196+ preview := post(t, handler, "/mint/preview", form)1197+ if preview.Code != http.StatusOK {1198+ t.Fatalf("preview answered %d: %s", preview.Code, preview.Body)1199+ }1200+ for _, want := range []string{"1024 px", "21 raster tiles", "7 source groups", "12 MiB"} {1201+ if !strings.Contains(preview.Body.String(), want) {1202+ t.Errorf("preview misses %q:\n%s", want, preview.Body)1203+ }1204+ }1205+1206+ request := httptest.NewRequest(http.MethodPost, "/mint", strings.NewReader(form.Encode()))1207+ request.Header.Set("Content-Type", "application/x-www-form-urlencoded")1208+ request.Header.Set("HX-Request", "true")1209+ response := httptest.NewRecorder()1210+ handler.ServeHTTP(response, request)1211+ if response.Code != http.StatusNoContent {1212+ t.Fatalf("mint answered %d: %s", response.Code, response.Body)1213+ }1214+ if got := response.Header().Get("HX-Redirect"); got != "/v/my-region/my-region" {1215+ t.Errorf("mint redirect = %q", got)1216+ }1217+ if minter.request.Title != "My Region" || minter.request.Bounds != [4]float64{-105.1, 39.6, -104.9, 39.8} {1218+ t.Errorf("mint request = %+v", minter.request)1219+ }1220+ if page := get(t, handler, "/v/my-region/my-region", nil); page.Code != http.StatusOK {1221+ t.Errorf("minted volume did not open: %d %s", page.Code, page.Body)1222+ }1223+}1224+1225+func TestMintRoutesRefuseUnavailableOrInvalidRequests(t *testing.T) {1226+ handler, _ := newApp(t, volume("earth", "Earth", tunicStamp))1227+ if got := post(t, handler, "/mint", url.Values{}); got.Code != http.StatusNotFound {1228+ t.Fatalf("host without a minter answered %d", got.Code)1229+ }1230+1231+ host := &fakeHost{volumes: &fakeVolumes{location: "/library"}, sessions: hostenv.NewMemorySessions()}1232+ minter := &fakeMinter{err: errors.New("selection is outside the supported area")}1233+ handler = app.New(host, app.Options{Minter: minter})1234+ form := url.Values{1235+ "title": {"My Region"}, "west": {"2"}, "south": {"48"},1236+ "east": {"3"}, "north": {"49"}, "detail": {"12"}, "topo": {"true"},1237+ }1238+ if got := post(t, handler, "/mint/preview", form); got.Code != http.StatusUnprocessableEntity || !strings.Contains(got.Body.String(), "outside the supported area") {1239+ t.Fatalf("invalid preview = %d %s", got.Code, got.Body)1240+ }1241+}1242+11061243 // The keyboard the shell declares, read back out of the page.11071244 //11081245 // These are string assertions on markup, which is usually a smell, and here it
M internal/app/assets/assets.go +1 -0
A internal/app/assets/css/mint.css +218 -0
@@ -0,0 +1,218 @@
1+.create-atlas {2+ display: inline-flex;3+ align-items: center;4+ gap: 0.35rem;5+ min-height: 2.25rem;6+ padding: 0 0.9rem;7+ border: 1px solid color-mix(in srgb, var(--accent) 55%, var(--line));8+ border-radius: 999px;9+ background: var(--accent-wash);10+ color: var(--text-strong);11+ font-size: 0.78rem;12+ font-weight: 750;13+ text-decoration: none;14+ white-space: nowrap;15+}16+17+.create-atlas:hover { background: var(--accent-wash-strong); }18+19+.mint-panel {20+ position: absolute;21+ z-index: 25;22+ inset: 0 0 0 auto;23+ width: min(24rem, calc(100% - 2rem));24+ padding: 1.15rem;25+ overflow: auto;26+ border-left: 1px solid var(--line-strong);27+ background: color-mix(in srgb, var(--panel) 96%, transparent);28+ box-shadow: -1.5rem 0 4rem rgba(0, 0, 0, 0.28);29+ transform: translateX(102%);30+ transition: transform 180ms ease;31+}32+33+.mint-panel:target,34+.mint-panel[data-open="true"] { transform: translateX(0); }35+36+.mint-panel > header {37+ display: flex;38+ align-items: flex-start;39+ justify-content: space-between;40+ gap: 1rem;41+}42+43+.mint-panel header small {44+ color: var(--accent);45+ font-size: 0.62rem;46+ font-weight: 800;47+ letter-spacing: 0.12em;48+}49+50+.mint-panel h2 { margin: 0.2rem 0 0; font-size: 1.45rem; }51+52+.mint-close {53+ display: grid;54+ place-items: center;55+ width: 2rem;56+ height: 2rem;57+ border-radius: 50%;58+ color: var(--muted);59+ background: var(--panel-raised);60+ text-decoration: none;61+ font-size: 1.25rem;62+}63+64+.mint-stages {65+ display: grid;66+ grid-template-columns: repeat(4, 1fr);67+ gap: 0.35rem;68+ padding: 0;69+ margin: 1rem 0;70+ list-style: none;71+}72+73+.mint-stages li {74+ padding: 0.38rem 0.2rem;75+ border-radius: 0.4rem;76+ background: var(--panel-raised);77+ color: var(--muted);78+ font-size: 0.64rem;79+ text-align: center;80+}81+82+.mint-stages .done { color: var(--data); }83+.mint-stages .active { color: var(--accent-ink); background: var(--accent); font-weight: 800; }84+85+.mint-panel label,86+.mint-panel legend { color: var(--muted); font-size: 0.68rem; }87+88+.mint-panel input[type="text"],89+.mint-panel input[type="number"],90+.mint-panel input:not([type]) {91+ width: 100%;92+ margin-top: 0.3rem;93+ padding: 0.55rem 0.6rem;94+ border: 1px solid var(--line-strong);95+ border-radius: 0.45rem;96+ color: var(--text-strong);97+ background: var(--panel-raised);98+}99+100+.mint-title { display: block; margin: 0.8rem 0; }101+102+.mint-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 0.8rem; }103+.mint-actions button {104+ padding: 0.55rem;105+ border: 1px solid var(--line-strong);106+ border-radius: 0.45rem;107+ color: var(--text);108+ background: var(--panel-raised);109+}110+.mint-actions button[aria-pressed="true"] { color: var(--accent-ink); background: var(--accent); }111+112+.mint-bounds,113+.mint-sources { margin: 0.8rem 0; padding: 0.7rem; border: 1px solid var(--line); border-radius: 0.55rem; }114+.mint-bounds { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }115+.mint-bounds legend,116+.mint-sources legend { padding: 0 0.25rem; }117+118+.mint-detail { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 0.55rem; }119+.mint-detail output { color: var(--data); }120+121+.mint-sources label { display: flex; align-items: center; gap: 0.6rem; padding: 0.45rem 0; }122+.mint-sources label + label { border-top: 1px solid var(--line); }123+.mint-sources span { display: flex; width: 100%; justify-content: space-between; gap: 1rem; }124+.mint-sources b { color: var(--text); }125+.mint-sources small { color: var(--muted); }126+127+.mint-presentation { margin: 0.7rem 0; color: var(--muted); font-size: 0.7rem; }128+.mint-presentation-grid { display: grid; grid-template-columns: 1fr 5.5rem; gap: 0.55rem; margin-top: 0.65rem; }129+.mint-presentation-grid label { min-width: 0; }130+.mint-presentation-grid input { display: block; width: 100%; margin-top: 0.25rem; }131+.mint-presentation-grid input[type="color"] { height: 2rem; padding: 0.12rem; border: 1px solid var(--line-strong); border-radius: 0.4rem; background: var(--panel-raised); }132+133+.mint-preview {134+ display: grid;135+ grid-template-columns: 1fr 1fr;136+ gap: 0.45rem;137+ margin: 0.8rem 0;138+}139+.mint-preview span,140+.mint-preview[data-error="true"] {141+ padding: 0.55rem;142+ border: 1px solid var(--line);143+ border-radius: 0.45rem;144+ background: var(--panel-raised);145+ color: var(--muted);146+ font-size: 0.65rem;147+}148+.mint-preview b { display: block; color: var(--text); }149+.mint-preview[data-error="true"] { display: block; border-color: var(--warm-strong); }150+.mint-preview[data-error="true"] strong { color: var(--warm-strong); }151+.mint-preview[data-error="true"] p { margin: 0.25rem 0 0; }152+153+.mint-submit {154+ width: 100%;155+ padding: 0.75rem;156+ border: 0;157+ border-radius: 0.55rem;158+ background: var(--accent);159+ color: var(--accent-ink);160+ font-weight: 800;161+}162+.mint-working { display: none; }163+.mint-submit.htmx-request .mint-ready { display: none; }164+.mint-submit.htmx-request .mint-working,165+.htmx-request .mint-working { display: inline; }166+#atlas-mint-form.htmx-request .mint-submit { pointer-events: none; opacity: 0.82; }167+.mint-panel:has(#atlas-mint-form.htmx-request) .mint-stages li { color: var(--data); }168+.mint-panel:has(#atlas-mint-form.htmx-request) .mint-stages li:last-child {169+ color: var(--accent-ink);170+ background: var(--accent);171+ font-weight: 800;172+}173+174+.mint-advanced { margin-top: 0.7rem; color: var(--muted); font-size: 0.68rem; }175+.mint-advanced p { margin: 0.45rem 0 0; }176+177+.mint-zone {178+ position: absolute;179+ z-index: 20;180+ inset: 24% 29%;181+ border: 2px solid var(--accent);182+ background: rgba(32, 138, 174, 0.08);183+ box-shadow: 0 0 0 9999px rgba(5, 10, 16, 0.28);184+ pointer-events: none;185+}186+.map-panel:has(.mint-panel:target) .mint-zone { display: block; }187+.mint-zone[data-visible="true"] { display: block; }188+.mint-zone i {189+ position: absolute;190+ width: 0.65rem;191+ height: 0.65rem;192+ border: 2px solid var(--map-surround);193+ border-radius: 50%;194+ background: var(--accent);195+}196+.mint-zone i:nth-child(1) { left: -0.4rem; top: -0.4rem; }197+.mint-zone i:nth-child(2) { right: -0.4rem; top: -0.4rem; }198+.mint-zone i:nth-child(3) { right: -0.4rem; bottom: -0.4rem; }199+.mint-zone i:nth-child(4) { left: -0.4rem; bottom: -0.4rem; }200+.mint-zone > span {201+ position: absolute;202+ top: 0.55rem;203+ left: 0.55rem;204+ padding: 0.3rem 0.45rem;205+ border-radius: 0.35rem;206+ background: var(--panel);207+ color: var(--text);208+ font-size: 0.65rem;209+}210+211+.map-panel.mint-drawing atlas-chart { cursor: crosshair; }212+213+@media (max-width: 720px) {214+ .create-atlas { padding: 0 0.6rem; }215+ .create-atlas { font-size: 0; }216+ .create-atlas span { font-size: 1rem; }217+ .mint-panel { width: 100%; }218+}
A internal/app/mint.go +120 -0
@@ -0,0 +1,120 @@
1+package app2+3+import (4+ "fmt"5+ "log/slog"6+ "math"7+ "net/http"8+ "strconv"9+ "strings"10+11+ "github.com/FelineStateMachine/atlas/internal/logging"12+ "github.com/FelineStateMachine/atlas/internal/minting"13+)14+15+const maxMintFormBytes = 32 << 1016+17+func (a *App) handleMintPreview(w http.ResponseWriter, r *http.Request) {18+ if a.minter == nil {19+ http.NotFound(w, r)20+ return21+ }22+ request, err := parseMintRequest(w, r)23+ if err != nil {24+ a.writeMintPreview(w, newMintView(request, minting.Preview{}, err), http.StatusUnprocessableEntity)25+ return26+ }27+ preview, err := a.minter.Preview(request)28+ status := http.StatusOK29+ if err != nil {30+ status = http.StatusUnprocessableEntity31+ }32+ a.writeMintPreview(w, newMintView(request, preview, err), status)33+}34+35+func (a *App) handleMint(w http.ResponseWriter, r *http.Request) {36+ if a.minter == nil {37+ http.NotFound(w, r)38+ return39+ }40+ request, err := parseMintRequest(w, r)41+ if err != nil {42+ a.writeMintPreview(w, newMintView(request, minting.Preview{}, err), http.StatusUnprocessableEntity)43+ return44+ }45+ result, err := a.minter.Mint(r.Context(), request, func(event minting.Event) {46+ slog.Info(event.Message, logging.Op("mint"), slog.String("stage", event.Stage))47+ })48+ if err != nil {49+ a.writeMintPreview(w, newMintView(request, minting.Preview{}, err), http.StatusUnprocessableEntity)50+ return51+ }52+ changed, err := a.env.Volumes().Rescan()53+ if err != nil {54+ http.Error(w, "the minted Atlas was built but the library could not be refreshed", http.StatusInternalServerError)55+ return56+ }57+ a.announce(changed)58+ destination := "/v/" + result.Slug + "/" + result.World59+ if r.Header.Get("HX-Request") == "true" {60+ w.Header().Set("HX-Redirect", destination)61+ w.WriteHeader(http.StatusNoContent)62+ return63+ }64+ http.Redirect(w, r, destination, http.StatusSeeOther)65+}66+67+func parseMintRequest(w http.ResponseWriter, r *http.Request) (minting.Request, error) {68+ r.Body = http.MaxBytesReader(w, r.Body, maxMintFormBytes)69+ if err := r.ParseForm(); err != nil {70+ return minting.Request{}, fmt.Errorf("read mint choices: %w", err)71+ }72+ request := minting.Request{Title: strings.TrimSpace(r.FormValue("title"))}73+ if request.Title == "" || len([]rune(request.Title)) > 80 {74+ return request, fmt.Errorf("name the Atlas with 1 to 80 characters")75+ }76+ for index, field := range []string{"west", "south", "east", "north"} {77+ value, err := strconv.ParseFloat(r.FormValue(field), 64)78+ if err != nil || math.IsNaN(value) || math.IsInf(value, 0) {79+ return request, fmt.Errorf("%s coordinate is invalid", field)80+ }81+ request.Bounds[index] = value82+ }83+ detail, err := strconv.Atoi(r.FormValue("detail"))84+ if err != nil {85+ return request, fmt.Errorf("detail zoom is invalid")86+ }87+ request.DetailZoom = detail88+ request.Topo = r.FormValue("topo") == "true"89+ request.Roads = r.FormValue("roads") == "true"90+ request.Hydro = r.FormValue("hydro") == "true"91+ request.Counties = r.FormValue("counties") == "true"92+ request.RoadLabel = strings.TrimSpace(r.FormValue("road-label"))93+ request.HydroLabel = strings.TrimSpace(r.FormValue("hydro-label"))94+ request.CountyLabel = strings.TrimSpace(r.FormValue("county-label"))95+ request.RoadColor = strings.TrimSpace(r.FormValue("road-color"))96+ request.HydroColor = strings.TrimSpace(r.FormValue("hydro-color"))97+ request.HydroFill = strings.TrimSpace(r.FormValue("hydro-fill"))98+ request.CountyColor = strings.TrimSpace(r.FormValue("county-color"))99+ return request, nil100+}101+102+func newMintView(request minting.Request, preview minting.Preview, err error) MintView {103+ view := MintView{104+ Available: true, Request: request, Preview: preview,105+ Pixels: strconv.FormatInt(preview.Pixels, 10) + " px",106+ Tiles: strconv.FormatInt(preview.RasterTiles, 10) + " raster tiles",107+ Groups: strconv.Itoa(preview.Requests) + " source groups",108+ Bytes: strconv.FormatInt((preview.EstimatedBytes+(1<<20)-1)/(1<<20), 10) + " MiB",109+ }110+ if err != nil {111+ view.Error = err.Error()112+ }113+ return view114+}115+116+func (a *App) writeMintPreview(w http.ResponseWriter, view MintView, status int) {117+ w.Header().Set("Content-Type", "text/html; charset=utf-8")118+ w.WriteHeader(status)119+ a.writePage(w, "mint-preview", View{Mint: view})120+}
A internal/app/templates/mint.tmpl +84 -0
@@ -0,0 +1,84 @@
1+{{/*2+ mint — Earth as the native authoring surface.3+4+ The form is complete HTML: bounds, detail and sources can be edited and5+ submitted without the seam. The seam enhances those same fields from the6+ live camera and a rectangle drawn over the map. It owns no recipe and does7+ no capture; the native capability is the only thing that can mint.8+*/}}9+{{define "mint"}}{{if .Mint.Available}}10+<div id="atlas-mint-zone" class="mint-zone" hidden aria-hidden="true">11+ <i></i><i></i><i></i><i></i>12+ <span>Atlas boundary</span>13+</div>14+<aside id="atlas-mint" class="mint-panel" aria-label="Create an Atlas">15+ <header>16+ <div><small>EARTH BECOMES THE AUTHORING SURFACE</small><h2>Mint an Atlas</h2></div>17+ <a href="#map" class="mint-close" aria-label="Close Atlas creator">×</a>18+ </header>19+ <ol class="mint-stages" aria-label="Mint workflow">20+ <li class="done">View</li><li class="active">Zone</li><li>Sources</li><li>Mint</li>21+ </ol>22+ <form id="atlas-mint-form" action="/mint" method="post" hx-post="/mint"23+ hx-config='{"timeout":600000}'24+ hx-sync="this:drop"25+ hx-indicator="#atlas-mint-form" hx-target="#atlas-mint-preview" hx-swap="outerHTML">26+ <label class="mint-title">Name27+ <input name="title" value="{{.Mint.Request.Title}}" maxlength="80" required>28+ </label>29+ <div class="mint-actions">30+ <button type="button" data-mint-current-view>Use current view</button>31+ <button type="button" data-mint-draw aria-pressed="false">Draw zone</button>32+ </div>33+ <fieldset class="mint-bounds">34+ <legend>WGS84 boundary</legend>35+ <label>West<input name="west" type="number" step="any" value="{{index .Mint.Request.Bounds 0}}" required></label>36+ <label>South<input name="south" type="number" step="any" value="{{index .Mint.Request.Bounds 1}}" required></label>37+ <label>East<input name="east" type="number" step="any" value="{{index .Mint.Request.Bounds 2}}" required></label>38+ <label>North<input name="north" type="number" step="any" value="{{index .Mint.Request.Bounds 3}}" required></label>39+ </fieldset>40+ <details class="mint-presentation"><summary>Presentation</summary>41+ <div class="mint-presentation-grid">42+ <label>Road label<input name="road-label" value="{{.Mint.Request.RoadLabel}}"></label>43+ <label>Road color<input name="road-color" type="color" value="{{.Mint.Request.RoadColor}}"></label>44+ <label>Water label<input name="hydro-label" value="{{.Mint.Request.HydroLabel}}"></label>45+ <label>Water line<input name="hydro-color" type="color" value="{{.Mint.Request.HydroColor}}"></label>46+ <label>Water fill<input name="hydro-fill" type="color" value="{{.Mint.Request.HydroFill}}"></label>47+ <label>County label<input name="county-label" value="{{.Mint.Request.CountyLabel}}"></label>48+ <label>County color<input name="county-color" type="color" value="{{.Mint.Request.CountyColor}}"></label>49+ </div>50+ </details>51+ <label class="mint-detail">Detail52+ <input name="detail" type="range" min="6" max="16" value="{{.Mint.Request.DetailZoom}}">53+ <output data-mint-detail>z{{.Mint.Request.DetailZoom}}</output>54+ </label>55+ <fieldset class="mint-sources">56+ <legend>Public source pack · U.S. coverage</legend>57+ <label><input type="checkbox" name="topo" value="true"{{if .Mint.Request.Topo}} checked{{end}}><span><b>USGS Topo</b><small>Raster</small></span></label>58+ <label><input type="checkbox" name="roads" value="true"{{if .Mint.Request.Roads}} checked{{end}}><span><b>TIGER Roads</b><small>Paths</small></span></label>59+ <label><input type="checkbox" name="hydro" value="true"{{if .Mint.Request.Hydro}} checked{{end}}><span><b>Water</b><small>Paths + areas</small></span></label>60+ <label><input type="checkbox" name="counties" value="true"{{if .Mint.Request.Counties}} checked{{end}}><span><b>Counties</b><small>Areas</small></span></label>61+ </fieldset>62+ <div hx-post="/mint/preview"63+ hx-trigger="change delay:250ms from:#atlas-mint-form, atlas:mint-area from:window"64+ hx-include="#atlas-mint-form" hx-target="#atlas-mint-preview" hx-swap="outerHTML">65+ {{template "mint-preview" .}}66+ </div>67+ <button class="mint-submit" type="submit">68+ <span class="mint-ready">Mint, install &amp; open</span>69+ <span class="mint-working" id="atlas-mint-progress" aria-live="polite">Capturing and compiling…</span>70+ </button>71+ <details class="mint-advanced"><summary>Advanced recipe</summary>72+ <p>Atlas keeps one portable <code>.atlas-project</code> manifest for this volume. The compiled Atlas remains the preview truth.</p>73+ </details>74+ </form>75+</aside>76+{{end}}{{end}}77+78+{{define "mint-preview"}}<div id="atlas-mint-preview" class="mint-preview"{{if .Mint.Error}} data-error="true"{{end}}>79+ {{if .Mint.Error}}<strong>Selection needs attention</strong><p>{{.Mint.Error}}</p>80+ {{else}}<span><b>{{.Mint.Pixels}}</b> aligned output</span>81+ <span><b>{{.Mint.Tiles}}</b></span>82+ <span><b>{{.Mint.Groups}}</b></span>83+ <span><b>{{.Mint.Bytes}}</b> evidence estimate</span>{{end}}84+</div>{{end}}
M internal/app/templates/shell.tmpl +1 -0
M internal/app/templates/templates.go +1 -0
M internal/app/templates/topbar.tmpl +3 -0
M internal/app/view.go +22 -0
A internal/authoring/minter.go +222 -0
@@ -0,0 +1,222 @@
1+package authoring2+3+import (4+ "context"5+ "fmt"6+ "os"7+ "path/filepath"8+ "strings"9+ "sync"10+ "unicode"11+12+ "github.com/FelineStateMachine/atlas/format/vnext"13+ "github.com/FelineStateMachine/atlas/internal/minting"14+)15+16+// NativeMinterOptions names the app-owned recipe, evidence, and library17+// locations. They are supplied by the desktop host and never cross the HTTP18+// application boundary.19+type NativeMinterOptions struct {20+ RecipesDir string21+ CacheDir string22+ LibraryDir string23+}24+25+// NativeMinter turns one compact request into a durable single manifest and26+// runs the same deterministic builder used by the CLI and Workbench.27+type NativeMinter struct {28+ options NativeMinterOptions29+ build func(context.Context, BuildOptions) (BuildResult, error)30+ mu sync.Mutex31+}32+33+var _ minting.Minter = (*NativeMinter)(nil)34+35+// NewNativeMinter prepares the three app-owned locations.36+func NewNativeMinter(options NativeMinterOptions) (*NativeMinter, error) {37+ for name, directory := range map[string]string{38+ "recipes": options.RecipesDir, "cache": options.CacheDir, "library": options.LibraryDir,39+ } {40+ if strings.TrimSpace(directory) == "" {41+ return nil, fmt.Errorf("native minter %s directory is empty", name)42+ }43+ if err := os.MkdirAll(directory, 0o755); err != nil {44+ return nil, fmt.Errorf("create native minter %s directory: %w", name, err)45+ }46+ }47+ return &NativeMinter{options: options, build: Build}, nil48+}49+50+// Default is the neutral, immediately valid starting request.51+func (m *NativeMinter) Default() minting.Request {52+ profile := DefaultUSAreaProfile()53+ return minting.Request{54+ Title: "My Atlas", Bounds: profile.Bounds, DetailZoom: profile.DetailZoom,55+ Topo: profile.IncludeTopo, Roads: profile.IncludeRoads,56+ Hydro: profile.IncludeHydro, Counties: profile.IncludeCounties,57+ RoadLabel: profile.Presentation.RoadLabel, HydroLabel: profile.Presentation.HydroLabel,58+ CountyLabel: profile.Presentation.CountyLabel, RoadColor: profile.Presentation.RoadColor,59+ HydroColor: profile.Presentation.HydroColor, HydroFill: profile.Presentation.HydroFill,60+ CountyColor: profile.Presentation.CountyColor,61+ }62+}63+64+// Preview plans the exact project Mint will persist without touching network65+// evidence or the library.66+func (m *NativeMinter) Preview(request minting.Request) (minting.Preview, error) {67+ _, summary, err := mintProject(request)68+ if err != nil {69+ return minting.Preview{}, err70+ }71+ return minting.Preview{72+ Pixels: summary.PixelSize, RasterTiles: summary.RasterTiles,73+ Requests: summary.CaptureRequests, EstimatedBytes: summary.EstimatedBytes,74+ }, nil75+}76+77+// Mint serializes builds that share one cache and library. It atomically78+// replaces the app-managed manifest before invoking the ordinary builder, so79+// a failed capture leaves an inspectable recipe and no partial Atlas.80+func (m *NativeMinter) Mint(ctx context.Context, request minting.Request, emit func(minting.Event)) (minting.Result, error) {81+ m.mu.Lock()82+ defer m.mu.Unlock()83+84+ project, _, err := mintProject(request)85+ if err != nil {86+ return minting.Result{}, err87+ }88+ descriptors, _, err := vnext.Scan(m.options.LibraryDir, vnext.StandardSchema())89+ if err != nil {90+ return minting.Result{}, fmt.Errorf("scan Atlas library before mint: %w", err)91+ }92+ project.Release.Revision = nextMintRevision(descriptors, project.ID)93+ manifest, err := MarshalProjectYAML(project)94+ if err != nil {95+ return minting.Result{}, err96+ }97+ projectPath := filepath.Join(m.options.RecipesDir, project.ID+ProjectExtension)98+ if err := writeManagedManifest(projectPath, manifest); err != nil {99+ return minting.Result{}, err100+ }101+ result, err := m.build(ctx, BuildOptions{102+ ProjectPath: projectPath, CacheDir: m.options.CacheDir, LibraryDir: m.options.LibraryDir,103+ Event: func(event Event) {104+ if emit != nil {105+ emit(minting.Event{106+ Stage: event.Stage, Message: event.Message, Current: event.Current,107+ Total: event.Total, Bytes: event.Bytes, Cached: event.Cached, Artifact: event.Artifact,108+ })109+ }110+ },111+ })112+ if err != nil {113+ return minting.Result{}, err114+ }115+ return minting.Result{116+ Slug: result.Descriptor.Slug, World: project.Target.World,117+ Title: result.Descriptor.Title, Stamp: result.Descriptor.Stamp,118+ }, nil119+}120+121+func nextMintRevision(descriptors []vnext.Descriptor, slug string) int {122+ revision := 1123+ for _, descriptor := range descriptors {124+ if descriptor.Slug == slug && descriptor.Revision >= revision {125+ revision = descriptor.Revision + 1126+ }127+ }128+ return revision129+}130+131+func mintProject(request minting.Request) (Project, USAreaSummary, error) {132+ id := mintSlug(request.Title)133+ if err := vnext.ValidSlug(id); err != nil {134+ return Project{}, USAreaSummary{}, fmt.Errorf("Atlas name: %w", err)135+ }136+ profile := DefaultUSAreaProfile()137+ profile.ID, profile.Title = id, strings.TrimSpace(request.Title)138+ profile.Bounds, profile.DetailZoom = request.Bounds, request.DetailZoom139+ profile.IncludeTopo, profile.IncludeRoads = request.Topo, request.Roads140+ profile.IncludeHydro, profile.IncludeCounties = request.Hydro, request.Counties141+ if request.RoadLabel != "" {142+ profile.Presentation.RoadLabel = request.RoadLabel143+ }144+ if request.HydroLabel != "" {145+ profile.Presentation.HydroLabel = request.HydroLabel146+ }147+ if request.CountyLabel != "" {148+ profile.Presentation.CountyLabel = request.CountyLabel149+ }150+ if request.RoadColor != "" {151+ profile.Presentation.RoadColor = request.RoadColor152+ }153+ if request.HydroColor != "" {154+ profile.Presentation.HydroColor = request.HydroColor155+ }156+ if request.HydroFill != "" {157+ profile.Presentation.HydroFill = request.HydroFill158+ }159+ if request.CountyColor != "" {160+ profile.Presentation.CountyColor = request.CountyColor161+ }162+ return NewUSAreaProject(profile)163+}164+165+func mintSlug(title string) string {166+ var out strings.Builder167+ dash := false168+ for _, character := range strings.ToLower(strings.TrimSpace(title)) {169+ if character <= unicode.MaxASCII && (unicode.IsLetter(character) || unicode.IsDigit(character)) {170+ if out.Len() >= 48 {171+ break172+ }173+ out.WriteRune(character)174+ dash = false175+ continue176+ }177+ if out.Len() > 0 && !dash {178+ out.WriteByte('-')179+ dash = true180+ }181+ }182+ return strings.Trim(out.String(), "-")183+}184+185+func writeManagedManifest(path string, data []byte) (err error) {186+ directory := filepath.Dir(path)187+ temporary, err := os.CreateTemp(directory, ".atlas-project-*")188+ if err != nil {189+ return fmt.Errorf("create managed manifest: %w", err)190+ }191+ temporaryPath := temporary.Name()192+ defer func() {193+ _ = temporary.Close()194+ if err != nil {195+ _ = os.Remove(temporaryPath)196+ }197+ }()198+ if err = temporary.Chmod(0o644); err != nil {199+ return fmt.Errorf("set managed manifest permissions: %w", err)200+ }201+ if _, err = temporary.Write(data); err != nil {202+ return fmt.Errorf("write managed manifest: %w", err)203+ }204+ if err = temporary.Sync(); err != nil {205+ return fmt.Errorf("sync managed manifest: %w", err)206+ }207+ if err = temporary.Close(); err != nil {208+ return fmt.Errorf("close managed manifest: %w", err)209+ }210+ if err = os.Rename(temporaryPath, path); err != nil {211+ return fmt.Errorf("publish managed manifest: %w", err)212+ }213+ directoryFile, openErr := os.Open(directory)214+ if openErr != nil {215+ return fmt.Errorf("open managed manifest directory: %w", openErr)216+ }217+ defer directoryFile.Close()218+ if syncErr := directoryFile.Sync(); syncErr != nil {219+ return fmt.Errorf("sync managed manifest directory: %w", syncErr)220+ }221+ return nil222+}
A internal/authoring/minter_test.go +95 -0
@@ -0,0 +1,95 @@
1+package authoring2+3+import (4+ "context"5+ "os"6+ "path/filepath"7+ "testing"8+9+ "github.com/FelineStateMachine/atlas/format/vnext"10+ "github.com/FelineStateMachine/atlas/internal/minting"11+)12+13+func TestNativeMinterPersistsOneRecipeAndReturnsTheInstalledVolume(t *testing.T) {14+ root := t.TempDir()15+ minter, err := NewNativeMinter(NativeMinterOptions{16+ RecipesDir: filepath.Join(root, "recipes"),17+ CacheDir: filepath.Join(root, "cache"),18+ LibraryDir: filepath.Join(root, "library"),19+ })20+ if err != nil {21+ t.Fatal(err)22+ }23+ var built BuildOptions24+ minter.build = func(_ context.Context, options BuildOptions) (BuildResult, error) {25+ built = options26+ if options.Event != nil {27+ options.Event(Event{Stage: "publish", Message: "native build installed", Artifact: "/library/my-region.atlas"})28+ }29+ return BuildResult{Descriptor: vnext.Descriptor{Slug: "my-region", Title: "My Region", Stamp: "abc123"}}, nil30+ }31+ request := minting.Request{32+ Title: "My Region", Bounds: [4]float64{-105.1, 39.6, -104.9, 39.8}, DetailZoom: 12,33+ Topo: true, Roads: true, Hydro: true, Counties: true,34+ }35+ var events []minting.Event36+ result, err := minter.Mint(context.Background(), request, func(event minting.Event) { events = append(events, event) })37+ if err != nil {38+ t.Fatal(err)39+ }40+ if result.Slug != "my-region" || result.World != "my-region" || result.Title != "My Region" || result.Stamp != "abc123" {41+ t.Fatalf("mint result = %+v", result)42+ }43+ if built.CacheDir != filepath.Join(root, "cache") || built.LibraryDir != filepath.Join(root, "library") || built.ProjectPath != filepath.Join(root, "recipes", "my-region"+ProjectExtension) {44+ t.Fatalf("build options = %+v", built)45+ }46+ project, err := LoadProject(built.ProjectPath)47+ if err != nil {48+ t.Fatalf("load managed recipe: %v", err)49+ }50+ if project.ID != "my-region" || project.Title != "My Region" {51+ t.Fatalf("managed recipe identity = %q / %q", project.ID, project.Title)52+ }53+ entries, err := os.ReadDir(filepath.Join(root, "recipes"))54+ if err != nil {55+ t.Fatal(err)56+ }57+ if len(entries) != 1 || entries[0].Name() != "my-region"+ProjectExtension {58+ t.Fatalf("managed recipes = %v", entries)59+ }60+ if len(events) != 1 || events[0].Stage != "publish" || events[0].Artifact == "" {61+ t.Fatalf("mint events = %+v", events)62+ }63+}64+65+func TestNativeMinterPreviewUsesTheSameRecipeAsMint(t *testing.T) {66+ minter, err := NewNativeMinter(NativeMinterOptions{67+ RecipesDir: filepath.Join(t.TempDir(), "recipes"), CacheDir: t.TempDir(), LibraryDir: t.TempDir(),68+ })69+ if err != nil {70+ t.Fatal(err)71+ }72+ request := minter.Default()73+ request.Title = "A Map"74+ preview, err := minter.Preview(request)75+ if err != nil {76+ t.Fatal(err)77+ }78+ if preview.Pixels < 1 || preview.RasterTiles < 1 || preview.Requests != 7 || preview.EstimatedBytes < 1 {79+ t.Fatalf("preview = %+v", preview)80+ }81+}82+83+func TestNativeMinterPublishesARevisionPastEveryExistingBuild(t *testing.T) {84+ descriptors := []vnext.Descriptor{85+ {Slug: "my-map", Revision: 2},86+ {Slug: "another-map", Revision: 99},87+ {Slug: "my-map", Revision: 7},88+ }89+ if got := nextMintRevision(descriptors, "my-map"); got != 8 {90+ t.Fatalf("next revision = %d, want 8", got)91+ }92+ if got := nextMintRevision(descriptors, "new-map"); got != 1 {93+ t.Fatalf("new revision = %d, want 1", got)94+ }95+}
M internal/authoring/us_profile.go +30 -33
@@ -11,17 +11,20 @@ import (
1111 )1212 1313 const (14- usTopoService = "https://basemap.nationalmap.gov/arcgis/rest/services/USGSTopo/MapServer/export"15- tigerRoads = "https://tigerweb.geo.census.gov/arcgis/rest/services/TIGERweb/Transportation/MapServer"16- tigerHydro = "https://tigerweb.geo.census.gov/arcgis/rest/services/TIGERweb/Hydro/MapServer"17- tigerCounties = "https://tigerweb.geo.census.gov/arcgis/rest/services/TIGERweb/State_County/MapServer/1"18- webMercatorR = 20037508.34278924419- maxExportSize = int64(4096)14+ usTopoService = "https://basemap.nationalmap.gov/arcgis/rest/services/USGSTopo/MapServer/export"15+ tigerRoads = "https://tigerweb.geo.census.gov/arcgis/rest/services/TIGERweb/Transportation/MapServer"16+ tigerHydro = "https://tigerweb.geo.census.gov/arcgis/rest/services/TIGERweb/Hydro/MapServer"17+ tigerCounties = "https://tigerweb.geo.census.gov/arcgis/rest/services/TIGERweb/State_County/MapServer/1"18+ webMercatorR = 20037508.34278924419+ webMercatorLat = 85.0511287820+ maxExportSize = int64(4096)2021 )2122 2223 // USAreaProfile is the compact authoring choice behind the Workbench's2324 // nationwide default. Bounds are WGS84 west, south, east, north coordinates.2425 type USAreaProfile struct {26+ ID string27+ Title string2528 Bounds [4]float642629 DetailZoom int2730 IncludeTopo bool
@@ -54,6 +57,7 @@ type USAreaSummary struct {
5457 // DefaultUSAreaProfile selects all official nationwide source families.5558 func DefaultUSAreaProfile() USAreaProfile {5659 return USAreaProfile{60+ ID: "sample-region", Title: "Sample Region",5761 Bounds: [4]float64{-105.1, 39.6, -104.9, 39.8},5862 DetailZoom: 12, IncludeTopo: true, IncludeRoads: true,5963 IncludeHydro: true, IncludeCounties: true,
@@ -79,7 +83,7 @@ func NewUSAreaProject(profile USAreaProfile) (Project, USAreaSummary, error) {
7983 if err := validateUSPresentation(profile); err != nil {8084 return Project{}, USAreaSummary{}, err8185 }82- project := usProjectBase(window, profile.DetailZoom)86+ project := usProjectBase(window, profile.DetailZoom, profile.ID, profile.Title)8387 if profile.IncludeTopo {8488 project.Rasters = append(project.Rasters, usTopoRaster(window, profile.DetailZoom))8589 }
@@ -158,11 +162,16 @@ type usWindow struct {
158162 }159163 160164 func usTileWindow(bounds [4]float64, zoom int) (usWindow, error) {165+ for _, coordinate := range bounds {166+ if math.IsNaN(coordinate) || math.IsInf(coordinate, 0) {167+ return usWindow{}, fmt.Errorf("area coordinates must be finite")168+ }169+ }161170 if bounds[0] >= bounds[2] || bounds[1] >= bounds[3] {162171 return usWindow{}, fmt.Errorf("area requires west < east and south < north")163172 }164- if !insideUSRegion(bounds) {165- return usWindow{}, fmt.Errorf("area must stay within one supported United States region")173+ if bounds[0] < -180 || bounds[2] > 180 || bounds[1] < -webMercatorLat || bounds[3] > webMercatorLat {174+ return usWindow{}, fmt.Errorf("area must stay within the Web Mercator world")166175 }167176 if zoom < 6 || zoom > 16 {168177 return usWindow{}, fmt.Errorf("detail zoom must be between 6 and 16")
@@ -184,24 +193,6 @@ func usTileWindow(bounds [4]float64, zoom int) (usWindow, error) {
184193 return usWindow{originX: originX, originY: originY, side: side, pixels: pixels, maxZoom: int64(math.Log2(float64(side))), bbox: bbox, transform: transform}, nil185194 }186195 187-func insideUSRegion(bounds [4]float64) bool {188- regions := [][4]float64{189- {-125, 24, -66, 50}, // contiguous states190- {-180, 50, -129, 72}, // Alaska191- {-161, 18, -154, 23}, // Hawaii192- {-68, 17, -65, 19}, // Puerto Rico193- {-65.2, 17.5, -64.4, 18.6}, // U.S. Virgin Islands194- {144, 13, 146.2, 21}, // Guam and Northern Mariana Islands195- {-171, -15, -168, -10}, // American Samoa196- }197- for _, region := range regions {198- if bounds[0] >= region[0] && bounds[1] >= region[1] && bounds[2] <= region[2] && bounds[3] <= region[3] {199- return true200- }201- }202- return false203-}204-205196 func lonTile(longitude float64, count int64) int64 {206197 return int64(math.Floor((longitude + 180) / 360 * float64(count)))207198 }
@@ -239,17 +230,23 @@ func mercatorTileBounds(originX, originY, side, count int64) [4]float64 {
239230 }240231 }241232 242-func usProjectBase(window usWindow, sourceZoom int) Project {233+func usProjectBase(window usWindow, sourceZoom int, id, title string) Project {234+ if id == "" {235+ id = "sample-region"236+ }237+ if title == "" {238+ title = "Sample Region"239+ }243240 return Project{244- Schema: ProjectSchema, SchemaNamespace: "example.invalid/atlas/sample-region/v1",245- ID: "sample-region", Title: "Sample Region",246- Target: Target{World: "sample-region", Title: "Sample Region", CoordinateSpace: CoordinateSpace{247- ID: "sample-region-grid", Kind: "projected", Unit: "pixel", Definition: "atlas:tile-plane",241+ Schema: ProjectSchema, SchemaNamespace: "atlas.local/" + id + "/v1",242+ ID: id, Title: title,243+ Target: Target{World: id, Title: title, CoordinateSpace: CoordinateSpace{244+ ID: id + "-grid", Kind: "projected", Unit: "pixel", Definition: "atlas:tile-plane",248245 Extent: [4]float64{0, 0, float64(window.pixels), float64(window.pixels)},249246 SourceZoom: int64(sourceZoom), OriginX: window.originX, OriginY: window.originY,250247 TileSize: 256, Size: window.pixels,251248 }},252- Presentation: Presentation{ID: "default", Title: "Sample Region"},249+ Presentation: Presentation{ID: "default", Title: title},253250 Release: Release{Revision: 1},254251 Budgets: BuildBudgets{RequestBytes: 512 << 20, TotalBytes: 4 << 30, Requests: 10_000, RasterTiles: 10_000, RasterPixels: 1_000_000_000},255252 }
M internal/authoring/us_profile_test.go +32 -1
@@ -142,6 +142,37 @@ func TestUSAreaProjectSupportsAlaskaAndHawaii(t *testing.T) {
142142 }143143 }144144 145+func TestUSAreaProjectSelectionIsGeographicRatherThanSourceBounded(t *testing.T) {146+ for name, bounds := range map[string][4]float64{147+ "Paris": {2.28, 48.82, 2.42, 48.91},148+ "Sydney": {151.14, -33.92, 151.26, -33.82},149+ } {150+ t.Run(name, func(t *testing.T) {151+ profile := DefaultUSAreaProfile()152+ profile.Bounds, profile.DetailZoom = bounds, 11153+ if _, _, err := NewUSAreaProject(profile); err != nil {154+ t.Fatalf("an arbitrary geographic selection was coupled to source coverage: %v", err)155+ }156+ })157+ }158+}159+160+func TestUSAreaProjectCarriesCreatorOwnedIdentity(t *testing.T) {161+ profile := DefaultUSAreaProfile()162+ profile.ID = "my-region"163+ profile.Title = "My Region"164+ project, _, err := NewUSAreaProject(profile)165+ if err != nil {166+ t.Fatal(err)167+ }168+ if project.ID != "my-region" || project.Title != "My Region" || project.Target.World != "my-region" || project.Target.Title != "My Region" {169+ t.Fatalf("creator identity was not carried: %+v", project)170+ }171+ if project.Target.CoordinateSpace.ID != "my-region-grid" || project.Presentation.Title != "My Region" {172+ t.Fatalf("dependent identity was not carried: target=%+v presentation=%+v", project.Target, project.Presentation)173+ }174+}175+145176 func TestUSRoadLayersDoNotFuseSharedPagingObjectIDs(t *testing.T) {146177 profile := DefaultUSAreaProfile()147178 profile.Bounds = [4]float64{-105.1, 39.6, -104.9, 39.8}
@@ -182,7 +213,7 @@ func TestUSAreaProjectRefusesOutOfBoundsOrExplosiveSelections(t *testing.T) {
182213 zoom int183214 message string184215 }{185- {name: "not a U.S. region", bounds: [4]float64{2, 48, 3, 49}, zoom: 10, message: "United States region"},216+ {name: "outside Web Mercator", bounds: [4]float64{2, 85, 3, 89}, zoom: 10, message: "Web Mercator world"},186217 {name: "empty", bounds: [4]float64{-105, 40, -105, 40}, zoom: 10, message: "west < east"},187218 {name: "too detailed", bounds: [4]float64{-124, 25, -67, 49}, zoom: 16, message: "4096"},188219 }
A internal/minting/minting.go +60 -0
@@ -0,0 +1,60 @@
1+// Package minting declares the capability shared by the pure Atlas web2+// application and a native host that can turn an authored region into a3+// validated Atlas file. It contains contracts only; filesystem and network4+// work remain in the implementation supplied by the host.5+package minting6+7+import "context"8+9+// Request is the compact, user-owned recipe for one geographic Atlas.10+type Request struct {11+ Title string12+ Bounds [4]float6413+ DetailZoom int14+ Topo bool15+ Roads bool16+ Hydro bool17+ Counties bool18+ RoadLabel string19+ HydroLabel string20+ CountyLabel string21+ RoadColor string22+ HydroColor string23+ HydroFill string24+ CountyColor string25+}26+27+// Preview is the bounded plan shown before a build begins.28+type Preview struct {29+ Pixels int6430+ RasterTiles int6431+ Requests int32+ EstimatedBytes int6433+}34+35+// Event is one stage reported by the deterministic authoring pipeline.36+type Event struct {37+ Stage string38+ Message string39+ Current int40+ Total int41+ Bytes int6442+ Cached bool43+ Artifact string44+}45+46+// Result identifies the installed volume the application should open.47+type Result struct {48+ Slug string49+ World string50+ Title string51+ Stamp string52+}53+54+// Minter is an optional native capability. Headless hosts omit it; the55+// desktop supplies it without teaching the HTTP application about paths.56+type Minter interface {57+ Default() Request58+ Preview(Request) (Preview, error)59+ Mint(context.Context, Request, func(Event)) (Result, error)60+}
M internal/workbench/operations_test.go +3 -3
M main.go +10 -1
M render/boot.ts +3 -0
M render/chart/element.ts +26 -0
@@ -142,6 +142,11 @@ export function sameCamera(a: Camera | null, b: Camera | null): boolean {
142142 return a.x === b.x && a.y === b.y && a.zoom === b.zoom && a.rotation === b.rotation;143143 }144144 145+function finiteExtent(values: readonly number[]): [number, number, number, number] | null {146+ if (values.length < 4 || values.some((value) => !Number.isFinite(value))) return null;147+ return [values[0] ?? 0, values[1] ?? 0, values[2] ?? 0, values[3] ?? 0];148+}149+145150 /**146151 * The rectangle a shape occupies, or nothing when it carries no drawable147152 * geometry. Outer rings and lines are enough: a hole is inside its own ring.
@@ -569,6 +574,27 @@ export class AtlasChart extends HTMLElement {
569574 return { x: centre[0] ?? 0, y: centre[1] ?? 0, zoom, rotation: view.getRotation() };570575 }571576 577+ /** The local world rectangle currently visible through this pane. */578+ visibleExtent(): [number, number, number, number] | null {579+ const size = this.map?.getSize();580+ if (!this.view || !size || !size[0] || !size[1]) return null;581+ return finiteExtent(this.view.calculateExtent(size));582+ }583+584+ /** Resolve a screen rectangle, relative to this pane, into local world space. */585+ extentForPixels(rect: readonly [number, number, number, number]): [number, number, number, number] | null {586+ if (!this.map) return null;587+ const first = this.map.getCoordinateFromPixel([rect[0], rect[1]]);588+ const second = this.map.getCoordinateFromPixel([rect[2], rect[3]]);589+ if (!first || !second) return null;590+ return finiteExtent([591+ Math.min(first[0] ?? NaN, second[0] ?? NaN),592+ Math.min(first[1] ?? NaN, second[1] ?? NaN),593+ Math.max(first[0] ?? NaN, second[0] ?? NaN),594+ Math.max(first[1] ?? NaN, second[1] ?? NaN),595+ ]);596+ }597+572598 /**573599 * One step of a zoom control, held to the lens's own depth.574600 *
A render/mint.ts +178 -0
@@ -0,0 +1,178 @@
1+// Earth authoring enhancement.2+//3+// The application renders the complete form and owns every route. This module4+// contributes only the two facts a browser camera can answer: which local5+// world rectangle is visible, and which rectangle a pointer drew. Both are6+// converted into the WGS84 fields the server already rendered.7+8+import type { TileGrid } from "./data/payload.ts";9+import type { AtlasChart } from "./chart/element.ts";10+import type { AtlasViewport } from "./viewport.ts";11+12+export type Extent = readonly [number, number, number, number];13+14+// Six decimal places are the authored form contract. Keeping the clamp on15+// that same precision prevents a whole-Earth camera from rounding one unit16+// past the native Web Mercator boundary.17+const MAX_MERCATOR_LATITUDE = 85.051128;18+19+/** Convert local y-up Atlas pixels into a WGS84 west/south/east/north box. */20+export function worldExtentToWGS84(extent: Extent, grid: TileGrid): [number, number, number, number] | null {21+ const zoom = grid.sourceZoom;22+ const tileSize = grid.tileSize;23+ const originX = grid.originX ?? grid.firstTile;24+ const originY = grid.originY ?? grid.firstTile;25+ const scale = tileSize * 2 ** zoom;26+ if (![...extent, zoom, tileSize, originX, originY, scale].every(Number.isFinite) || tileSize <= 0 || scale <= 0) return null;27+ const west = longitude(originX * tileSize + extent[0], scale);28+ const east = longitude(originX * tileSize + extent[2], scale);29+ const north = latitude(originY * tileSize - extent[3], scale);30+ const south = latitude(originY * tileSize - extent[1], scale);31+ const bounds = [west, south, east, north] as const;32+ if (!bounds.every(Number.isFinite) || west >= east || south >= north) return null;33+ return bounds.map((value) => rounded(value)) as [number, number, number, number];34+}35+36+function longitude(pixel: number, scale: number): number {37+ return Math.max(-180, Math.min(180, pixel / scale * 360 - 180));38+}39+40+function latitude(pixel: number, scale: number): number {41+ const mercator = Math.PI - 2 * Math.PI * pixel / scale;42+ const degrees = Math.atan(Math.sinh(mercator)) * 180 / Math.PI;43+ return Math.max(-MAX_MERCATOR_LATITUDE, Math.min(MAX_MERCATOR_LATITUDE, degrees));44+}45+46+function rounded(value: number): number { return Number(value.toFixed(6)); }47+48+let drawing = false;49+let start: { x: number; y: number } | null = null;50+const wired = new WeakSet<EventTarget>();51+52+/** Wire the enhancement onto whichever mint controls are currently rendered. */53+export function wireMinting(): void {54+ const create = document.querySelector<HTMLElement>("#atlas-create");55+ if (create && !wired.has(create)) {56+ wired.add(create);57+ create.addEventListener("click", () => window.setTimeout(useCurrentView, 220));58+ }59+ const current = document.querySelector<HTMLButtonElement>("[data-mint-current-view]");60+ if (current && !wired.has(current)) {61+ wired.add(current);62+ current.addEventListener("click", useCurrentView);63+ }64+ const draw = document.querySelector<HTMLButtonElement>("[data-mint-draw]");65+ if (draw && !wired.has(draw)) {66+ wired.add(draw);67+ draw.addEventListener("click", () => setDrawing(!drawing));68+ }69+ const detail = document.querySelector<HTMLInputElement>('input[name="detail"]');70+ if (detail && !wired.has(detail)) {71+ wired.add(detail);72+ detail.addEventListener("input", () => writeDetail(detail));73+ }74+ const chart = document.querySelector<AtlasChart>("atlas-chart");75+ if (chart && !wired.has(chart)) {76+ wired.add(chart);77+ chart.addEventListener("pointerdown", beginZone, { capture: true });78+ }79+}80+81+function useCurrentView(): void {82+ const chart = document.querySelector<AtlasChart>("atlas-chart");83+ const panel = document.querySelector<HTMLElement>("#atlas-mint");84+ if (!chart) return;85+ const chartBox = chart.getBoundingClientRect();86+ const panelBox = panel?.getBoundingClientRect();87+ const visibleWidth = panelBox && panelBox.left > chartBox.left88+ ? Math.min(chartBox.width, panelBox.left - chartBox.left)89+ : chartBox.width;90+ const extent = chart.extentForPixels([0, 0, visibleWidth, chartBox.height]) ?? chart.visibleExtent();91+ if (!extent) return;92+ fillBounds(extent);93+ showZone({ left: chartBox.left, top: chartBox.top, right: chartBox.left + visibleWidth, bottom: chartBox.bottom });94+}95+96+function beginZone(event: PointerEvent): void {97+ if (!drawing || event.button !== 0) return;98+ event.preventDefault();99+ event.stopImmediatePropagation();100+ start = { x: event.clientX, y: event.clientY };101+ showZone({ left: event.clientX, top: event.clientY, right: event.clientX, bottom: event.clientY });102+ window.addEventListener("pointermove", moveZone, { capture: true });103+ window.addEventListener("pointerup", finishZone, { capture: true, once: true });104+ window.addEventListener("pointercancel", cancelZone, { capture: true, once: true });105+}106+107+function moveZone(event: PointerEvent): void {108+ if (!start) return;109+ event.preventDefault();110+ showZone({ left: start.x, top: start.y, right: event.clientX, bottom: event.clientY });111+}112+113+function finishZone(event: PointerEvent): void {114+ window.removeEventListener("pointermove", moveZone, { capture: true });115+ window.removeEventListener("pointercancel", cancelZone, { capture: true });116+ if (!start) return;117+ event.preventDefault();118+ const chart = document.querySelector<AtlasChart>("atlas-chart");119+ const box = chart?.getBoundingClientRect();120+ const width = Math.abs(event.clientX - start.x);121+ const height = Math.abs(event.clientY - start.y);122+ if (chart && box && width >= 12 && height >= 12) {123+ const extent = chart.extentForPixels([124+ Math.min(start.x, event.clientX) - box.left,125+ Math.min(start.y, event.clientY) - box.top,126+ Math.max(start.x, event.clientX) - box.left,127+ Math.max(start.y, event.clientY) - box.top,128+ ]);129+ if (extent) fillBounds(extent);130+ }131+ start = null;132+ setDrawing(false);133+}134+135+function cancelZone(): void {136+ window.removeEventListener("pointermove", moveZone, { capture: true });137+ window.removeEventListener("pointerup", finishZone, { capture: true });138+ start = null;139+ setDrawing(false);140+}141+142+function fillBounds(extent: Extent): void {143+ const viewport = document.querySelector<AtlasViewport>("atlas-viewport");144+ const bounds = viewport?.current ? worldExtentToWGS84(extent, viewport.current.grid) : null;145+ if (!bounds) return;146+ ["west", "south", "east", "north"].forEach((name, index) => {147+ const field = document.querySelector<HTMLInputElement>(`#atlas-mint input[name="${name}"]`);148+ if (field) field.value = String(bounds[index] ?? "");149+ });150+ window.dispatchEvent(new CustomEvent("atlas:mint-area"));151+}152+153+function showZone(rect: { left: number; top: number; right: number; bottom: number }): void {154+ const zone = document.querySelector<HTMLElement>("#atlas-mint-zone");155+ const panel = document.querySelector<HTMLElement>(".map-panel");156+ if (!zone || !panel) return;157+ const base = panel.getBoundingClientRect();158+ zone.hidden = false;159+ zone.dataset.visible = "true";160+ zone.style.left = `${Math.min(rect.left, rect.right) - base.left}px`;161+ zone.style.top = `${Math.min(rect.top, rect.bottom) - base.top}px`;162+ zone.style.width = `${Math.abs(rect.right - rect.left)}px`;163+ zone.style.height = `${Math.abs(rect.bottom - rect.top)}px`;164+ zone.style.right = "auto";165+ zone.style.bottom = "auto";166+}167+168+function setDrawing(value: boolean): void {169+ drawing = value;170+ const button = document.querySelector<HTMLButtonElement>("[data-mint-draw]");171+ button?.setAttribute("aria-pressed", String(value));172+ document.querySelector(".map-panel")?.classList.toggle("mint-drawing", value);173+}174+175+function writeDetail(field: HTMLInputElement): void {176+ const output = document.querySelector<HTMLOutputElement>("[data-mint-detail]");177+ if (output) output.value = `z${field.value}`;178+}
A render/test/mint.test.ts +23 -0