1package vnext23import (4 "encoding/base64"5 "encoding/json"6 "fmt"7 "math"8 "path"9 "sort"10 "strconv"11 "strings"1213 "github.com/FelineStateMachine/atlas/format/bundle"14 "github.com/FelineStateMachine/atlas/format/semconv"15)1617type v3World struct {18 Attrs map[string]string `json:"attrs"`19 Grid *v3Grid `json:"grid"`20 Lenses []v3Lens `json:"lenses"`21 Collections []v3Collection `json:"collections"`22 Merged []v3Merge `json:"merged"`23}2425type v3Grid struct {26 SourceZoom *int `json:"sourceZoom"`27 FirstTile *int `json:"firstTile"`28 TileSize *int `json:"tileSize"`29 Size *int `json:"size"`30}3132type v3Lens struct {33 Name string `json:"name"`34 Tiles string `json:"tiles"`35 MinZoom int64 `json:"minZoom"`36 MaxZoom int64 `json:"maxZoom"`37 FullZoom int64 `json:"fullZoom"`38 SourceZoom int64 `json:"sourceZoom"`39 Formats []string `json:"formats"`40 Bounds *RasterRect `json:"bounds"`41 Surface *RasterRect `json:"surface"`42 Interpolate bool `json:"interpolate"`43 Background string `json:"background"`44 Shard int64 `json:"shard"`45 Coverage map[string]v3Coverage `json:"coverage"`46}4748type v3Coverage struct {49 X int64 `json:"x"`50 Y int64 `json:"y"`51 W int64 `json:"w"`52 H int64 `json:"h"`53 Bits string `json:"bits"`54}5556type v3Collection struct {57 ID int64 `json:"id"`58 Title string `json:"title"`59 Kind string `json:"kind"`60 Group string `json:"group"`61 Icon string `json:"icon"`62 IconAsset string `json:"iconAsset"`63 IconPicture bool `json:"iconPicture"`64 Color string `json:"color"`65 IconColor string `json:"iconColor"`66 Visible *bool `json:"visible"`67 Attrs map[string]string `json:"attrs"`68 Features []v3Feature `json:"features"`69}7071type v3Feature struct {72 ID int64 `json:"id"`73 Title string `json:"title"`74 Subtitle string `json:"subtitle"`75 Parent *int64 `json:"parent"`76 Center *bundle.Coordinate `json:"center"`77 Shard int64 `json:"shard"`78 Attrs map[string]string `json:"attrs"`79 Geometry []v3Geometry `json:"geometry"`80}8182type v3Geometry struct {83 Type string `json:"type"`84 Coordinates json.RawMessage `json:"coordinates"`85}8687type v3Merge struct {88 Slug string `json:"slug"`89 Source string `json:"source"`90 Origin bool `json:"origin"`91}9293type v3Text struct {94 Description string `json:"d"`95 Links []v3Link `json:"l"`96 Attrs map[string]string `json:"a"`97}9899type v3Link struct {100 Title string `json:"title"`101 LocationID int64 `json:"locationId"`102}103104// ImportV3 turns the frozen document-shaped format into the vNext semantic105// graph for migration tooling and old test fixtures. Production scanners and106// installers reject v3 files after the hard cutover.107func ImportV3(reader *bundle.Reader) (Bundle, error) {108 if err := reader.Validate(); err != nil {109 return Bundle{}, fmt.Errorf("validate v3 bundle: %w", err)110 }111 volume, err := ImportV3Volume(reader.Manifest, reader.ReadEntry)112 if err != nil {113 return Bundle{}, err114 }115 assets, err := importV3Assets(reader)116 if err != nil {117 return Bundle{}, err118 }119 volume.Assets = assets120 compiled, err := Compile(volume)121 if err != nil {122 return Bundle{}, fmt.Errorf("compile imported v3 bundle: %w", err)123 }124 compiled.Release.CreatedAt = reader.Manifest.Version.CreatedAt125 compiled.Release.Revision = reader.Manifest.Version.Revision126 for _, name := range reader.Names() {127 if !strings.HasPrefix(name, bundle.TilesPrefix) {128 continue129 }130 data, err := reader.ReadEntry(name)131 if err != nil {132 return Bundle{}, err133 }134 compiled.Blobs = append(compiled.Blobs, Blob{Name: name, Data: data})135 }136 sort.Slice(compiled.Blobs, func(i, j int) bool { return compiled.Blobs[i].Name < compiled.Blobs[j].Name })137 return compiled, nil138}139140// ImportV3Volume crosses the migration boundary without choosing a physical141// vNext layout. Runtime adapters use this form to make the semantic graph142// authoritative while opaque assets continue through their existing door.143func ImportV3Volume(manifest bundle.Manifest, read func(string) ([]byte, error)) (Volume, error) {144 volume := Volume{ID: manifest.Volume.Slug, Title: manifest.Volume.Title}145 for _, entry := range manifest.Worlds {146 world, err := importV3World(read, manifest, entry)147 if err != nil {148 return Volume{}, err149 }150 volume.Worlds = append(volume.Worlds, world)151 }152 return volume, nil153}154155func importV3World(read func(string) ([]byte, error), manifest bundle.Manifest, entry bundle.WorldEntry) (World, error) {156 detail, err := read(bundle.WorldEntryName(entry.Slug, bundle.WorldSuffix))157 if err != nil {158 return World{}, err159 }160 var source v3World161 if err := json.Unmarshal(detail, &source); err != nil {162 return World{}, fmt.Errorf("decode world %s: %w", entry.Slug, err)163 }164 textData, err := read(bundle.WorldEntryName(entry.Slug, bundle.TextSuffix))165 if err != nil {166 return World{}, err167 }168 var texts map[string]v3Text169 if err := json.Unmarshal(textData, &texts); err != nil {170 return World{}, fmt.Errorf("decode world %s text: %w", entry.Slug, err)171 }172 packed, err := read(bundle.WorldEntryName(entry.Slug, bundle.PackedSuffix))173 if err != nil {174 return World{}, err175 }176 locations, err := bundle.UnpackLocations(packed)177 if err != nil {178 return World{}, err179 }180181 grid := manifest.TileGrid182 if source.Grid != nil {183 assignInt(&grid.SourceZoom, source.Grid.SourceZoom)184 assignInt(&grid.FirstTile, source.Grid.FirstTile)185 assignInt(&grid.TileSize, source.Grid.TileSize)186 assignInt(&grid.Size, source.Grid.Size)187 }188 world := World{189 ID: entry.Slug, Title: entry.Title,190 CoordinateSpace: CoordinateSpace{191 ID: entry.Slug + "/coordinates", Kind: "projected", Unit: "world-pixel", Definition: "atlas:v3/tile-grid",192 Extent: [4]float64{0, 0, float64(grid.Size), float64(grid.Size)}, SourceZoom: int64(grid.SourceZoom),193 FirstTile: int64(grid.FirstTile), TileSize: int64(grid.TileSize), Size: int64(grid.Size),194 },195 Claims: claimsFromV3("world", source.Attrs),196 Presentation: Presentation{ID: entry.Slug + "/presentation/default", Title: "Default"},197 }198 origin := v3Origin(source.Merged)199 for index, collection := range source.Collections {200 set, err := importV3Collection(entry, collection, texts, origin, grid)201 if err != nil {202 return World{}, fmt.Errorf("world %s collection %s: %w", entry.Slug, collection.Title, err)203 }204 world.FeatureSets = append(world.FeatureSets, set)205 styleID := entry.Slug + "/style/" + strconv.FormatInt(collection.ID, 10)206 layerID := entry.Slug + "/layer/" + strconv.FormatInt(collection.ID, 10)207 color := collection.Color208 if color == "" {209 color = collection.IconColor210 }211 world.Presentation.Styles = append(world.Presentation.Styles, Style{212 ID: styleID, Icon: collection.Icon, IconAsset: collection.IconAsset, IconPicture: collection.IconPicture,213 RenderAs: collection.Attrs[semconv.KeyRenderAs], Fill: color, Stroke: color,214 })215 visible := collection.Visible == nil || *collection.Visible216 world.Presentation.Layers = append(world.Presentation.Layers, Layer{217 ID: layerID, FeatureSet: set.ID, Style: styleID, Group: collection.Group,218 LabelPolicy: collection.Attrs[semconv.KeyLabelPolicy], Visible: visible,219 Order: int64(index), MinZoom: 0, MaxZoom: maxV3Zoom(source.Lenses),220 })221 world.Presentation.Legend = append(world.Presentation.Legend, LegendEntry{Layer: layerID, Label: collection.Title, Order: int64(index)})222 }223 for _, location := range locations {224 owner := int(location.Owner)225 if owner >= len(world.FeatureSets) {226 return World{}, fmt.Errorf("world %s point owner %d is out of range", entry.Slug, owner)227 }228 text := texts[strconv.FormatInt(location.ID, 10)]229 feature := Feature{230 ID: v3FeatureID(entry.Slug, location.ID), Title: location.Title, Shard: location.Shard,231 Geometry: Geometry{Kind: GeometryPoint, Parts: []GeometryPart{{Rings: [][]Position{{projectV3(location.Lng, location.Lat, grid)}}}}},232 Properties: propertiesFromV3(text.Attrs), Provenance: v3Provenance(origin, location.ID, entry.UpdatedAt),233 Description: text.Description,234 }235 ensureV3Contract(&world.FeatureSets[owner], feature.Properties)236 if location.Member != 0 {237 feature.Relationships = append(feature.Relationships, Relationship{Predicate: "within", Target: v3FeatureID(entry.Slug, location.Member)})238 }239 feature.Relationships = append(feature.Relationships, relationshipsFromV3(entry.Slug, text.Links)...)240 world.FeatureSets[owner].Features = append(world.FeatureSets[owner].Features, feature)241 }242 for index := range world.FeatureSets {243 sort.Slice(world.FeatureSets[index].Properties, func(i, j int) bool {244 return world.FeatureSets[index].Properties[i].ID.String() < world.FeatureSets[index].Properties[j].ID.String()245 })246 }247 for index, lens := range source.Lenses {248 raster, err := importV3Raster(entry.Slug, grid.TileSize, index, lens)249 if err != nil {250 return World{}, err251 }252 world.RasterPyramids = append(world.RasterPyramids, raster)253 }254 return world, nil255}256257func importV3Collection(entry bundle.WorldEntry, source v3Collection, texts map[string]v3Text, origin string, grid bundle.TileGrid) (FeatureSet, error) {258 set := FeatureSet{259 ID: entry.Slug + "/set/" + strconv.FormatInt(source.ID, 10), Title: source.Title,260 SemanticType: "geometry." + source.Kind, Claims: claimsFromV3("collection", source.Attrs),261 }262 contract := make(map[string]Field)263 for _, feature := range source.Features {264 for key := range feature.Attrs {265 contract[key] = v3AttributeField("feature", key)266 }267 for key := range texts[strconv.FormatInt(feature.ID, 10)].Attrs {268 contract[key] = v3AttributeField("feature", key)269 }270 }271 for _, field := range contract {272 set.Properties = append(set.Properties, field)273 }274 sort.Slice(set.Properties, func(i, j int) bool { return set.Properties[i].ID.String() < set.Properties[j].ID.String() })275 for _, sourceFeature := range source.Features {276 text := texts[strconv.FormatInt(sourceFeature.ID, 10)]277 geometry, err := geometryFromV3(sourceFeature.Geometry, grid)278 if err != nil {279 return FeatureSet{}, fmt.Errorf("feature %d geometry: %w", sourceFeature.ID, err)280 }281 feature := Feature{282 ID: v3FeatureID(entry.Slug, sourceFeature.ID), Title: sourceFeature.Title, Subtitle: sourceFeature.Subtitle,283 Description: text.Description, Shard: sourceFeature.Shard, Geometry: geometry,284 Properties: propertiesFromV3(mergeV3Attrs(sourceFeature.Attrs, text.Attrs)),285 Relationships: relationshipsFromV3(entry.Slug, text.Links), Provenance: v3Provenance(origin, sourceFeature.ID, entry.UpdatedAt),286 }287 if sourceFeature.Center != nil {288 center := projectV3(sourceFeature.Center.Lng, sourceFeature.Center.Lat, grid)289 feature.Center = ¢er290 }291 if sourceFeature.Parent != nil {292 feature.Relationships = append(feature.Relationships, Relationship{Predicate: "within", Target: v3FeatureID(entry.Slug, *sourceFeature.Parent)})293 }294 set.Features = append(set.Features, feature)295 }296 return set, nil297}298299func importV3Raster(worldID string, tileSize, index int, lens v3Lens) (RasterPyramid, error) {300 raster := RasterPyramid{301 ID: worldID + "/raster/" + strconv.Itoa(index), Name: lens.Name, Codec: "raster-tile", TileSize: int64(tileSize),302 MinZoom: lens.MinZoom, MaxZoom: lens.MaxZoom, FullZoom: lens.FullZoom, SourceZoom: lens.SourceZoom,303 Template: bundle.TilesPrefix + lens.Tiles + "/{z}/{x}/{y}.{format}", Formats: lens.Formats,304 Bounds: lens.Bounds, Surface: lens.Surface, Interpolate: lens.Interpolate, Background: lens.Background, Shard: lens.Shard,305 }306 levels := make([]int, 0, len(lens.Coverage))307 for level := range lens.Coverage {308 zoom, err := strconv.Atoi(level)309 if err != nil {310 return RasterPyramid{}, fmt.Errorf("raster %s has invalid coverage zoom %q", lens.Name, level)311 }312 levels = append(levels, zoom)313 }314 sort.Ints(levels)315 for _, zoom := range levels {316 coverage := lens.Coverage[strconv.Itoa(zoom)]317 bits, err := base64.StdEncoding.DecodeString(coverage.Bits)318 if err != nil {319 return RasterPyramid{}, fmt.Errorf("decode raster %s coverage: %w", lens.Name, err)320 }321 raster.Coverage = append(raster.Coverage, RasterCoverage{Zoom: int64(zoom), X: coverage.X, Y: coverage.Y, W: coverage.W, H: coverage.H, Bits: bits})322 }323 return raster, nil324}325326func geometryFromV3(source []v3Geometry, grid bundle.TileGrid) (Geometry, error) {327 var out Geometry328 for _, item := range source {329 switch item.Type {330 case "Point":331 var point Position332 if err := json.Unmarshal(item.Coordinates, &point); err != nil {333 return Geometry{}, err334 }335 out.Kind, out.Parts = GeometryPoint, append(out.Parts, GeometryPart{Rings: [][]Position{{projectV3Position(point, grid)}}})336 case "LineString":337 var line []Position338 if err := json.Unmarshal(item.Coordinates, &line); err != nil {339 return Geometry{}, err340 }341 out.Kind, out.Parts = GeometryLineString, append(out.Parts, GeometryPart{Rings: [][]Position{projectV3Positions(line, grid)}})342 case "MultiLineString":343 var lines [][]Position344 if err := json.Unmarshal(item.Coordinates, &lines); err != nil {345 return Geometry{}, err346 }347 out.Kind = GeometryLineString348 for _, line := range lines {349 out.Parts = append(out.Parts, GeometryPart{Rings: [][]Position{projectV3Positions(line, grid)}})350 }351 case "Polygon":352 var rings [][]Position353 if err := json.Unmarshal(item.Coordinates, &rings); err != nil {354 return Geometry{}, err355 }356 out.Kind, out.Parts = GeometryPolygon, append(out.Parts, GeometryPart{Rings: projectV3Rings(rings, grid)})357 case "MultiPolygon":358 var polygons [][][]Position359 if err := json.Unmarshal(item.Coordinates, &polygons); err != nil {360 return Geometry{}, err361 }362 out.Kind = GeometryPolygon363 for _, rings := range polygons {364 out.Parts = append(out.Parts, GeometryPart{Rings: projectV3Rings(rings, grid)})365 }366 default:367 return Geometry{}, fmt.Errorf("unsupported v3 geometry %q", item.Type)368 }369 }370 return out, validateGeometry(out)371}372373func 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}382383func projectV3Position(position Position, grid bundle.TileGrid) Position {384 return projectV3(position[0], position[1], grid)385}386387func 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}394395func 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}402403func importV3Assets(reader *bundle.Reader) ([]Asset, error) {404 var assets []Asset405 for _, name := range reader.Names() {406 if !strings.HasPrefix(name, bundle.IconsPrefix) {407 continue408 }409 data, err := reader.ReadEntry(name)410 if err != nil {411 return nil, err412 }413 assets = append(assets, Asset{ID: strings.TrimPrefix(name, bundle.IconsPrefix), MediaType: mediaTypeForV3(name), Data: data, Provenance: "atlas-bundle/v3"})414 }415 return assets, nil416}417418func claimsFromV3(owner string, attrs map[string]string) []Property {419 properties := propertiesFromV3Namespace(owner, attrs)420 for index := range properties {421 properties[index].Field.Optional = true422 }423 return properties424}425426func propertiesFromV3(attrs map[string]string) []Property {427 return propertiesFromV3Namespace("feature", attrs)428}429430func propertiesFromV3Namespace(owner string, attrs map[string]string) []Property {431 keys := make([]string, 0, len(attrs))432 for key := range attrs {433 keys = append(keys, key)434 }435 sort.Strings(keys)436 out := make([]Property, 0, len(keys))437 for _, key := range keys {438 field := v3AttributeField(owner, key)439 out = append(out, Property{FieldID: field.ID, Field: field, Value: StringValue(attrs[key])})440 }441 return out442}443444func ensureV3Contract(set *FeatureSet, properties []Property) {445 for _, property := range properties {446 id := property.fieldID()447 found := false448 for _, field := range set.Properties {449 if field.ID == id {450 found = true451 break452 }453 }454 if !found {455 set.Properties = append(set.Properties, property.Field)456 }457 }458}459460func v3AttributeField(owner, key string) Field {461 return Field{ID: IDFromName("dev.atlas.v3.attribute."+owner, key), Name: key, Kind: KindString, Optional: true}462}463464func v3FeatureID(world string, id int64) string {465 return world + "/feature/" + strconv.FormatInt(id, 10)466}467468func v3Origin(merged []v3Merge) string {469 for _, account := range merged {470 if account.Origin && account.Source != "" {471 return account.Source472 }473 }474 for _, account := range merged {475 if account.Source != "" {476 return account.Source477 }478 }479 return "atlas-bundle/v3"480}481482func v3Provenance(source string, id int64, capturedAt string) []Provenance {483 return []Provenance{{Source: source, NativeID: strconv.FormatInt(id, 10), CapturedAt: capturedAt}}484}485486func relationshipsFromV3(world string, links []v3Link) []Relationship {487 out := make([]Relationship, 0, len(links))488 for _, link := range links {489 out = append(out, Relationship{Predicate: "references", Target: v3FeatureID(world, link.LocationID)})490 }491 return out492}493494func mergeV3Attrs(left, right map[string]string) map[string]string {495 out := make(map[string]string, len(left)+len(right))496 for key, value := range left {497 out[key] = value498 }499 for key, value := range right {500 out[key] = value501 }502 return out503}504505func maxV3Zoom(lenses []v3Lens) int64 {506 var maximum int64507 for _, lens := range lenses {508 if lens.MaxZoom > maximum {509 maximum = lens.MaxZoom510 }511 }512 return maximum513}514515func assignInt(target *int, source *int) {516 if source != nil {517 *target = *source518 }519}520521func mediaTypeForV3(name string) string {522 switch strings.ToLower(path.Ext(name)) {523 case ".svg":524 return "image/svg+xml"525 case ".png":526 return "image/png"527 case ".jpg", ".jpeg":528 return "image/jpeg"529 case ".webp":530 return "image/webp"531 default:532 return "application/octet-stream"533 }534}