clone

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

feat: cut presentation over to native vNext

Dami committed d889899
commit
d8898999a400cde4bb8797999c065a1612ab952f
parent
224e5d2d9c8f7a9cf23a2ed852d33af997ca8eec
author
Dami <dami.etoile@gmail.com>
54 files changed, +1956 -1725
MREADME.md4+-
Mcmd/atlas/measure.go4+-
Mdocs/app.md6+-
Mdocs/generate.md58+--
Mdocs/render-seam.md34+-
Mformat/vnext/decode.go2+-
Mformat/vnext/import_v3.go58++-
Mformat/vnext/model.go1+
Rincluded/earth-20260803-3aae4ec1d1ee.atlas → included/earth-20260803-28305c3d1811.atlasbin
Minternal/app/app.go13+-
Minternal/app/app_test.go118++++--
Minternal/app/data.go52+-
Minternal/app/events.go4+-
Minternal/app/legend.go20+-
Minternal/app/legend_test.go8+-
Minternal/app/pages.go5+-
Minternal/app/session.go7+-
Minternal/app/view.go33+-
Minternal/app/vnext_projection_test.go116+----
Minternal/app/vnext_runtime.go496+++++++-----------------
Minternal/app/world.go247+-----------
Minternal/enrich/maturity/gate.go10+-
Minternal/enrich/maturity/maturity.go211++++++++--
Minternal/generate/compose/compose_test.go11-
Ainternal/generate/compose/model.go58+++
Minternal/generate/compose/native.go55++-
Dinternal/generate/compose/payload.go294--------------
Minternal/workbench/bundles_test.go184+++------
Minternal/workbench/diff.go6+-
Minternal/workbench/library.go38+-
Minternal/workbench/workbench.go6+-
Minternal/workbench/workbench_test.go4+-
Mrender/chart/overview.ts4+-
Mrender/chart/styles.ts13+-
Mrender/data/payload.ts36+-
Mrender/data/plane.ts87+++--
Mrender/data/pyramid.ts5+-
Arender/data/semantic.ts638++++++++++++++++++++++++++++++
Mrender/globe/element.ts14+-
Mrender/index.ts3+-
Mrender/test/element.test.ts29+-
Mrender/test/grid.test.ts15+-
Arender/test/legacy.ts145+++++++
Mrender/test/markers.test.ts6+-
Mrender/test/pins.test.ts2+-
Mrender/test/styles.test.ts35+-
Mrender/test/visibility.test.ts9+-
Mrender/test/vnext.test.ts47++
Mrender/viewport.ts46+--
Mrender/world/model.ts334++++++++--------
Mrender/world/visibility.ts4+-
Mtests/island/display_test.go16+-
Mtests/island/harness_test.go10+-
Mtests/island/island_test.go20+-
M README.md +2 -2
M cmd/atlas/measure.go +2 -2
M docs/app.md +3 -3
@@ -58,7 +58,7 @@ Location() string // a label: where the library li
5858 The registry model is **scan at launch, rescan on import — never watch** (issue5959 #5 §2, decision 15). A file dropped into the library from outside appears at6060 the next launch. The fold that decides which build of a volume serves is61-`format/bundle`'s and is pure — newest capture, then policy revision, then61+`format/vnext`'s and is pure — newest capture, then policy revision, then6262 stamp, then locator — so a host only ever has to produce descriptors.6363 6464 `Location()` is a label, not a path the handler takes apart: it is what the
@@ -125,7 +125,7 @@ composed `dist/bundles`. The headless host reads the same two variables
125125 `included/` (`//go:embed included/*.atlas`) and installs it into the library126126 before the host is constructed, so the first scan already serves Earth and a127127 first launch opens onto a world instead of the empty-library card. The install128-goes through `format/bundle`'s own path — validated, versioned, staged,128+goes through `format/vnext`'s own path — validated, versioned, staged,129129 idempotent — so a second launch is a no-op and another Earth build in the130130 library stays beside it for the fold to order; a built-in that cannot be131131 installed is a startup error, never a silent absence. After installation the
@@ -665,7 +665,7 @@ registry is a depcheck failure, not a review comment.
665665 666666 | File | What it decides |667667 |---|---|668-| `world.go` | the payload model: collections in order, points unpacked from `ATLASLOC`, shape rings projected onto the world square, the parent chain, containment |668+| `world.go` + `vnext_runtime.go` | the semantic model: Presentation applied to FeatureSets, durable string identities, direct coordinate-space geometry, typed properties, relationships, provenance, the parent chain and containment |669669 | `legend.go` | the tree — sections, rows, counts, the label ladder, the feature index order, isolating, the solo chip, the opening arrangement |670670 | `filter.go` | what stands: the hide set, the search, the AND-across/OR-within highlight filter, the shard, the counts and the words above them |671671 | `view.go` | the card, the dock, the grid and overview chrome, the viewport state node |
M docs/generate.md +19 -39
@@ -963,44 +963,24 @@ it actually covers, clipped to that window, because those are not the same
963963 rectangle and anything measuring a world rather than drawing it wants the964964 ground.965965 966-### 5.3 The payload967-968-Three parts, because they are wanted at three different moments.969-970-- `worlds/<slug>.json` — lenses and the ordered collections array. Point971- collections carry no inline features; shape collections inline theirs whole.972- **Point collections are written first**, so a packed location's owner is the973- ordinal of its collection among the points.974-- `worlds/<slug>.bin` — `ATLASLOC` v3, the point features packed.975-- `worlds/<slug>.text` — prose, links and per-feature attributes, keyed by976- feature id as a decimal string. A point earns an entry for prose, links or977- attributes; a shape earns one for prose alone and marks `hasText`, keeping978- its attributes inline because a card needs them the moment ground is asked979- about.980-981-Its field names, its field order, and which fields are omitted when empty all982-feed the stamp, so the shape is frozen with the format version.983-984-Every world opens a **provenance account** — where its ground came from, and985-what it held when it got here — merged with anything or not. A single-source986-build carries exactly one entry, marked `origin`. The ledger fields the enrich987-lane adds extend that entry in place, after `added`, so an origin account's988-bytes never move.966+### 5.3 The semantic volume989967 990-### 5.4 Stamping and writing968+Composition writes one semantic graph: Volume → Worlds → CoordinateSpace,969+FeatureSets/Features, RasterPyramids and Presentation, plus volume Assets.970+Feature geometry is converted into the declared coordinate space before the971+file is compiled. Properties are typed fields; membership and links are972+Relationships; source identity and capture time are Provenance records.991973 992-The stamp is taken over **named parts**, sorted, one `"<name> <hash>"` line993-each, joined by newlines, SHA-256:974+`vnext.Compile` lowers that graph into schema-described typed tables. Feature975+identity is a durable string and does not depend on a collection ordinal.976+Presentation points at semantic identities, so a new layer/style/legend can be977+applied without rewriting feature meaning.994978 995-```996-atlas.json hash of the manifest as it stands before its own stamp997- and creation time are filled in — the revision is in998-worlds/<slug>.json hash of the payload bytes999-worlds/<slug>.bin1000-worlds/<slug>.text1001-tiles/<pyramid> the pyramid's derivation stamp, not its bytes1002-icons/<name> hash of the artwork1003-```979+### 5.4 Stamping and writing980+981+The stamp is derived by the vNext writer from the canonical bootstrap: release982+metadata, canonical schema identity, every typed table block and every opaque983+blob identity. Callers cannot provide or retain a stale stamp.1004984 1005985 `createdAt` is the **newest capture time across the volume's worlds**, never1006986 the build clock. The name is `<slug>-<YYYYMMDD>-<stamp12>.atlas`. Building the
@@ -1014,10 +994,10 @@ it is renamed into place, so the name appears whole or not at all; and then it
1014994 is reopened from disk and validated, because the file that will serve is the1015995 copy and it is the copy's promises that matter.1016996 1017-Entry order inside the zip: the manifest, then each world's three parts in world1018-order, then the pyramids in sorted local name order (walked lexically, so level1019-`10` precedes level `2`), then the icons in sorted name order. Tiles and packed1020-locations are **stored uncompressed** so a reader serves them as byte ranges.997+Entry order inside the zip is deterministic: the bootstrap first, canonical998+schema, typed `data/*.pack` tables, then opaque blobs. Table blocks, raster999+tiles and content-addressed assets are stored so readers can map or range-read1000+them without inflating a document.10211001 10221002 ### 5.5 The policy revision10231003
M docs/render-seam.md +10 -24
@@ -176,30 +176,16 @@ whole. `rescan()` is what the boot module calls after a swap.
176176 177177 ### 4.1 The world model178178 179-Built once per world, immutable afterwards. Points come from180-`worlds/<slug>.bin` as zero-copy typed-array views (`docs/format.md` §7);181-shapes come inline from the payload's collections.182-183-**The projection nobody wrote down.** A payload spells positions as `lat`/`lng`184-in the volume's own world space — which is the slippy-tile grid the capture185-was cut from, not WGS 84 — and every renderer needs them as world pixels:186-187-```188-worldTiles = 2^grid.sourceZoom189-xTile = (lng + 180) / 360 · worldTiles190-yTile = (1 − asinh(tan(lat·π/180)) / π) / 2 · worldTiles191-x = (xTile − grid.firstTile) · grid.tileSize192-y = −(yTile − grid.firstTile) · grid.tileSize193-```194-195-`grid` is the manifest's `tileGrid` with the world payload's own `grid`196-override applied (`docs/format.md` §6.2) — every corpus volume overrides it,197-so skipping the override puts every feature about 850,000 pixels off the map.198-**This arithmetic belongs in `docs/format.md` and is not there.** It is199-recorded here, and in `internal/app/world.go`, until it moves.200-201-The sign flip is the one place the format's y-down pixels meet the y-negative202--down coordinates everything downstream speaks.179+Built once per world, immutable afterwards. `schema.json` describes every180+packed column under `data/*.pack`; those columns reconstruct Volume → World →181+FeatureSets/Features, RasterPyramids, Presentation and Assets. Presentation is182+then applied to the semantic data without a document or GeoJSON intermediary.183+184+Geometry is already expressed in the world's declared CoordinateSpace. The185+standard `atlas:tile-plane` is y-down pixel space; the renderer only flips y186+once at its OpenLayers boundary. Projection from source longitude/latitude is187+a producer responsibility and therefore part of the stamped data, not an188+implicit reader convention.203189 204190 ### 4.2 The standing set205191
M format/vnext/decode.go +1 -1
M format/vnext/import_v3.go +45 -13
@@ -4,12 +4,14 @@ import (
44 "encoding/base64"55 "encoding/json"66 "fmt"7+ "math"78 "path"89 "sort"910 "strconv"1011 "strings"1112 1213 "github.com/FelineStateMachine/atlas/format/bundle"14+ "github.com/FelineStateMachine/atlas/format/semconv"1315 )1416 1517 type v3World struct {
@@ -195,7 +197,7 @@ func importV3World(read func(string) ([]byte, error), manifest bundle.Manifest,
195197 }196198 origin := v3Origin(source.Merged)197199 for index, collection := range source.Collections {198- set, err := importV3Collection(entry, collection, texts, origin)200+ set, err := importV3Collection(entry, collection, texts, origin, grid)199201 if err != nil {200202 return World{}, fmt.Errorf("world %s collection %s: %w", entry.Slug, collection.Title, err)201203 }
@@ -208,12 +210,12 @@ func importV3World(read func(string) ([]byte, error), manifest bundle.Manifest,
208210 }209211 world.Presentation.Styles = append(world.Presentation.Styles, Style{210212 ID: styleID, Icon: collection.Icon, IconAsset: collection.IconAsset, IconPicture: collection.IconPicture,211- RenderAs: collection.Attrs["atlas.render.as"], Fill: color, Stroke: color,213+ RenderAs: collection.Attrs[semconv.KeyRenderAs], Fill: color, Stroke: color,212214 })213215 visible := collection.Visible == nil || *collection.Visible214216 world.Presentation.Layers = append(world.Presentation.Layers, Layer{215217 ID: layerID, FeatureSet: set.ID, Style: styleID, Group: collection.Group,216- LabelPolicy: collection.Attrs["atlas.label.policy"], Visible: visible,218+ LabelPolicy: collection.Attrs[semconv.KeyLabelPolicy], Visible: visible,217219 Order: int64(index), MinZoom: 0, MaxZoom: maxV3Zoom(source.Lenses),218220 })219221 world.Presentation.Legend = append(world.Presentation.Legend, LegendEntry{Layer: layerID, Label: collection.Title, Order: int64(index)})
@@ -226,7 +228,7 @@ func importV3World(read func(string) ([]byte, error), manifest bundle.Manifest,
226228 text := texts[strconv.FormatInt(location.ID, 10)]227229 feature := Feature{228230 ID: v3FeatureID(entry.Slug, location.ID), Title: location.Title, Shard: location.Shard,229- Geometry: Geometry{Kind: GeometryPoint, Parts: []GeometryPart{{Rings: [][]Position{{{location.Lng, location.Lat}}}}}},231+ Geometry: Geometry{Kind: GeometryPoint, Parts: []GeometryPart{{Rings: [][]Position{{projectV3(location.Lng, location.Lat, grid)}}}}},230232 Properties: propertiesFromV3(text.Attrs), Provenance: v3Provenance(origin, location.ID, entry.UpdatedAt),231233 Description: text.Description,232234 }
@@ -252,7 +254,7 @@ func importV3World(read func(string) ([]byte, error), manifest bundle.Manifest,
252254 return world, nil253255 }254256 255-func importV3Collection(entry bundle.WorldEntry, source v3Collection, texts map[string]v3Text, origin string) (FeatureSet, error) {257+func importV3Collection(entry bundle.WorldEntry, source v3Collection, texts map[string]v3Text, origin string, grid bundle.TileGrid) (FeatureSet, error) {256258 set := FeatureSet{257259 ID: entry.Slug + "/set/" + strconv.FormatInt(source.ID, 10), Title: source.Title,258260 SemanticType: "geometry." + source.Kind, Claims: claimsFromV3("collection", source.Attrs),
@@ -272,7 +274,7 @@ func importV3Collection(entry bundle.WorldEntry, source v3Collection, texts map[
272274 sort.Slice(set.Properties, func(i, j int) bool { return set.Properties[i].ID.String() < set.Properties[j].ID.String() })273275 for _, sourceFeature := range source.Features {274276 text := texts[strconv.FormatInt(sourceFeature.ID, 10)]275- geometry, err := geometryFromV3(sourceFeature.Geometry)277+ geometry, err := geometryFromV3(sourceFeature.Geometry, grid)276278 if err != nil {277279 return FeatureSet{}, fmt.Errorf("feature %d geometry: %w", sourceFeature.ID, err)278280 }
@@ -283,7 +285,7 @@ func importV3Collection(entry bundle.WorldEntry, source v3Collection, texts map[
283285 Relationships: relationshipsFromV3(entry.Slug, text.Links), Provenance: v3Provenance(origin, sourceFeature.ID, entry.UpdatedAt),284286 }285287 if sourceFeature.Center != nil {286- center := Position{sourceFeature.Center.Lng, sourceFeature.Center.Lat}288+ center := projectV3(sourceFeature.Center.Lng, sourceFeature.Center.Lat, grid)287289 feature.Center = &center288290 }289291 if sourceFeature.Parent != nil {
@@ -321,7 +323,7 @@ func importV3Raster(worldID string, tileSize, index int, lens v3Lens) (RasterPyr
321323 return raster, nil322324 }323325 324-func geometryFromV3(source []v3Geometry) (Geometry, error) {326+func geometryFromV3(source []v3Geometry, grid bundle.TileGrid) (Geometry, error) {325327 var out Geometry326328 for _, item := range source {327329 switch item.Type {
@@ -330,13 +332,13 @@ func geometryFromV3(source []v3Geometry) (Geometry, error) {
330332 if err := json.Unmarshal(item.Coordinates, &point); err != nil {331333 return Geometry{}, err332334 }333- out.Kind, out.Parts = GeometryPoint, append(out.Parts, GeometryPart{Rings: [][]Position{{point}}})335+ out.Kind, out.Parts = GeometryPoint, append(out.Parts, GeometryPart{Rings: [][]Position{{projectV3Position(point, grid)}}})334336 case "LineString":335337 var line []Position336338 if err := json.Unmarshal(item.Coordinates, &line); err != nil {337339 return Geometry{}, err338340 }339- out.Kind, out.Parts = GeometryLineString, append(out.Parts, GeometryPart{Rings: [][]Position{line}})341+ out.Kind, out.Parts = GeometryLineString, append(out.Parts, GeometryPart{Rings: [][]Position{projectV3Positions(line, grid)}})340342 case "MultiLineString":341343 var lines [][]Position342344 if err := json.Unmarshal(item.Coordinates, &lines); err != nil {
@@ -344,14 +346,14 @@ func geometryFromV3(source []v3Geometry) (Geometry, error) {
344346 }345347 out.Kind = GeometryLineString346348 for _, line := range lines {347- out.Parts = append(out.Parts, GeometryPart{Rings: [][]Position{line}})349+ out.Parts = append(out.Parts, GeometryPart{Rings: [][]Position{projectV3Positions(line, grid)}})348350 }349351 case "Polygon":350352 var rings [][]Position351353 if err := json.Unmarshal(item.Coordinates, &rings); err != nil {352354 return Geometry{}, err353355 }354- out.Kind, out.Parts = GeometryPolygon, append(out.Parts, GeometryPart{Rings: rings})356+ out.Kind, out.Parts = GeometryPolygon, append(out.Parts, GeometryPart{Rings: projectV3Rings(rings, grid)})355357 case "MultiPolygon":356358 var polygons [][][]Position357359 if err := json.Unmarshal(item.Coordinates, &polygons); err != nil {
@@ -359,7 +361,7 @@ func geometryFromV3(source []v3Geometry) (Geometry, error) {
359361 }360362 out.Kind = GeometryPolygon361363 for _, rings := range polygons {362- out.Parts = append(out.Parts, GeometryPart{Rings: rings})364+ out.Parts = append(out.Parts, GeometryPart{Rings: projectV3Rings(rings, grid)})363365 }364366 default:365367 return Geometry{}, fmt.Errorf("unsupported v3 geometry %q", item.Type)
@@ -368,6 +370,36 @@ func geometryFromV3(source []v3Geometry) (Geometry, error) {
368370 return out, validateGeometry(out)369371 }370372 373+func projectV3(lng, lat float64, grid bundle.TileGrid) Position {374+ worldTiles := math.Pow(2, float64(grid.SourceZoom))375+ xTile := (lng + 180) / 360 * worldTiles376+ yTile := (1 - math.Asinh(math.Tan(lat*math.Pi/180))/math.Pi) / 2 * worldTiles377+ return Position{378+ (xTile - float64(grid.FirstTile)) * float64(grid.TileSize),379+ (yTile - float64(grid.FirstTile)) * float64(grid.TileSize),380+ }381+}382+383+func projectV3Position(position Position, grid bundle.TileGrid) Position {384+ return projectV3(position[0], position[1], grid)385+}386+387+func projectV3Positions(positions []Position, grid bundle.TileGrid) []Position {388+ out := make([]Position, len(positions))389+ for index, position := range positions {390+ out[index] = projectV3Position(position, grid)391+ }392+ return out393+}394+395+func projectV3Rings(rings [][]Position, grid bundle.TileGrid) [][]Position {396+ out := make([][]Position, len(rings))397+ for index, ring := range rings {398+ out[index] = projectV3Positions(ring, grid)399+ }400+ return out401+}402+371403 func importV3Assets(reader *bundle.Reader) ([]Asset, error) {372404 var assets []Asset373405 for _, name := range reader.Names() {
M format/vnext/model.go +1 -0
R included/earth-20260803-3aae4ec1d1ee.atlas → included/earth-20260803-28305c3d1811.atlas +0 -0

Binary file.

M internal/app/app.go +5 -8
@@ -10,9 +10,8 @@
1010 //1111 // Two planes share the mux and answer to different rules.1212 //13-// The **data plane** (/data) is byte-compatible with the implementation this14-// one replaces (docs/app.md §2.1; the differences accepted against the15-// reference are decision 18). The catalog is composed from whatever is13+// The **data plane** (/data) serves the native vNext schema, packed typed14+// tables and content-addressed blobs. The catalog is composed from whatever is1615 // installed right now and is never cached; volume content sits under a URL1716 // carrying the build's stamp, so it may be cached forever and a new build1817 // arrives at new URLs. app_test.go is the gate on the plane's shape.
@@ -43,14 +42,12 @@ type App struct {
4342 events *hub4443 mux *http.ServeMux4544 46- // worlds and texts hold what a world costs to stand up: a payload47- // decoded, every location unpacked, every ring projected. That is work45+ // worlds holds what a semantic world costs to stand up. That is work4846 // worth doing once per build rather than once per keystroke of a4947 // search, and the keys carry the build's stamp, so a new build is a new5048 // entry and nothing is ever stale. They are memory, not state: two Apps5149 // over one host still answer the same, only slower.5250 worlds *worldCache53- texts *textCache5451 semantic *semanticCache5552 5653 // writing serializes the read-modify-write of one volume's record. Two
@@ -78,7 +75,7 @@ type Options struct {
7875 func New(env hostenv.Hostenv, opts Options) *App {7976 a := &App{8077 env: env, static: opts.Static, events: newHub(), mux: http.NewServeMux(),81- worlds: newWorldCache(), texts: newTextCache(), semantic: newSemanticCache(),78+ worlds: newWorldCache(), semantic: newSemanticCache(),8279 }8380 a.routes()8481 return a
@@ -90,7 +87,7 @@ func (a *App) ServeHTTP(w http.ResponseWriter, r *http.Request) { a.mux.ServeHTT
9087 // routes is the whole URL surface, in the order docs/app.md lists it. Every9188 // route is spelled here; nothing registers itself.9289 func (a *App) routes() {93- // The data plane, byte-compatible with the reference implementation.90+ // The native vNext data plane.9491 a.mux.HandleFunc("GET /data/catalog.json", a.handleCatalog)9592 a.mux.HandleFunc("GET /data/v/{slug}/{stamp}/{rest...}", a.handleContent)9693
M internal/app/app_test.go +82 -36
@@ -2,12 +2,16 @@ package app_test
22 33 import (44 "context"5+ "crypto/sha256"56 "encoding/json"67 "errors"8+ "fmt"79 "io"10+ "mime"811 "net/http"912 "net/http/httptest"1013 "net/url"14+ "path"1115 "strings"1216 "testing"1317
@@ -41,19 +45,54 @@ func (v *fakeVolume) Info() hostenv.VolumeInfo {
4145 4246 func (v *fakeVolume) Semantic() vnext.Volume {4347 semantic, _ := vnext.ImportV3Volume(v.manifest, func(name string) ([]byte, error) { return v.Blob(name) })48+ for name, data := range v.entries {49+ if !strings.HasPrefix(name, bundle.IconsPrefix) {50+ continue51+ }52+ semantic.Assets = append(semantic.Assets, vnext.Asset{53+ ID: strings.TrimPrefix(name, bundle.IconsPrefix), MediaType: mime.TypeByExtension(path.Ext(name)), Data: data,54+ })55+ }56+ for index := range semantic.Assets {57+ digest := sha256.Sum256(semantic.Assets[index].Data)58+ semantic.Assets[index].Path = fmt.Sprintf("assets/%x", digest)59+ }4460 return semantic4561 }4662 4763 func (v *fakeVolume) Blob(name string) ([]byte, error) {4864 held, ok := v.entries[name]49- if !ok {50- return nil, errors.New("no such entry")65+ if ok {66+ return held, nil67+ }68+ compiled, err := vnext.Compile(v.Semantic())69+ if err == nil {70+ for _, blob := range compiled.Blobs {71+ if blob.Name == name {72+ return blob.Data, nil73+ }74+ }5175 }52- return held, nil76+ return nil, errors.New("no such entry")5377 }5478 55-func (v *fakeVolume) Schema() ([]byte, error) { return vnext.StandardSchema().Canonical() }56-func (v *fakeVolume) TableBlock(string) ([]byte, error) { return nil, errors.New("no typed fixture") }79+func (v *fakeVolume) Schema() ([]byte, error) { return vnext.StandardSchema().Canonical() }80+func (v *fakeVolume) TableBlock(name string) ([]byte, error) {81+ compiled, err := vnext.Compile(v.Semantic())82+ if err != nil {83+ return nil, err84+ }85+ hash, err := compiled.Schema.Hash()86+ if err != nil {87+ return nil, err88+ }89+ for _, table := range compiled.Tables {90+ if table.Name == name {91+ return vnext.EncodeBlock(hash, table.Table)92+ }93+ }94+ return nil, errors.New("no typed fixture")95+}5796 5897 type fakeVolumes struct {5998 volumes []hostenv.Volume
@@ -222,18 +261,19 @@ func TestCatalogComposition(t *testing.T) {
222261 func TestContentPlane(t *testing.T) {223262 handler, _ := newApp(t, volume("tunic", "TUNIC", tunicStamp))224263 base := "/data/v/tunic/" + bundle.ShortStamp(tunicStamp)264+ markerDigest := sha256.Sum256([]byte("<svg/>"))265+ marker := fmt.Sprintf("%s/assets/%x", base, markerDigest)225266 226267 served := []struct {227268 name string228269 path string229270 kind string230- size int271+ body string231272 }{232- {"a world payload", base + "/worlds/overworld.json", "application/json", 126},233- {"the packed locations", base + "/worlds/overworld.bin", "application/octet-stream", 20},234- {"the deferred prose", base + "/worlds/overworld.text", "application/json", 2},235- {"an icon", base + "/icons/marker.svg", "image/svg+xml", 6},236- {"a tile", base + "/tiles/overworld/0/0/0.jpg", "image/jpeg", 6},273+ {"the native schema", base + "/schema.json", "application/schema+json", ""},274+ {"a native typed table", base + "/data/volumes.pack", "application/vnd.atlas.table", ""},275+ {"a content-addressed asset", marker, "image/svg+xml", "<svg/>"},276+ {"a tile", base + "/tiles/overworld/0/0/0.jpg", "image/jpeg", "raster"},237277 }238278 for _, tt := range served {239279 t.Run(tt.name, func(t *testing.T) {
@@ -244,8 +284,11 @@ func TestContentPlane(t *testing.T) {
244284 if kind := got.Header().Get("Content-Type"); kind != tt.kind {245285 t.Errorf("Content-Type = %q, want %q", kind, tt.kind)246286 }247- if got.Body.Len() != tt.size {248- t.Errorf("body is %d bytes, want %d", got.Body.Len(), tt.size)287+ if tt.body != "" && got.Body.String() != tt.body {288+ t.Errorf("body is %q, want %q", got.Body.String(), tt.body)289+ }290+ if got.Body.Len() == 0 {291+ t.Error("native content body is empty")249292 }250293 if cache := got.Header().Get("Cache-Control"); cache != "private, max-age=31536000, immutable" {251294 t.Errorf("Cache-Control = %q: a stamped URL names one build forever", cache)
@@ -259,10 +302,11 @@ func TestContentPlane(t *testing.T) {
259302 }{260303 {"a stamp that is not the serving build", "/data/v/tunic/000000000000/worlds/overworld.json"},261304 {"a volume that is not installed", "/data/v/not-a-volume/" + bundle.ShortStamp(tunicStamp) + "/worlds/overworld.json"},262- {"an entry outside worlds, tiles and icons", base + "/atlas.json"},263- {"an extension the plane names no type for", base + "/worlds/overworld.txt"},264- {"a world the bundle does not hold", base + "/worlds/not-a-world.json"},265- {"a path with no extension at all", base + "/worlds/overworld"},305+ {"the retired world JSON projection", base + "/worlds/overworld.json"},306+ {"the retired packed-location projection", base + "/worlds/overworld.bin"},307+ {"the retired deferred-text projection", base + "/worlds/overworld.text"},308+ {"the retired icon namespace", base + "/icons/marker.svg"},309+ {"an entry the native bundle does not hold", base + "/atlas.json"},266310 {"a path under the shell that is not a page", "/not-a-page"},267311 }268312 for _, tt := range refused {
@@ -1180,25 +1224,25 @@ func TestOneZoneCanBeAskedForExclusively(t *testing.T) {
11801224 11811225 // The accumulating form, which is what the row's right button asks and11821226 // what this control is not: two zones highlighted is two zones.1183- highlight(t, url.Values{"feature": {"91"}})1184- highlight(t, url.Values{"feature": {"92"}})1227+ highlight(t, url.Values{"feature": {"overworld/feature/91"}})1228+ highlight(t, url.Values{"feature": {"overworld/feature/92"}})11851229 if held := session(t).Highlighted; len(held) != 2 {11861230 t.Fatalf("highlighting twice did not accumulate: %v", held)11871231 }11881232 // The collection the exclusive zone belongs to is away, so the ride-along11891233 // has something to do.11901234 if got := post(t, handler, "/session/collections",1191- url.Values{"volume": {"tunic"}, "collection": {"901"}, "visible": {"0"}}); got.Code != http.StatusOK {1235+ url.Values{"volume": {"tunic"}, "collection": {"overworld/layer/901"}, "visible": {"0"}}); got.Code != http.StatusOK {11921236 t.Fatalf("hiding a collection answered %d", got.Code)11931237 }11941238 11951239 // The exclusive form replaces the set rather than joining it.1196- answer := highlight(t, url.Values{"feature": {"94"}, "only": {"1"}})1240+ answer := highlight(t, url.Values{"feature": {"overworld/feature/94"}, "only": {"1"}})11971241 held := session(t)1198- if len(held.Highlighted) != 1 || held.Highlighted[0] != "94" {1242+ if len(held.Highlighted) != 1 || held.Highlighted[0] != "overworld/feature/94" {11991243 t.Errorf("the exclusive press did not replace the set: %v", held.Highlighted)12001244 }1201- if contains(held.Hidden, "901") {1245+ if contains(held.Hidden, "overworld/layer/901") {12021246 t.Errorf("the exclusive press left its own collection hidden: %v", held.Hidden)12031247 }12041248 // It moves what a highlight moves, and nothing else: the same three
@@ -1212,7 +1256,7 @@ func TestOneZoneCanBeAskedForExclusively(t *testing.T) {
12121256 // Pressing it again on the zone that is already alone is the way out. It12131257 // is the isolate chip's own toggle, one row further in: a control that set12141258 // a filter is the control that lifts it.1215- highlight(t, url.Values{"feature": {"94"}, "only": {"1"}})1259+ highlight(t, url.Values{"feature": {"overworld/feature/94"}, "only": {"1"}})12161260 if held := session(t).Highlighted; len(held) != 0 {12171261 t.Errorf("pressing the exclusive control again did not clear the highlights: %v", held)12181262 }
@@ -1220,8 +1264,8 @@ func TestOneZoneCanBeAskedForExclusively(t *testing.T) {
12201264 // And a zone that is alone by another route -- highlighted one at a time12211265 // until one was left -- is exclusive too, because the state is derived12221266 // from the set rather than from which button reached it.1223- highlight(t, url.Values{"feature": {"93"}})1224- highlight(t, url.Values{"feature": {"93"}, "only": {"1"}})1267+ highlight(t, url.Values{"feature": {"overworld/feature/93"}})1268+ highlight(t, url.Values{"feature": {"overworld/feature/93"}, "only": {"1"}})12251269 if held := session(t).Highlighted; len(held) != 0 {12261270 t.Errorf("a lone highlight was not treated as an exclusive one: %v", held)12271271 }
@@ -1233,7 +1277,7 @@ func TestOneZoneCanBeAskedForExclusively(t *testing.T) {
12331277 func TestAZoneRowWearsItsExclusiveControl(t *testing.T) {12341278 handler, _ := newApp(t, zonedVolume())12351279 if got := post(t, handler, "/session/highlight",1236- url.Values{"volume": {"tunic"}, "feature": {"93"}, "only": {"1"}}); got.Code != http.StatusOK {1280+ url.Values{"volume": {"tunic"}, "feature": {"overworld/feature/93"}, "only": {"1"}}); got.Code != http.StatusOK {12371281 t.Fatalf("/session/highlight answered %d: %s", got.Code, got.Body)12381282 }12391283 page := get(t, handler, "/v/tunic/overworld", nil)
@@ -1245,7 +1289,7 @@ func TestAZoneRowWearsItsExclusiveControl(t *testing.T) {
12451289 // One control per zone, and the request it carries is the exclusive form12461290 // of the highlight concern -- not the isolate route the collection rows12471291 // use, which would put every other collection away.1248- for _, zone := range []string{"91", "92", "93", "94"} {1292+ for _, zone := range []string{"overworld/feature/91", "overworld/feature/92", "overworld/feature/93", "overworld/feature/94"} {12491293 markup := zoneOnlyButton(shell, zone)12501294 if markup == "" {12511295 t.Fatalf("the zone %s has no exclusive control:\n%s", zone, legendOf(t, shell))
@@ -1262,15 +1306,15 @@ func TestAZoneRowWearsItsExclusiveControl(t *testing.T) {
12621306 }12631307 // It is the same control the collection rows wear, so it reads and draws12641308 // as one: the carried `.only-button` rule is what reveals it.1265- if !strings.Contains(zoneOnlyButton(shell, "91"), `aria-label="Exclusively R1"`) {1266- t.Errorf("the control does not say what it does:\n%s", zoneOnlyButton(shell, "91"))1309+ if !strings.Contains(zoneOnlyButton(shell, "overworld/feature/91"), `aria-label="Exclusively R1"`) {1310+ t.Errorf("the control does not say what it does:\n%s", zoneOnlyButton(shell, "overworld/feature/91"))12671311 }12681312 // And the pressed state is the state, not the press: R3 is the whole of12691313 // what is highlighted, so its control is the one that reads pressed.1270- if !strings.Contains(zoneOnlyButton(shell, "93"), `aria-pressed="true"`) {1271- t.Errorf("the exclusive zone's control does not read pressed:\n%s", zoneOnlyButton(shell, "93"))1314+ if !strings.Contains(zoneOnlyButton(shell, "overworld/feature/93"), `aria-pressed="true"`) {1315+ t.Errorf("the exclusive zone's control does not read pressed:\n%s", zoneOnlyButton(shell, "overworld/feature/93"))12721316 }1273- for _, zone := range []string{"91", "92", "94"} {1317+ for _, zone := range []string{"overworld/feature/91", "overworld/feature/92", "overworld/feature/94"} {12741318 if !strings.Contains(zoneOnlyButton(shell, zone), `aria-pressed="false"`) {12751319 t.Errorf("the zone %s reads exclusive while %s is:\n%s", zone, "93", zoneOnlyButton(shell, zone))12761320 }
@@ -1278,8 +1322,8 @@ func TestAZoneRowWearsItsExclusiveControl(t *testing.T) {
12781322 // The control is a sibling of the row's own button and not a child of it,12791323 // which is the whole of how one click cannot be the other: an event12801324 // reaches its ancestors and never its siblings.1281- row := shell[strings.Index(shell, `data-zone="93"`):]1282- row = row[:strings.Index(row, `data-zone-only="93"`)]1325+ row := shell[strings.Index(shell, `data-zone="overworld/feature/93"`):]1326+ row = row[:strings.Index(row, `data-zone-only="overworld/feature/93"`)]12831327 if strings.Count(row, "</button>") != 1 {12841328 t.Errorf("the exclusive control is nested inside the row's own button:\n%s", row)12851329 }
@@ -1297,6 +1341,7 @@ func TestAZoneRowWearsItsExclusiveControl(t *testing.T) {
12971341 // and no colour of their own -- and it is the shape the payload below has.12981342 func TestALegendRowWearsTheCollectionsArtwork(t *testing.T) {12991343 held := volume("bend-or", "Bend, Oregon", tunicStamp)1344+ held.entries["icons/std--maki-monument.svg"] = []byte("<svg/>")13001345 held.entries["worlds/overworld.json"] = []byte(`{"lenses":[],"collections":[13011346 {"id":1496244488,"title":"Historic Resources","kind":"point","group":"Heritage",13021347 "icon":"historic-resources","iconAsset":"std--maki-monument.svg",
@@ -1312,11 +1357,12 @@ func TestALegendRowWearsTheCollectionsArtwork(t *testing.T) {
13121357 }13131358 shell := page.Body.String()13141359 base := "/data/v/bend-or/" + bundle.ShortStamp(tunicStamp)1360+ digest := sha256.Sum256([]byte("<svg/>"))13151361 13161362 // The collection that carries a glyph: the artwork, named the way the13171363 // seam names it, and no initials to draw over it.13181364 wearing := `<span class="category-icon has-source-icon" style="--pin-icon: ` +1319- `url('` + base + `/icons/std--maki-monument.svg')" title="historic-resources"></span>`1365+ `url('` + fmt.Sprintf("%s/assets/%x", base, digest) + `')" title="historic-resources"></span>`13201366 if !strings.Contains(shell, wearing) {13211367 t.Errorf("the Historic Resources row draws no artwork; want\n\t%s\nin\n%s", wearing, legendOf(t, shell))13221368 }
M internal/app/data.go +23 -29
@@ -8,16 +8,13 @@ import (
88 "strconv"99 "strings"1010 11- "github.com/FelineStateMachine/atlas/format/bundle"1211 "github.com/FelineStateMachine/atlas/format/vnext"1312 "github.com/FelineStateMachine/atlas/internal/app/hostenv"1413 "github.com/FelineStateMachine/atlas/internal/logging"1514 )1615 17-// The data plane. Its shape is not a choice this rewrite gets to make: the18-// seam reads it, and it stays byte-compatible with the implementation this19-// one replaces (issue #5 §4.2, docs/app.md §2.1). app_test.go holds this20-// file to that shape.16+// The native vNext data plane. app_test.go holds schema, typed tables,17+// content-addressed assets and the absence of the retired projections to it.2118 2219 // BasePath is the URL prefix volume content is served under. It appears in the2320 // composed catalog so nothing has to assemble a content URL from parts it had
@@ -131,7 +128,7 @@ func (a *App) handleContent(w http.ResponseWriter, r *http.Request) {
131128 http.NotFound(w, r)132129 return133130 }134- serveProjectedContent(w, data, "application/schema+json")131+ serveContent(w, data, "application/schema+json")135132 return136133 }137134 if strings.HasPrefix(rest, "data/") && strings.HasSuffix(rest, ".pack") {
@@ -140,30 +137,10 @@ func (a *App) handleContent(w http.ResponseWriter, r *http.Request) {
140137 http.NotFound(w, r)141138 return142139 }143- serveProjectedContent(w, data, contentTypes[".pack"])140+ serveContent(w, data, contentTypes[".pack"])144141 return145142 }146- if strings.HasPrefix(rest, bundle.WorldsPrefix) {147- data, kind, projected, err := a.projectedContent(held, rest)148- if err != nil || !projected {149- http.NotFound(w, r)150- return151- }152- serveProjectedContent(w, data, kind)153- return154- }155- if !strings.HasPrefix(rest, bundle.TilesPrefix) &&156- !strings.HasPrefix(rest, bundle.IconsPrefix) {157- http.NotFound(w, r)158- return159- }160- dot := strings.LastIndexByte(rest, '.')161- if dot < 0 {162- http.NotFound(w, r)163- return164- }165- kind, ok := contentTypes[rest[dot:]]166- if !ok {143+ if strings.HasPrefix(rest, "worlds/") || strings.HasPrefix(rest, "icons/") {167144 http.NotFound(w, r)168145 return169146 }
@@ -173,6 +150,23 @@ func (a *App) handleContent(w http.ResponseWriter, r *http.Request) {
173150 http.NotFound(w, r)174151 return175152 }153+ kind := ""154+ if dot := strings.LastIndexByte(rest, '.'); dot >= 0 {155+ kind = contentTypes[rest[dot:]]156+ }157+ if kind == "" {158+ if semantic, err := a.semanticVolume(held); err == nil {159+ for _, asset := range semantic.Assets {160+ if asset.Path == rest {161+ kind = asset.MediaType162+ break163+ }164+ }165+ }166+ }167+ if kind == "" {168+ kind = http.DetectContentType(data)169+ }176170 w.Header().Set("Content-Type", kind)177171 w.Header().Set("Content-Length", strconv.Itoa(len(data)))178172 w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
@@ -184,7 +178,7 @@ func (a *App) handleContent(w http.ResponseWriter, r *http.Request) {
184178 }185179 }186180 187-func serveProjectedContent(w http.ResponseWriter, data []byte, kind string) {181+func serveContent(w http.ResponseWriter, data []byte, kind string) {188182 w.Header().Set("Content-Type", kind)189183 w.Header().Set("Content-Length", strconv.Itoa(len(data)))190184 w.Header().Set("Cache-Control", "private, max-age=31536000, immutable")
M internal/app/events.go +2 -2
M internal/app/legend.go +8 -12
M internal/app/legend_test.go +4 -4
M internal/app/pages.go +2 -3
M internal/app/session.go +3 -4
M internal/app/view.go +19 -14
@@ -445,16 +445,13 @@ func namespacesOf(keys ...string) []string {
445445 return out446446 }447447 448-// detail builds the card for one feature, or the closed card when nothing is449-// selected. Prose, links and the attributes that only matter once a card is450-// open all come out of worlds/<slug>.text, which is why it is read here and451-// not when the world is stood up.452-func (a *App) detail(volume hostenv.Volume, model *worldModel, session Session, id string) DetailView {448+// detail builds the card directly from the selected semantic feature.449+func (a *App) detail(_ hostenv.Volume, model *worldModel, session Session, id string) DetailView {453450 if id == "" || !session.Detail.Open || model == nil {454451 return DetailView{}455452 }456453 out := DetailView{Open: true, ID: id}457- text := a.text(volume, model.Slug)454+ var feature *vnext.Feature458455 459456 switch pin, isPoint := model.PointByID[id]; {460457 case isPoint:
@@ -468,7 +465,7 @@ func (a *App) detail(volume hostenv.Volume, model *worldModel, session Session,
468465 // Which is what earns this card the empty cell row: a place a point469466 // stands is a place the cell systems can name.470467 out.Point = true471- out.Source = model.Origin468+ feature = pin.Feature472469 default:473470 shape, isShape := model.ShapeByID[id]474471 if !isShape {
@@ -480,14 +477,20 @@ func (a *App) detail(volume hostenv.Volume, model *worldModel, session Session,
480477 out.Category = shape.Subtitle481478 }482479 out.Color = colorFor(shape.ID)483- out.Source = model.Origin484- out.Rows = append(out.Rows, attributeRows(shape.Attrs)...)480+ feature = shape.Feature485481 }486482 487- if held, ok := text[id]; ok {488- out.Description = cleanDescription(held.Description)489- for _, link := range held.Links {490- linked := strconv.FormatInt(link, 10)483+ if feature != nil {484+ out.Description = cleanDescription(feature.Description)485+ out.Rows = append(out.Rows, attributeRows(propertiesToAttrs(feature.Properties))...)486+ if len(feature.Provenance) > 0 {487+ out.Source = feature.Provenance[0].Source488+ }489+ for _, relationship := range feature.Relationships {490+ if relationship.Predicate != "references" {491+ continue492+ }493+ linked := relationship.Target491494 title := linked492495 if pin, ok := model.PointByID[linked]; ok {493496 title = pin.Title
@@ -496,7 +499,9 @@ func (a *App) detail(volume hostenv.Volume, model *worldModel, session Session,
496499 }497500 out.Links = append(out.Links, DetailLink{ID: linked, Title: title})498501 }499- out.Rows = append(out.Rows, attributeRows(held.Attrs)...)502+ }503+ if out.Source == "" {504+ out.Source = model.Origin500505 }501506 if out.Description == "" {502507 out.Description = "No description is included in the archive."
M internal/app/vnext_projection_test.go +22 -94
@@ -1,19 +1,16 @@
11 package app22 33 import (4- "encoding/json"5- "path/filepath"6- "runtime"4+ "strings"75 "testing"86 9- "github.com/FelineStateMachine/atlas/format/bundle"107 "github.com/FelineStateMachine/atlas/format/vnext"118 )129 13-func TestVNextEarthProjectsToTheEstablishedRendererContract(t *testing.T) {10+func TestVNextEarthBuildsTheRendererDirectly(t *testing.T) {1411 t.Parallel()1512 16- native, err := vnext.OpenFile(includedEarthPath(t), vnext.StandardSchema())13+ native, err := vnext.OpenFile("../../included/earth-20260803-28305c3d1811.atlas", vnext.StandardSchema())1714 if err != nil {1815 t.Fatalf("open native Earth: %v", err)1916 }
@@ -23,108 +20,39 @@ func TestVNextEarthProjectsToTheEstablishedRendererContract(t *testing.T) {
2320 t.Fatalf("restore native Earth: %v", err)2421 }2522 26- payload, locations, texts, err := presentVNextWorld(semantic.Worlds[0])27- if err != nil {28- t.Fatalf("present vNext Earth: %v", err)29- }30- if len(payload.Collections) != 7 || len(payload.Lenses) != 1 {31- t.Fatalf("projection has %d collections and %d lenses", len(payload.Collections), len(payload.Lenses))32- }33- if len(locations) != 202 {34- t.Fatalf("projection has %d point locations", len(locations))35- }36- if len(texts) != 379 {37- t.Fatalf("projection has %d text records", len(texts))38- }39- countries := projectedCollection(payload, "Countries")40- if countries == nil || len(countries.Features) != 177 {41- t.Fatalf("Countries projection = %#v", countries)42- }43- fiji := projectedFeature(*countries, "Fiji")44- if fiji == nil || len(fiji.Geometry) != 1 || fiji.Geometry[0].Type != "MultiPolygon" {45- t.Fatalf("Fiji projection = %#v", fiji)46- }47-48- model, err := buildVNextWorld(semantic.Worlds[0])23+ model, err := buildVNextWorld(semantic.Worlds[0], semantic.Assets)4924 if err != nil {5025 t.Fatalf("build vNext world: %v", err)5126 }52- if len(model.Points) != 202 || len(model.Shapes) != 177 || len(model.Members) != 7 {53- t.Fatalf("runtime model has %d points, %d shapes, %d collections", len(model.Points), len(model.Shapes), len(model.Members))27+ if len(model.Points) != 202 || len(model.Shapes) != 177 || len(model.Members) != 7 || len(model.Lenses) != 1 {28+ t.Fatalf("runtime model has %d points, %d shapes, %d collections and %d lenses", len(model.Points), len(model.Shapes), len(model.Members), len(model.Lenses))5429 }55-}56-57-func TestVNextEarthServesTheThreeRendererPayloads(t *testing.T) {58- t.Parallel()59-60- native, err := vnext.OpenFile(includedEarthPath(t), vnext.StandardSchema())61- if err != nil {62- t.Fatalf("open native Earth: %v", err)30+ countries := nativeCollection(model, "Countries")31+ if countries == nil || len(countries.Shapes) != 177 {32+ t.Fatalf("Countries collection = %#v", countries)6333 }64- defer native.Close()65- semantic, err := native.Volume()66- if err != nil {67- t.Fatalf("restore native Earth: %v", err)68- }69- world := semantic.Worlds[0]70-71- payloadBytes, kind, held, err := projectedVNextEntry(world, bundle.WorldEntryName(world.ID, bundle.WorldSuffix))72- if err != nil || !held || kind != "application/json" {73- t.Fatalf("project world entry = %q, %t, %v", kind, held, err)74- }75- var payload worldPayload76- if err := json.Unmarshal(payloadBytes, &payload); err != nil {77- t.Fatalf("decode projected world: %v", err)78- }79- if len(payload.Collections) != 7 || len(payload.Lenses[0].Formats) != 7 {80- t.Fatalf("projected payload = %#v", payload)81- }82-83- locationBytes, kind, held, err := projectedVNextEntry(world, bundle.WorldEntryName(world.ID, bundle.PackedSuffix))84- if err != nil || !held || kind != "application/octet-stream" {85- t.Fatalf("project locations entry = %q, %t, %v", kind, held, err)86- }87- locations, err := bundle.UnpackLocations(locationBytes)88- if err != nil || len(locations) != 202 {89- t.Fatalf("projected locations = %d, %v", len(locations), err)90- }91-92- textBytes, kind, held, err := projectedVNextEntry(world, bundle.WorldEntryName(world.ID, bundle.TextSuffix))93- if err != nil || !held || kind != "application/json" {94- t.Fatalf("project text entry = %q, %t, %v", kind, held, err)95- }96- var texts map[string]featureText97- if err := json.Unmarshal(textBytes, &texts); err != nil || len(texts) != 379 {98- t.Fatalf("projected text = %d, %v", len(texts), err)99- }100-}101-102-func includedEarthPath(t *testing.T) string {103- t.Helper()104- _, file, _, ok := runtime.Caller(0)105- if !ok {106- t.Fatal("locate projection test")34+ fiji := nativeShape(countries, "Fiji")35+ if fiji == nil || len(fiji.Polygons) < 2 || fiji.Feature == nil {36+ t.Fatalf("Fiji native shape = %#v", fiji)10737 }108- paths, err := filepath.Glob(filepath.Join(filepath.Dir(file), "..", "..", "included", "earth-*.atlas"))109- if err != nil || len(paths) != 1 {110- t.Fatalf("locate native Earth: %v, %v", paths, err)38+ if !strings.Contains(fiji.ID, "/feature/") || countries.ID == "" {39+ t.Fatalf("native identities were not preserved: feature=%q layer=%q", fiji.ID, countries.ID)11140 }112- return paths[0]11341 }11442 115-func projectedCollection(payload worldPayload, title string) *payloadCollection {116- for index := range payload.Collections {117- if payload.Collections[index].Title == title {118- return &payload.Collections[index]43+func nativeCollection(model *worldModel, title string) *collectionModel {44+ for _, collection := range model.Members {45+ if collection.Title == title {46+ return collection11947 }12048 }12149 return nil12250 }12351 124-func projectedFeature(collection payloadCollection, title string) *payloadFeature {125- for index := range collection.Features {126- if collection.Features[index].Title == title {127- return &collection.Features[index]52+func nativeShape(collection *collectionModel, title string) *shapeModel {53+ for _, shape := range collection.Shapes {54+ if shape.Title == title {55+ return shape12856 }12957 }13058 return nil
M internal/app/vnext_runtime.go +143 -353
@@ -2,16 +2,12 @@ package app
22 33 import (44 "encoding/base64"5- "encoding/json"65 "fmt"7- "hash/fnv"86 "math"97 "sort"108 "strconv"11- "strings"129 "sync"1310 14- "github.com/FelineStateMachine/atlas/format/bundle"1511 "github.com/FelineStateMachine/atlas/format/semconv"1612 "github.com/FelineStateMachine/atlas/format/vnext"1713 "github.com/FelineStateMachine/atlas/internal/app/cells"
@@ -64,50 +60,18 @@ func semanticWorld(volume vnext.Volume, slug string) (vnext.World, bool) {
6460 return vnext.World{}, false6561 }6662 67-func (a *App) projectedContent(source hostenv.Volume, name string) ([]byte, string, bool, error) {68- volume, err := a.semanticVolume(source)69- if err != nil {70- return nil, "", true, err71- }72- for _, world := range volume.Worlds {73- data, kind, held, err := projectedVNextEntry(world, name)74- if held {75- return data, kind, true, err76- }77- }78- return nil, "", false, nil79-}80-81-type compatibilityIDs struct {82- byName map[string]int6483- byID map[int64]string84-}85-86-func newCompatibilityIDs() *compatibilityIDs {87- return &compatibilityIDs{byName: make(map[string]int64), byID: make(map[int64]string)}88-}89-90-func (ids *compatibilityIDs) resolve(name string) (int64, error) {91- if held, ok := ids.byName[name]; ok {92- return held, nil93- }94- candidate := numericTail(name)95- if candidate <= 0 {96- hash := fnv.New64a()97- _, _ = hash.Write([]byte(name))98- candidate = int64(hash.Sum64() & math.MaxInt64)99- if candidate == 0 {100- candidate = 1101- }102- }103- if owner, held := ids.byID[candidate]; held && owner != name {104- return 0, fmt.Errorf("compatibility ID %d is shared by %s and %s", candidate, owner, name)63+// buildVNextWorld is the native presentation boundary. Stable schema IDs,64+// typed properties, semantic relationships and coordinate-space geometry flow65+// straight into the app model; no v3 JSON, ATLASLOC table or GeoJSON-shaped66+// compatibility object is created along the way.67+func buildVNextWorld(world vnext.World, assets []vnext.Asset) (*worldModel, error) {68+ space := world.CoordinateSpace69+ grid := tileGrid{SourceZoom: int(space.SourceZoom), FirstTile: int(space.FirstTile), TileSize: int(space.TileSize), Size: int(space.Size)}70+ model := &worldModel{71+ Slug: world.ID, Lenses: nativeRasters(world.RasterPyramids), Attrs: propertiesToAttrs(world.Claims),72+ Origin: worldOrigin(world), Grid: grid,73+ ByID: map[string]*collectionModel{}, PointByID: map[string]*pointModel{}, ShapeByID: map[string]*shapeModel{},10574 }106- ids.byName[name], ids.byID[candidate] = candidate, name107- return candidate, nil108-}109-110-func presentVNextWorld(world vnext.World) (worldPayload, []bundle.Location, map[string]featureText, error) {11175 sets := make(map[string]vnext.FeatureSet, len(world.FeatureSets))11276 for _, set := range world.FeatureSets {11377 sets[set.ID] = set
@@ -116,192 +80,125 @@ func presentVNextWorld(world vnext.World) (worldPayload, []bundle.Location, map[
11680 for _, style := range world.Presentation.Styles {11781 styles[style.ID] = style11882 }83+ assetPaths := make(map[string]string, len(assets))84+ for _, asset := range assets {85+ assetPaths[asset.ID] = asset.Path86+ }11987 layers := append([]vnext.Layer(nil), world.Presentation.Layers...)12088 sort.SliceStable(layers, func(i, j int) bool { return layers[i].Order < layers[j].Order })121- projection := worldProjection{122- world: world, sets: sets, styles: styles,123- collectionIDs: newCompatibilityIDs(), featureIDs: newCompatibilityIDs(),124- text: make(map[string]featureText),125- }126- return projection.present(layers)127-}128-129-func projectedVNextEntry(world vnext.World, name string) ([]byte, string, bool, error) {130- payload, locations, text, err := presentVNextWorld(world)131- if err != nil {132- return nil, "", true, err133- }134- switch name {135- case bundle.WorldEntryName(world.ID, bundle.WorldSuffix):136- data, err := json.Marshal(payload)137- return data, "application/json", true, err138- case bundle.WorldEntryName(world.ID, bundle.PackedSuffix):139- return bundle.PackLocations(locations), "application/octet-stream", true, nil140- case bundle.WorldEntryName(world.ID, bundle.TextSuffix):141- data, err := json.Marshal(text)142- return data, "application/json", true, err143- default:144- return nil, "", false, nil145- }146-}147-148-type worldProjection struct {149- world vnext.World150- sets map[string]vnext.FeatureSet151- styles map[string]vnext.Style152- collectionIDs *compatibilityIDs153- featureIDs *compatibilityIDs154- text map[string]featureText155-}156-157-func (projection worldProjection) present(layers []vnext.Layer) (worldPayload, []bundle.Location, map[string]featureText, error) {158- payload := worldPayload{159- Grid: &payloadGrid{160- SourceZoom: intPointer(int(projection.world.CoordinateSpace.SourceZoom)),161- FirstTile: intPointer(int(projection.world.CoordinateSpace.FirstTile)),162- TileSize: intPointer(int(projection.world.CoordinateSpace.TileSize)),163- Size: intPointer(int(projection.world.CoordinateSpace.Size)),164- },165- Attrs: claimsToAttrs(projection.world.Claims),166- }167- payload.Lenses = projectRasters(projection.world.RasterPyramids)168- if source := worldOrigin(projection.world); source != "" {169- payload.Merged = []payloadMerge{{Source: source, Origin: true}}170- }171- var locations []bundle.Location172- for owner, layer := range layers {173- collection, points, err := projection.collection(layer, owner)174- if err != nil {175- return worldPayload{}, nil, nil, err89+ for index, layer := range layers {90+ set, ok := sets[layer.FeatureSet]91+ if !ok {92+ return nil, fmt.Errorf("layer %s refers to missing feature set %s", layer.ID, layer.FeatureSet)17693 }177- payload.Collections = append(payload.Collections, collection)178- locations = append(locations, points...)179- }180- return payload, locations, projection.text, nil181-}182-183-func (projection worldProjection) collection(layer vnext.Layer, owner int) (payloadCollection, []bundle.Location, error) {184- set, held := projection.sets[layer.FeatureSet]185- if !held {186- return payloadCollection{}, nil, fmt.Errorf("layer %s refers to missing feature set %s", layer.ID, layer.FeatureSet)187- }188- style, held := projection.styles[layer.Style]189- if !held {190- return payloadCollection{}, nil, fmt.Errorf("layer %s refers to missing style %s", layer.ID, layer.Style)191- }192- id, err := projection.collectionIDs.resolve(set.ID)193- if err != nil {194- return payloadCollection{}, nil, err195- }196- kind := strings.TrimPrefix(set.SemanticType, "geometry.")197- collection := payloadCollection{198- ID: id, Title: set.Title, Kind: kind, Group: layer.Group, Icon: style.Icon,199- IconAsset: style.IconAsset, IconPicture: style.IconPicture,200- Color: projectedColor(kind, style), Visible: boolPointer(layer.Visible),201- Attrs: claimsToAttrs(set.Claims),202- }203- collection.Attrs[semconv.KeyGeometryKind] = kind204- putAttr(collection.Attrs, semconv.KeyLabelPolicy, layer.LabelPolicy)205- putAttr(collection.Attrs, semconv.KeyRenderAs, style.RenderAs)206- return projection.features(set, collection, owner)207-}208-209-func (projection worldProjection) features(set vnext.FeatureSet, collection payloadCollection, owner int) (payloadCollection, []bundle.Location, error) {210- if owner > math.MaxUint16 {211- return payloadCollection{}, nil, fmt.Errorf("presentation has too many collections")212- }213- var locations []bundle.Location214- for _, feature := range set.Features {215- id, err := projection.featureIDs.resolve(feature.ID)216- if err != nil {217- return payloadCollection{}, nil, err94+ style, ok := styles[layer.Style]95+ if !ok {96+ return nil, fmt.Errorf("layer %s refers to missing style %s", layer.ID, layer.Style)21897 }219- text, err := projection.featureText(feature)220- if err != nil {221- return payloadCollection{}, nil, err98+ kind := semanticGeometryKind(set.SemanticType)99+ attrs := propertiesToAttrs(set.Claims)100+ collection := &collectionModel{101+ ID: layer.ID, Title: legendLabel(world.Presentation.Legend, layer.ID, set.Title), Kind: kind,102+ Group: layer.Group, Icon: style.Icon, IconAsset: assetPaths[style.IconAsset],103+ Color: nativeColor(kind, style), Attrs: attrs, Index: index,104+ Curated: layer.LabelPolicy, RenderAs: style.RenderAs, Hidden: !layer.Visible,222105 }223- projection.text[strconv.FormatInt(id, 10)] = text224- if feature.Geometry.Kind == vnext.GeometryPoint {225- location, err := projection.point(feature, id, owner)226- if err != nil {227- return payloadCollection{}, nil, err228- }229- locations = append(locations, location)230- continue106+ if collection.Curated == "" {107+ collection.Curated = semconv.LabelPolicy(kind, attrs)231108 }232- shape, err := projection.shape(feature, id, text)233- if err != nil {234- return payloadCollection{}, nil, err109+ if collection.RenderAs == "" {110+ collection.RenderAs = semconv.RenderAs(attrs, "")111+ }112+ model.Members = append(model.Members, collection)113+ model.ByID[collection.ID] = collection114+ for featureIndex := range set.Features {115+ feature := &set.Features[featureIndex]116+ if err := addNativeFeature(model, collection, feature, grid); err != nil {117+ return nil, err118+ }235119 }236- collection.Features = append(collection.Features, shape)237- }238- return collection, locations, nil239-}240-241-func (projection worldProjection) point(feature vnext.Feature, id int64, owner int) (bundle.Location, error) {242- if len(feature.Geometry.Parts) != 1 || len(feature.Geometry.Parts[0].Rings) != 1 || len(feature.Geometry.Parts[0].Rings[0]) != 1 {243- return bundle.Location{}, fmt.Errorf("point %s has an invalid geometry", feature.ID)244- }245- position := feature.Geometry.Parts[0].Rings[0][0]246- member, err := projection.relationship(feature.Relationships, "within")247- if err != nil {248- return bundle.Location{}, err249- }250- return bundle.Location{251- ID: id, Title: feature.Title, Lat: position[1], Lng: position[0],252- Member: member, Shard: feature.Shard, Owner: uint16(owner),253- }, nil254-}255-256-func (projection worldProjection) shape(feature vnext.Feature, id int64, text featureText) (payloadFeature, error) {257- geometry, err := projectGeometry(feature.Geometry)258- if err != nil {259- return payloadFeature{}, err260- }261- shape := payloadFeature{262- ID: id, Title: feature.Title, Subtitle: feature.Subtitle, HasText: hasFeatureText(text),263- Shard: feature.Shard, Attrs: propertiesToAttrs(feature.Properties), Geometry: geometry,264- }265- if feature.Center != nil {266- shape.Center = &bundle.Coordinate{Lat: feature.Center[1], Lng: feature.Center[0]}267120 }268- if parent, err := projection.relationship(feature.Relationships, "within"); err != nil {269- return payloadFeature{}, err270- } else if parent != 0 {271- shape.Parent = &parent121+ for _, shape := range model.Shapes {122+ shape.Depth = shapeDepth(model, shape, 0)272123 }273- return shape, nil124+ return model, nil274125 }275126 276-func (projection worldProjection) featureText(feature vnext.Feature) (featureText, error) {277- text := featureText{Description: feature.Description, Attrs: propertiesToAttrs(feature.Properties)}278- for _, relationship := range feature.Relationships {279- if relationship.Predicate != "references" {280- continue127+func addNativeFeature(model *worldModel, collection *collectionModel, feature *vnext.Feature, grid tileGrid) error {128+ if feature.Geometry.Kind == vnext.GeometryPoint {129+ if len(feature.Geometry.Parts) != 1 || len(feature.Geometry.Parts[0].Rings) != 1 || len(feature.Geometry.Parts[0].Rings[0]) != 1 {130+ return fmt.Errorf("point %s has an invalid geometry", feature.ID)131+ }132+ position := feature.Geometry.Parts[0].Rings[0][0]133+ lat, lng := grid.unproject(position[0], position[1])134+ pin := &pointModel{135+ ID: feature.ID, Title: feature.Title, Lat: lat, Lng: lng, X: position[0], Y: -position[1],136+ Shard: feature.Shard, Feature: feature, Collection: collection,281137 }282- id, err := projection.featureIDs.resolve(relationship.Target)283- if err != nil {284- return featureText{}, err138+ model.Points = append(model.Points, pin)139+ model.PointByID[pin.ID] = pin140+ collection.Count++141+ return nil142+ }143+ shape, err := buildNativeShape(feature, collection)144+ if err != nil {145+ return err146+ }147+ collection.Shapes = append(collection.Shapes, shape)148+ collection.Count++149+ model.Shapes = append(model.Shapes, shape)150+ model.ShapeByID[shape.ID] = shape151+ return nil152+}153+154+func buildNativeShape(feature *vnext.Feature, collection *collectionModel) (*shapeModel, error) {155+ shape := &shapeModel{156+ ID: feature.ID, Title: feature.Title, Subtitle: feature.Subtitle, Shard: feature.Shard,157+ HasText: hasNativeText(feature), Attrs: propertiesToAttrs(feature.Properties), Feature: feature, Collection: collection,158+ MinX: math.Inf(1), MinY: math.Inf(1), MaxX: math.Inf(-1), MaxY: math.Inf(-1),159+ }160+ shape.Parent = relationshipTarget(feature.Relationships, "within")161+ for _, part := range feature.Geometry.Parts {162+ switch feature.Geometry.Kind {163+ case vnext.GeometryPolygon:164+ polygon := make([][]point, 0, len(part.Rings))165+ for _, ring := range part.Rings {166+ polygon = append(polygon, shape.nativePoints(ring))167+ }168+ if len(polygon) > 0 {169+ shape.Polygons = append(shape.Polygons, polygon)170+ }171+ case vnext.GeometryLineString:172+ if len(part.Rings) != 1 {173+ return nil, fmt.Errorf("line %s has an invalid part", feature.ID)174+ }175+ if line := shape.nativePoints(part.Rings[0]); len(line) > 1 {176+ shape.Lines = append(shape.Lines, line)177+ }178+ default:179+ return nil, fmt.Errorf("feature %s has unsupported geometry kind %d", feature.ID, feature.Geometry.Kind)285180 }286- text.Links = append(text.Links, id)287181 }288- return text, nil182+ shape.Drawn = len(shape.Polygons) > 0 || len(shape.Lines) > 0183+ return shape, nil289184 }290185 291-func (projection worldProjection) relationship(relationships []vnext.Relationship, predicate string) (int64, error) {292- for _, relationship := range relationships {293- if relationship.Predicate == predicate {294- return projection.featureIDs.resolve(relationship.Target)295- }186+func (shape *shapeModel) nativePoints(positions []vnext.Position) []point {187+ out := make([]point, 0, len(positions))188+ for _, position := range positions {189+ x, y := position[0], -position[1]190+ out = append(out, point{X: x, Y: y})191+ shape.MinX, shape.MaxX = math.Min(shape.MinX, x), math.Max(shape.MaxX, x)192+ shape.MinY, shape.MaxY = math.Min(shape.MinY, y), math.Max(shape.MaxY, y)296193 }297- return 0, nil194+ return out298195 }299196 300-func projectRasters(rasters []vnext.RasterPyramid) []payloadLens {197+func nativeRasters(rasters []vnext.RasterPyramid) []payloadLens {301198 out := make([]payloadLens, 0, len(rasters))302199 for _, raster := range rasters {303200 lens := payloadLens{304- Name: raster.Name, Tiles: rasterTiles(raster.Template), MinZoom: int(raster.MinZoom), MaxZoom: int(raster.MaxZoom),201+ Name: raster.Name, Tiles: raster.Template, MinZoom: int(raster.MinZoom), MaxZoom: int(raster.MaxZoom),305202 FullZoom: int(raster.FullZoom), SourceZoom: int(raster.SourceZoom), Formats: append([]string(nil), raster.Formats...),306203 Bounds: cellRect(raster.Bounds), Surface: cellRect(raster.Surface), Interpolate: raster.Interpolate,307204 Background: raster.Background, Shard: int(raster.Shard),
@@ -320,124 +217,17 @@ func projectRasters(rasters []vnext.RasterPyramid) []payloadLens {
320217 return out321218 }322219 323-func projectGeometry(geometry vnext.Geometry) ([]payloadGeometry, error) {324- if geometry.Kind == vnext.GeometryPolygon {325- polygons := make([][][]vnext.Position, 0, len(geometry.Parts))326- for _, part := range geometry.Parts {327- polygons = append(polygons, part.Rings)328- }329- if len(polygons) == 1 {330- return marshalGeometry("Polygon", polygons[0])331- }332- return marshalGeometry("MultiPolygon", polygons)333- }334- if geometry.Kind == vnext.GeometryLineString {335- lines := make([][]vnext.Position, 0, len(geometry.Parts))336- for _, part := range geometry.Parts {337- if len(part.Rings) != 1 {338- return nil, fmt.Errorf("line geometry has an invalid part")339- }340- lines = append(lines, part.Rings[0])341- }342- if len(lines) == 1 {343- return marshalGeometry("LineString", lines[0])344- }345- return marshalGeometry("MultiLineString", lines)346- }347- return nil, fmt.Errorf("geometry kind %d cannot be an inline shape", geometry.Kind)348-}349-350-func marshalGeometry(kind string, coordinates any) ([]payloadGeometry, error) {351- data, err := json.Marshal(coordinates)352- if err != nil {353- return nil, fmt.Errorf("marshal %s geometry: %w", kind, err)354- }355- return []payloadGeometry{{Type: kind, Coordinates: data}}, nil356-}357-358-func buildVNextWorld(world vnext.World) (*worldModel, error) {359- payload, locations, _, err := presentVNextWorld(world)360- if err != nil {361- return nil, err362- }363- space := world.CoordinateSpace364- grid := tileGrid{SourceZoom: int(space.SourceZoom), FirstTile: int(space.FirstTile), TileSize: int(space.TileSize), Size: int(space.Size)}365- return buildProjectedWorld(world.ID, payload, locations, grid), nil366-}367-368-func buildProjectedWorld(slug string, decoded worldPayload, locations []bundle.Location, grid tileGrid) *worldModel {369- model := &worldModel{370- Slug: slug, Lenses: decoded.Lenses, Attrs: decoded.Attrs, Grid: grid,371- ByID: map[string]*collectionModel{}, PointByID: map[string]*pointModel{}, ShapeByID: map[string]*shapeModel{},372- }373- for _, account := range decoded.Merged {374- if account.Origin {375- model.Origin = account.Source376- break377- }378- }379- buildProjectedCollections(model, decoded.Collections, grid)380- buildProjectedPoints(model, locations, grid)381- for _, shape := range model.Shapes {382- shape.Depth = shapeDepth(model, shape, 0)383- }384- return model385-}386-387-func buildProjectedCollections(model *worldModel, collections []payloadCollection, grid tileGrid) {388- for at, held := range collections {389- kind := held.Kind390- if kind == "" {391- kind = semconv.GeometryPoint392- }393- collection := &collectionModel{394- ID: strconv.FormatInt(held.ID, 10), Title: held.Title, Kind: kind, Group: held.Group,395- Icon: held.Icon, IconAsset: held.IconAsset, Color: held.Color, IconColor: held.IconColor,396- Attrs: held.Attrs, Curated: semconv.LabelPolicy(kind, held.Attrs), RenderAs: semconv.RenderAs(held.Attrs, ""),397- Hidden: held.Visible != nil && !*held.Visible, Index: at, Count: len(held.Features),398- }399- model.Members = append(model.Members, collection)400- model.ByID[collection.ID] = collection401- for _, feature := range held.Features {402- shape := buildShape(feature, collection, grid)403- collection.Shapes = append(collection.Shapes, shape)404- model.Shapes = append(model.Shapes, shape)405- model.ShapeByID[shape.ID] = shape406- }407- }408-}409-410-func buildProjectedPoints(model *worldModel, locations []bundle.Location, grid tileGrid) {411- for _, location := range locations {412- owner := int(location.Owner)413- if owner >= len(model.Members) {414- continue415- }416- collection := model.Members[owner]417- x, y := grid.project(location.Lat, location.Lng)418- pin := &pointModel{419- ID: strconv.FormatInt(location.ID, 10), Title: location.Title, Lat: location.Lat, Lng: location.Lng,420- X: x, Y: y, Shard: location.Shard, Collection: collection,421- }422- model.Points = append(model.Points, pin)423- model.PointByID[pin.ID] = pin424- collection.Count++425- }426-}427-428-func claimsToAttrs(claims []vnext.Property) map[string]string { return propertiesToAttrs(claims) }429-430220 func propertiesToAttrs(properties []vnext.Property) map[string]string {431221 out := make(map[string]string, len(properties))432222 for _, property := range properties {433223 if property.Field.Name != "" {434- out[property.Field.Name] = projectedValue(property.Value)224+ out[property.Field.Name] = nativeValue(property.Value)435225 }436226 }437227 return out438228 }439229 440-func projectedValue(value vnext.Value) string {230+func nativeValue(value vnext.Value) string {441231 switch value.Kind {442232 case vnext.KindBool:443233 return strconv.FormatBool(value.Bool)
@@ -456,6 +246,29 @@ func projectedValue(value vnext.Value) string {
456246 }457247 }458248 249+func semanticGeometryKind(value string) string {250+ if len(value) > len("geometry.") && value[:len("geometry.")] == "geometry." {251+ return value[len("geometry."):]252+ }253+ if value == "" {254+ return semconv.GeometryPoint255+ }256+ return value257+}258+259+func relationshipTarget(relationships []vnext.Relationship, predicate string) string {260+ for _, relationship := range relationships {261+ if relationship.Predicate == predicate {262+ return relationship.Target263+ }264+ }265+ return ""266+}267+268+func hasNativeText(feature *vnext.Feature) bool {269+ return feature.Description != "" || len(feature.Properties) > 0 || len(feature.Relationships) > 0270+}271+459272 func worldOrigin(world vnext.World) string {460273 for _, set := range world.FeatureSets {461274 for _, feature := range set.Features {
@@ -467,19 +280,20 @@ func worldOrigin(world vnext.World) string {
467280 return ""468281 }469282 470-func projectedColor(kind string, style vnext.Style) string {471- if kind == semconv.GeometryPath && style.Stroke != "" {472- return style.Stroke283+func legendLabel(entries []vnext.LegendEntry, layer, fallback string) string {284+ for _, entry := range entries {285+ if entry.Layer == layer && entry.Label != "" {286+ return entry.Label287+ }473288 }474- return style.Fill289+ return fallback475290 }476291 477-func rasterTiles(template string) string {478- held := strings.TrimPrefix(template, bundle.TilesPrefix)479- if at := strings.IndexByte(held, '/'); at >= 0 {480- return held[:at]292+func nativeColor(kind string, style vnext.Style) string {293+ if kind == semconv.GeometryPath && style.Stroke != "" {294+ return style.Stroke481295 }482- return held296+ return style.Fill483297 }484298 485299 func cellRect(rect *vnext.RasterRect) *cells.Rect {
@@ -488,27 +302,3 @@ func cellRect(rect *vnext.RasterRect) *cells.Rect {
488302 }489303 return &cells.Rect{X: rect.X, Y: rect.Y, Width: rect.Width, Height: rect.Height}490304 }491-492-func numericTail(value string) int64 {493- if at := strings.LastIndexByte(value, '/'); at >= 0 {494- value = value[at+1:]495- }496- parsed, err := strconv.ParseInt(value, 10, 64)497- if err != nil {498- return 0499- }500- return parsed501-}502-503-func hasFeatureText(text featureText) bool {504- return text.Description != "" || len(text.Links) > 0 || len(text.Attrs) > 0505-}506-507-func putAttr(attrs map[string]string, key, value string) {508- if value != "" {509- attrs[key] = value510- }511-}512-513-func intPointer(value int) *int { return &value }514-func boolPointer(value bool) *bool { return &value }
M internal/app/world.go +13 -234
@@ -1,58 +1,17 @@
11 package app22 33 import (4- "encoding/json"54 "math"65 "sort"7- "strconv"86 "sync"97 10- "github.com/FelineStateMachine/atlas/format/bundle"118 "github.com/FelineStateMachine/atlas/format/semconv"129 "github.com/FelineStateMachine/atlas/format/vnext"1310 "github.com/FelineStateMachine/atlas/internal/app/cells"1411 "github.com/FelineStateMachine/atlas/internal/app/hostenv"1512 )1613 17-// The world as the display logic reads it.18-//19-// A world payload is JSON on the data plane and the seam reads it there20-// directly; the application reads the same bytes for a different reason. Every21-// display decision the reference implementation made in the browser -- which22-// legend sections exist, what each row counts, which names speak, which23-// features survive a filter -- is made here instead, once, in Go, before a24-// template is handed anything (issue #5 §4.5).25-//26-// The decoding below is deliberately lenient in the reader's direction: a27-// field this build has never heard of is ignored, and a collection missing28-// half its optional fields still renders. Conventions are read only through29-// format/semconv; nothing here compares an "atlas." string literal of its own.30-//31-// The payload's Go shape lives here rather than in format/bundle because the32-// format package serves these bytes without opening them: nothing in it needs33-// a struct for the payload, and inventing one there would export a shape the34-// format lane has not committed to. When the format lane wants one, this35-// moves and the app imports it.36-37-// worldPayload is worlds/<slug>.json.38-type worldPayload struct {39- Attrs map[string]string `json:"attrs"`40- Grid *payloadGrid `json:"grid"`41- Lenses []payloadLens `json:"lenses"`42- Collections []payloadCollection `json:"collections"`43- Merged []payloadMerge `json:"merged"`44-}45-46-// payloadGrid is a world's override of the volume's tile window. Absent47-// fields keep the volume's.48-type payloadGrid struct {49- SourceZoom *int `json:"sourceZoom"`50- FirstTile *int `json:"firstTile"`51- TileSize *int `json:"tileSize"`52- Size *int `json:"size"`53-}54-55-// payloadLens is one raster pyramid picturing this world.14+// payloadLens is the presentation-ready view of one native raster pyramid.5615 type payloadLens struct {5716 Name string `json:"name"`5817 Tiles string `json:"tiles"`
@@ -81,53 +40,6 @@ type payloadCoverage struct {
8140 Bits string `json:"bits"`8241 }8342 84-// payloadCollection is one ordered group of features.85-type payloadCollection struct {86- ID int64 `json:"id"`87- Title string `json:"title"`88- Kind string `json:"kind"`89- Group string `json:"group"`90- Icon string `json:"icon"`91- IconAsset string `json:"iconAsset"`92- IconPicture bool `json:"iconPicture,omitempty"`93- // Color is the collection's own accent and IconColor the older spelling94- // of the same fact. Both are read because the seam reads both, and a95- // legend that fell back to the palette where the seam honoured a96- // declared colour would draw a different world than the map.97- Color string `json:"color"`98- IconColor string `json:"iconColor"`99- Visible *bool `json:"visible"`100- Attrs map[string]string `json:"attrs"`101- Features []payloadFeature `json:"features"`102-}103-104-// payloadFeature is one path or area. Points are not here: they live packed105-// in the .bin, and owner indexes this array.106-type payloadFeature struct {107- ID int64 `json:"id"`108- Title string `json:"title"`109- Subtitle string `json:"subtitle"`110- Parent *int64 `json:"parent"`111- Center *bundle.Coordinate `json:"center"`112- HasText bool `json:"hasText"`113- Shard int64 `json:"shard"`114- Attrs map[string]string `json:"attrs"`115- Geometry []payloadGeometry `json:"geometry"`116-}117-118-// payloadGeometry is one GeoJSON-shaped ring set in the volume's own degrees.119-type payloadGeometry struct {120- Type string `json:"type"`121- Coordinates json.RawMessage `json:"coordinates"`122-}123-124-// payloadMerge is one source's account of what it contributed.125-type payloadMerge struct {126- Slug string `json:"slug"`127- Source string `json:"source"`128- Origin bool `json:"origin"`129-}130-13143 // ---------------------------------------------------------------------------13244 // The model13345 // ---------------------------------------------------------------------------
@@ -198,6 +110,7 @@ type pointModel struct {
198110 Lat, Lng float64199111 X, Y float64200112 Shard int64113+ Feature *vnext.Feature201114 Collection *collectionModel202115 }203116
@@ -211,6 +124,7 @@ type shapeModel struct {
211124 Depth int212125 HasText bool213126 Attrs map[string]string127+ Feature *vnext.Feature214128 Collection *collectionModel215129 // Shard is the layer of a split world this ground belongs to. A shape on216130 // another lens's shard is elsewhere in the world rather than filtered
@@ -242,6 +156,15 @@ func (g tileGrid) project(lat, lng float64) (x, y float64) {
242156 -(yTile - float64(g.FirstTile)) * float64(g.TileSize)243157 }244158 159+func (g tileGrid) unproject(x, y float64) (lat, lng float64) {160+ worldTiles := math.Pow(2, float64(g.SourceZoom))161+ xTile := x/float64(g.TileSize) + float64(g.FirstTile)162+ yTile := y/float64(g.TileSize) + float64(g.FirstTile)163+ lng = xTile/worldTiles*360 - 180164+ lat = math.Atan(math.Sinh(math.Pi*(1-2*yTile/worldTiles))) * 180 / math.Pi165+ return lat, lng166+}167+245168 // ---------------------------------------------------------------------------246169 // Building247170 // ---------------------------------------------------------------------------
@@ -299,7 +222,7 @@ func (a *App) world(volume hostenv.Volume, slug string) *worldModel {
299222 if !held {300223 return nil301224 }302- model, err := buildVNextWorld(world)225+ model, err := buildVNextWorld(world, semantic.Assets)303226 if err != nil {304227 return nil305228 }
@@ -318,86 +241,6 @@ func shapeDepth(model *worldModel, shape *shapeModel, guard int) int {
318241 return 1 + shapeDepth(model, parent, guard+1)319242 }320243 321-func buildShape(feature payloadFeature, collection *collectionModel, grid tileGrid) *shapeModel {322- shape := &shapeModel{323- ID: strconv.FormatInt(feature.ID, 10),324- Title: feature.Title,325- Subtitle: feature.Subtitle,326- HasText: feature.HasText,327- Shard: feature.Shard,328- Attrs: feature.Attrs,329- Collection: collection,330- MinX: math.Inf(1), MinY: math.Inf(1),331- MaxX: math.Inf(-1), MaxY: math.Inf(-1),332- }333- if feature.Parent != nil {334- shape.Parent = strconv.FormatInt(*feature.Parent, 10)335- }336- for _, geometry := range feature.Geometry {337- switch geometry.Type {338- case "Polygon":339- var rings [][][2]float64340- if json.Unmarshal(geometry.Coordinates, &rings) != nil {341- continue342- }343- shape.addPolygon(rings, grid)344- case "MultiPolygon":345- var polygons [][][][2]float64346- if json.Unmarshal(geometry.Coordinates, &polygons) != nil {347- continue348- }349- for _, rings := range polygons {350- shape.addPolygon(rings, grid)351- }352- case "LineString":353- var line [][2]float64354- if json.Unmarshal(geometry.Coordinates, &line) != nil {355- continue356- }357- shape.addLine(line, grid)358- case "MultiLineString":359- var lines [][][2]float64360- if json.Unmarshal(geometry.Coordinates, &lines) != nil {361- continue362- }363- for _, line := range lines {364- shape.addLine(line, grid)365- }366- }367- }368- shape.Drawn = len(shape.Polygons) > 0 || len(shape.Lines) > 0369- return shape370-}371-372-// Coordinates arrive GeoJSON-ordered -- longitude first -- and land on the373-// world square through the volume's own tile window.374-func (s *shapeModel) addPolygon(rings [][][2]float64, grid tileGrid) {375- converted := make([][]point, 0, len(rings))376- for _, ring := range rings {377- converted = append(converted, s.convert(ring, grid))378- }379- if len(converted) > 0 {380- s.Polygons = append(s.Polygons, converted)381- }382-}383-384-func (s *shapeModel) addLine(line [][2]float64, grid tileGrid) {385- if converted := s.convert(line, grid); len(converted) > 1 {386- s.Lines = append(s.Lines, converted)387- }388-}389-390-func (s *shapeModel) convert(ring [][2]float64, grid tileGrid) []point {391- out := make([]point, 0, len(ring))392- for _, pair := range ring {393- x, y := grid.project(pair[1], pair[0])394- out = append(out, point{X: x, Y: y})395- s.MinX, s.MaxX = math.Min(s.MinX, x), math.Max(s.MaxX, x)396- s.MinY, s.MaxY = math.Min(s.MinY, y), math.Max(s.MaxY, y)397- }398- return out399-}400-401244 // ---------------------------------------------------------------------------402245 // Containment403246 // ---------------------------------------------------------------------------
@@ -480,70 +323,6 @@ func segmentDistanceSquared(a, b point, x, y float64) float64 {
480323 return (cx-x)*(cx-x) + (cy-y)*(cy-y)481324 }482325 483-// ---------------------------------------------------------------------------484-// Text485-// ---------------------------------------------------------------------------486-487-// featureText is one feature's entry in worlds/<slug>.text: prose, links, and488-// the attributes that only matter once a card is open.489-type featureText struct {490- Description string `json:"d"`491- Links []int64 `json:"l"`492- Attrs map[string]string `json:"a"`493-}494-495-// text reads one world's prose file. The whole file is read rather than one496-// entry because it is a single JSON object and there is nothing to seek to;497-// it is small beside the tiles and it is read only when a card opens.498-func (a *App) text(volume hostenv.Volume, world string) map[string]featureText {499- info := volume.Info()500- key := info.Slug + "@" + vnext.ShortStamp(info.Stamp) + "/" + world + ".text"501- if held, ok := a.texts.get(key); ok {502- return held503- }504- out := map[string]featureText{}505- if semantic, err := a.semanticVolume(volume); err == nil {506- if held, ok := semanticWorld(semantic, world); ok {507- if _, _, projected, err := presentVNextWorld(held); err == nil {508- out = projected509- }510- }511- }512- a.texts.put(key, out)513- return out514-}515-516-// textCache is the same shape of cache as worldCache, for the prose file.517-type textCache struct {518- mu sync.Mutex519- held map[string]map[string]featureText520- order []string521-}522-523-func newTextCache() *textCache {524- return &textCache{held: map[string]map[string]featureText{}}525-}526-527-func (c *textCache) get(key string) (map[string]featureText, bool) {528- c.mu.Lock()529- defer c.mu.Unlock()530- held, ok := c.held[key]531- return held, ok532-}533-534-func (c *textCache) put(key string, held map[string]featureText) {535- c.mu.Lock()536- defer c.mu.Unlock()537- if _, seen := c.held[key]; !seen {538- c.order = append(c.order, key)539- for len(c.order) > worldsHeld {540- delete(c.held, c.order[0])541- c.order = c.order[1:]542- }543- }544- c.held[key] = held545-}546-547326 // sortedIDs is the order a set of collection ids is written in everywhere it548327 // is written: as strings, which is how the session record and the state549328 // island both spell it (docs/app.md §6).
M internal/enrich/maturity/gate.go +5 -5
M internal/enrich/maturity/maturity.go +178 -33
@@ -43,6 +43,7 @@ import (
4343 4444 "github.com/FelineStateMachine/atlas/format/bundle"4545 "github.com/FelineStateMachine/atlas/format/semconv"46+ "github.com/FelineStateMachine/atlas/format/vnext"4647 "github.com/FelineStateMachine/atlas/internal/enrich"4748 )4849
@@ -253,51 +254,31 @@ type WorldParts struct {
253254 // the cartography diagnostic needs what only the table of contents knows: every254255 // tile entry's checksum and size, for de-duplicating filler.255256 func Measure(path string, table Table) (*Score, error) {256- reader, err := zip.OpenReader(path)257+ native, err := vnext.OpenFile(path, vnext.StandardSchema())257258 if err != nil {258259 return nil, err259260 }260- defer reader.Close()261-262- entries := make(map[string]*zip.File, len(reader.File))263- for _, file := range reader.File {264- entries[file.Name] = file265- }266- var manifest bundle.Manifest267- if err := readEntry(entries, bundle.ManifestName, &manifest); err != nil {261+ defer native.Close()262+ volume, err := native.Volume()263+ if err != nil {268264 return nil, err269265 }270-271- parts := make([]WorldParts, 0, len(manifest.Worlds))272- for _, entry := range manifest.Worlds {273- payload, err := readBytes(entries, bundle.WorldEntryName(entry.Slug, bundle.WorldSuffix))274- if err != nil {275- return nil, err276- }277- text, err := readBytes(entries, bundle.WorldEntryName(entry.Slug, bundle.TextSuffix))278- if err != nil {279- return nil, err280- }281- packed, err := readBytes(entries, bundle.WorldEntryName(entry.Slug, bundle.PackedSuffix))282- if err != nil {283- return nil, err284- }285- locations, err := bundle.UnpackLocations(packed)286- if err != nil {287- return nil, fmt.Errorf("world %s: %w", entry.Slug, err)288- }289- parts = append(parts, WorldParts{Slug: entry.Slug, Payload: payload, Text: text, Locations: locations})290- }291-292- score, err := ScoreParts(manifest, parts, table)266+ score, err := scoreNativeVolume(volume, native.Release, table)293267 if err != nil {294268 return nil, err295269 }296270 score.Path = path297271 score.File = filepath.Base(path)298272 273+ archive, err := zip.OpenReader(path)274+ if err != nil {275+ return nil, err276+ }277+ defer archive.Close()278+299279 seen := make(map[[2]uint64]bool)300- for name, file := range entries {280+ for _, file := range archive.File {281+ name := file.Name301282 if !strings.HasPrefix(name, "tiles/") {302283 continue303284 }
@@ -314,6 +295,170 @@ func Measure(path string, table Table) (*Score, error) {
314295 return score, nil315296 }316297 298+func scoreNativeVolume(volume vnext.Volume, release vnext.Release, table Table) (*Score, error) {299+ policy, enriched := enrich.Enriched(release.Revision)300+ score := &Score{301+ TableVersion: table.Version, Volume: volume.ID, Title: release.Title, Stamp: release.Stamp,302+ CreatedAt: release.CreatedAt, Revision: release.Revision, Enriched: enriched, EnrichPolicy: policy,303+ }304+ score.Axes.Conventions = 2305+ score.Axes.Geometry = semconv.SurfacePlane + "-default"306+ assets := make(map[string]bool, len(volume.Assets))307+ for _, asset := range volume.Assets {308+ assets[asset.ID] = true309+ }310+ groups := make(map[string]bool)311+ var lengths []int312+ for _, world := range volume.Worlds {313+ worldScore := scoreNativeWorld(score, table, world, assets, &lengths, groups)314+ score.Worlds = append(score.Worlds, worldScore)315+ score.Total += worldScore.Total316+ }317+ sort.Ints(lengths)318+ if len(lengths) > 0 {319+ score.Axes.MedianLength = lengths[len(lengths)/2]320+ }321+ return score, nil322+}323+324+func scoreNativeWorld(score *Score, table Table, world vnext.World, assets map[string]bool, lengths *[]int, groups map[string]bool) WorldScore {325+ out := WorldScore{Slug: world.ID}326+ worldAttrs := nativeAttrs(world.Claims)327+ out.World += table.World.Convention * registered(worldAttrs, semconv.EntityWorld)328+ score.Axes.UnknownAttrs += unknown(worldAttrs)329+ switch worldAttrs[semconv.KeyGeometrySurface] {330+ case semconv.SurfaceSphere:331+ score.Axes.Geometry = semconv.SurfaceSphere332+ case semconv.SurfacePlane:333+ if score.Axes.Geometry != semconv.SurfaceSphere {334+ score.Axes.Geometry = semconv.SurfacePlane335+ }336+ }337+ for _, raster := range world.RasterPyramids {338+ score.Axes.Lenses++339+ out.World += table.World.Lens340+ if depth := int(raster.MaxZoom-raster.MinZoom) + 1; depth > 0 {341+ out.World += table.World.LensZoom * depth342+ }343+ score.Axes.Depth = max(score.Axes.Depth, int(raster.MaxZoom))344+ }345+ sets := make(map[string]vnext.FeatureSet, len(world.FeatureSets))346+ for _, set := range world.FeatureSets {347+ sets[set.ID] = set348+ }349+ styles := make(map[string]vnext.Style, len(world.Presentation.Styles))350+ for _, style := range world.Presentation.Styles {351+ styles[style.ID] = style352+ }353+ accounts := make(map[string]*enrich.Account)354+ var accountOrder []string355+ for _, layer := range world.Presentation.Layers {356+ set, ok := sets[layer.FeatureSet]357+ if !ok {358+ continue359+ }360+ style := styles[layer.Style]361+ attrs := nativeAttrs(set.Claims)362+ kind := strings.TrimPrefix(set.SemanticType, "geometry.")363+ score.Axes.UnknownAttrs += unknown(attrs)364+ out.Collections += table.Collection.Convention * registered(attrs, semconv.EntityCollection)365+ score.Axes.Collections++366+ if !groups[layer.Group] {367+ groups[layer.Group] = true368+ score.Axes.Groups++369+ }370+ if style.RenderAs != "" {371+ score.Axes.RenderDeclared++372+ }373+ labels := style.RenderAs == semconv.RenderAsText374+ resolvesIcon := style.IconAsset != "" && assets[style.IconAsset]375+ if labels {376+ score.Axes.TextSets++377+ } else if kind == semconv.GeometryPoint {378+ score.Axes.IconsWanted++379+ if resolvesIcon {380+ score.Axes.IconsCarried++381+ }382+ }383+ for _, feature := range set.Features {384+ featureAttrs := nativeAttrs(feature.Properties)385+ vertices := nativeVertices(feature.Geometry)386+ score.Axes.Features++387+ if feature.Geometry.Kind == vnext.GeometryPoint {388+ score.Axes.Points++389+ } else {390+ score.Axes.Shapes++391+ score.Axes.Vertices += vertices392+ }393+ corroboration := max(0, len(feature.Provenance)-1)394+ for index, provenance := range feature.Provenance {395+ account := accounts[provenance.Source]396+ if account == nil {397+ account = &enrich.Account{Source: provenance.Source, Origin: index == 0}398+ accounts[provenance.Source] = account399+ accountOrder = append(accountOrder, provenance.Source)400+ }401+ switch feature.Geometry.Kind {402+ case vnext.GeometryPoint:403+ account.DonorFeatures.Point++404+ case vnext.GeometryLineString:405+ account.DonorFeatures.Path++406+ case vnext.GeometryPolygon:407+ account.DonorFeatures.Area++408+ }409+ }410+ out.Features += scoreFeature(score, table, featureFacts{411+ title: feature.Title, description: feature.Description, attrs: featureAttrs,412+ resolvesIcon: resolvesIcon, vertices: vertices, corroboration: corroboration,413+ }, lengths)414+ }415+ }416+ for _, source := range accountOrder {417+ score.Ledger = append(score.Ledger, LedgerLine{World: world.ID, Account: *accounts[source]})418+ }419+ out.Total = out.Features + out.Collections + out.World420+ return out421+}422+423+func nativeAttrs(properties []vnext.Property) map[string]string {424+ out := make(map[string]string, len(properties))425+ for _, property := range properties {426+ if property.Field.Name != "" {427+ out[property.Field.Name] = nativeValue(property.Value)428+ }429+ }430+ return out431+}432+433+func nativeValue(value vnext.Value) string {434+ switch value.Kind {435+ case vnext.KindBool:436+ return strconv.FormatBool(value.Bool)437+ case vnext.KindInt64:438+ return strconv.FormatInt(value.Int64, 10)439+ case vnext.KindFloat64:440+ return strconv.FormatFloat(value.Float64, 'g', -1, 64)441+ case vnext.KindString:442+ return value.String443+ case vnext.KindBytes:444+ return fmt.Sprintf("%x", value.Bytes)445+ case vnext.KindID:446+ return value.ID.String()447+ default:448+ return ""449+ }450+}451+452+func nativeVertices(geometry vnext.Geometry) int {453+ total := 0454+ for _, part := range geometry.Parts {455+ for _, ring := range part.Rings {456+ total += len(ring)457+ }458+ }459+ return total460+}461+317462 // ScoreParts scores a build from its manifest and its worlds' payloads. It318463 // touches no filesystem and reads no rasters: the cartography axis is the only319464 // thing an archive knows that these bytes do not, and it is a diagnostic.
M internal/generate/compose/compose_test.go +0 -11
A internal/generate/compose/model.go +58 -0
M internal/generate/compose/native.go +40 -15
@@ -10,6 +10,7 @@ import (
1010 "strconv"1111 "strings"1212 13+ "github.com/FelineStateMachine/atlas/format/semconv"1314 "github.com/FelineStateMachine/atlas/format/vnext"1415 "github.com/FelineStateMachine/atlas/internal/generate/doc"1516 "github.com/FelineStateMachine/atlas/internal/generate/tiles"
@@ -65,7 +66,10 @@ func nativeWorld(options Options, source composedWorld, pyramids map[string]tile
6566 Presentation: vnext.Presentation{ID: source.Slug + "/presentation", Title: source.Title},6667 }6768 for index, collection := range source.Collections {68- set, style, layer, err := nativeCollection(options.Document.Source.Name, source, collection, index)69+ set, style, layer, err := nativeCollection(options.Document.Source.Name, source, collection, index, surfaceGrid{70+ SourceZoom: grid.SourceZoom, FirstTile: grid.FirstTile,71+ TileSize: options.Tiles.TileSize, Size: options.Tiles.Size,72+ })6973 if err != nil {7074 return vnext.World{}, err7175 }
@@ -86,7 +90,7 @@ func nativeWorld(options Options, source composedWorld, pyramids map[string]tile
8690 return world, nil8791 }8892 89-func nativeCollection(provenanceSource string, world composedWorld, source composedCollection, order int) (vnext.FeatureSet, vnext.Style, vnext.Layer, error) {93+func nativeCollection(provenanceSource string, world composedWorld, source composedCollection, order int, grid surfaceGrid) (vnext.FeatureSet, vnext.Style, vnext.Layer, error) {9094 key := source.Key9195 if key == "" {9296 key = strconv.FormatInt(source.ID, 10)
@@ -95,7 +99,7 @@ func nativeCollection(provenanceSource string, world composedWorld, source compo
9599 set := vnext.FeatureSet{ID: setID, Title: source.Title, SemanticType: "geometry." + source.Kind, Claims: stringProperties("featureSet", source.Attrs, true)}96100 contract := make(map[vnext.ID]vnext.Field)97101 for _, item := range source.Features {98- feature, err := nativeFeature(provenanceSource, world, item)102+ feature, err := nativeFeature(provenanceSource, world, item, grid)99103 if err != nil {100104 return vnext.FeatureSet{}, vnext.Style{}, vnext.Layer{}, fmt.Errorf("feature %d: %w", item.ID, err)101105 }
@@ -111,16 +115,17 @@ func nativeCollection(provenanceSource string, world composedWorld, source compo
111115 styleID := world.Slug + "/style/" + key112116 style := vnext.Style{113117 ID: styleID, Icon: source.Icon, IconAsset: source.IconAsset, IconPicture: source.IconPicture,114- Stroke: source.IconColor, Fill: source.Color,118+ RenderAs: semconv.RenderAs(source.Attrs, ""), Stroke: source.IconColor, Fill: source.Color,115119 }116120 layer := vnext.Layer{117121 ID: world.Slug + "/layer/" + key, FeatureSet: setID, Style: styleID,118- Group: source.Group, Visible: source.Visible, Order: int64(order), MaxZoom: 32,122+ Group: source.Group, LabelPolicy: semconv.LabelPolicy(source.Kind, source.Attrs),123+ Visible: source.Visible, Order: int64(order), MaxZoom: 32,119124 }120125 return set, style, layer, nil121126 }122127 123-func nativeFeature(provenanceSource string, world composedWorld, source composedFeature) (vnext.Feature, error) {128+func nativeFeature(provenanceSource string, world composedWorld, source composedFeature, grid surfaceGrid) (vnext.Feature, error) {124129 id := world.Slug + "/feature/" + strconv.FormatInt(source.ID, 10)125130 feature := vnext.Feature{126131 ID: id, Title: source.Title, Subtitle: source.Subtitle, Description: source.Description,
@@ -128,16 +133,16 @@ func nativeFeature(provenanceSource string, world composedWorld, source composed
128133 Provenance: []vnext.Provenance{{Source: provenanceSource, NativeID: strconv.FormatInt(source.ID, 10), CapturedAt: world.CapturedAt}},129134 }130135 if source.At != nil {131- feature.Geometry = vnext.Geometry{Kind: vnext.GeometryPoint, Parts: []vnext.GeometryPart{{Rings: [][]vnext.Position{{{source.At.Lng, source.At.Lat}}}}}}136+ feature.Geometry = vnext.Geometry{Kind: vnext.GeometryPoint, Parts: []vnext.GeometryPart{{Rings: [][]vnext.Position{{projectPosition(source.At.Lng, source.At.Lat, grid)}}}}}132137 } else {133- geometry, err := nativeGeometry(source.Geometry)138+ geometry, err := nativeGeometry(source.Geometry, grid)134139 if err != nil {135140 return vnext.Feature{}, err136141 }137142 feature.Geometry = geometry138143 }139144 if source.Center != nil {140- center := vnext.Position{source.Center.Lng, source.Center.Lat}145+ center := projectPosition(source.Center.Lng, source.Center.Lat, grid)141146 feature.Center = &center142147 }143148 if source.Member != 0 {
@@ -152,7 +157,7 @@ func nativeFeature(provenanceSource string, world composedWorld, source composed
152157 return feature, nil153158 }154159 155-func nativeGeometry(items []doc.Geometry) (vnext.Geometry, error) {160+func nativeGeometry(items []doc.Geometry, grid surfaceGrid) (vnext.Geometry, error) {156161 var geometry vnext.Geometry157162 for _, item := range items {158163 switch item.Type {
@@ -161,13 +166,13 @@ func nativeGeometry(items []doc.Geometry) (vnext.Geometry, error) {
161166 if err := json.Unmarshal(item.Coordinates, &point); err != nil {162167 return vnext.Geometry{}, err163168 }164- geometry.Kind, geometry.Parts = vnext.GeometryPoint, append(geometry.Parts, vnext.GeometryPart{Rings: [][]vnext.Position{{point}}})169+ geometry.Kind, geometry.Parts = vnext.GeometryPoint, append(geometry.Parts, vnext.GeometryPart{Rings: [][]vnext.Position{{projectPosition(point[0], point[1], grid)}}})165170 case "LineString":166171 var line []vnext.Position167172 if err := json.Unmarshal(item.Coordinates, &line); err != nil {168173 return vnext.Geometry{}, err169174 }170- geometry.Kind, geometry.Parts = vnext.GeometryLineString, append(geometry.Parts, vnext.GeometryPart{Rings: [][]vnext.Position{line}})175+ geometry.Kind, geometry.Parts = vnext.GeometryLineString, append(geometry.Parts, vnext.GeometryPart{Rings: [][]vnext.Position{projectPositions(line, grid)}})171176 case "MultiLineString":172177 var lines [][]vnext.Position173178 if err := json.Unmarshal(item.Coordinates, &lines); err != nil {
@@ -175,14 +180,14 @@ func nativeGeometry(items []doc.Geometry) (vnext.Geometry, error) {
175180 }176181 geometry.Kind = vnext.GeometryLineString177182 for _, line := range lines {178- geometry.Parts = append(geometry.Parts, vnext.GeometryPart{Rings: [][]vnext.Position{line}})183+ geometry.Parts = append(geometry.Parts, vnext.GeometryPart{Rings: [][]vnext.Position{projectPositions(line, grid)}})179184 }180185 case "Polygon":181186 var rings [][]vnext.Position182187 if err := json.Unmarshal(item.Coordinates, &rings); err != nil {183188 return vnext.Geometry{}, err184189 }185- geometry.Kind, geometry.Parts = vnext.GeometryPolygon, append(geometry.Parts, vnext.GeometryPart{Rings: rings})190+ geometry.Kind, geometry.Parts = vnext.GeometryPolygon, append(geometry.Parts, vnext.GeometryPart{Rings: projectRings(rings, grid)})186191 case "MultiPolygon":187192 var polygons [][][]vnext.Position188193 if err := json.Unmarshal(item.Coordinates, &polygons); err != nil {
@@ -190,7 +195,7 @@ func nativeGeometry(items []doc.Geometry) (vnext.Geometry, error) {
190195 }191196 geometry.Kind = vnext.GeometryPolygon192197 for _, rings := range polygons {193- geometry.Parts = append(geometry.Parts, vnext.GeometryPart{Rings: rings})198+ geometry.Parts = append(geometry.Parts, vnext.GeometryPart{Rings: projectRings(rings, grid)})194199 }195200 default:196201 return vnext.Geometry{}, fmt.Errorf("unsupported geometry %q", item.Type)
@@ -199,6 +204,26 @@ func nativeGeometry(items []doc.Geometry) (vnext.Geometry, error) {
199204 return geometry, nil200205 }201206 207+func projectPosition(lng, lat float64, grid surfaceGrid) vnext.Position {208+ return vnext.Position{projectX(lng, grid), projectY(lat, grid)}209+}210+211+func projectPositions(positions []vnext.Position, grid surfaceGrid) []vnext.Position {212+ out := make([]vnext.Position, len(positions))213+ for index, position := range positions {214+ out[index] = projectPosition(position[0], position[1], grid)215+ }216+ return out217+}218+219+func projectRings(rings [][]vnext.Position, grid surfaceGrid) [][]vnext.Position {220+ out := make([][]vnext.Position, len(rings))221+ for index, ring := range rings {222+ out[index] = projectPositions(ring, grid)223+ }224+ return out225+}226+202227 func nativeRaster(world string, tileSize, index int, source lens) (vnext.RasterPyramid, error) {203228 raster := vnext.RasterPyramid{204229 ID: world + "/raster/" + strconv.Itoa(index), Name: source.Name, Codec: rasterCodec(source.Formats),
D internal/generate/compose/payload.go +0 -294
@@ -1,294 +0,0 @@
1-package compose2-3-import (4- "encoding/json"5- "strconv"6-7- "github.com/FelineStateMachine/atlas/format/bundle"8- "github.com/FelineStateMachine/atlas/internal/generate/doc"9- "github.com/FelineStateMachine/atlas/internal/generate/tiles"10-)11-12-// The wire shape of a world payload. Its field names, its field order, and13-// which fields are omitted when empty all feed the bundle's stamp, so this is14-// frozen with the format version: a change here restamps every bundle in every15-// library. docs/format.md is normative for what these mean; this file is where16-// composition writes them.17-//18-// A world travels in three parts because they are wanted at three different19-// moments. The payload proper is read when a world opens; the packed locations20-// are read with it and hold the point features, which are overwhelmingly21-// numbers and cost several times their weight written as text; the prose is22-// read one feature at a time, when a card opens, and is half a world's bulk.23-24-// worldPayload is everything needed to draw a world except its point features,25-// which travel packed alongside.26-type worldPayload struct {27- // Grid travels only with a world cut from a window of its own; every other28- // world sits in the volume's shared window and says nothing.29- Grid *worldGrid `json:"grid,omitempty"`30- Lenses []lens `json:"lenses"`31- Collections []wireCollection `json:"collections"`32- // Attrs is the world speaking the shared conventions.33- Attrs map[string]string `json:"attrs,omitempty"`34- // Merged is the world's provenance: one account per contributor, the35- // world's own origin account first.36- //37- // A single-source build carries exactly one entry, which composition writes38- // itself from the origin struct below. Cross-source composition is the39- // enrich lane's work and its ledger vocabulary is the enrich lane's to40- // define, so the accounts travel here already serialized: composition41- // splices in what it was handed, verbatim, and never learns what a matched42- // pair or a held feature is. That is what keeps the origin account's bytes43- // exactly where they were while the ledger beside it grows.44- Merged []json.RawMessage `json:"merged,omitempty"`45-}46-47-type worldGrid struct {48- SourceZoom int `json:"sourceZoom"`49- FirstTile int `json:"firstTile"`50-}51-52-// lens is one picture of a world: which pyramid draws it, over what zooms, and53-// which of its tiles exist.54-type lens struct {55- Name string `json:"name"`56- Tiles string `json:"tiles"`57- MinZoom int `json:"minZoom"`58- MaxZoom int `json:"maxZoom"`59- FullZoom int `json:"fullZoom"`60- SourceZoom int `json:"sourceZoom"`61- Formats []string `json:"formats"`62- Bounds *tiles.Box `json:"bounds,omitempty"`63- // Surface is the ground the world covers, where Bounds is the window cut64- // from the pyramid to draw it. On a piece of a split sheet the window is65- // grown to take in the title printed beside it, so anything dividing the66- // world up measures the surface instead and leaves no cell on blank margin.67- Surface *tiles.Box `json:"surface,omitempty"`68- Interpolate bool `json:"interpolate"`69- Background string `json:"background,omitempty"`70- Shard int64 `json:"shard,omitempty"`71- Coverage map[string]*tiles.Coverage `json:"coverage,omitempty"`72-}73-74-// wireCollection is one collection as the payload lists it. A point collection75-// carries no inline features -- its members ride the packed payload, owned by76-// this collection's index -- where a path or area collection inlines its77-// features whole.78-type wireCollection struct {79- ID int64 `json:"id"`80- Title string `json:"title"`81- Group string `json:"group,omitempty"`82- Kind string `json:"kind"`83- Icon string `json:"icon,omitempty"`84- IconAsset string `json:"iconAsset,omitempty"`85- IconPicture bool `json:"iconPicture,omitempty"`86- Color string `json:"color,omitempty"`87- IconColor string `json:"iconColor,omitempty"`88- Visible bool `json:"visible"`89- Attrs map[string]string `json:"attrs,omitempty"`90- Features []wireFeature `json:"features,omitempty"`91-}92-93-// wireFeature is one shape feature as the payload inlines it. Its prose defers94-// into the text payload behind the hasText marker, exactly the way a point95-// feature's does; its attributes stay inline, because a card needs them the96-// moment ground is asked about.97-type wireFeature struct {98- ID int64 `json:"id"`99- Title string `json:"title"`100- Subtitle string `json:"subtitle,omitempty"`101- HasText bool `json:"hasText,omitempty"`102- Parent *int64 `json:"parent,omitempty"`103- Center *coordinate `json:"center,omitempty"`104- Shard int64 `json:"shard,omitempty"`105- Geometry []doc.Geometry `json:"geometry"`106- Attrs map[string]string `json:"attrs,omitempty"`107-}108-109-type coordinate struct {110- Lat float64 `json:"lat"`111- Lng float64 `json:"lng"`112-}113-114-// featureText holds what only a selected feature needs.115-type featureText struct {116- Description string `json:"d,omitempty"`117- Links []link `json:"l,omitempty"`118- // Attrs carries a point feature's own conventions -- its true planetary119- // coordinates, where a source published them -- read when its card opens,120- // like everything else here.121- Attrs map[string]string `json:"a,omitempty"`122-}123-124-// link is a cross-reference to another feature of the same world.125-type link struct {126- Title string `json:"title"`127- LocationID int64 `json:"locationId"`128-}129-130-// origin is a world's account of where it came from. Every world opens one,131-// merged with anything or not: provenance is part of a world, not a side effect132-// of composition. It carries both spellings of the source -- the label a person133-// reads and the slug a registry names it by -- so a ledger line and a source134-// card point at each other without a translation table.135-type origin struct {136- Source string `json:"source"`137- Slug string `json:"slug,omitempty"`138- // Origin marks the account of the source the world itself came from, as139- // against a donor folded in later.140- Origin bool `json:"origin,omitempty"`141- // DonorFeatures on an origin account is simply the world's own tally at142- // composition.143- DonorFeatures counts `json:"donorFeatures"`144- Added int `json:"added"`145-}146-147-// counts is features by kind, as a ledger speaks of them.148-type counts struct {149- Point int `json:"point"`150- Path int `json:"path"`151- Area int `json:"area"`152-}153-154-// tally counts a world's features by kind: the manifest's per-kind counts, the155-// origin account's view of what it holds, and the yardstick a later merge audit156-// measures the composed world against.157-func tally(collections []composedCollection) counts {158- var out counts159- for _, collection := range collections {160- switch collection.Kind {161- case doc.KindPoint:162- out.Point += len(collection.Features)163- case doc.KindPath:164- out.Path += len(collection.Features)165- default:166- out.Area += len(collection.Features)167- }168- }169- return out170-}171-172-// buildPayload splits one world three ways: its lenses and collections as a173-// payload, its point features packed, and every kind's prose keyed by feature174-// id. Feature identifiers share one space per world, so one world never keys175-// two things alike.176-//177-// Point collections are written first and shape collections after, so a packed178-// location's owner is simply the ordinal of its collection among the points --179-// and the wire order is the legend order: collections as the source ordered180-// them, points before shapes.181-func buildPayload(w composedWorld) (worldPayload, []byte, map[string]featureText) {182- payload := worldPayload{183- Grid: w.Grid,184- // The collections array is always present, even empty: a raster-only185- // world holds none, and a reader iterating the legend should meet a186- // list rather than a null.187- Collections: []wireCollection{},188- Lenses: w.Lenses,189- Attrs: w.Attrs,190- Merged: w.Merged,191- }192- var locations []bundle.Location193- text := make(map[string]featureText)194-195- for _, collection := range w.Collections {196- if collection.Kind != doc.KindPoint {197- continue198- }199- owner := uint16(len(payload.Collections))200- payload.Collections = append(payload.Collections, listed(collection))201- for _, pin := range collection.Features {202- var lat, lng float64203- if pin.At != nil {204- lat, lng = pin.At.Lat, pin.At.Lng205- }206- locations = append(locations, bundle.Location{207- ID: pin.ID,208- Title: pin.Title,209- Lat: lat,210- Lng: lng,211- Member: pin.Member,212- Shard: pin.Shard,213- Owner: owner,214- })215- if pin.Description != "" || len(pin.Links) > 0 || len(pin.Attrs) > 0 {216- text[strconv.FormatInt(pin.ID, 10)] = featureText{217- Description: pin.Description,218- Links: links(pin.Links),219- Attrs: pin.Attrs,220- }221- }222- }223- }224- for _, collection := range w.Collections {225- if collection.Kind == doc.KindPoint {226- continue227- }228- entry := listed(collection)229- for _, shape := range collection.Features {230- feature := wireFeature{231- ID: shape.ID,232- Title: shape.Title,233- Subtitle: shape.Subtitle,234- Parent: optionalID(shape.Parent),235- Center: position(shape.Center),236- Shard: shape.Shard,237- Geometry: shape.Geometry,238- Attrs: shape.Attrs,239- }240- if shape.Description != "" {241- text[strconv.FormatInt(shape.ID, 10)] = featureText{Description: shape.Description}242- feature.HasText = true243- }244- entry.Features = append(entry.Features, feature)245- }246- payload.Collections = append(payload.Collections, entry)247- }248- return payload, bundle.PackLocations(locations), text249-}250-251-func listed(collection composedCollection) wireCollection {252- return wireCollection{253- ID: collection.ID,254- Title: collection.Title,255- Group: collection.Group,256- Kind: collection.Kind,257- Icon: collection.Icon,258- IconAsset: collection.IconAsset,259- IconPicture: collection.IconPicture,260- Color: collection.Color,261- IconColor: collection.IconColor,262- Visible: collection.Visible,263- Attrs: collection.Attrs,264- }265-}266-267-func links(in []doc.Link) []link {268- if len(in) == 0 {269- return nil270- }271- out := make([]link, 0, len(in))272- for _, l := range in {273- out = append(out, link{Title: l.Title, LocationID: l.Feature})274- }275- return out276-}277-278-// optionalID turns the document's "zero is nothing" into the wire's "absent is279-// nothing". The two spellings mean the same, and the wire's is what a reader280-// has always seen.281-func optionalID(id int64) *int64 {282- if id == 0 {283- return nil284- }285- value := id286- return &value287-}288-289-func position(p *doc.Position) *coordinate {290- if p == nil {291- return nil292- }293- return &coordinate{Lat: p.Lat, Lng: p.Lng}294-}
M internal/workbench/bundles_test.go +65 -119
@@ -1,43 +1,30 @@
11 package workbench22 33 import (4- "bytes"5- "encoding/json"64 "os"75 "path/filepath"86 "strconv"7+ "strings"98 "testing"109 11- "github.com/FelineStateMachine/atlas/format/bundle"1210 "github.com/FelineStateMachine/atlas/format/semconv"11+ "github.com/FelineStateMachine/atlas/format/vnext"1312 )1413 15-// The bundles these tests measure.16-//17-// Every bundle is stated: written here from a few lines of Go, so a page test18-// can say exactly what a build holds and then read the page back. They go19-// through format/bundle's real writer: nothing opaque is checked in, and a20-// bundle a test measures is a bundle the application could open.21-14+// The native volumes these tests measure are stated here, then compiled by15+// the same vNext writer used by production.2216 type bundleSpec struct {23- slug string24- title string25- createdAt string26- stamp string27- revision int28- conventions int29- worlds []worldSpec17+ slug, title, createdAt, stamp string18+ revision, conventions int19+ worlds []worldSpec3020 }3121 3222 type worldSpec struct {33- slug string34- title string35- icon string36- attrs map[string]string37- features []featureSpec38- // merged is the world's ledger, stated as the payload carries it.39- merged []map[string]any40- lenses []map[string]any23+ slug, title, icon string24+ attrs map[string]string25+ features []featureSpec26+ merged []map[string]any27+ lenses []map[string]any4128 }4229 4330 type featureSpec struct {
@@ -46,7 +33,6 @@ type featureSpec struct {
4633 description string4734 }4835 49-// write builds one bundle in dir and answers with its path.5036 func (b bundleSpec) write(t *testing.T, dir string) string {5137 t.Helper()5238 if b.slug == "" {
@@ -59,122 +45,82 @@ func (b bundleSpec) write(t *testing.T, dir string) string {
5945 b.createdAt = "2026-01-01T00:00:00Z"6046 }6147 if b.stamp == "" {62- b.stamp = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"63- }64- if b.conventions == 0 {65- b.conventions = semconv.Version66- }67-68- manifest := bundle.Manifest{69- Format: bundle.Format,70- FormatVersion: bundle.FormatVersion,71- Conventions: b.conventions,72- Volume: bundle.Volume{Slug: b.slug, Title: b.title},73- Version: bundle.Version{74- Stamp: b.stamp,75- CreatedAt: b.createdAt,76- Revision: b.revision,77- },78- TileGrid: bundle.TileGrid{SourceZoom: 5, FirstTile: 0, TileSize: 256, Size: 8192},48+ b.stamp = strings.Repeat("0", 64)7949 }50+ volume := vnext.Volume{ID: b.slug, Title: b.title}8051 for _, world := range b.worlds {81- manifest.Worlds = append(manifest.Worlds, bundle.WorldEntry{82- Slug: world.slug,83- Title: world.titleOr(),84- Points: len(world.features),85- UpdatedAt: b.createdAt,86- })52+ volume.Worlds = append(volume.Worlds, world.native())53+ if world.icon != "" {54+ volume.Assets = append(volume.Assets, vnext.Asset{ID: world.icon, MediaType: "image/png", Data: []byte("icon")})55+ }8756 }88-89- path := filepath.Join(dir, bundle.VersionedFileName(manifest))90- file, err := os.Create(path)57+ compiled, err := vnext.Compile(volume)9158 if err != nil {9259 t.Fatal(err)9360 }94- defer file.Close()95- writer, err := bundle.NewWriter(file, manifest)61+ compiled.Release.CreatedAt = b.createdAt62+ compiled.Release.Revision = b.revision63+ path := filepath.Join(dir, b.slug+"-"+vnext.ShortStamp(b.stamp)+vnext.Extension)64+ file, err := os.Create(path)9665 if err != nil {9766 t.Fatal(err)9867 }99- for _, world := range b.worlds {100- payload, text, packed := world.parts(t)101- if err := writer.AddDeflated(bundle.WorldEntryName(world.slug, bundle.WorldSuffix), payload); err != nil {102- t.Fatal(err)103- }104- if err := writer.AddDeflated(bundle.WorldEntryName(world.slug, bundle.TextSuffix), text); err != nil {105- t.Fatal(err)106- }107- if err := writer.AddStored(bundle.WorldEntryName(world.slug, bundle.PackedSuffix), bytes.NewReader(packed)); err != nil {108- t.Fatal(err)109- }68+ if err := vnext.Write(file, compiled); err != nil {69+ file.Close()70+ t.Fatal(err)11071 }111- if err := writer.Close(); err != nil {72+ if err := file.Close(); err != nil {11273 t.Fatal(err)11374 }11475 return path11576 }11677 117-func (w worldSpec) titleOr() string {118- if w.title != "" {119- return w.title78+func (w worldSpec) native() vnext.World {79+ title := w.title80+ if title == "" {81+ title = "Overworld"12082 }121- return "Overworld"122-}123-124-// parts is one world's three payloads: the collections and the ledger, the125-// prose, and the packed point features.126-func (w worldSpec) parts(t *testing.T) (payload, text, packed []byte) {127- t.Helper()12883 attrs := w.attrs12984 if attrs == nil {13085 attrs = map[string]string{semconv.KeyGeometrySurface: semconv.SurfacePlane}13186 }132- lenses := w.lenses133- if lenses == nil {134- lenses = []map[string]any{{"tiles": w.slug, "minZoom": 0, "maxZoom": 2, "formats": []string{"jpg"}}}135- }136- collection := map[string]any{137- "id": 1,138- "title": "Markers",139- "group": "Places",140- "kind": semconv.GeometryPoint,141- "attrs": map[string]string{semconv.KeyRenderAs: semconv.RenderAsPin},142- }143- if w.icon != "" {144- collection["iconAsset"] = w.icon145- }146- body := map[string]any{147- "attrs": attrs,148- "lenses": lenses,149- "collections": []any{collection},150- }151- if w.merged != nil {152- body["merged"] = w.merged153- }154- payload = marshal(t, body)155-156- prose := map[string]any{}157- locations := make([]bundle.Location, 0, len(w.features))158- for at, feature := range w.features {159- if feature.description != "" {160- prose[strconv.FormatInt(feature.id, 10)] = map[string]any{"d": feature.description}87+ setID := w.slug + "/set/markers"88+ styleID := w.slug + "/style/markers"89+ layerID := w.slug + "/layer/markers"90+ set := vnext.FeatureSet{ID: setID, Title: "Markers", SemanticType: "geometry.point"}91+ sources := []string{"Test Origin"}92+ for _, account := range w.merged {93+ if source, ok := account["source"].(string); ok && source != "" {94+ sources = append(sources, source)16195 }162- locations = append(locations, bundle.Location{163- ID: feature.id,164- Owner: 0,165- Lat: float64(at),166- Lng: float64(at),167- Title: feature.title,168- })16996 }170- return payload, marshal(t, prose), bundle.PackLocations(locations)97+ for index, item := range w.features {98+ feature := vnext.Feature{99+ ID: w.slug + "/feature/" + strconv.FormatInt(item.id, 10), Title: item.title, Description: item.description,100+ Geometry: vnext.Geometry{Kind: vnext.GeometryPoint, Parts: []vnext.GeometryPart{{Rings: [][]vnext.Position{{{float64(index), float64(index)}}}}}},101+ }102+ for _, source := range sources {103+ feature.Provenance = append(feature.Provenance, vnext.Provenance{Source: source, NativeID: strconv.FormatInt(item.id, 10)})104+ }105+ set.Features = append(set.Features, feature)106+ }107+ world := vnext.World{108+ ID: w.slug, Title: title,109+ CoordinateSpace: vnext.CoordinateSpace{ID: w.slug + "/space", Kind: "projected", Unit: "pixel", Definition: "atlas:tile-plane", Extent: [4]float64{0, 0, 8192, 8192}, SourceZoom: 5, TileSize: 256, Size: 8192},110+ Claims: testProperties("world", attrs), FeatureSets: []vnext.FeatureSet{set},111+ Presentation: vnext.Presentation{ID: w.slug + "/presentation", Title: title},112+ }113+ world.Presentation.Styles = []vnext.Style{{ID: styleID, IconAsset: w.icon, RenderAs: semconv.RenderAsPin}}114+ world.Presentation.Layers = []vnext.Layer{{ID: layerID, FeatureSet: setID, Style: styleID, Group: "Places", Visible: true, MaxZoom: 32}}115+ world.Presentation.Legend = []vnext.LegendEntry{{Layer: layerID, Label: "Markers"}}116+ world.RasterPyramids = []vnext.RasterPyramid{{ID: w.slug + "/raster/0", Name: "Surface", Codec: "image/jpeg", TileSize: 256, MinZoom: 0, MaxZoom: 2, Formats: []string{"jpg", "jpg", "jpg"}, Template: "tiles/" + w.slug + "/{z}/{x}/{y}.{format}"}}117+ return world171118 }172119 173-func marshal(t *testing.T, value any) []byte {174- t.Helper()175- data, err := json.Marshal(value)176- if err != nil {177- t.Fatal(err)120+func testProperties(owner string, attrs map[string]string) []vnext.Property {121+ out := make([]vnext.Property, 0, len(attrs))122+ for key, value := range attrs {123+ out = append(out, vnext.Property{Field: vnext.Field{ID: vnext.CoreID(owner + ".test." + key), Name: key, Kind: vnext.KindString, Optional: true}, Value: vnext.StringValue(value)})178124 }179- return data125+ return out180126 }
M internal/workbench/diff.go +3 -3
M internal/workbench/library.go +20 -18
@@ -7,7 +7,7 @@ import (
77 "sync"88 "time"99 10- "github.com/FelineStateMachine/atlas/format/bundle"10+ "github.com/FelineStateMachine/atlas/format/vnext"1111 "github.com/FelineStateMachine/atlas/internal/enrich/maturity"1212 )1313
@@ -99,7 +99,7 @@ func (v *volume) Sources() []string {
9999 // A bundle that will not measure is reported in skipped rather than failing the100100 // scan: one half-written file should not take the workbench down.101101 func (l *library) volumes() (volumes []*volume, skipped []string, err error) {102- paths, err := filepath.Glob(filepath.Join(l.dir, "*"+bundle.Extension))102+ paths, err := filepath.Glob(filepath.Join(l.dir, "*"+vnext.Extension))103103 if err != nil {104104 return nil, nil, err105105 }
@@ -123,7 +123,7 @@ func (l *library) volumes() (volumes []*volume, skipped []string, err error) {
123123 124124 for _, held := range bySlug {125125 sort.Slice(held.Builds, func(a, b int) bool {126- return bundle.Newer(descriptorOf(held.Builds[a]), descriptorOf(held.Builds[b]))126+ return vnext.Newer(descriptorOf(held.Builds[a]), descriptorOf(held.Builds[b]))127127 })128128 held.Title = held.Serving().Title129129 }
@@ -176,9 +176,9 @@ func (l *library) score(path string) (*maturity.Score, error) {
176176 177177 // descriptorOf is a scored build as the format's registry sees it, so newest178178 // first means here exactly what it means to a reader opening the library.179-// Ordering has one definition and it lives in format/bundle.180-func descriptorOf(s *maturity.Score) bundle.Descriptor {181- return bundle.Descriptor{179+// Ordering has one definition and it lives in format/vnext.180+func descriptorOf(s *maturity.Score) vnext.Descriptor {181+ return vnext.Descriptor{182182 Locator: s.Path,183183 Slug: s.Volume,184184 Title: s.Title,
@@ -191,24 +191,26 @@ func descriptorOf(s *maturity.Score) bundle.Descriptor {
191191 // features reads one build's point features, by world, for a diff. Only a192192 // comparison ever needs them, so they are unpacked on demand rather than kept193193 // beside every score.194-func features(score *maturity.Score) (map[string]map[int64]string, error) {195- reader, err := bundle.Open(score.Path)194+func features(score *maturity.Score) (map[string]map[string]string, error) {195+ reader, err := vnext.OpenFile(score.Path, vnext.StandardSchema())196196 if err != nil {197197 return nil, err198198 }199199 defer reader.Close()200200 201- byWorld := make(map[string]map[int64]string, len(score.Worlds))202- for _, world := range score.Worlds {203- packed, err := reader.Locations(world.Slug)204- if err != nil {205- return nil, err206- }207- held := make(map[int64]string, packed.Len())208- for at := 0; at < packed.Len(); at++ {209- held[packed.ID(at)] = packed.Title(at)201+ volume, err := reader.Volume()202+ if err != nil {203+ return nil, err204+ }205+ byWorld := make(map[string]map[string]string, len(volume.Worlds))206+ for _, world := range volume.Worlds {207+ held := make(map[string]string)208+ for _, set := range world.FeatureSets {209+ for _, feature := range set.Features {210+ held[feature.ID] = feature.Title211+ }210212 }211- byWorld[world.Slug] = held213+ byWorld[world.ID] = held212214 }213215 return byWorld, nil214216 }
M internal/workbench/workbench.go +3 -3
M internal/workbench/workbench_test.go +2 -2
M render/chart/overview.ts +3 -1
M render/chart/styles.ts +5 -8
@@ -43,7 +43,7 @@ import { gridTheme, paletteColor } from "@atlas/analysis";
4343 import type { CellVisual } from "@atlas/analysis";4444 import type { Collection } from "../data/payload.ts";4545 import { labelPolicy, renderAs } from "../data/payload.ts";46-import type { PointRecord, ShapeRecord } from "../world/model.ts";46+import { stableRank, type PointRecord, type ShapeRecord } from "../world/model.ts";4747 import type { Visibility } from "../world/visibility.ts";4848 import type { Scene } from "../scene/read.ts";4949 import {
@@ -117,10 +117,7 @@ export function collectionColor(collection: Collection, ordinal: number): string
117117 * colour, exactly as it does there.118118 */119119 export function featureColor(id: string): string {120- const value = Number(id);121- if (!Number.isFinite(value) || !Number.isInteger(value)) return paletteColor(0);122- const magnitude = Math.abs(value) % 4294967296;123- return paletteColor(Math.imul(magnitude, 2654435761) >>> 0);120+ return paletteColor(stableRank(id));124121 }125122 126123 /**
@@ -145,9 +142,9 @@ export function curatedPointPolicy(collection: Collection): string {
145142 * to marks already handed to OpenLayers.146143 */147144 export class Styles {148- private readonly faces = new Map<number, MarkerFace>();145+ private readonly faces = new Map<string | number, MarkerFace>();149146 private readonly marks = new Map<string, Style>();150- private readonly colors = new Map<number, string>();147+ private readonly colors = new Map<string | number, string>();151148 /** The rasters this world has already asked to be told about. */152149 private readonly waiting = new Set<string>();153150
@@ -179,7 +176,7 @@ export class Styles {
179176 }180177 181178 color(collection: Collection): string {182- return this.colors.get(collection.id) ?? paletteColor(collection.id);179+ return this.colors.get(collection.id) ?? paletteColor(stableRank(String(collection.id)));183180 }184181 185182 /**
M render/data/payload.ts +21 -15
@@ -1,16 +1,8 @@
1-// What `/data` says, as types.1+// Presentation-facing types shared by the native model and renderers.22 //3-// These are `docs/format.md` §4 and §6 written as TypeScript, and nothing4-// more: no defaults are applied here, no key is renamed, nothing is dropped.5-// A reader is lenient (format.md §2.3) — every optional field really is6-// optional, unknown keys ride along untouched, and the only thing this seam7-// refuses is a `formatVersion` it does not know, which the catalog never8-// carries because the server already refused it.9-//10-// The seam reads these shapes and nothing else off the wire. Everything the11-// application decided — what is hidden, what is highlighted, which lens is12-// open — arrives through the scene description instead (`scene/`), because13-// data flows one way and the payload is not where a session lives.3+// The wire contract itself lives in semantic.ts and vnext.ts. The older4+// document-shaped interfaces below remain only for extracted test fixtures;5+// production has no endpoint or decoder for them.146 157 import {168 KEY_LABEL_POLICY,
@@ -40,8 +32,12 @@ export interface CoverageLevel {
4032 4133 /** One raster pyramid picturing a world. */4234 export interface Lens {35+ readonly id?: string;4336 readonly name: string;37+ /** Stable presentation key, derived from the semantic raster identity. */4438 readonly tiles: string;39+ /** Native blob path template; URLs substitute z/x/y/format directly. */40+ readonly template?: string;4541 readonly minZoom: number;4642 readonly maxZoom: number;4743 readonly fullZoom: number;
@@ -85,7 +81,10 @@ export type Kind = "point" | "path" | "area";
8581 8682 /** One ordered group of features. The array's order is load-bearing. */8783 export interface Collection {88- readonly id: number;84+ /** The presentation layer identity; never a position or compatibility hash. */85+ readonly id: string | number;86+ readonly featureSet?: string;87+ readonly style?: string;8988 readonly title: string;9089 readonly group?: string;9190 readonly kind: Kind;
@@ -95,6 +94,8 @@ export interface Collection {
9594 readonly color?: string;9695 readonly iconColor?: string;9796 readonly visible: boolean;97+ readonly labelPolicy?: string;98+ readonly renderAs?: string;9899 readonly attrs?: Attrs;99100 readonly features?: readonly ShapeFeature[];100101 }
@@ -160,14 +161,19 @@ export function tileFormat(lens: Lens, z: number): string | null {
160161 return lens.formats[z - lens.minZoom] ?? null;161162 }162163 164+/** The native template; the fallback exists only for hand-built test lenses. */165+export function tileTemplate(lens: Lens): string {166+ return lens.template ?? `tiles/${lens.tiles}/{z}/{x}/{y}.{format}`;167+}168+163169 /** The label policy an area collection curates, `always` when it says nothing. */164170 export function labelPolicy(collection: Collection): "always" | "quiet" {165- return collection.attrs?.[KEY_LABEL_POLICY] === "quiet" ? "quiet" : "always";171+ return (collection.labelPolicy || collection.attrs?.[KEY_LABEL_POLICY]) === "quiet" ? "quiet" : "always";166172 }167173 168174 /** How a point collection draws: markers, or floating text. Absent means pin. */169175 export function renderAs(collection: Collection): "pin" | "text" {170- return collection.attrs?.[KEY_RENDER_AS] === "text" ? "text" : "pin";176+ return (collection.renderAs || collection.attrs?.[KEY_RENDER_AS]) === "text" ? "text" : "pin";171177 }172178 173179 /** The ground width of a path collection's features, in world pixels. */
M render/data/plane.ts +55 -32
@@ -17,19 +17,28 @@
1717 // scene. The seam says so on the stream and gives up on that base; it does1818 // not reload the page, because navigation is the application's to decide.1919 //20-// Nothing here interprets a payload. The types are `payload.ts`, the packed21-// locations are `atlasloc.ts`, and what to draw is the scene's business.20+// The one interpretation here is structural: schema-described typed columns21+// become the semantic Volume graph. Presentation decisions remain the world's.2222 2323 import { logger } from "../log.ts";24-import { LocationTable } from "./atlasloc.ts";25-import type { Catalog, Lens, TextPayload, WorldPayload } from "./payload.ts";26-import { tileFormat } from "./payload.ts";24+import type { Catalog, Lens } from "./payload.ts";25+import { tileFormat, tileTemplate } from "./payload.ts";26+import {27+ decodeVolume, TABLE_NAMES,28+ type NativeTables, type Volume, type World,29+} from "./semantic.ts";30+import { PackedTable, type Schema } from "./vnext.ts";2731 2832 const log = logger("data");2933 3034 /** A build's URL prefix: `/data/v/<slug>/<stamp12>`. */3135 export type Base = string;3236 37+export interface OpenWorld {38+ readonly volume: Volume;39+ readonly world: World;40+}41+3342 /** Raised when a base answered 404 — the build moved out from under us. */3443 export class BuildMovedError extends Error {3544 readonly url: string;
@@ -44,7 +53,7 @@ export class BuildMovedError extends Error {
4453 /**4554 * One page's reading of the plane.4655 *47- * Payloads are kept per URL, which is per build, because the URL names the56+ * Native volumes are kept per base, which is per build, because the URL names the4857 * build. Two worlds of one volume share nothing but the base; two builds of4958 * one volume share nothing at all, which is the whole cache story.5059 */
@@ -58,30 +67,12 @@ export class DataPlane {
5867 return (await response.json()) as Catalog;5968 }6069 61- /** `worlds/<slug>.json` — read when the world opens. */62- world(base: Base, world: string): Promise<WorldPayload> {63- return this.json<WorldPayload>(`${base}/worlds/${world}.json`);64- }65-66- /** `worlds/<slug>.text` — read lazily, when a card opens. */67- text(base: Base, world: string): Promise<TextPayload> {68- return this.json<TextPayload>(`${base}/worlds/${world}.text`);69- }70-71- /**72- * `worlds/<slug>.bin` — every point feature, as views over the downloaded73- * buffer. The response is read as an `ArrayBuffer` precisely so the views74- * can be built over it without a copy.75- */76- locations(base: Base, world: string): Promise<LocationTable> {77- return this.keep(`${base}/worlds/${world}.bin`, async (url) => {78- const buffer = await this.bytes(url);79- const table = LocationTable.over(buffer);80- log.info("the packed locations are open", {81- op: "render", path: url, count: table.count,82- });83- return table;84- });70+ /** One semantic world, reconstructed from the schema-described tables. */71+ async world(base: Base, world: string): Promise<OpenWorld> {72+ const volume = await this.volume(base);73+ const found = volume.worlds.find((candidate) => candidate.id === world);74+ if (!found) throw new Error(`${base} does not contain world ${world}`);75+ return { volume, world: found };8576 }8677 8778 /** Where an icon asset lives. Icons are fetched by the browser, as images. */
@@ -93,7 +84,28 @@ export class DataPlane {
9384 tileURL(base: Base, lens: Lens, z: number, x: number, y: number): string | null {9485 const extension = tileFormat(lens, z);9586 if (!extension) return null;96- return `${base}/tiles/${lens.tiles}/${z}/${x}/${y}.${extension}`;87+ return `${base}/${fillTemplate(tileTemplate(lens), z, x, y, extension)}`;88+ }89+90+ private volume(base: Base): Promise<Volume> {91+ return this.keep(`${base}/@semantic-volume`, async () => {92+ const schemaURL = `${base}/schema.json`;93+ const schemaBytes = new Uint8Array(await this.bytes(schemaURL));94+ const schema = JSON.parse(new TextDecoder().decode(schemaBytes)) as Schema;95+ const schemaHash = await sha256(schemaBytes);96+ const entries = await Promise.all(TABLE_NAMES.map(async (name) => {97+ const url = `${base}/data/${name}.pack`;98+ const packed = PackedTable.over(await this.bytes(url));99+ if (packed.schemaHash !== schemaHash) throw new Error(`${url} was encoded against another schema`);100+ return [name, packed] as const;101+ }));102+ const tables = new Map(entries) as NativeTables;103+ const volume = await decodeVolume(schema, tables);104+ log.info("the semantic volume is open", {105+ op: "render", path: base, worlds: volume.worlds.length,106+ });107+ return volume;108+ });97109 }98110 99111 private json<T>(url: string): Promise<T> {
@@ -144,5 +156,16 @@ export class DataPlane {
144156 */145157 export function iconURL(base: Base, asset: string): string {146158 const path = asset.split("/").map((segment) => encodeURIComponent(segment)).join("/");147- return `${base}/icons/${path}`;159+ return `${base}/${path}`;160+}161+162+export function fillTemplate(template: string, z: number, x: number, y: number, format: string): string {163+ return template.replaceAll("{z}", String(z)).replaceAll("{x}", String(x))164+ .replaceAll("{y}", String(y)).replaceAll("{format}", format);165+}166+167+async function sha256(bytes: Uint8Array): Promise<string> {168+ const copy = Uint8Array.from(bytes);169+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", copy.buffer));170+ return [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");148171 }
M render/data/pyramid.ts +3 -2
A render/data/semantic.ts +638 -0
@@ -0,0 +1,638 @@
1+// The semantic vNext graph as the browser reads it.2+//3+// Nothing in this module knows the v3 document shapes. Stable field identities4+// select columns, wire kinds select decoders, and unknown typed properties stay5+// attached to the entity that owns them.6+7+import { PackedTable, type Schema } from "./vnext.ts";8+9+export type Position = readonly [number, number];10+11+export interface GeometryPart {12+ readonly rings: readonly (readonly Position[])[];13+}14+15+export interface Geometry {16+ readonly kind: 1 | 2 | 3;17+ readonly parts: readonly GeometryPart[];18+}19+20+export type PropertyValue = boolean | bigint | number | string | Uint8Array;21+22+export interface Property {23+ readonly id: string;24+ readonly name: string;25+ readonly kind: number;26+ readonly value: PropertyValue;27+}28+29+export interface Relationship {30+ readonly predicate: string;31+ readonly target: string;32+}33+34+export interface Provenance {35+ readonly source: string;36+ readonly nativeID: string;37+ readonly capturedAt: string;38+}39+40+export interface Feature {41+ readonly id: string;42+ readonly title: string;43+ readonly subtitle: string;44+ readonly description: string;45+ readonly center: Position | null;46+ readonly shard: number;47+ readonly geometry: Geometry;48+ readonly properties: readonly Property[];49+ readonly relationships: readonly Relationship[];50+ readonly provenance: readonly Provenance[];51+}52+53+export interface FeatureSet {54+ readonly id: string;55+ readonly title: string;56+ readonly semanticType: string;57+ readonly properties: readonly FieldDefinition[];58+ readonly claims: readonly Property[];59+ readonly features: readonly Feature[];60+}61+62+export interface FieldDefinition {63+ readonly id: string;64+ readonly name: string;65+ readonly kind: number;66+ readonly optional: boolean;67+}68+69+export interface CoordinateSpace {70+ readonly id: string;71+ readonly kind: string;72+ readonly unit: string;73+ readonly definition: string;74+ readonly extent: readonly [number, number, number, number];75+ readonly sourceZoom: number;76+ readonly firstTile: number;77+ readonly tileSize: number;78+ readonly size: number;79+}80+81+export interface RasterRect {82+ readonly x: number;83+ readonly y: number;84+ readonly width: number;85+ readonly height: number;86+}87+88+export interface RasterCoverage {89+ readonly zoom: number;90+ readonly x: number;91+ readonly y: number;92+ readonly w: number;93+ readonly h: number;94+ readonly bits: Uint8Array;95+}96+97+export interface RasterPyramid {98+ readonly id: string;99+ readonly name: string;100+ readonly codec: string;101+ readonly tileSize: number;102+ readonly minZoom: number;103+ readonly maxZoom: number;104+ readonly fullZoom: number;105+ readonly sourceZoom: number;106+ readonly template: string;107+ readonly formats: readonly string[];108+ readonly bounds: RasterRect | null;109+ readonly surface: RasterRect | null;110+ readonly interpolate: boolean;111+ readonly background: string;112+ readonly shard: number;113+ readonly coverage: readonly RasterCoverage[];114+}115+116+export interface Style {117+ readonly id: string;118+ readonly symbol: string;119+ readonly icon: string;120+ readonly iconAsset: string;121+ readonly iconPicture: boolean;122+ readonly renderAs: string;123+ readonly stroke: string;124+ readonly fill: string;125+}126+127+export interface Layer {128+ readonly id: string;129+ readonly featureSet: string;130+ readonly style: string;131+ readonly group: string;132+ readonly labelPolicy: string;133+ readonly visible: boolean;134+ readonly order: number;135+ readonly minZoom: number;136+ readonly maxZoom: number;137+}138+139+export interface LegendEntry {140+ readonly layer: string;141+ readonly label: string;142+ readonly order: number;143+}144+145+export interface Presentation {146+ readonly id: string;147+ readonly title: string;148+ readonly styles: readonly Style[];149+ readonly layers: readonly Layer[];150+ readonly legend: readonly LegendEntry[];151+}152+153+export interface World {154+ readonly id: string;155+ readonly title: string;156+ readonly coordinateSpace: CoordinateSpace;157+ readonly claims: readonly Property[];158+ readonly featureSets: readonly FeatureSet[];159+ readonly rasterPyramids: readonly RasterPyramid[];160+ readonly presentation: Presentation;161+}162+163+export interface Asset {164+ readonly id: string;165+ readonly mediaType: string;166+ readonly path: string;167+ readonly provenance: string;168+}169+170+export interface Volume {171+ readonly id: string;172+ readonly title: string;173+ readonly worlds: readonly World[];174+ readonly assets: ReadonlyMap<string, Asset>;175+}176+177+export const TABLE_NAMES = [178+ "volumes", "worlds", "coordinate-spaces", "feature-sets",179+ "property-definitions", "features", "relationships", "provenance",180+ "raster-pyramids", "raster-formats", "raster-coverage", "presentations",181+ "styles", "layers", "legend", "assets",182+] as const;183+184+export type TableName = typeof TABLE_NAMES[number];185+export type NativeTables = ReadonlyMap<TableName, PackedTable>;186+187+/** The browser-side form of format/vnext.CoreID. */188+export async function coreID(name: string): Promise<string> {189+ const source = new TextEncoder().encode(`dev.atlas.core\0${name}`);190+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", source)).slice(0, 16);191+ digest[6] = ((digest[6] ?? 0) & 0x0f) | 0x50;192+ digest[8] = ((digest[8] ?? 0) & 0x3f) | 0x80;193+ const plain = [...digest].map((byte) => byte.toString(16).padStart(2, "0")).join("");194+ return `${plain.slice(0, 8)}-${plain.slice(8, 12)}-${plain.slice(12, 16)}-${plain.slice(16, 20)}-${plain.slice(20)}`;195+}196+197+/** Decode the durable geometry encoding without translating through GeoJSON. */198+export function decodeGeometry(bytes: Uint8Array): Geometry {199+ if (bytes.byteLength < 5) throw new Error("geometry is shorter than its header");200+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);201+ const kind = bytes[0];202+ if (kind !== 1 && kind !== 2 && kind !== 3) throw new Error(`geometry kind ${kind ?? 0} is unsupported`);203+ const count = view.getUint32(1, true);204+ const parts: GeometryPart[] = [];205+ let at = 5;206+ for (let part = 0; part < count; part++) {207+ const rings = readCount(view, at, "geometry part");208+ at += 4;209+ const decoded: Position[][] = [];210+ for (let ring = 0; ring < rings; ring++) {211+ const positions = readCount(view, at, "geometry ring");212+ at += 4;213+ const length = positions * 16;214+ if (at > bytes.byteLength || length > bytes.byteLength - at) throw new Error("geometry positions are truncated");215+ const line: Position[] = [];216+ for (let position = 0; position < positions; position++) {217+ line.push([view.getFloat64(at, true), view.getFloat64(at + 8, true)]);218+ at += 16;219+ }220+ decoded.push(line);221+ }222+ parts.push({ rings: decoded });223+ }224+ if (at !== bytes.byteLength) throw new Error("geometry has trailing bytes");225+ return { kind, parts };226+}227+228+function readCount(view: DataView, at: number, subject: string): number {229+ if (at < 0 || at + 4 > view.byteLength) throw new Error(`${subject} is truncated`);230+ return view.getUint32(at, true);231+}232+233+const CORE_FIELDS = [234+ "volume.id", "volume.title",235+ "world.id", "world.title", "world.coordinateSpace",236+ "coordinate.id", "coordinate.world", "coordinate.kind", "coordinate.unit",237+ "coordinate.definition", "coordinate.extent", "coordinate.sourceZoom",238+ "coordinate.firstTile", "coordinate.tileSize", "coordinate.size",239+ "featureSet.id", "featureSet.world", "featureSet.title", "featureSet.semanticType",240+ "propertyDefinition.featureSet", "propertyDefinition.field", "propertyDefinition.name",241+ "propertyDefinition.kind", "propertyDefinition.optional",242+ "feature.id", "feature.featureSet", "feature.title", "feature.subtitle",243+ "feature.description", "feature.center", "feature.shard", "feature.geometry",244+ "relationship.feature", "relationship.predicate", "relationship.target",245+ "provenance.feature", "provenance.source", "provenance.nativeID", "provenance.capturedAt",246+ "rasterPyramid.id", "rasterPyramid.world", "rasterPyramid.name", "rasterPyramid.codec",247+ "rasterPyramid.tileSize", "rasterPyramid.minZoom", "rasterPyramid.maxZoom",248+ "rasterPyramid.fullZoom", "rasterPyramid.sourceZoom", "rasterPyramid.template",249+ "rasterPyramid.bounds", "rasterPyramid.surface", "rasterPyramid.interpolate",250+ "rasterPyramid.background", "rasterPyramid.shard",251+ "rasterFormat.raster", "rasterFormat.zoom", "rasterFormat.format",252+ "rasterCoverage.raster", "rasterCoverage.zoom", "rasterCoverage.x", "rasterCoverage.y",253+ "rasterCoverage.w", "rasterCoverage.h", "rasterCoverage.bits",254+ "presentation.id", "presentation.world", "presentation.title",255+ "style.id", "style.presentation", "style.symbol", "style.icon", "style.iconAsset",256+ "style.iconPicture", "style.renderAs", "style.stroke", "style.fill",257+ "layer.id", "layer.presentation", "layer.featureSet", "layer.style", "layer.group",258+ "layer.labelPolicy", "layer.visible", "layer.order", "layer.minZoom", "layer.maxZoom",259+ "legend.presentation", "legend.layer", "legend.label", "legend.order",260+ "asset.id", "asset.mediaType", "asset.path", "asset.provenance",261+] as const;262+263+type CoreField = typeof CORE_FIELDS[number];264+265+class NativeTable {266+ readonly #table: PackedTable;267+ readonly #schema: Schema;268+ readonly #ids: ReadonlyMap<string, string>;269+ readonly #fields: ReadonlyMap<string, { readonly id: string; readonly name: string; readonly kind: number }>;270+271+ constructor(table: PackedTable, schema: Schema, ids: ReadonlyMap<string, string>) {272+ this.#table = table;273+ this.#schema = schema;274+ this.#ids = ids;275+ const type = schema.types.find((candidate) => candidate.id === table.rootType);276+ if (!type) throw new Error(`schema does not describe table type ${table.rootType}`);277+ this.#fields = new Map(type.fields.map((field) => [field.id, field]));278+ }279+280+ get rows(): number { return this.#table.rows; }281+282+ string(name: CoreField, row: number): string {283+ const value = this.#column(name).string(row);284+ if (value === null) throw new Error(`${name} is null at row ${row}`);285+ return value;286+ }287+288+ optionalString(name: CoreField, row: number): string {289+ return this.#column(name).string(row) ?? "";290+ }291+292+ int(name: CoreField, row: number): number {293+ const value = this.#column(name).int64(row);294+ if (value === null) throw new Error(`${name} is null at row ${row}`);295+ return safeNumber(value, name);296+ }297+298+ bool(name: CoreField, row: number): boolean {299+ const value = this.#column(name).bool(row);300+ if (value === null) throw new Error(`${name} is null at row ${row}`);301+ return value;302+ }303+304+ bytes(name: CoreField, row: number): Uint8Array {305+ const value = this.#column(name).bytes(row);306+ if (value === null) throw new Error(`${name} is null at row ${row}`);307+ return value;308+ }309+310+ optionalBytes(name: CoreField, row: number): Uint8Array | null {311+ return this.#column(name).bytes(row);312+ }313+314+ identifier(name: CoreField, row: number): string {315+ const value = this.#column(name).identifier(row);316+ if (value === null) throw new Error(`${name} is null at row ${row}`);317+ return value;318+ }319+320+ extensions(row: number, owner: string): Property[] {321+ const core = new Set(CORE_FIELDS.filter((name) => name.startsWith(`${owner}.`)).map((name) => this.#ids.get(name)));322+ const properties: Property[] = [];323+ for (const column of this.#table.columns) {324+ if (core.has(column.id) || column.isNull(row)) continue;325+ const field = this.#fields.get(column.id);326+ if (!field) throw new Error(`schema does not describe extension field ${column.id}`);327+ const value = columnValue(column, row);328+ if (value !== null) properties.push({ id: field.id, name: field.name, kind: field.kind, value });329+ }330+ return properties;331+ }332+333+ #column(name: CoreField) {334+ const id = this.#ids.get(name);335+ if (!id) throw new Error(`browser core schema has no ${name}`);336+ const column = this.#table.column(id);337+ if (!column) throw new Error(`table ${this.#table.rootType} has no ${name}`);338+ return column;339+ }340+}341+342+interface WorldBuilder {343+ id: string;344+ title: string;345+ coordinateSpace: CoordinateSpace | null;346+ claims: Property[];347+ featureSets: FeatureSetBuilder[];348+ rasterPyramids: RasterBuilder[];349+ presentation: PresentationBuilder | null;350+}351+352+interface FeatureSetBuilder extends Omit<FeatureSet, "features" | "properties"> {353+ features: FeatureBuilder[];354+ properties: FieldDefinition[];355+}356+357+interface FeatureBuilder extends Omit<Feature, "relationships" | "provenance"> {358+ relationships: Relationship[];359+ provenance: Provenance[];360+}361+362+interface RasterBuilder extends Omit<RasterPyramid, "formats" | "coverage"> {363+ formats: string[];364+ coverage: RasterCoverage[];365+}366+367+interface PresentationBuilder extends Omit<Presentation, "styles" | "layers" | "legend"> {368+ styles: Style[];369+ layers: Layer[];370+ legend: LegendEntry[];371+}372+373+/** Reconstruct the semantic graph directly from schema-described packed tables. */374+export async function decodeVolume(schema: Schema, tables: NativeTables): Promise<Volume> {375+ const ids = new Map<string, string>();376+ await Promise.all(CORE_FIELDS.map(async (name) => ids.set(name, await coreID(name))));377+ const table = (name: TableName): NativeTable => {378+ const held = tables.get(name);379+ if (!held) throw new Error(`volume has no data/${name}.pack`);380+ return new NativeTable(held, schema, ids);381+ };382+383+ const root = table("volumes");384+ if (root.rows !== 1) throw new Error(`volume table has ${root.rows} rows`);385+ const worlds = decodeWorlds(table);386+ const sets = decodeFeatureSets(table, worlds);387+ const features = decodeFeatures(table, sets);388+ decodeEvidence(table, features);389+ decodeRasters(table, worlds);390+ decodePresentation(table, worlds);391+ const assets = decodeAssets(table);392+ const complete = [...worlds.values()].map(completeWorld);393+ return { id: root.string("volume.id", 0), title: root.string("volume.title", 0), worlds: complete, assets };394+}395+396+function decodeWorlds(table: (name: TableName) => NativeTable): Map<string, WorldBuilder> {397+ const source = table("worlds");398+ const worlds = new Map<string, WorldBuilder>();399+ for (let row = 0; row < source.rows; row++) {400+ const id = source.string("world.id", row);401+ worlds.set(id, {402+ id, title: source.string("world.title", row), coordinateSpace: null,403+ claims: source.extensions(row, "world"), featureSets: [], rasterPyramids: [], presentation: null,404+ });405+ }406+ const coordinates = table("coordinate-spaces");407+ for (let row = 0; row < coordinates.rows; row++) {408+ const world = requireFrom(worlds, coordinates.string("coordinate.world", row), "coordinate world");409+ world.coordinateSpace = {410+ id: coordinates.string("coordinate.id", row), kind: coordinates.string("coordinate.kind", row),411+ unit: coordinates.string("coordinate.unit", row), definition: coordinates.string("coordinate.definition", row),412+ extent: decodeExtent(coordinates.bytes("coordinate.extent", row)),413+ sourceZoom: coordinates.int("coordinate.sourceZoom", row), firstTile: coordinates.int("coordinate.firstTile", row),414+ tileSize: coordinates.int("coordinate.tileSize", row), size: coordinates.int("coordinate.size", row),415+ };416+ }417+ return worlds;418+}419+420+function decodeFeatureSets(421+ table: (name: TableName) => NativeTable,422+ worlds: ReadonlyMap<string, WorldBuilder>,423+): Map<string, FeatureSetBuilder> {424+ const source = table("feature-sets");425+ const sets = new Map<string, FeatureSetBuilder>();426+ for (let row = 0; row < source.rows; row++) {427+ const id = source.string("featureSet.id", row);428+ const set: FeatureSetBuilder = {429+ id, title: source.string("featureSet.title", row), semanticType: source.string("featureSet.semanticType", row),430+ claims: source.extensions(row, "featureSet"), properties: [], features: [],431+ };432+ requireFrom(worlds, source.string("featureSet.world", row), "feature-set world").featureSets.push(set);433+ sets.set(id, set);434+ }435+ const definitions = table("property-definitions");436+ for (let row = 0; row < definitions.rows; row++) {437+ requireFrom(sets, definitions.string("propertyDefinition.featureSet", row), "property definition set").properties.push({438+ id: definitions.identifier("propertyDefinition.field", row),439+ name: definitions.string("propertyDefinition.name", row), kind: definitions.int("propertyDefinition.kind", row),440+ optional: definitions.bool("propertyDefinition.optional", row),441+ });442+ }443+ return sets;444+}445+446+function decodeFeatures(447+ table: (name: TableName) => NativeTable,448+ sets: ReadonlyMap<string, FeatureSetBuilder>,449+): Map<string, FeatureBuilder> {450+ const source = table("features");451+ const features = new Map<string, FeatureBuilder>();452+ for (let row = 0; row < source.rows; row++) {453+ const id = source.string("feature.id", row);454+ const center = source.optionalBytes("feature.center", row);455+ const feature: FeatureBuilder = {456+ id, title: source.string("feature.title", row), subtitle: source.optionalString("feature.subtitle", row),457+ description: source.optionalString("feature.description", row), center: center ? decodePosition(center) : null,458+ shard: source.int("feature.shard", row), geometry: decodeGeometry(source.bytes("feature.geometry", row)),459+ properties: source.extensions(row, "feature"), relationships: [], provenance: [],460+ };461+ requireFrom(sets, source.string("feature.featureSet", row), "feature set").features.push(feature);462+ features.set(id, feature);463+ }464+ return features;465+}466+467+function decodeEvidence(468+ table: (name: TableName) => NativeTable,469+ features: ReadonlyMap<string, FeatureBuilder>,470+): void {471+ const relationships = table("relationships");472+ for (let row = 0; row < relationships.rows; row++) {473+ requireFrom(features, relationships.string("relationship.feature", row), "relationship feature").relationships.push({474+ predicate: relationships.string("relationship.predicate", row), target: relationships.string("relationship.target", row),475+ });476+ }477+ const provenance = table("provenance");478+ for (let row = 0; row < provenance.rows; row++) {479+ requireFrom(features, provenance.string("provenance.feature", row), "provenance feature").provenance.push({480+ source: provenance.string("provenance.source", row), nativeID: provenance.string("provenance.nativeID", row),481+ capturedAt: provenance.string("provenance.capturedAt", row),482+ });483+ }484+}485+486+function decodeRasters(487+ table: (name: TableName) => NativeTable,488+ worlds: ReadonlyMap<string, WorldBuilder>,489+): void {490+ const source = table("raster-pyramids");491+ const rasters = new Map<string, RasterBuilder>();492+ for (let row = 0; row < source.rows; row++) {493+ const id = source.string("rasterPyramid.id", row);494+ const bounds = source.optionalBytes("rasterPyramid.bounds", row);495+ const surface = source.optionalBytes("rasterPyramid.surface", row);496+ const raster: RasterBuilder = {497+ id, name: source.string("rasterPyramid.name", row), codec: source.string("rasterPyramid.codec", row),498+ tileSize: source.int("rasterPyramid.tileSize", row), minZoom: source.int("rasterPyramid.minZoom", row),499+ maxZoom: source.int("rasterPyramid.maxZoom", row), fullZoom: source.int("rasterPyramid.fullZoom", row),500+ sourceZoom: source.int("rasterPyramid.sourceZoom", row), template: source.string("rasterPyramid.template", row),501+ bounds: bounds ? decodeRect(bounds) : null, surface: surface ? decodeRect(surface) : null,502+ interpolate: source.bool("rasterPyramid.interpolate", row), background: source.optionalString("rasterPyramid.background", row),503+ shard: source.int("rasterPyramid.shard", row), formats: [], coverage: [],504+ };505+ requireFrom(worlds, source.string("rasterPyramid.world", row), "raster world").rasterPyramids.push(raster);506+ rasters.set(id, raster);507+ }508+ const formats = table("raster-formats");509+ const byRaster = new Map<string, Map<number, string>>();510+ for (let row = 0; row < formats.rows; row++) {511+ const id = formats.string("rasterFormat.raster", row);512+ requireFrom(rasters, id, "raster format raster");513+ const held = byRaster.get(id) ?? new Map<number, string>();514+ held.set(formats.int("rasterFormat.zoom", row), formats.string("rasterFormat.format", row));515+ byRaster.set(id, held);516+ }517+ for (const [id, raster] of rasters) {518+ const held = byRaster.get(id) ?? new Map<number, string>();519+ for (let zoom = raster.minZoom; zoom <= raster.maxZoom; zoom++) raster.formats.push(held.get(zoom) ?? "");520+ }521+ const coverage = table("raster-coverage");522+ for (let row = 0; row < coverage.rows; row++) {523+ requireFrom(rasters, coverage.string("rasterCoverage.raster", row), "raster coverage raster").coverage.push({524+ zoom: coverage.int("rasterCoverage.zoom", row), x: coverage.int("rasterCoverage.x", row),525+ y: coverage.int("rasterCoverage.y", row), w: coverage.int("rasterCoverage.w", row),526+ h: coverage.int("rasterCoverage.h", row), bits: coverage.bytes("rasterCoverage.bits", row),527+ });528+ }529+}530+531+function decodePresentation(532+ table: (name: TableName) => NativeTable,533+ worlds: ReadonlyMap<string, WorldBuilder>,534+): void {535+ const source = table("presentations");536+ const presentations = new Map<string, PresentationBuilder>();537+ for (let row = 0; row < source.rows; row++) {538+ const id = source.string("presentation.id", row);539+ const presentation: PresentationBuilder = {540+ id, title: source.string("presentation.title", row), styles: [], layers: [], legend: [],541+ };542+ requireFrom(worlds, source.string("presentation.world", row), "presentation world").presentation = presentation;543+ presentations.set(id, presentation);544+ }545+ const styles = table("styles");546+ for (let row = 0; row < styles.rows; row++) {547+ requireFrom(presentations, styles.string("style.presentation", row), "style presentation").styles.push({548+ id: styles.string("style.id", row), symbol: styles.optionalString("style.symbol", row),549+ icon: styles.optionalString("style.icon", row), iconAsset: styles.optionalString("style.iconAsset", row),550+ iconPicture: styles.bool("style.iconPicture", row), renderAs: styles.optionalString("style.renderAs", row),551+ stroke: styles.optionalString("style.stroke", row), fill: styles.optionalString("style.fill", row),552+ });553+ }554+ const layers = table("layers");555+ for (let row = 0; row < layers.rows; row++) {556+ requireFrom(presentations, layers.string("layer.presentation", row), "layer presentation").layers.push({557+ id: layers.string("layer.id", row), featureSet: layers.string("layer.featureSet", row),558+ style: layers.string("layer.style", row), group: layers.optionalString("layer.group", row),559+ labelPolicy: layers.optionalString("layer.labelPolicy", row), visible: layers.bool("layer.visible", row),560+ order: layers.int("layer.order", row), minZoom: layers.int("layer.minZoom", row), maxZoom: layers.int("layer.maxZoom", row),561+ });562+ }563+ const legend = table("legend");564+ for (let row = 0; row < legend.rows; row++) {565+ requireFrom(presentations, legend.string("legend.presentation", row), "legend presentation").legend.push({566+ layer: legend.string("legend.layer", row), label: legend.string("legend.label", row), order: legend.int("legend.order", row),567+ });568+ }569+ for (const presentation of presentations.values()) {570+ presentation.layers.sort((left, right) => left.order - right.order || left.id.localeCompare(right.id));571+ presentation.legend.sort((left, right) => left.order - right.order || left.layer.localeCompare(right.layer));572+ }573+}574+575+function decodeAssets(table: (name: TableName) => NativeTable): ReadonlyMap<string, Asset> {576+ const source = table("assets");577+ const assets = new Map<string, Asset>();578+ for (let row = 0; row < source.rows; row++) {579+ const id = source.string("asset.id", row);580+ assets.set(id, {581+ id, mediaType: source.string("asset.mediaType", row), path: source.string("asset.path", row),582+ provenance: source.optionalString("asset.provenance", row),583+ });584+ }585+ return assets;586+}587+588+function completeWorld(builder: WorldBuilder): World {589+ if (!builder.coordinateSpace) throw new Error(`world ${builder.id} has no coordinate space`);590+ if (!builder.presentation) throw new Error(`world ${builder.id} has no presentation`);591+ return {592+ id: builder.id, title: builder.title, coordinateSpace: builder.coordinateSpace,593+ claims: builder.claims, featureSets: builder.featureSets, rasterPyramids: builder.rasterPyramids,594+ presentation: builder.presentation,595+ };596+}597+598+function decodeExtent(bytes: Uint8Array): readonly [number, number, number, number] {599+ if (bytes.byteLength !== 32) throw new Error("extent is not four float64 values");600+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);601+ return [view.getFloat64(0, true), view.getFloat64(8, true), view.getFloat64(16, true), view.getFloat64(24, true)];602+}603+604+function decodePosition(bytes: Uint8Array): Position {605+ if (bytes.byteLength !== 16) throw new Error("position is not two float64 values");606+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);607+ return [view.getFloat64(0, true), view.getFloat64(8, true)];608+}609+610+function decodeRect(bytes: Uint8Array): RasterRect {611+ const [x, y, width, height] = decodeExtent(bytes);612+ return { x, y, width, height };613+}614+615+function columnValue(column: ReturnType<PackedTable["column"]> & {}, row: number): PropertyValue | null {616+ switch (column.kind) {617+ case 1: return column.bool(row);618+ case 2: return column.int64(row);619+ case 3: return column.float64(row);620+ case 4: return column.string(row);621+ case 5: return column.bytes(row);622+ case 6: return column.identifier(row);623+ default: throw new Error(`field ${column.id} has unsupported wire kind ${column.kind}`);624+ }625+}626+627+function safeNumber(value: bigint, subject: string): number {628+ if (value > BigInt(Number.MAX_SAFE_INTEGER) || value < BigInt(Number.MIN_SAFE_INTEGER)) {629+ throw new Error(`${subject} exceeds JavaScript's safe integer range`);630+ }631+ return Number(value);632+}633+634+function requireFrom<K, V>(source: ReadonlyMap<K, V>, key: K, subject: string): V {635+ const held = source.get(key);636+ if (held === undefined) throw new Error(`${subject} ${String(key)} does not exist`);637+ return held;638+}
M render/globe/element.ts +8 -6
@@ -59,6 +59,7 @@ import {
5959 import type { GeoMapping, PlanCell, Ring } from "@atlas/analysis";6060 import { logger } from "../log.ts";6161 import type { WorldContext } from "../context.ts";62+import { tilePath } from "../data/pyramid.ts";6263 import type { Line as ShapeLine, ShapeRecord } from "../world/model.ts";6364 import type { Attrs, Collection, Lens } from "../data/payload.ts";6465 import { iconURL } from "../data/plane.ts";
@@ -410,7 +411,7 @@ export class AtlasGlobe extends HTMLElement {
410411 */411412 show(context: WorldContext): void {412413 this.context = context;413- this.equirect = equirectOf(context.model.payload.attrs ?? {});414+ this.equirect = equirectOf(context.model.attrs);414415 if (!this.equirect || !this.globe) return;415416 const worldKey = `${context.base}/${context.model.slug}`;416417 if (worldKey !== this.worldKey) {
@@ -899,8 +900,8 @@ export class AtlasGlobe extends HTMLElement {
899900 for (const held of this.sprites.values()) {900901 const sprite = held as THREE.Sprite;901902 if (!sprite.isSprite) continue;902- const stood = held.userData as { id?: string; owner?: number };903- const mark = marks.get(stood.owner ?? -1) ?? BARE_MARKER;903+ const stood = held.userData as { id?: string; owner?: string | number };904+ const mark = marks.get(stood.owner ?? "") ?? BARE_MARKER;904905 const chosen = stood.id === context.scene.selected;905906 sprite.material = markerMaterial(mark, chosen);906907 const size = chosen ? PIN_SELECTED_SIZE : PIN_SIZE;
@@ -1472,7 +1473,8 @@ export class AtlasGlobe extends HTMLElement {
14721473 if (!context || !lens) return null;14731474 const extension = lens.formats[z - lens.minZoom];14741475 if (!extension) return null;1475- return `${context.base}/tiles/${lens.tiles}/${z}/${x}/${y}.${extension}`;1476+ const path = tilePath(lens, z, x, y);1477+ return path ? `${context.base}/${path}` : null;14761478 }14771479 14781480 private pick(lat: number, lng: number): void {
@@ -2143,9 +2145,9 @@ const BARE_MARKER: Marker = {
21432145 };21442146 21452147 /** Every collection's marker, in payload order, which is palette order. */2146-function markersOf(context: WorldContext): Map<number, Marker> {2148+function markersOf(context: WorldContext): Map<string | number, Marker> {21472149 const outset = outsetColor(context.outset);2148- const marker = (collection: Collection, ordinal: number): [number, Marker] => [2150+ const marker = (collection: Collection, ordinal: number): [string | number, Marker] => [21492151 collection.id,21502152 {21512153 asset: collection.iconAsset ?? "",
M render/index.ts +1 -2
M render/test/element.test.ts +10 -19
@@ -39,6 +39,7 @@ import { shapeContains } from "../world/visibility.ts";
3939 import type { Collection } from "../data/payload.ts";4040 import { payloads, tileGrid, volumes } from "./fixtures.ts";4141 import { gamePlane } from "./models.ts";42+import { legacyOpenWorld } from "./legacy.ts";4243 4344 // The seam's chart element extends `HTMLElement` at the moment it is defined,4445 // which is one browser global too many for `node --test`. Nothing else in the
@@ -61,7 +62,11 @@ function shape(
6162 return {6263 id: "1", title: "Two Pieces", subtitle: "", collection: kind === "area" ? AREAS : PATHS,6364 kind, shard: 0, lines, holes, center: null,64- feature: { id: 1, title: "Two Pieces", geometry: [] },65+ feature: {66+ id: "1", title: "Two Pieces", subtitle: "", description: "", center: null, shard: 0,67+ geometry: { kind: kind === "path" ? 2 : 3, parts: [] },68+ properties: [], relationships: [], provenance: [],69+ },6570 };6671 }6772
@@ -191,7 +196,7 @@ test("containment asks every part, and every part's own holes", () => {
191196 function city(): WorldModel {192197 const payload = payloads("bend-or").get("2026-08-02");193198 assert.ok(payload, "the city fixture has a world payload");194- return new WorldModel("2026-08-02", payload, worldGrid(tileGrid("bend-or"), payload), null);199+ return new WorldModel(legacyOpenWorld("2026-08-02", payload, worldGrid(tileGrid("bend-or"), payload), null));195200 }196201 197202 /**
@@ -200,21 +205,7 @@ function city(): WorldModel {
200205 * — or, for a path, its single run's.201206 */202207 function payloadParts(shape: ShapeRecord): number[][] {203- const rings = (part: unknown) => (part as unknown[]).map((ring) => (ring as unknown[]).length);204- const counts: number[][] = [];205- for (const geometry of shape.feature.geometry ?? []) {206- const coordinates = geometry.coordinates as unknown[];207- if (geometry.type === "MultiPolygon") {208- for (const polygon of coordinates) counts.push(rings(polygon));209- } else if (geometry.type === "Polygon") {210- counts.push(rings(coordinates));211- } else if (geometry.type === "MultiLineString") {212- for (const line of coordinates) counts.push([(line as unknown[]).length]);213- } else if (geometry.type === "LineString") {214- counts.push([coordinates.length]);215- }216- }217- return counts;208+ return shape.feature.geometry.parts.map((part) => part.rings.map((ring) => ring.length));218209 }219210 220211 test("the city's zoning draws every piece of ground it owns", () => {
@@ -255,13 +246,13 @@ test("no shape in any volume, corpus or invented, loses a part", () => {
255246 for (const slug of volumes()) {256247 for (const [name, payload] of payloads(slug)) {257248 worlds.push(258- [`${slug}/${name}`, new WorldModel(name, payload, worldGrid(tileGrid(slug), payload), null)]);249+ [`${slug}/${name}`, new WorldModel(legacyOpenWorld(name, payload, worldGrid(tileGrid(slug), payload), null))]);259250 }260251 }261252 const city = gamePlane();262253 for (const [name, payload] of city.worlds) {263254 worlds.push(264- [`${city.slug}/${name}`, new WorldModel(name, payload, city.tileGrid, null)]);255+ [`${city.slug}/${name}`, new WorldModel(legacyOpenWorld(name, payload, city.tileGrid, null))]);265256 }266257 let multipart = 0;267258 for (const [label, world] of worlds) {
M render/test/grid.test.ts +8 -7
@@ -34,6 +34,7 @@ import { cellMoved, drawGrid, planCells } from "../chart/grid.ts";
3434 import { labelFitsCell } from "../chart/styles.ts";3535 import type { DrawnCell } from "../chart/grid.ts";3636 import { WorldModel, worldGrid } from "../world/model.ts";37+import { legacyOpenWorld } from "./legacy.ts";3738 import type { PointRecord } from "../world/model.ts";3839 import { Visibility } from "../world/visibility.ts";3940 import { EMPTY_SCENE } from "../scene/read.ts";
@@ -100,8 +101,8 @@ function world(slug: string, name: string): WorldModel {
100101 assert.ok(payload, `${slug}/${name} has a payload`);101102 // The world's own window overrides the manifest's, which is the whole102103 // difference between a coordinate on this world and one 850,000 pixels away.103- return new WorldModel(name, payload, worldGrid(tileGrid(slug), payload),104- table(locations(slug, name)));104+ return new WorldModel(legacyOpenWorld(name, payload, worldGrid(tileGrid(slug), payload),105+ table(locations(slug, name))));105106 }106107 107108 /**
@@ -164,7 +165,7 @@ function sweep(points: readonly PointRecord[], extent: Extent): number {
164165 function draw(model: WorldModel, cell: string): {165166 cells: DrawnCell[]; extent: readonly number[] | null; standing: PointRecord[];166167 } {167- const lens = model.payload.lenses[0] ?? null;168+ const lens = model.lenses[0] ?? null;168169 const ground = model.ground(lens);169170 const system = cellSystems.require("geohash");170171 const standing = [...new Visibility(model, EMPTY_SCENE, lens?.shard ?? 0, null).standing()];
@@ -325,7 +326,7 @@ test("descending into a real cell holds the window's own context", () => {
325326 326327 test("the chosen path draws under the pins and the context over them", () => {327328 const model = world("mars", "global");328- const lens = model.payload.lenses[0] ?? null;329+ const lens = model.lenses[0] ?? null;329330 const ground = model.ground(lens);330331 const system = cellSystems.require("geohash");331332 const chosen = new VectorSource({ wrapX: false });
@@ -361,7 +362,7 @@ test("the chosen path draws under the pins and the context over them", () => {
361362 362363 /** Every feature one draw built, chosen path first, in the sources' own order. */363364 function features(model: WorldModel, cell: string): FeatureLike[] {364- const lens = model.payload.lenses[0] ?? null;365+ const lens = model.lenses[0] ?? null;365366 const chosen = new VectorSource({ wrapX: false });366367 const context = new VectorSource({ wrapX: false });367368 drawGrid(model.ground(lens), cellSystems.require("geohash"), cell, [], chosen, context);
@@ -376,7 +377,7 @@ function planned(model: WorldModel, cell: string): PlanCell[] {
376377 /** What one cell paints at one resolution: null is "nothing at all". */377378 function paints(cell: PlanCell, resolution: number, subgridVisible: boolean): boolean {378379 const model = world("mars", "global");379- const ground = model.ground(model.payload.lenses[0] ?? null);380+ const ground = model.ground(model.lenses[0] ?? null);380381 return gridCellVisual(ground, cellSystems.require("geohash"), cell, {381382 subgridVisible,382383 labelled: labelFitsCell(cell.hash, cell.role, cell.extent, resolution),
@@ -396,7 +397,7 @@ test("a cell held two levels down plans every one of its children, in the frozen
396397 ];397398 const model = world("mars", "global");398399 const plan = planCells(399- model.ground(model.payload.lenses[0] ?? null), cellSystems.require("geohash"), "m6");400+ model.ground(model.lenses[0] ?? null), cellSystems.require("geohash"), "m6");400401 assert.deepEqual(401402 plan.map((cell) => [cell.hash, cell.role, cell.contextDistance]), expected,402403 "ninety-five cells, position for position");
A render/test/legacy.ts +145 -0
@@ -0,0 +1,145 @@
1+// Test-only adapter for the committed v3 JSON corpus. Production code has no2+// legacy presentation reader; these old extractions remain useful as expected3+// pictures while the corpus is rewritten as semantic fixtures.4+5+import type { LocationTable } from "../data/atlasloc.ts";6+import type { Collection, Geometry as LegacyGeometry, WorldPayload, TileGrid } from "../data/payload.ts";7+import type { OpenWorld } from "../data/plane.ts";8+import type {9+ Feature, Geometry, GeometryPart, Property, RasterCoverage, RasterPyramid,10+} from "../data/semantic.ts";11+import { project } from "../world/model.ts";12+13+export function legacyOpenWorld(14+ slug: string,15+ payload: WorldPayload,16+ grid: TileGrid,17+ locations: LocationTable | null,18+): OpenWorld {19+ const sets = payload.collections.map((collection, owner) => ({20+ id: `legacy/set/${collection.id}`,21+ title: collection.title,22+ semanticType: `geometry.${collection.kind}`,23+ properties: [],24+ claims: properties(collection.attrs),25+ features: features(collection, owner, grid, locations),26+ }));27+ const styles = payload.collections.map((collection) => ({28+ id: `legacy/style/${collection.id}`, symbol: "", icon: collection.icon ?? "",29+ iconAsset: "", iconPicture: Boolean(collection.iconPicture),30+ renderAs: collection.renderAs ?? collection.attrs?.["atlas.render.as"] ?? "",31+ stroke: collection.kind === "path" ? collection.color ?? collection.iconColor ?? "" : "",32+ fill: collection.color ?? collection.iconColor ?? "",33+ }));34+ const layers = payload.collections.map((collection, order) => ({35+ id: String(collection.id), presentation: "legacy/presentation",36+ featureSet: `legacy/set/${collection.id}`, style: `legacy/style/${collection.id}`,37+ group: collection.group ?? "", labelPolicy: collection.labelPolicy ?? collection.attrs?.["atlas.label.policy"] ?? "",38+ visible: collection.visible, order, minZoom: 0, maxZoom: 32,39+ }));40+ const rasters = payload.lenses.map((lens): RasterPyramid => ({41+ id: lens.id ?? lens.tiles, name: lens.name, codec: "image/fixture", tileSize: grid.tileSize,42+ minZoom: lens.minZoom, maxZoom: lens.maxZoom, fullZoom: lens.fullZoom,43+ sourceZoom: lens.sourceZoom, template: lens.template ?? `tiles/${lens.tiles}/{z}/{x}/{y}.{format}`,44+ formats: lens.formats, bounds: lens.bounds ?? null, surface: lens.surface ?? null,45+ interpolate: lens.interpolate, background: lens.background ?? "", shard: lens.shard ?? 0,46+ coverage: Object.entries(lens.coverage ?? {}).map(([zoom, level]): RasterCoverage => ({47+ zoom: Number(zoom), x: level.x, y: level.y, w: level.w, h: level.h, bits: unbase64(level.bits),48+ })),49+ }));50+ return {51+ volume: { id: "legacy", title: "Legacy fixture", worlds: [], assets: new Map() },52+ world: {53+ id: slug, title: slug,54+ coordinateSpace: {55+ id: `${slug}/space`, kind: "projected", unit: "pixel", definition: "atlas:tile-plane",56+ extent: [0, 0, grid.size, grid.size], sourceZoom: grid.sourceZoom,57+ firstTile: grid.firstTile, tileSize: grid.tileSize, size: grid.size,58+ },59+ claims: properties(payload.attrs), featureSets: sets, rasterPyramids: rasters,60+ presentation: {61+ id: "legacy/presentation", title: slug, styles, layers,62+ legend: layers.map((layer, order) => ({ layer: layer.id, label: sets[order]?.title ?? "", order })),63+ },64+ },65+ };66+}67+68+function features(69+ collection: Collection,70+ owner: number,71+ grid: TileGrid,72+ locations: LocationTable | null,73+): Feature[] {74+ const out: Feature[] = [];75+ if (collection.kind === "point" && locations) {76+ for (let row = 0; row < locations.count; row++) {77+ if (locations.owner[row] !== owner) continue;78+ const shown = project(grid, locations.lat[row] ?? 0, locations.lng[row] ?? 0);79+ const id = String(locations.id[row] ?? 0);80+ const position = [shown[0], -shown[1]] as const;81+ out.push(feature(id, locations.title(row), {82+ kind: 1, parts: [{ rings: [[position]] }],83+ }, locations.shard[row] ?? 0, [], locations.member[row] ? [{ predicate: "within", target: String(locations.member[row]) }] : []));84+ }85+ }86+ for (const shape of collection.features ?? []) {87+ const relationships = shape.parent === undefined ? [] : [{ predicate: "within", target: String(shape.parent) }];88+ const held = feature(String(shape.id), shape.title, legacyGeometry(shape.geometry, collection.kind, grid), shape.shard ?? 0,89+ properties(shape.attrs), relationships);90+ (held as { subtitle: string }).subtitle = shape.subtitle ?? "";91+ if (shape.center) {92+ const shown = project(grid, shape.center.lat, shape.center.lng);93+ (held as { center: readonly [number, number] }).center = [shown[0], -shown[1]];94+ }95+ out.push(held);96+ }97+ return out;98+}99+100+function feature(101+ id: string,102+ title: string,103+ geometry: Geometry,104+ shard: number,105+ attrs: readonly Property[],106+ relationships: readonly { readonly predicate: string; readonly target: string }[],107+): Feature {108+ return {109+ id, title, subtitle: "", description: "", center: null, shard, geometry,110+ properties: attrs, relationships, provenance: [],111+ };112+}113+114+function legacyGeometry(items: readonly LegacyGeometry[], kind: Collection["kind"], grid: TileGrid): Geometry {115+ const parts: GeometryPart[] = [];116+ for (const item of items) {117+ switch (item.type) {118+ case "Polygon": parts.push({ rings: rings(item.coordinates, grid) }); break;119+ case "MultiPolygon": for (const polygon of item.coordinates as unknown[]) parts.push({ rings: rings(polygon, grid) }); break;120+ case "LineString": parts.push({ rings: [line(item.coordinates, grid)] }); break;121+ case "MultiLineString": for (const raw of item.coordinates as unknown[]) parts.push({ rings: [line(raw, grid)] }); break;122+ }123+ }124+ return { kind: kind === "path" ? 2 : 3, parts };125+}126+127+function rings(raw: unknown, grid: TileGrid): readonly (readonly (readonly [number, number])[])[] {128+ return (raw as unknown[]).map((held) => line(held, grid));129+}130+131+function line(raw: unknown, grid: TileGrid): readonly (readonly [number, number])[] {132+ return (raw as [number, number][]).map(([lng, lat]) => {133+ const shown = project(grid, lat, lng);134+ return [shown[0], -shown[1]] as const;135+ });136+}137+138+function properties(attrs?: Readonly<Record<string, string>>): Property[] {139+ return Object.entries(attrs ?? {}).map(([name, value]) => ({ id: `legacy:${name}`, name, kind: 4, value }));140+}141+142+function unbase64(value: string): Uint8Array {143+ const binary = atob(value);144+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));145+}
M render/test/markers.test.ts +3 -3
M render/test/pins.test.ts +1 -1
M render/test/styles.test.ts +19 -16
@@ -54,6 +54,7 @@ import type { ShapeRecord } from "../world/model.ts";
5454 import {5555 OUTSET_COLORS, forgetMarkerRasters, legibleIconColor, outsetColor,5656 } from "../chart/markers.ts";57+import { legacyOpenWorld } from "./legacy.ts";5758 5859 // ---- the page a raster is composed on --------------------------------5960
@@ -158,7 +159,12 @@ const COLLECTIONS = [PLAIN, SPOKEN, QUIETED, TEXT, SHRINES, DAEDRIC, LETTERED];
158159 function pin(collection: Collection, id = "7", title = "Goodsprings"): PointRecord {159160 return {160161 id, index: 0, title, collection,161- coordinate: [0, 0], member: 0, shard: 0, priority: 42,162+ coordinate: [0, 0], member: "", shard: 0, priority: 42,163+ feature: {164+ id, title, subtitle: "", description: "", center: null, shard: 0,165+ geometry: { kind: 1, parts: [{ rings: [[[0, 0]]] }] },166+ properties: [], relationships: [], provenance: [],167+ },162168 };163169 }164170
@@ -176,8 +182,8 @@ function styles(
176182 outset = "dark",177183 repaint?: () => void,178184 ): Styles {179- const model = new WorldModel(180- "world", { lenses: [], collections: COLLECTIONS }, grid, null);185+ const model = new WorldModel(legacyOpenWorld(186+ "world", { lenses: [], collections: COLLECTIONS }, grid, null));181187 const full: Scene = { ...EMPTY_SCENE, ...scene };182188 const built = new Styles({183189 visibility: new Visibility(model, full, 0, null, hovered),
@@ -432,8 +438,8 @@ test("a colour that would vanish into the rim is taken off it", () => {
432438 const DIM: Collection = {433439 id: 9, title: "Caverns", kind: "point", visible: true, color: "#0d1014",434440 };435- const model = new WorldModel(436- "world", { lenses: [], collections: [DIM] }, grid, null);441+ const model = new WorldModel(legacyOpenWorld(442+ "world", { lenses: [], collections: [DIM] }, grid, null));437443 const scene: Scene = { ...EMPTY_SCENE };438444 const built = new Styles({439445 visibility: new Visibility(model, scene, 0, null, null),
@@ -604,14 +610,11 @@ function zone(collection: Collection, id: string): ShapeRecord {
604610 }605611 606612 test("a shape wears its feature's colour, which is the index's own", () => {607- // The server's colorFor (internal/app/legend.go): Knuth's multiplicative608- // hash over the id, mod the ten-colour wheel. By hand: 1496244488 ·609- // 2654435761 mod 2^32 mod 10 = 2, and 39191589 lands on 1 -- so the two610- // zones wear the wheel's third and second colours, exactly as their index611- // rows do, and an id that is not a number wears the wheel's first.612- assert.equal(featureColor("1496244488"), "#82b56a");613- assert.equal(featureColor("39191589"), "#c9924b");614- assert.equal(featureColor("zoning-b1"), "#4fb3d5");613+ // Native identities are arbitrary stable strings. They keep their colour614+ // without being parsed into a compatibility integer first.615+ assert.equal(featureColor("world/feature/1496244488"), featureColor("world/feature/1496244488"));616+ assert.notEqual(featureColor("world/feature/1496244488"), featureColor("world/feature/39191589"));617+ assert.notEqual(featureColor("zoning-b1"), featureColor("zoning-o1"));615618 616619 // A zoning collection is one row with one declared colour, but B-1 and617620 // O-1 are different grounds: the map draws each in its feature's colour,
@@ -622,12 +625,12 @@ test("a shape wears its feature's colour, which is the index's own", () => {
622625 const built = styles({});623626 const first = built.area(zone(zoning, "1496244488"), false);624627 const second = built.area(zone(zoning, "39191589"), false);625- assert.equal(first.getStroke()?.getColor(), "#82b56a");626- assert.equal(second.getStroke()?.getColor(), "#c9924b");628+ assert.equal(first.getStroke()?.getColor(), featureColor("1496244488"));629+ assert.equal(second.getStroke()?.getColor(), featureColor("39191589"));627630 assert.notEqual(first.getStroke()?.getColor(), zoning.color);628631 629632 // A path is the same rule at a different width.630633 const trails: Collection = { id: 4, title: "Trails", kind: "path", visible: true };631634 const [, ink] = built.path({ ...zone(trails, "1496244488"), kind: "path" }, 2, false);632- assert.equal(ink?.getStroke()?.getColor(), "#82b56a");635+ assert.equal(ink?.getStroke()?.getColor(), featureColor("1496244488"));633636 });
M render/test/visibility.test.ts +5 -4
@@ -15,6 +15,7 @@ import { WorldModel, project } from "../world/model.ts";
1515 import { Visibility } from "../world/visibility.ts";1616 import type { Collection, TileGrid, WorldPayload } from "../data/payload.ts";1717 import { LocationTable } from "../data/atlasloc.ts";18+import { legacyOpenWorld } from "./legacy.ts";1819 1920 const grid: TileGrid = { sourceZoom: 5, firstTile: 0, tileSize: 256, size: 8192 };2021
@@ -81,7 +82,7 @@ function table(): LocationTable {
8182 return LocationTable.over(buffer);8283 }8384 84-const model = new WorldModel("w", payload(), grid, table());85+const model = new WorldModel(legacyOpenWorld("w", payload(), grid, table()));8586 8687 function scene(over: Partial<Scene> = {}): Scene {8788 return { ...EMPTY_SCENE, ...over };
@@ -158,13 +159,13 @@ test("a held cell narrows exactly the way a highlight does", () => {
158159 });159160 160161 test("a pin's place in the crowd is stable and rarity-first", () => {161- const shrines = model.points.filter((point) => point.collection.id === 1);162- const chests = model.points.filter((point) => point.collection.id === 2);162+ const shrines = model.points.filter((point) => point.collection.id === "1");163+ const chests = model.points.filter((point) => point.collection.id === "2");163164 // Equal-sized collections rank equally by rarity, so the tie-break is the164165 // id's own hash — and it is the same on every run.165166 assert.equal(shrines.length, 2);166167 assert.equal(chests.length, 2);167- const again = new WorldModel("w", payload(), grid, table());168+ const again = new WorldModel(legacyOpenWorld("w", payload(), grid, table()));168169 assert.deepEqual(again.points.map((point) => point.priority),169170 model.points.map((point) => point.priority));170171 });
M render/test/vnext.test.ts +47 -0
@@ -4,6 +4,7 @@ import {
44 mergeSchemas, PackedTable, projectFields,55 type FieldSchema, type Schema,66 } from "../data/vnext.ts";7+import { coreID, decodeGeometry } from "../data/semantic.ts";78 89 const ROOT = "11111111-1111-5111-8111-111111111111";910 const TITLE = "22222222-2222-5222-8222-222222222222";
@@ -55,6 +56,28 @@ test("a corrupt column is refused at the browser boundary", () => {
5556 assert.throws(() => PackedTable.over(buffer), /checksum/);5657 });5758 59+test("the browser derives the same stable core identities as the writer", async () => {60+ assert.equal(await coreID("feature.id"), "3aaf6328-ca62-58e2-8645-eab4421aa628");61+ assert.notEqual(await coreID("feature.id"), await coreID("feature.title"));62+});63+64+test("native geometry preserves parts, rings, and string identities without GeoJSON", () => {65+ const bytes = geometry(3, [66+ [67+ [[0, 0], [4, 0], [4, 4], [0, 0]],68+ [[1, 1], [2, 1], [1, 1]],69+ ],70+ [[[10, 10], [12, 10], [10, 10]]],71+ ]);72+ assert.deepEqual(decodeGeometry(bytes), {73+ kind: 3,74+ parts: [75+ { rings: [[[0, 0], [4, 0], [4, 4], [0, 0]], [[1, 1], [2, 1], [1, 1]]] },76+ { rings: [[[10, 10], [12, 10], [10, 10]]] },77+ ],78+ });79+});80+5881 function fixtureBlock(): ArrayBuffer {5982 const title = variableColumn([new TextEncoder().encode("Clinic"), null]);6083 const emergency = fixedInt64Column([4n, 5n]);
@@ -147,3 +170,27 @@ function crc32(...parts: readonly Uint8Array[]): number {
147170 }148171 return (crc ^ 0xffffffff) >>> 0;149172 }173+174+function geometry(kind: number, parts: readonly (readonly (readonly [number, number][])[])[]): Uint8Array {175+ const length = 5 + parts.reduce((partSum, rings) => partSum + 4 + rings.reduce(176+ (ringSum, positions) => ringSum + 4 + positions.length * 16, 0), 0);177+ const bytes = new Uint8Array(length);178+ const view = new DataView(bytes.buffer);179+ bytes[0] = kind;180+ view.setUint32(1, parts.length, true);181+ let at = 5;182+ for (const rings of parts) {183+ view.setUint32(at, rings.length, true);184+ at += 4;185+ for (const positions of rings) {186+ view.setUint32(at, positions.length, true);187+ at += 4;188+ for (const [x, y] of positions) {189+ view.setFloat64(at, x, true);190+ view.setFloat64(at + 8, y, true);191+ at += 16;192+ }193+ }194+ }195+ return bytes;196+}
M render/viewport.ts +12 -34
@@ -24,10 +24,10 @@ import { wireKeyboard } from "./keys.ts";
2424 import { RowHover } from "./hover.ts";2525 import { DataPlane } from "./data/plane.ts";2626 import { reportCamera } from "./data/report.ts";27-import type { Catalog, Lens, TileGrid } from "./data/payload.ts";27+import type { Lens } from "./data/payload.ts";2828 import { SceneWatcher } from "./scene/observe.ts";2929 import type { Scene, SceneChange } from "./scene/read.ts";30-import { WorldModel, worldGrid } from "./world/model.ts";30+import { WorldModel } from "./world/model.ts";3131 import { Visibility } from "./world/visibility.ts";3232 import type { WorldContext } from "./context.ts";3333 import { AtlasChart } from "./chart/element.ts";
@@ -39,7 +39,6 @@ const log = logger("viewport");
3939 export class AtlasViewport extends HTMLElement {4040 private readonly plane = new DataPlane();4141 private watcher: SceneWatcher | null = null;42- private catalog: Catalog | null = null;4342 private readonly worlds = new Map<string, Promise<WorldModel>>();4443 private context: WorldContext | null = null;4544 private generation = 0;
@@ -152,17 +151,12 @@ export class AtlasViewport extends HTMLElement {
152151 if (scene.surface !== "sphere") this.dropSphere();153152 const mine = ++this.generation;154153 try {155- const grid = await this.tileGrid(scene);156- if (!grid) return;157- const worldTitle = this.catalog?.volumes158- .find((entry) => entry.slug === scene.volume)?.worlds159- .find((world) => world.slug === scene.world)?.title ?? scene.world;160- const model = await this.model(scene, grid);154+ const model = await this.model(scene);161155 // A scene that moved again while a payload was in flight wins: the last162156 // thing the reader asked for is the thing to draw.163157 if (mine !== this.generation) return;164158 165- const lens = model.payload.lenses[this.lensIndex(model, scene)] ?? null;159+ const lens = model.lenses[this.lensIndex(model, scene)] ?? null;166160 const ground = model.ground(lens);167161 const system = this.system(scene, ground);168162 const test = system ? cellTest(ground, system, scene.gridCell) : null;
@@ -170,9 +164,9 @@ export class AtlasViewport extends HTMLElement {
170164 const context: WorldContext = {171165 scene,172166 base: scene.base,173- grid: worldGrid(grid, model.payload),167+ grid: model.grid,174168 model,175- worldTitle,169+ worldTitle: model.title,176170 lens,177171 lensIndex: this.lensIndex(model, scene),178172 outset: outsetOf(model),
@@ -190,7 +184,7 @@ export class AtlasViewport extends HTMLElement {
190184 // declare a sphere and still flatten in a way no globe can invert, and191185 // only the payload knows. Asked before the chart is shown, so a chart192186 // coming back up is measured against a pane that is already on screen.193- const sphere = AtlasGlobe.offers(model.payload.attrs ?? {});187+ const sphere = AtlasGlobe.offers(model.attrs);194188 if (!sphere) this.dropSphere();195189 this.chart?.show(context);196190 if (sphere) this.globe?.show(context);
@@ -246,9 +240,9 @@ export class AtlasViewport extends HTMLElement {
246240 }247241 248242 private lensIndex(model: WorldModel, scene: Scene): number {249- const named = model.payload.lenses.findIndex((lens: Lens) => lens.name === scene.lens);243+ const named = model.lenses.findIndex((lens: Lens) => lens.name === scene.lens);250244 if (named >= 0) return named;251- return Math.min(Math.max(scene.lensIndex, 0), Math.max(0, model.payload.lenses.length - 1));245+ return Math.min(Math.max(scene.lensIndex, 0), Math.max(0, model.lenses.length - 1));252246 }253247 254248 /**
@@ -428,28 +422,12 @@ export class AtlasViewport extends HTMLElement {
428422 field.setSelectionRange?.(before, before);429423 }430424 431- private async tileGrid(scene: Scene): Promise<TileGrid | null> {432- this.catalog ??= await this.plane.catalog();433- const volume = this.catalog.volumes.find((entry) => entry.slug === scene.volume);434- if (!volume) {435- log.warn("the catalog does not list the volume the page is about", {436- op: "render", volume: scene.volume,437- });438- return null;439- }440- return volume.tileGrid;441- }442-443- private model(scene: Scene, grid: TileGrid): Promise<WorldModel> {425+ private model(scene: Scene): Promise<WorldModel> {444426 const key = `${scene.base}/${scene.world}`;445427 const held = this.worlds.get(key);446428 if (held) return held;447429 const building = (async () => {448- const [payload, table] = await Promise.all([449- this.plane.world(scene.base, scene.world),450- this.plane.locations(scene.base, scene.world),451- ]);452- return new WorldModel(scene.world, payload, worldGrid(grid, payload), table);430+ return new WorldModel(await this.plane.world(scene.base, scene.world));453431 })();454432 this.worlds.set(key, building);455433 return building;
@@ -693,5 +671,5 @@ function cellRow(slug: string, label: string, value: string): HTMLElement {
693671 }694672 695673 function outsetOf(model: WorldModel): string {696- return model.payload.attrs?.[KEY_ICON_OUTSET] ?? "";674+ return model.attrs[KEY_ICON_OUTSET] ?? "";697675 }
M render/world/model.ts +161 -173
@@ -1,31 +1,15 @@
1-// The world as the seam holds it: every feature, in world coordinates, once.1+// One semantic world, presented once for both panes.22 //3-// Three payloads arrive from `/data` and one model comes out of them. The4-// model is built once per world and is immutable afterwards: filters, grids5-// and selections are questions asked *of* it (`visibility.ts`), never edits6-// to it, which is what lets the chart and the globe read the same registry7-// and answer the same counts.8-//9-// TWO CONVENTIONS MEET HERE, and both are load-bearing.10-//11-// A payload speaks **y-down world pixels** and spells positions as12-// `lat`/`lng` in the volume's own world space — which is the slippy-tile13-// grid the capture was cut from, not WGS 84. `project` is that conversion,14-// and it is the same arithmetic the application does server-side15-// (`internal/app/world.go`). It is **not** written down in `docs/format.md`,16-// which is a gap in the format spec rather than a decision of this lane; it17-// is written down in `docs/render-seam.md` §3 until it moves where it18-// belongs.19-//20-// Everything downstream — OpenLayers, the analysis lane's cell systems, the21-// grid, the overview — speaks **OL world coordinates**: x east, y22-// negative-down. The sign flips exactly once, here.3+// The network boundary is schema + packed vNext tables. This model applies4+// the world's replaceable Presentation to its FeatureSets; it never receives5+// a v3 document, numeric compatibility identity, or GeoJSON translation.236 247 import type { Ground } from "@atlas/analysis";25-import type { LocationTable } from "../data/atlasloc.ts";8+import type { Collection, Lens, TileGrid, WorldPayload } from "../data/payload.ts";9+import type { OpenWorld } from "../data/plane.ts";2610 import type {27- Collection, Geometry, Lens, ShapeFeature, TileGrid, WorldPayload,28-} from "../data/payload.ts";11+ Feature, Geometry, Property, RasterPyramid, Style,12+} from "../data/semantic.ts";2913 3014 /** `[x, y]` in OL world coordinates: x east, y negative-down. */3115 export type Coordinate = [number, number];
@@ -33,26 +17,20 @@ export type Coordinate = [number, number];
3317 /** A closed or open run of coordinates: a ring, or a line. */3418 export type Line = Coordinate[];3519 36-/**37- * Where a `lat`/`lng` pair lands on the world square.38- *39- * A pin arrives as a latitude and longitude in the source tile grid, which is40- * not the same grid for every volume: most worlds are cut from a 32-tile41- * square at zoom 13, and a world cut from a window of its own carries where42- * that window sits (format.md §6.2). Both describe the same world square, so43- * everything downstream works in world units either way.44- */20+/** A coordinate-space position enters OpenLayers by flipping y exactly once. */21+export function displayPosition(position: readonly [number, number]): Coordinate {22+ return [position[0], -position[1]];23+}24+25+/** Geographic coordinates to the native tile-plane space used by producers. */4526 export function project(grid: TileGrid, lat: number, lng: number): Coordinate {4627 const worldTiles = 2 ** grid.sourceZoom;4728 const xTile = ((lng + 180) / 360) * worldTiles;4829 const yTile = (1 - Math.asinh(Math.tan((lat * Math.PI) / 180)) / Math.PI) / 2 * worldTiles;49- return [50- (xTile - grid.firstTile) * grid.tileSize,51- -(yTile - grid.firstTile) * grid.tileSize,52- ];30+ return [(xTile - grid.firstTile) * grid.tileSize, -(yTile - grid.firstTile) * grid.tileSize];5331 }5432 55-/** The volume's grid, with the world's own window override applied. */33+/** Kept for test fixtures extracted before coordinate spaces became native. */5634 export function worldGrid(volume: TileGrid, payload: WorldPayload): TileGrid {5735 return payload.grid ? { ...volume, ...payload.grid } : volume;5836 }
@@ -64,19 +42,13 @@ export interface PointRecord {
6442 readonly title: string;6543 readonly collection: Collection;6644 readonly coordinate: Coordinate;67- readonly member: number;45+ readonly member: string;6846 readonly shard: number;69- /**70- * The order this pin takes its place in when pins overlap: rarer71- * collections first, ties broken by a stable hash of the id so two runs72- * draw the crowd in the same order. Also the declutter priority — a label73- * that has to give way gives way to a rarer one, never to whichever74- * happened to be built first.75- */47+ readonly feature: Feature;7648 readonly priority: number;7749 }7850 79-/** One path or area feature, projected. */51+/** One path or area feature, already in display coordinates. */8052 export interface ShapeRecord {8153 readonly id: string;8254 readonly title: string;
@@ -84,182 +56,198 @@ export interface ShapeRecord {
8456 readonly collection: Collection;8557 readonly kind: "path" | "area";8658 readonly shard: number;87- /** Outer rings (areas) or lines (paths), already in world coordinates. */8859 readonly lines: readonly Line[];89- /** Inner rings, per outer ring, for areas that carry holes. */9060 readonly holes: readonly (readonly Line[])[];9161 readonly center: Coordinate | null;92- readonly feature: ShapeFeature;62+ readonly feature: Feature;9363 }9464 95-/** The whole of one world, built once. */65+/** The whole of one world, built once from native semantics. */9666 export class WorldModel {9767 readonly points: readonly PointRecord[];9868 readonly shapes: readonly ShapeRecord[];9969 readonly pointByID: ReadonlyMap<string, PointRecord>;10070 readonly shapeByID: ReadonlyMap<string, ShapeRecord>;10171 readonly collections: readonly Collection[];102-10372 readonly slug: string;104- readonly payload: WorldPayload;73+ readonly title: string;74+ readonly lenses: readonly Lens[];75+ readonly attrs: Readonly<Record<string, string>>;10576 readonly grid: TileGrid;10677 107- constructor(108- slug: string,109- payload: WorldPayload,110- grid: TileGrid,111- table: LocationTable | null,112- ) {113- this.slug = slug;114- this.payload = payload;115- this.grid = grid;116- this.collections = payload.collections;117- const points: PointRecord[] = [];118- const pointByID = new Map<string, PointRecord>();119- if (table) {120- // The packed payload's `owner` indexes the collections array, which is121- // the only collection identity a reader needs and the whole reason that122- // array's order is significant (format.md §7.1).123- const sizes = new Map<number, number>();124- for (let i = 0; i < table.count; i++) {125- const owner = table.owner[i] ?? 0;126- sizes.set(owner, (sizes.get(owner) ?? 0) + 1);127- }128- for (let i = 0; i < table.count; i++) {129- const owner = table.owner[i] ?? 0;130- const collection = payload.collections[owner];131- if (!collection || collection.kind !== "point") continue;132- const id = String(table.id[i] ?? 0);133- const record: PointRecord = {134- id,135- index: i,136- title: table.title(i),137- collection,138- coordinate: project(grid, table.lat[i] ?? 0, table.lng[i] ?? 0),139- member: table.member[i] ?? 0,140- shard: table.shard[i] ?? 0,141- priority: priorityOf(sizes.get(owner) ?? 0, table.id[i] ?? 0),142- };143- points.push(record);144- pointByID.set(id, record);145- }146- }147- this.points = points;148- this.pointByID = pointByID;78+ constructor(open: OpenWorld) {79+ const { volume, world } = open;80+ this.slug = world.id;81+ this.title = world.title;82+ this.grid = {83+ sourceZoom: world.coordinateSpace.sourceZoom,84+ firstTile: world.coordinateSpace.firstTile,85+ tileSize: world.coordinateSpace.tileSize,86+ size: world.coordinateSpace.size,87+ };88+ this.attrs = propertiesToAttrs(world.claims);89+ this.lenses = world.rasterPyramids.map(presentRaster);14990 91+ const sets = new Map(world.featureSets.map((set) => [set.id, set]));92+ const styles = new Map(world.presentation.styles.map((style) => [style.id, style]));93+ const labels = new Map(world.presentation.legend.map((entry) => [entry.layer, entry.label]));94+ const collections: Collection[] = [];95+ const points: PointRecord[] = [];15096 const shapes: ShapeRecord[] = [];151- const shapeByID = new Map<string, ShapeRecord>();152- for (const collection of payload.collections) {153- if (collection.kind === "point") continue;154- for (const feature of collection.features ?? []) {155- const record = shapeRecord(grid, collection, feature);156- if (!record) continue;157- shapes.push(record);158- shapeByID.set(record.id, record);97+98+ for (const layer of world.presentation.layers) {99+ const set = sets.get(layer.featureSet);100+ const style = styles.get(layer.style);101+ if (!set || !style) throw new Error(`layer ${layer.id} has an unresolved feature set or style`);102+ const kind = semanticKind(set.semanticType);103+ const asset = style.iconAsset ? volume.assets.get(style.iconAsset) : undefined;104+ const collection: Collection = {105+ id: layer.id, featureSet: set.id, style: style.id,106+ title: labels.get(layer.id) || set.title, group: layer.group, kind,107+ icon: style.icon || style.symbol, iconAsset: asset?.path ?? "", iconPicture: style.iconPicture,108+ color: presentedColor(kind, style), visible: layer.visible,109+ labelPolicy: layer.labelPolicy, renderAs: style.renderAs,110+ attrs: propertiesToAttrs(set.claims),111+ };112+ collections.push(collection);113+ for (const feature of set.features) {114+ if (feature.geometry.kind === 1) points.push(pointRecord(feature, collection, points.length));115+ else {116+ const shape = shapeRecord(feature, collection);117+ if (shape) shapes.push(shape);118+ }159119 }160120 }121+122+ const sizes = new Map<string | number, number>();123+ for (const point of points) sizes.set(point.collection.id, (sizes.get(point.collection.id) ?? 0) + 1);124+ this.points = points.map((point, index) => ({125+ ...point, index, priority: priorityOf(sizes.get(point.collection.id) ?? 0, point.id),126+ }));161127 this.shapes = shapes;162- this.shapeByID = shapeByID;128+ this.pointByID = new Map(this.points.map((point) => [point.id, point]));129+ this.shapeByID = new Map(shapes.map((shape) => [shape.id, shape]));130+ this.collections = collections;163131 }164132 165- /** Either kind of feature, by the id the application spells it with. */166133 feature(id: string): PointRecord | ShapeRecord | null {167134 return this.pointByID.get(id) ?? this.shapeByID.get(id) ?? null;168135 }169136 170- /** The ground a cell system divides: the analysis lane's own descriptor. */171137 ground(lens: Lens | null): Ground {172138 return {173139 tileGridSize: this.grid.size,174140 lens: lens ? { surface: lens.surface ?? null, bounds: lens.bounds ?? null } : null,175- world: { attrs: this.payload.attrs ?? {} },141+ world: { attrs: this.attrs },176142 };177143 }178144 }179145 180-/**181- * A pin's place in the crowd.182- *183- * Rarity first — a collection with three members is what a reader came to184- * find, and one with nine hundred is the texture behind it — then a stable185- * hash of the feature's own id, so a tie is broken the same way on every run186- * and in every pane.187- */188-export function priorityOf(members: number, id: number): number {146+export function priorityOf(members: number, id: string): number {189147 const rarity = Math.max(0, 1_000_000 - Math.min(members, 999) * 1000);190148 return rarity + (stableRank(id) % 1000);191149 }192150 193-/** A small deterministic hash: the same id ranks the same way forever. */194-export function stableRank(id: number): number {151+/** FNV-1a over the durable identity; arbitrary strings rank reproducibly. */152+export function stableRank(id: string): number {195153 let hash = 2166136261 >>> 0;196- const text = String(id);197- for (let i = 0; i < text.length; i++) {198- hash ^= text.charCodeAt(i);154+ for (let i = 0; i < id.length; i++) {155+ hash ^= id.charCodeAt(i);199156 hash = Math.imul(hash, 16777619) >>> 0;200157 }201158 return hash >>> 0;202159 }203160 204-function shapeRecord(205- grid: TileGrid,206- collection: Collection,207- feature: ShapeFeature,208-): ShapeRecord | null {209- const lines: Line[] = [];210- const holes: Line[][] = [];211- for (const geometry of feature.geometry ?? []) {212- for (const { outer, inner } of readGeometry(grid, geometry)) {213- lines.push(outer);214- holes.push(inner);215- }161+function pointRecord(feature: Feature, collection: Collection, index: number): PointRecord {162+ const position = feature.geometry.parts[0]?.rings[0]?.[0];163+ if (!position || feature.geometry.parts.length !== 1 || feature.geometry.parts[0]?.rings.length !== 1 ||164+ feature.geometry.parts[0]?.rings[0]?.length !== 1) {165+ throw new Error(`point ${feature.id} has invalid native geometry`);216166 }167+ return {168+ id: feature.id, index, title: feature.title, collection, coordinate: displayPosition(position),169+ member: feature.relationships.find((edge) => edge.predicate === "within")?.target ?? "",170+ shard: feature.shard, feature, priority: 0,171+ };172+}173+174+function shapeRecord(feature: Feature, collection: Collection): ShapeRecord | null {175+ const { lines, holes } = displayGeometry(feature.geometry);217176 if (!lines.length) return null;218177 return {219- id: String(feature.id),220- title: feature.title ?? "",221- subtitle: feature.subtitle ?? "",222- collection,223- kind: collection.kind === "path" ? "path" : "area",224- shard: feature.shard ?? 0,225- lines,226- holes,227- center: feature.center ? project(grid, feature.center.lat, feature.center.lng) : null,228- feature,178+ id: feature.id, title: feature.title, subtitle: feature.subtitle, collection,179+ kind: feature.geometry.kind === 2 ? "path" : "area", shard: feature.shard,180+ lines, holes, center: feature.center ? displayPosition(feature.center) : null, feature,229181 };230182 }231183 232-type Piece = { outer: Line; inner: Line[] };184+function displayGeometry(geometry: Geometry): { lines: Line[]; holes: Line[][] } {185+ const lines: Line[] = [];186+ const holes: Line[][] = [];187+ for (const part of geometry.parts) {188+ if (geometry.kind === 2) {189+ const line = part.rings[0];190+ if (part.rings.length !== 1 || !line) continue;191+ lines.push(line.map(displayPosition));192+ holes.push([]);193+ continue;194+ }195+ if (geometry.kind === 3) {196+ const [outer, ...inner] = part.rings;197+ if (!outer) continue;198+ lines.push(outer.map(displayPosition));199+ holes.push(inner.map((ring) => ring.map(displayPosition)));200+ }201+ }202+ return { lines, holes };203+}204+205+function semanticKind(value: string): "point" | "path" | "area" {206+ const kind = value.startsWith("geometry.") ? value.slice("geometry.".length) : value;207+ if (kind !== "point" && kind !== "path" && kind !== "area") throw new Error(`semantic geometry kind ${value} is unsupported`);208+ return kind;209+}233210 234-/**235- * GeoJSON-shaped geometry, projected into world coordinates.236- *237- * Coordinate pairs are `[lng, lat]` as GeoJSON writes them, in the volume's238- * own world space. Types are read leniently: a geometry whose type does not239- * fit its collection's kind was refused at build time (format.md §9.3), so a240- * type this reader does not know is a newer producer, not a broken bundle.241- */242-function readGeometry(grid: TileGrid, geometry: Geometry): Piece[] {243- const point = (pair: unknown): Coordinate => {244- const [lng, lat] = pair as [number, number];245- return project(grid, lat, lng);211+function presentRaster(raster: RasterPyramid): Lens {212+ const coverage: Record<string, { x: number; y: number; w: number; h: number; bits: string }> = {};213+ for (const level of raster.coverage) {214+ coverage[String(level.zoom)] = {215+ x: level.x, y: level.y, w: level.w, h: level.h, bits: base64(level.bits),216+ };217+ }218+ const lens: Lens = {219+ id: raster.id, name: raster.name, tiles: raster.id, template: raster.template,220+ minZoom: raster.minZoom, maxZoom: raster.maxZoom, fullZoom: raster.fullZoom,221+ sourceZoom: raster.sourceZoom, formats: raster.formats,222+ interpolate: raster.interpolate,246223 };247- const line = (raw: unknown): Line => (raw as unknown[]).map(point);248- const polygon = (raw: unknown): Piece => {249- const rings = (raw as unknown[]).map(line);250- const [outer, ...inner] = rings;251- return { outer: outer ?? [], inner };224+ return {225+ ...lens,226+ ...(raster.bounds ? { bounds: raster.bounds } : {}),227+ ...(raster.surface ? { surface: raster.surface } : {}),228+ ...(raster.background ? { background: raster.background } : {}),229+ ...(raster.shard ? { shard: raster.shard } : {}),230+ ...(Object.keys(coverage).length ? { coverage } : {}),252231 };253- switch (geometry.type) {254- case "Polygon":255- return [polygon(geometry.coordinates)];256- case "MultiPolygon":257- return (geometry.coordinates as unknown[]).map(polygon);258- case "LineString":259- return [{ outer: line(geometry.coordinates), inner: [] }];260- case "MultiLineString":261- return (geometry.coordinates as unknown[]).map((raw) => ({ outer: line(raw), inner: [] }));262- default:263- return [];264- }232+}233+234+function presentedColor(kind: string, style: Style): string {235+ return kind === "path" && style.stroke ? style.stroke : style.fill;236+}237+238+function propertiesToAttrs(properties: readonly Property[]): Readonly<Record<string, string>> {239+ const attrs: Record<string, string> = {};240+ for (const property of properties) attrs[property.name] = propertyText(property.value);241+ return attrs;242+}243+244+function propertyText(value: Property["value"]): string {245+ if (value instanceof Uint8Array) return base64(value);246+ return String(value);247+}248+249+function base64(bytes: Uint8Array): string {250+ let binary = "";251+ for (const byte of bytes) binary += String.fromCharCode(byte);252+ return btoa(binary);265253 }
M render/world/visibility.ts +2 -2
M tests/island/display_test.go +8 -8
@@ -109,12 +109,12 @@ func TestTheLabelLadderReadsTheCuration(t *testing.T) {
109109 switch collection.Kind {110110 case "area":111111 if collection.Attrs["atlas.label.policy"] == "quiet" {112- wantSilent = append(wantSilent, collection.ID.String())112+ wantSilent = append(wantSilent, collection.NativeID)113113 } else {114- wantSpeaking = append(wantSpeaking, collection.ID.String())114+ wantSpeaking = append(wantSpeaking, collection.NativeID)115115 }116116 case "path":117- pathCollection = collection.ID.String()117+ pathCollection = collection.NativeID118118 }119119 }120120 sortStrings(wantSpeaking)
@@ -228,8 +228,8 @@ func TestTheLabelToggleWearsTheMarkOfWhatItAsks(t *testing.T) {
228228 want string229229 wrong string230230 }{231- {"901", "Regions, an area", bar, tag},232- {"902", "Districts, points curated as text", tag, bar},231+ {"overworld/layer/901", "Regions, an area", bar, tag},232+ {"overworld/layer/902", "Districts, points curated as text", tag, bar},233233 } {234234 button := toggleMarkup(body, tt.id)235235 switch {
@@ -245,7 +245,7 @@ func TestTheLabelToggleWearsTheMarkOfWhatItAsks(t *testing.T) {
245245 // A plain pin row has no policy to flip, so it has no button -- and the246246 // column is held open anyway, which is the half a missing element would247247 // silently get right and a wrong element would silently get wrong.248- pins := rowMarkup(body, "903")248+ pins := rowMarkup(body, "overworld/layer/903")249249 if pins == "" {250250 t.Fatal("the plain pin row is not in the legend")251251 }
@@ -329,13 +329,13 @@ func TestTheLegendTreeIsOneTree(t *testing.T) {
329329 }330330 // A shape collection carries a feature index; the corpus names which.331331 if watersheds := collectionNamed(t, payload, "Watersheds"); !strings.Contains(page,332- `data-feature-index="`+watersheds.ID.String()+`"`) {332+ `data-feature-index="`+watersheds.NativeID+`"`) {333333 t.Error("a shape row carries no feature index")334334 }335335 // The city's point collection is drawn as pins, so it has no unfolding336336 // chevron: the affordance appears where the capability does.337337 if historic := collectionNamed(t, payload, "Historic Resources"); strings.Contains(page,338- `data-expand-collection="`+historic.ID.String()+`"`) {338+ `data-expand-collection="`+historic.NativeID+`"`) {339339 t.Error("a point collection was given a feature index")340340 }341341 }
M tests/island/harness_test.go +8 -2
@@ -226,11 +226,13 @@ func firstWorld(t *testing.T, volume hostenv.Volume) string {
226226 227227 // corpusWorld is the slice of a payload the tests read expectations out of.228228 type corpusWorld struct {229+ Slug string `json:"-"`229230 Collections []corpusCollection `json:"collections"`230231 }231232 232233 type corpusCollection struct {233234 ID json.Number `json:"id"`235+ NativeID string `json:"-"`234236 Title string `json:"title"`235237 Kind string `json:"kind"`236238 Group string `json:"group"`
@@ -253,6 +255,10 @@ func readCorpusWorld(t *testing.T, slug, world string) corpusWorld {
253255 if err := json.Unmarshal(body, &out); err != nil {254256 t.Fatal(err)255257 }258+ out.Slug = world259+ for index := range out.Collections {260+ out.Collections[index].NativeID = world + "/layer/" + out.Collections[index].ID.String()261+ }256262 return out257263 }258264
@@ -292,7 +298,7 @@ func featureNamed(t *testing.T, world corpusWorld, title string) string {
292298 for _, collection := range world.Collections {293299 for _, feature := range collection.Features {294300 if feature.Title == title {295- return feature.ID.String()301+ return world.Slug + "/feature/" + feature.ID.String()296302 }297303 }298304 }
@@ -320,7 +326,7 @@ func firstPoint(t *testing.T, slug, world string) (id, title string) {
320326 if len(held.Locations) == 0 {321327 t.Fatalf("the corpus packs an empty location list for %s/%s", slug, world)322328 }323- return held.Locations[0].ID.String(), held.Locations[0].Title329+ return world + "/feature/" + held.Locations[0].ID.String(), held.Locations[0].Title324330 }325331 326332 // collectionNamed finds one collection by title, for the same reason.
M tests/island/island_test.go +10 -10
@@ -187,13 +187,13 @@ func TestTheHideSetIsThePayloadsOwnIDs(t *testing.T) {
187187 // leave the count.188188 mpo := collectionNamed(t, payload, "MPO Boundary")189189 answer := arrange(t, handler, slug, "collections",190- "collection", mpo.ID.String(), "visible", "0")190+ "collection", mpo.NativeID, "visible", "0")191191 if !strings.Contains(answer, countOf(everything-len(mpo.Features))) {192192 t.Errorf("hiding %s did not take its %d features off the count:\n%s",193193 mpo.Title, len(mpo.Features), answer)194194 }195195 page := openExplorer(t, handler, slug, world)196- if got := idStrings(t, entryOf(t, page)["hidden"]); !reflect.DeepEqual(got, []string{mpo.ID.String()}) {196+ if got := idStrings(t, entryOf(t, page)["hidden"]); !reflect.DeepEqual(got, []string{mpo.NativeID}) {197197 t.Errorf("hidden = %v, want the one row put away", got)198198 }199199
@@ -205,7 +205,7 @@ func TestTheHideSetIsThePayloadsOwnIDs(t *testing.T) {
205205 }206206 want := make([]string, 0, len(payload.Collections))207207 for _, collection := range payload.Collections {208- want = append(want, collection.ID.String())208+ want = append(want, collection.NativeID)209209 }210210 sort.Strings(want)211211 page = openExplorer(t, handler, slug, world)
@@ -284,12 +284,12 @@ func TestTheLabelLedgerKeepsOnlyDisagreements(t *testing.T) {
284284 285285 // A speaking area silenced disagrees with its curation and is recorded.286286 speaking := collectionNamed(t, payload, "Zoning")287- flip(speaking.ID.String())288- if got := ledger(); !reflect.DeepEqual(got, []string{speaking.ID.String() + "=quiet"}) {287+ flip(speaking.NativeID)288+ if got := ledger(); !reflect.DeepEqual(got, []string{speaking.NativeID + "=quiet"}) {289289 t.Errorf("labels = %v, want the one disagreement recorded", got)290290 }291291 // Flipped back, the override has nothing left to say and is dropped.292- flip(speaking.ID.String())292+ flip(speaking.NativeID)293293 if got := ledger(); len(got) != 0 {294294 t.Errorf("labels = %v; a flip back to the curated word should drop the override", got)295295 }
@@ -300,8 +300,8 @@ func TestTheLabelLedgerKeepsOnlyDisagreements(t *testing.T) {
300300 if quiet.Attrs["atlas.label.policy"] != "quiet" {301301 t.Fatalf("the corpus no longer curates %s quiet; this test needs a quiet collection", quiet.Title)302302 }303- flip(quiet.ID.String())304- if got := ledger(); !reflect.DeepEqual(got, []string{quiet.ID.String() + "=always"}) {303+ flip(quiet.NativeID)304+ if got := ledger(); !reflect.DeepEqual(got, []string{quiet.NativeID + "=always"}) {305305 t.Errorf("labels = %v, want the quiet collection's override", got)306306 }307307
@@ -309,11 +309,11 @@ func TestTheLabelLedgerKeepsOnlyDisagreements(t *testing.T) {
309309 // overrides behind. The rows that wear a toggle here are the areas: a310310 // path collection has no policy to flip, and the city's points draw as311311 // plain pins.312- flip(quiet.ID.String())312+ flip(quiet.NativeID)313313 for turn := 0; turn < 2; turn++ {314314 for _, collection := range payload.Collections {315315 if collection.Kind == "area" {316- flip(collection.ID.String())316+ flip(collection.NativeID)317317 }318318 }319319 }