clone

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

feat/atlas-next-horizon
atlas / internal / app / view.go
view.go 18.4K
1package app23import (4 "html/template"5 "regexp"6 "sort"7 "strings"89 "github.com/FelineStateMachine/atlas/format/semconv"10 "github.com/FelineStateMachine/atlas/format/vnext"11 "github.com/FelineStateMachine/atlas/internal/app/cells"12 "github.com/FelineStateMachine/atlas/internal/app/hostenv"13 "github.com/FelineStateMachine/atlas/internal/minting"14)1516// View is what every region is rendered with: the library, the volume and17// world in view, and the session that colours them. One type for every region18// is deliberate -- a region is a view of the same application state, not of a19// bespoke payload -- and it is the seam between "the handler decided" and "the20// template rendered".21//22// Everything a template needs must already be a field here. A template that23// has to compute is a decision that belongs in Go (issue #5 §4.5), so the24// fields below are answers rather than materials: the legend arrives as25// sections and rows already counted, the dock as a shortlist and the words26// above it, the viewport as the attributes its state node wears.27type View struct {28 // Title is the page title: the volume in view, or the application.29 Title string3031 // Library is every serving volume, in the catalog's order.32 Library []LibraryEntry3334 // LibraryDir is where bundles go, in the words of the reader's own35 // machine. Shown by the empty state; a label, never a path.36 LibraryDir string3738 // Volume is the volume in view, or nil on the empty-library page.39 Volume *VolumeView4041 // Session is the remembered state for the volume in view.42 Session Session4344 // Legend is the sidebar: the tree, the isolate chip, the footer count.45 Legend LegendView4647 // Dock is the panel beside the map.48 Dock DockView4950 // Detail is the open card, or its closed shape.51 Detail DetailView5253 // Grid is the cell navigator.54 Grid GridView5556 // Overview is the corner locator's chrome. What it draws is the seam's.57 Overview OverviewView5859 // Viewport is the inert state node the seam reads.60 Viewport ViewportView6162 // Island is the session as JSON, for the page's embedded state island.63 Island template.JS6465 // Feature is the feature a detail fragment was asked for.66 Feature string6768 // Rows are the progress rows an import has produced so far.69 Rows []ImportRow7071 // Mint is the optional embedded authoring drawer. It is available only72 // when this host can build and the world in view is a sphere.73 Mint MintView74}7576// MintView is the server-rendered baseline for native area authoring.77type MintView struct {78 Available bool79 Request minting.Request80 Preview minting.Preview81 Error string82 Pixels string83 Tiles string84 Groups string85 Bytes string86}8788// LibraryEntry is one volume as the topbar's volume selector lists it.89type LibraryEntry struct {90 Slug string91 Title string92 Stamp string93 Base string94 Worlds int95 Current bool96}9798// VolumeView is the volume the page is about.99type VolumeView struct {100 Slug string101 Title string102 Stamp string103 Base string104 Worlds []WorldView105 World WorldView106 Lenses []LensView107108 // Lens is the lens in view and LensIndex its place in the list, which109 // is the number the session schema publishes as `lens` (docs/app.md110 // §6).111 Lens string112 LensIndex int113114 // Sphere is whether this world declares a surface a globe can be draped115 // over, which is the whole reason the globe toggle is offered.116 Sphere bool117}118119// WorldView is one ground within the volume.120type WorldView struct {121 Slug string122 Title string123 Parent string124 Points int125 Paths int126 Areas int127 Current bool128}129130// LensView is one raster pyramid picturing the world in view.131type LensView struct {132 Index int133 Name string134 Current bool135}136137// DockView is the panel: what it counts, why the count is what it is, and the138// shortlist under it.139type DockView struct {140 Open bool141 Count string142 Flag string143 FlagTitle string144 Note string145 Empty string146 Rows []ListEntry147 RailName string148 Filtered bool149 Section string150 FoldLabel string151}152153// DetailView is one feature's card.154type DetailView struct {155 Open bool156 ID string157 Title string158 Category string159 Description string160 Color string161 Glyph string162 Icon string163 Coordinates string164 // Point is whether the card is about a point rather than a shape. The165 // card asks it about one row only: the grid cell, which is a name for166 // somewhere a *point* stands and which the seam fills in167 // (render/viewport.ts, writeCell). A shape has no such place, and the168 // reference hid the row on exactly that branch.169 Point bool170 Source string171 Links []DetailLink172 Rows []DetailRow173}174175// DetailLink is one feature the open one points at.176type DetailLink struct {177 ID string178 Title string179}180181// DetailRow is one term and its value in the card's definition list.182type DetailRow struct {183 Key string184 Label string185 Value string186}187188// GridView is the cell navigator's chrome. Which systems a world offers and189// what a cell draws are the analysis lane's (issue #5 §5.4); what is held is190// the session's, and that is all this carries.191type GridView struct {192 Enabled bool193 System string194 Name string195 Cell string196 Subgrid bool197 Offered bool198199 // Mark is the short mark the cycle button wears, Next names the system it200 // would step to, and Cycles is whether the button is on the page at all.201 //202 // A world most systems refuse -- every game map, which declares nothing203 // about what its picture is of -- offers geohash alone, and a control that204 // cycles between one thing is a control that does nothing. The reference205 // hid it (`syncGridSystemControl`: `hidden = systems.length < 2`), so the206 // question the button asks a reader is always a real one.207 Mark string208 Next string209 Cycles bool210211 // Length is how many characters the field accepts: the depth this212 // system's telescope stops at, spelled on the input so a reader cannot213 // type past a floor the record would silently clamp them to.214 Length int215}216217// OverviewView is the corner locator's chrome.218type OverviewView struct {219 Docked bool220 Label string221 World string222}223224// ViewportView is the inert state node: the attributes the seam reads off it,225// and the three lists it carries as <data> children.226type ViewportView struct {227 Volume string228 Base string229 World string230 Lens string231 LensIndex int232 Surface string233 Selected string234 Search string235 GridSystem string236 GridCell string237 Subgrid int238 Hidden []string239 Highlighted []string240 Overrides []LabelOverride241 Camera *Camera242}243244// LabelOverride is one reader-set label policy, as the state node carries it.245type LabelOverride struct {246 Collection string247 Policy string248}249250// view assembles what the regions are rendered with. The world it shows is251// the session's, falling back to the volume's first world, which is the one a252// volume opens on when nobody has opened it before.253func (a *App) view(held library, volume hostenv.Volume, session Session) View {254 return a.viewMode(held, volume, session, false)255}256257func (a *App) viewPageZero(held library, volume hostenv.Volume, session Session) View {258 return a.viewMode(held, volume, session, !sessionNeedsFeatures(session))259}260261func sessionNeedsFeatures(session Session) bool {262 return session.Search != "" || session.Selected != "" || session.Focused != "" ||263 len(session.Highlighted) > 0 || len(session.Expanded) > 0 || session.Detail.Open ||264 session.Dock.Open || session.Grid.Cell != ""265}266267func (a *App) viewMode(held library, volume hostenv.Volume, session Session, pageZero bool) View {268 out := View{269 Title: "Atlas",270 LibraryDir: a.env.Volumes().Location(),271 Session: session,272 }273 current := ""274 if volume != nil {275 current = volume.Info().Slug276 }277 for _, listed := range held.order {278 info := listed.Info()279 out.Library = append(out.Library, LibraryEntry{280 Slug: info.Slug, Title: info.Title, Stamp: vnext.ShortStamp(info.Stamp),281 Base: volumeBase(info), Worlds: len(info.Worlds), Current: info.Slug == current,282 })283 }284 if volume == nil {285 out.Island = island("", nil, nil, Session{})286 return out287 }288289 info := volume.Info()290 shown := VolumeView{291 Slug: info.Slug, Title: info.Title, Stamp: vnext.ShortStamp(info.Stamp), Base: volumeBase(info),292 }293 world := session.World294 if _, ok := worldEntry(info, world); !ok {295 world = info.Worlds[0].Slug296 }297 for _, entry := range info.Worlds {298 listed := WorldView{299 Slug: entry.Slug,300 Title: entry.Title,301 Parent: entry.Parent,302 Points: entry.Points,303 Paths: entry.Paths,304 Areas: entry.Areas,305 Current: entry.Slug == world,306 }307 if listed.Current {308 shown.World = listed309 }310 shown.Worlds = append(shown.Worlds, listed)311 }312313 var model *worldModel314 if pageZero {315 model = a.outlineWorld(volume, world)316 } else {317 model = a.world(volume, world)318 }319 var lens *payloadLens320 if model != nil {321 for at := range model.Lenses {322 entry := &model.Lenses[at]323 listed := LensView{Index: at, Name: entry.Name}324 if entry.Name == session.Lens || (session.Lens == "" && at == 0) {325 listed.Current = true326 shown.Lens, shown.LensIndex = entry.Name, at327 lens = entry328 }329 shown.Lenses = append(shown.Lenses, listed)330 }331 shown.Sphere = model.Attrs[semconv.KeyGeometrySurface] == "sphere"332 }333 out.Volume = &shown334 out.Title = info.Title335336 standing := visible(model, session, lens)337 out.Legend = legend(model, session, standing, lens, shown.Base)338 out.Dock = dockView(session, standing, model)339 out.Detail = a.detail(volume, model, session, session.Selected)340 out.Grid = gridView(session, model)341 out.Overview = OverviewView{Docked: session.Overview.Docked, World: shown.World.Title}342 if out.Overview.Docked {343 out.Overview.Label = "Bring the overview back"344 } else {345 out.Overview.Label = "Put the overview away"346 }347 out.Viewport = viewportView(shown, session)348 out.Island = island(info.Slug, model, &shown, session)349 if a.minter != nil && shown.Sphere {350 request := a.minter.Default()351 preview, err := a.minter.Preview(request)352 out.Mint = newMintView(request, preview, err)353 }354 return out355}356357// dockView is the panel's own account of what the filters left standing.358func dockView(session Session, standing visibility, model *worldModel) DockView {359 out := DockView{360 Open: session.Dock.Open,361 Count: standing.DockText,362 Flag: standing.DockFlag,363 FlagTitle: standing.DockFlagTitle,364 Note: standing.DockNote,365 Empty: standing.Empty,366 Rows: standing.Rows(),367 Filtered: standing.Filtered,368 Section: session.Dock.Section,369 }370 if out.Open {371 out.FoldLabel = "Put the panel away (⌘⌥B)"372 } else {373 out.FoldLabel = "Bring the panel back (⌘⌥B)"374 }375 // Folded, the panel is a rail with one word on it, and that word used to376 // read the same whether or not a feature was open: the map had something377 // picked out and the only panel saying which was the one just put away.378 // The rail carries the name until the card is closed.379 if model != nil && session.Selected != "" {380 if pin, ok := model.PointByID[session.Selected]; ok {381 out.RailName = railName(pin.Title)382 } else if shape, ok := model.ShapeByID[session.Selected]; ok {383 out.RailName = railName(shape.Title)384 }385 }386 return out387}388389// railNameLimit is where a name is cut on the rail. The rail runs the height390// of the window and a long name would run past the bottom of it; this is where391// a reader can still recognise the place.392const railNameLimit = 28393394func railName(title string) string {395 runes := []rune(title)396 if len(runes) <= railNameLimit {397 return title398 }399 return strings.TrimRight(string(runes[:railNameLimit-1]), " ") + "…"400}401402func gridView(session Session, model *worldModel) GridView {403 out := GridView{404 Enabled: session.Grid.System != "",405 System: session.Grid.System,406 Cell: session.Grid.Cell,407 Subgrid: session.Grid.Subgrid > 0,408 Offered: model != nil,409 }410 out.Name, out.Mark, out.Length = systemName(out.System), systemMark(out.System),411 inputLength(out.System, worldAttrs(model))412 // Which systems divide this world is the world's own answer, and the413 // button is offered only when there is a second one to step to.414 offered := cells.ApplicableSystems(worldAttrs(model))415 out.Cycles = len(offered) > 1 && out.System != ""416 out.Next = systemName(nextSystem(offered, out.System))417 return out418}419420// viewportView fills the inert state node. Data flows one way through it: the421// server writes, the seam reads and reconciles, and the only things that come422// back are a pick and a settled camera (issue #5 §5.5).423func viewportView(shown VolumeView, session Session) ViewportView {424 out := ViewportView{425 Volume: shown.Slug,426 Base: shown.Base,427 World: shown.World.Slug,428 Lens: shown.Lens,429 LensIndex: shown.LensIndex,430 Selected: session.Selected,431 Search: session.Search,432 GridSystem: session.Grid.System,433 GridCell: session.Grid.Cell,434 Subgrid: session.Grid.Subgrid,435 Hidden: sortedIDs(session.Hidden),436 Highlighted: sortedIDs(session.Highlighted),437 }438 out.Surface = "plane"439 if shown.Sphere {440 out.Surface = "sphere"441 }442 for collection, policy := range session.Labels {443 out.Overrides = append(out.Overrides, LabelOverride{Collection: collection, Policy: policy})444 }445 sort.Slice(out.Overrides, func(i, j int) bool {446 return out.Overrides[i].Collection < out.Overrides[j].Collection447 })448 if camera, ok := session.Cameras[shown.World.Slug]; ok {449 out.Camera = &camera450 }451 return out452}453454// ---------------------------------------------------------------------------455// The card456// ---------------------------------------------------------------------------457458// attributeLabels are the curated words a few attributes wear on a card. An459// attribute with no curated word wears its key, which is honest and rare.460// Keys come from the registry rather than as literals, so a key that is461// renamed there is a compile error here (issue #5 §9).462var attributeLabels = map[string]string{semconv.KeyHydroHUC12: "HUC-12"}463464// reservedPrefixes are the namespaces that are machinery rather than465// material: how a thing is drawn, whether its name speaks, how thick its line466// is, what shape it is. A card is about the place, not about the rendering.467//468// They are derived from the registry's own keys rather than spelled out, so a469// new rendering key lands inside the fence by construction: an attribute that470// governs how a thing draws is machinery whatever it is called.471var reservedPrefixes = namespacesOf(472 semconv.KeyRenderAs, semconv.KeyLabelPolicy,473 semconv.KeyStrokeWidthPx, semconv.KeyGeometryKind,474)475476// namespacesOf is the dotted namespace each key belongs to, with its trailing477// dot: atlas.render.as becomes atlas.render.478func namespacesOf(keys ...string) []string {479 out := make([]string, 0, len(keys))480 for _, key := range keys {481 if at := strings.LastIndexByte(key, '.'); at > 0 {482 out = append(out, key[:at+1])483 }484 }485 return out486}487488// detail builds the card directly from the selected semantic feature.489func (a *App) detail(_ hostenv.Volume, model *worldModel, session Session, id string) DetailView {490 if id == "" || !session.Detail.Open || model == nil {491 return DetailView{}492 }493 out := DetailView{Open: true, ID: id}494 var feature *vnext.Feature495496 switch pin, isPoint := model.PointByID[id]; {497 case isPoint:498 out.Title = pin.Title499 out.Category = strings.Join(nonEmpty(pin.Collection.Group, pin.Collection.Title), " / ")500 out.Color = collectionColor(pin.Collection)501 out.Glyph = initials(pin.Collection.Title)502 out.Icon = pin.Collection.Icon503 out.Coordinates = pin.Coordinates504 // Which is what earns this card the empty cell row: a place a point505 // stands is a place the cell systems can name.506 out.Point = true507 feature = pin.Feature508 default:509 shape, isShape := model.ShapeByID[id]510 if !isShape {511 return DetailView{}512 }513 out.Title = shape.Title514 out.Category = strings.Join(nonEmpty(shape.Collection.Group, shape.Collection.Title), " / ")515 if out.Category == "" {516 out.Category = shape.Subtitle517 }518 out.Color = colorFor(shape.ID)519 feature = shape.Feature520 }521522 if feature != nil {523 out.Description = cleanDescription(feature.Description)524 out.Rows = append(out.Rows, attributeRows(propertiesToAttrs(feature.Properties))...)525 if len(feature.Provenance) > 0 {526 out.Source = feature.Provenance[0].Source527 }528 for _, relationship := range feature.Relationships {529 if relationship.Predicate != "references" {530 continue531 }532 linked := relationship.Target533 title := linked534 if pin, ok := model.PointByID[linked]; ok {535 title = pin.Title536 } else if shape, ok := model.ShapeByID[linked]; ok {537 title = shape.Title538 }539 out.Links = append(out.Links, DetailLink{ID: linked, Title: title})540 }541 }542 if out.Source == "" {543 out.Source = model.Origin544 }545 if out.Description == "" {546 out.Description = "No description is included in the archive."547 }548 sort.SliceStable(out.Rows, func(i, j int) bool { return out.Rows[i].Label < out.Rows[j].Label })549 return out550}551552// attributeRows turns a feature's attributes into the rows its card shows:553// the curated label where one exists, the raw key where none does, and nothing554// at all for machinery or for the geographic pair the card already has a row555// for.556func attributeRows(attrs map[string]string) []DetailRow {557 var out []DetailRow558 for key, value := range attrs {559 if key == semconv.KeyGeoLat || key == semconv.KeyGeoLon {560 continue561 }562 reserved := false563 for _, prefix := range reservedPrefixes {564 if strings.HasPrefix(key, prefix) {565 reserved = true566 break567 }568 }569 if reserved {570 continue571 }572 label := attributeLabels[key]573 if label == "" {574 label = key575 }576 out = append(out, DetailRow{Key: key, Label: label, Value: value})577 }578 return out579}580581// linkText is the markdown link a source's prose arrives wearing. The card582// keeps the words and drops the address: a bundle carries no runtime URL, and583// a link nobody can follow is noise in a sentence.584var linkText = regexp.MustCompile(`\[([^\]]+)\]\([^)]*\)`)585586func cleanDescription(value string) string {587 out := linkText.ReplaceAllString(value, "$1")588 out = strings.Map(func(r rune) rune {589 switch r {590 case '*', '_', '`', '>', '#', '\r':591 return -1592 }593 return r594 }, out)595 for strings.Contains(out, "\n\n\n") {596 out = strings.ReplaceAll(out, "\n\n\n", "\n\n")597 }598 return strings.TrimSpace(out)599}600601func nonEmpty(values ...string) []string {602 out := make([]string, 0, len(values))603 for _, value := range values {604 if value != "" {605 out = append(out, value)606 }607 }608 return out609}610611// worldEntry finds one world in the native volume summary.612func worldEntry(info hostenv.VolumeInfo, slug string) (hostenv.WorldInfo, bool) {613 for _, entry := range info.Worlds {614 if entry.Slug == slug {615 return entry, true616 }617 }618 return hostenv.WorldInfo{}, false619}