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 / app / view.go
view.go 17.5K
1package app23import (4 "html/template"5 "regexp"6 "sort"7 "strconv"8 "strings"910 "github.com/FelineStateMachine/atlas/format/bundle"11 "github.com/FelineStateMachine/atlas/format/semconv"12 "github.com/FelineStateMachine/atlas/internal/app/cells"13 "github.com/FelineStateMachine/atlas/internal/app/hostenv"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 []ImportRow70}7172// LibraryEntry is one volume as the topbar's volume selector lists it.73type LibraryEntry struct {74 Slug string75 Title string76 Stamp string77 Base string78 Worlds int79 Current bool80}8182// VolumeView is the volume the page is about.83type VolumeView struct {84 Slug string85 Title string86 Stamp string87 Base string88 Worlds []WorldView89 World WorldView90 Lenses []LensView9192 // Lens is the lens in view and LensIndex its place in the list, which93 // is the number the session schema publishes as `lens` (docs/app.md94 // §6).95 Lens string96 LensIndex int9798 // Sphere is whether this world declares a surface a globe can be draped99 // over, which is the whole reason the globe toggle is offered.100 Sphere bool101}102103// WorldView is one ground within the volume.104type WorldView struct {105 Slug string106 Title string107 Parent string108 Points int109 Paths int110 Areas int111 Current bool112}113114// LensView is one raster pyramid picturing the world in view.115type LensView struct {116 Index int117 Name string118 Current bool119}120121// DockView is the panel: what it counts, why the count is what it is, and the122// shortlist under it.123type DockView struct {124 Open bool125 Count string126 Flag string127 FlagTitle string128 Note string129 Empty string130 Rows []ListEntry131 RailName string132 Filtered bool133 Section string134 FoldLabel string135}136137// DetailView is one feature's card.138type DetailView struct {139 Open bool140 ID string141 Title string142 Category string143 Description string144 Color string145 Glyph string146 Icon string147 Coordinates string148 // Point is whether the card is about a point rather than a shape. The149 // card asks it about one row only: the grid cell, which is a name for150 // somewhere a *point* stands and which the seam fills in151 // (render/viewport.ts, writeCell). A shape has no such place, and the152 // reference hid the row on exactly that branch.153 Point bool154 Source string155 Links []DetailLink156 Rows []DetailRow157}158159// DetailLink is one feature the open one points at.160type DetailLink struct {161 ID string162 Title string163}164165// DetailRow is one term and its value in the card's definition list.166type DetailRow struct {167 Key string168 Label string169 Value string170}171172// GridView is the cell navigator's chrome. Which systems a world offers and173// what a cell draws are the analysis lane's (issue #5 §5.4); what is held is174// the session's, and that is all this carries.175type GridView struct {176 Enabled bool177 System string178 Name string179 Cell string180 Subgrid bool181 Offered bool182183 // Mark is the short mark the cycle button wears, Next names the system it184 // would step to, and Cycles is whether the button is on the page at all.185 //186 // A world most systems refuse -- every game map, which declares nothing187 // about what its picture is of -- offers geohash alone, and a control that188 // cycles between one thing is a control that does nothing. The reference189 // hid it (`syncGridSystemControl`: `hidden = systems.length < 2`), so the190 // question the button asks a reader is always a real one.191 Mark string192 Next string193 Cycles bool194195 // Length is how many characters the field accepts: the depth this196 // system's telescope stops at, spelled on the input so a reader cannot197 // type past a floor the record would silently clamp them to.198 Length int199}200201// OverviewView is the corner locator's chrome.202type OverviewView struct {203 Docked bool204 Label string205 World string206}207208// ViewportView is the inert state node: the attributes the seam reads off it,209// and the three lists it carries as <data> children.210type ViewportView struct {211 Volume string212 Base string213 World string214 Lens string215 LensIndex int216 Surface string217 Selected string218 Search string219 GridSystem string220 GridCell string221 Subgrid int222 Hidden []string223 Highlighted []string224 Overrides []LabelOverride225 Camera *Camera226}227228// LabelOverride is one reader-set label policy, as the state node carries it.229type LabelOverride struct {230 Collection string231 Policy string232}233234// view assembles what the regions are rendered with. The world it shows is235// the session's, falling back to the volume's first world, which is the one a236// volume opens on when nobody has opened it before.237func (a *App) view(held library, volume hostenv.Volume, session Session) View {238 out := View{239 Title: "Atlas",240 LibraryDir: a.env.Volumes().Location(),241 Session: session,242 }243 current := ""244 if volume != nil {245 current = volume.Manifest().Volume.Slug246 }247 for _, listed := range held.order {248 manifest := listed.Manifest()249 out.Library = append(out.Library, LibraryEntry{250 Slug: manifest.Volume.Slug,251 Title: manifest.Volume.Title,252 Stamp: bundle.ShortStamp(manifest.Version.Stamp),253 Base: volumeBase(manifest),254 Worlds: len(manifest.Worlds),255 Current: manifest.Volume.Slug == current,256 })257 }258 if volume == nil {259 out.Island = island("", nil, nil, Session{})260 return out261 }262263 manifest := volume.Manifest()264 shown := VolumeView{265 Slug: manifest.Volume.Slug,266 Title: manifest.Volume.Title,267 Stamp: bundle.ShortStamp(manifest.Version.Stamp),268 Base: volumeBase(manifest),269 }270 world := session.World271 if _, ok := worldEntry(manifest, world); !ok {272 world = manifest.Worlds[0].Slug273 }274 for _, entry := range manifest.Worlds {275 listed := WorldView{276 Slug: entry.Slug,277 Title: entry.Title,278 Parent: entry.Parent,279 Points: entry.Points,280 Paths: entry.Paths,281 Areas: entry.Areas,282 Current: entry.Slug == world,283 }284 if listed.Current {285 shown.World = listed286 }287 shown.Worlds = append(shown.Worlds, listed)288 }289290 model := a.world(volume, world)291 var lens *payloadLens292 if model != nil {293 for at := range model.Lenses {294 entry := &model.Lenses[at]295 listed := LensView{Index: at, Name: entry.Name}296 if entry.Name == session.Lens || (session.Lens == "" && at == 0) {297 listed.Current = true298 shown.Lens, shown.LensIndex = entry.Name, at299 lens = entry300 }301 shown.Lenses = append(shown.Lenses, listed)302 }303 shown.Sphere = model.Attrs[semconv.KeyGeometrySurface] == "sphere"304 }305 out.Volume = &shown306 out.Title = manifest.Volume.Title307308 standing := visible(model, session, lens)309 out.Legend = legend(model, session, standing, lens, shown.Base)310 out.Dock = dockView(session, standing, model)311 out.Detail = a.detail(volume, model, session, session.Selected)312 out.Grid = gridView(session, model)313 out.Overview = OverviewView{Docked: session.Overview.Docked, World: shown.World.Title}314 if out.Overview.Docked {315 out.Overview.Label = "Bring the overview back"316 } else {317 out.Overview.Label = "Put the overview away"318 }319 out.Viewport = viewportView(shown, session)320 out.Island = island(manifest.Volume.Slug, model, &shown, session)321 return out322}323324// dockView is the panel's own account of what the filters left standing.325func dockView(session Session, standing visibility, model *worldModel) DockView {326 out := DockView{327 Open: session.Dock.Open,328 Count: standing.DockText,329 Flag: standing.DockFlag,330 FlagTitle: standing.DockFlagTitle,331 Note: standing.DockNote,332 Empty: standing.Empty,333 Rows: standing.Rows(),334 Filtered: standing.Filtered,335 Section: session.Dock.Section,336 }337 if out.Open {338 out.FoldLabel = "Put the panel away (⌘⌥B)"339 } else {340 out.FoldLabel = "Bring the panel back (⌘⌥B)"341 }342 // Folded, the panel is a rail with one word on it, and that word used to343 // read the same whether or not a feature was open: the map had something344 // picked out and the only panel saying which was the one just put away.345 // The rail carries the name until the card is closed.346 if model != nil && session.Selected != "" {347 if pin, ok := model.PointByID[session.Selected]; ok {348 out.RailName = railName(pin.Title)349 } else if shape, ok := model.ShapeByID[session.Selected]; ok {350 out.RailName = railName(shape.Title)351 }352 }353 return out354}355356// railNameLimit is where a name is cut on the rail. The rail runs the height357// of the window and a long name would run past the bottom of it; this is where358// a reader can still recognise the place.359const railNameLimit = 28360361func railName(title string) string {362 runes := []rune(title)363 if len(runes) <= railNameLimit {364 return title365 }366 return strings.TrimRight(string(runes[:railNameLimit-1]), " ") + "…"367}368369func gridView(session Session, model *worldModel) GridView {370 out := GridView{371 Enabled: session.Grid.System != "",372 System: session.Grid.System,373 Cell: session.Grid.Cell,374 Subgrid: session.Grid.Subgrid > 0,375 Offered: model != nil,376 }377 out.Name, out.Mark, out.Length = systemName(out.System), systemMark(out.System),378 inputLength(out.System, worldAttrs(model))379 // Which systems divide this world is the world's own answer, and the380 // button is offered only when there is a second one to step to.381 offered := cells.ApplicableSystems(worldAttrs(model))382 out.Cycles = len(offered) > 1 && out.System != ""383 out.Next = systemName(nextSystem(offered, out.System))384 return out385}386387// viewportView fills the inert state node. Data flows one way through it: the388// server writes, the seam reads and reconciles, and the only things that come389// back are a pick and a settled camera (issue #5 §5.5).390func viewportView(shown VolumeView, session Session) ViewportView {391 out := ViewportView{392 Volume: shown.Slug,393 Base: shown.Base,394 World: shown.World.Slug,395 Lens: shown.Lens,396 LensIndex: shown.LensIndex,397 Selected: session.Selected,398 Search: session.Search,399 GridSystem: session.Grid.System,400 GridCell: session.Grid.Cell,401 Subgrid: session.Grid.Subgrid,402 Hidden: sortedIDs(session.Hidden),403 Highlighted: sortedIDs(session.Highlighted),404 }405 out.Surface = "plane"406 if shown.Sphere {407 out.Surface = "sphere"408 }409 for collection, policy := range session.Labels {410 out.Overrides = append(out.Overrides, LabelOverride{Collection: collection, Policy: policy})411 }412 sort.Slice(out.Overrides, func(i, j int) bool {413 return out.Overrides[i].Collection < out.Overrides[j].Collection414 })415 if camera, ok := session.Cameras[shown.World.Slug]; ok {416 out.Camera = &camera417 }418 return out419}420421// ---------------------------------------------------------------------------422// The card423// ---------------------------------------------------------------------------424425// attributeLabels are the curated words a few attributes wear on a card. An426// attribute with no curated word wears its key, which is honest and rare.427// Keys come from the registry rather than as literals, so a key that is428// renamed there is a compile error here (issue #5 §9).429var attributeLabels = map[string]string{semconv.KeyHydroHUC12: "HUC-12"}430431// reservedPrefixes are the namespaces that are machinery rather than432// material: how a thing is drawn, whether its name speaks, how thick its line433// is, what shape it is. A card is about the place, not about the rendering.434//435// They are derived from the registry's own keys rather than spelled out, so a436// new rendering key lands inside the fence by construction: an attribute that437// governs how a thing draws is machinery whatever it is called.438var reservedPrefixes = namespacesOf(439 semconv.KeyRenderAs, semconv.KeyLabelPolicy,440 semconv.KeyStrokeWidthPx, semconv.KeyGeometryKind,441)442443// namespacesOf is the dotted namespace each key belongs to, with its trailing444// dot: atlas.render.as becomes atlas.render.445func namespacesOf(keys ...string) []string {446 out := make([]string, 0, len(keys))447 for _, key := range keys {448 if at := strings.LastIndexByte(key, '.'); at > 0 {449 out = append(out, key[:at+1])450 }451 }452 return out453}454455// detail builds the card for one feature, or the closed card when nothing is456// selected. Prose, links and the attributes that only matter once a card is457// open all come out of worlds/<slug>.text, which is why it is read here and458// not when the world is stood up.459func (a *App) detail(volume hostenv.Volume, model *worldModel, session Session, id string) DetailView {460 if id == "" || !session.Detail.Open || model == nil {461 return DetailView{}462 }463 out := DetailView{Open: true, ID: id}464 text := a.text(volume, model.Slug)465466 switch pin, isPoint := model.PointByID[id]; {467 case isPoint:468 out.Title = pin.Title469 out.Category = strings.Join(nonEmpty(pin.Collection.Group, pin.Collection.Title), " / ")470 out.Color = collectionColor(pin.Collection)471 out.Glyph = initials(pin.Collection.Title)472 out.Icon = pin.Collection.Icon473 out.Coordinates = strconv.FormatFloat(pin.Lat, 'f', 6, 64) + ", " +474 strconv.FormatFloat(pin.Lng, 'f', 6, 64)475 // Which is what earns this card the empty cell row: a place a point476 // stands is a place the cell systems can name.477 out.Point = true478 out.Source = model.Origin479 default:480 shape, isShape := model.ShapeByID[id]481 if !isShape {482 return DetailView{}483 }484 out.Title = shape.Title485 out.Category = strings.Join(nonEmpty(shape.Collection.Group, shape.Collection.Title), " / ")486 if out.Category == "" {487 out.Category = shape.Subtitle488 }489 out.Color = colorFor(shape.ID)490 out.Source = model.Origin491 out.Rows = append(out.Rows, attributeRows(shape.Attrs)...)492 }493494 if held, ok := text[id]; ok {495 out.Description = cleanDescription(held.Description)496 for _, link := range held.Links {497 linked := strconv.FormatInt(link, 10)498 title := linked499 if pin, ok := model.PointByID[linked]; ok {500 title = pin.Title501 } else if shape, ok := model.ShapeByID[linked]; ok {502 title = shape.Title503 }504 out.Links = append(out.Links, DetailLink{ID: linked, Title: title})505 }506 out.Rows = append(out.Rows, attributeRows(held.Attrs)...)507 }508 if out.Description == "" {509 out.Description = "No description is included in the archive."510 }511 sort.SliceStable(out.Rows, func(i, j int) bool { return out.Rows[i].Label < out.Rows[j].Label })512 return out513}514515// attributeRows turns a feature's attributes into the rows its card shows:516// the curated label where one exists, the raw key where none does, and nothing517// at all for machinery or for the geographic pair the card already has a row518// for.519func attributeRows(attrs map[string]string) []DetailRow {520 var out []DetailRow521 for key, value := range attrs {522 if key == semconv.KeyGeoLat || key == semconv.KeyGeoLon {523 continue524 }525 reserved := false526 for _, prefix := range reservedPrefixes {527 if strings.HasPrefix(key, prefix) {528 reserved = true529 break530 }531 }532 if reserved {533 continue534 }535 label := attributeLabels[key]536 if label == "" {537 label = key538 }539 out = append(out, DetailRow{Key: key, Label: label, Value: value})540 }541 return out542}543544// linkText is the markdown link a source's prose arrives wearing. The card545// keeps the words and drops the address: a bundle carries no runtime URL, and546// a link nobody can follow is noise in a sentence.547var linkText = regexp.MustCompile(`\[([^\]]+)\]\([^)]*\)`)548549func cleanDescription(value string) string {550 out := linkText.ReplaceAllString(value, "$1")551 out = strings.Map(func(r rune) rune {552 switch r {553 case '*', '_', '`', '>', '#', '\r':554 return -1555 }556 return r557 }, out)558 for strings.Contains(out, "\n\n\n") {559 out = strings.ReplaceAll(out, "\n\n\n", "\n\n")560 }561 return strings.TrimSpace(out)562}563564func nonEmpty(values ...string) []string {565 out := make([]string, 0, len(values))566 for _, value := range values {567 if value != "" {568 out = append(out, value)569 }570 }571 return out572}573574// worldEntry finds one world in a manifest.575func worldEntry(m bundle.Manifest, slug string) (bundle.WorldEntry, bool) {576 for _, entry := range m.Worlds {577 if entry.Slug == slug {578 return entry, true579 }580 }581 return bundle.WorldEntry{}, false582}