clone

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

main
atlas / internal / generate / compose / payload.go
payload.go 10.6K
1package compose23import (4 "encoding/json"5 "strconv"67 "github.com/FelineStateMachine/atlas/format/bundle"8 "github.com/FelineStateMachine/atlas/internal/generate/doc"9 "github.com/FelineStateMachine/atlas/internal/generate/tiles"10)1112// 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.2324// worldPayload is everything needed to draw a world except its point features,25// which travel packed alongside.26type 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}4647type worldGrid struct {48 SourceZoom int `json:"sourceZoom"`49 FirstTile int `json:"firstTile"`50}5152// lens is one picture of a world: which pyramid draws it, over what zooms, and53// which of its tiles exist.54type 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}7374// 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.78type 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}9293// 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.97type 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}108109type coordinate struct {110 Lat float64 `json:"lat"`111 Lng float64 `json:"lng"`112}113114// featureText holds what only a selected feature needs.115type 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}123124// link is a cross-reference to another feature of the same world.125type link struct {126 Title string `json:"title"`127 LocationID int64 `json:"locationId"`128}129130// 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.135type 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}146147// counts is features by kind, as a ledger speaks of them.148type counts struct {149 Point int `json:"point"`150 Path int `json:"path"`151 Area int `json:"area"`152}153154// 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.157func 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}171172// 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.181func 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)194195 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}250251func 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}266267func 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}277278// 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.281func optionalID(id int64) *int64 {282 if id == 0 {283 return nil284 }285 value := id286 return &value287}288289func position(p *doc.Position) *coordinate {290 if p == nil {291 return nil292 }293 return &coordinate{Lat: p.Lat, Lng: p.Lng}294}