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 / legend.go
legend.go 22.4K
1package app23import (4 "fmt"5 "sort"6 "strconv"7 "strings"89 "github.com/FelineStateMachine/atlas/format/semconv"10)1112// The legend, decided.13//14// One tree holds every kind of collection: shape collections, text labels and15// pin groups are sections of the same list, each holding rows of the same16// shape. Everything the reference implementation worked out in the browser --17// which sections exist, what each row counts, which rows wear a label toggle18// and which way it reads, what the solo chip says -- is worked out here, and19// the template does no arithmetic and asks no questions (issue #5 §4.5).2021// The palette a collection without a colour of its own draws from. It is the22// identity's own wheel, carried with the CSS system: mid-tone hues bright23// enough to read over world tiles and muted enough not to shout over the24// neutral chrome.25var palette = [...]string{26 "#4fb3d5", "#c9924b", "#82b56a", "#c96a6a", "#9581cc",27 "#4bc9a9", "#d4b04a", "#6a92c9", "#b08a5a", "#8fb3a2",28}2930// colorFor is the one colour a feature wears wherever it is drawn, so the31// legend, the index and the map cannot disagree about what it looks like. The32// hash is the reference implementation's, kept exactly, because a swatch that33// changed colour between builds would read as a data change.34func colorFor(id string) string {35 value, err := strconv.ParseInt(id, 10, 64)36 if err != nil {37 return palette[0]38 }39 if value < 0 {40 value = -value41 }42 mixed := uint32(uint32(value) * 2654435761)43 return palette[mixed%uint32(len(palette))]44}4546// collectionColor is the one colour a *collection* wears, and it is the seam's47// own ladder (render/chart/styles.ts, collectionColor) spelled in Go: the48// declared colour, then the older spelling of it, then the palette by the49// collection's place in the payload.50//51// It has to be the seam's ladder rather than the feature hash above, because52// the seam is what draws the map. A curated volume declares a colour and the53// two agreed anyway; an enriched one -- a city, whose collections arrive with54// no colour of their own -- fell through to two different fallbacks, and the55// reader saw a green swatch beside a blue pin.56func collectionColor(collection *collectionModel) string {57 if collection.Color != "" {58 return collection.Color59 }60 if collection.IconColor != "" {61 return collection.IconColor62 }63 return palette[((collection.Index%len(palette))+len(palette))%len(palette)]64}6566// iconAssetURL is where a collection's artwork lives, named exactly the way67// the seam names it (render/data/plane.ts, iconURL): every segment encoded,68// the separators left alone. Both surfaces have to spell one asset one way or69// the browser fetches it twice and the row and the pin disagree about which70// picture arrived.71func iconAssetURL(base, asset string) string {72 if base == "" || asset == "" {73 return ""74 }75 segments := strings.Split(asset, "/")76 for at, segment := range segments {77 segments[at] = encodeURIComponent(segment)78 }79 return base + "/icons/" + strings.Join(segments, "/")80}8182// encodeURIComponent is the browser function of that name. Go's own83// url.PathEscape is a different set -- it escapes `(` and leaves `&` alone --84// and a URL that differs from the seam's by one byte is a second cache entry85// for the same picture.86func encodeURIComponent(value string) string {87 var out strings.Builder88 for _, b := range []byte(value) {89 switch {90 case b >= 'A' && b <= 'Z', b >= 'a' && b <= 'z', b >= '0' && b <= '9':91 out.WriteByte(b)92 case strings.IndexByte("-_.!~*'()", b) >= 0:93 out.WriteByte(b)94 default:95 out.WriteString(fmt.Sprintf("%%%02X", b))96 }97 }98 return out.String()99}100101// initials is the two-letter stand-in a collection with no icon wears.102func initials(title string) string {103 out := make([]string, 0, 2)104 for _, word := range strings.Fields(title) {105 out = append(out, string([]rune(word)[:1]))106 if len(out) == 2 {107 break108 }109 }110 return strings.Join(out, "")111}112113// LegendView is the whole sidebar: the tree, the counts under it, and the114// words the toolbar says about the state it is in.115type LegendView struct {116 Sections []LegendSection117118 // Search is the reader's query, echoed back so the field survives a119 // swap holding what they typed.120 Search string121122 // Solo is what the isolate chip says, or empty when nothing is123 // isolated. Derived rather than remembered, so it is right however the124 // state was reached -- including by switching rows off one at a time.125 Solo string126127 // Count is the sidebar footer's readout: what the map is drawing.128 Count string129}130131// LegendSection is one folded group of rows.132type LegendSection struct {133 Key string134 Title string135 Count int136 Collapsed bool137138 // Shown is whether the section's switch reads on, which it does while139 // any one of its rows is visible.140 Shown bool141 Rows []LegendRow142}143144// LegendRow is one collection.145type LegendRow struct {146 ID string147 Title string148 Kind string149 Count int150 Color string151152 // Glyph is the two-letter stand-in a point row wears when its153 // collection carries no icon asset.154 Glyph string155 Icon string156157 // IconURL is the artwork itself, where a collection carries any, and158 // empty where it does not. The row draws one or the other and never159 // both: the reference set `--pin-icon` and the `has-source-icon` class160 // on the icon cell and cleared its text, or wrote the initials into it161 // (frontend/src/theme.js, applyCategoryGlyph). The choice is made here162 // so the template only spells it.163 IconURL string164165 Visible bool166167 // Labels is the label-policy toggle, present only where a collection168 // draws names on the ground: an area, or a point collection whose169 // curation drew it as text. A row without the capability has no button170 // to press, and the column stands empty so every row still lines up.171 Labels *LabelToggle172173 // Index is the feature index under a shape row: present, and rendered174 // hidden, whether or not the row is unfolded, so anything reaching for175 // a feature by name finds it without unfolding first.176 Index []IndexEntry177 Expanded bool178 Shapes bool179}180181// LabelToggle is one row's label-policy control. The word it says is what182// pressing it would do, not what is true now.183type LabelToggle struct {184 Collection string185 Speaking bool186 Action string // "Hide labels" or "Show labels"187 Label string // the spoken label, which carries the collection's name188 Policy string // the policy pressing it would set189190 // Glyph is which mark the button wears, because the two kinds of row191 // that wear one are not asking the same question. An area's names are192 // drawn *on* the ground and the toggle is whether they speak unasked,193 // which the reference drew as a bar and a stem. A point collection194 // curated as text has no marker to fall back to -- its names are the195 // whole of how it draws -- so the toggle is whether the collection is a196 // row of tags at all, and the reference drew a luggage tag. It is a word197 // rather than a boolean because the template renders a glyph per name198 // and a third kind would be a third name, not a nested `if`.199 Glyph string // glyphLabelBar or glyphLabelTag200}201202// The two marks a label toggle wears. They are the reference's own203// (frontend/src/legend.js), spelled here so the template names them rather204// than deciding between them.205const (206 glyphLabelBar = "bar"207 glyphLabelTag = "tag"208)209210// IndexEntry is one feature under an unfolded shape row.211type IndexEntry struct {212 ID string213 Title string214 Color string215 Depth int216 Highlighted bool217 Current bool218219 // Exclusive is whether this feature is the whole of what is highlighted,220 // which is the state the row's exclusive control wears. It is not a221 // second highlight flag: a lone highlight is exclusive whether it was222 // reached by the control or by highlighting one feature and taking the223 // others away, and the control says what pressing it would do -- press an224 // exclusive one again and the highlights are gone.225 Exclusive bool226}227228// legend builds the tree for one world under one session, seen through one229// lens: a split world offers one layer at a time, and the feature index lists230// the layer the reader is standing on.231func legend(model *worldModel, session Session, shown visibility, lens *payloadLens, base string) LegendView {232 out := LegendView{Search: session.Search, Count: shown.FooterText}233 if model == nil {234 return out235 }236 shard := int64(0)237 if lens != nil {238 shard = int64(lens.Shard)239 }240 hidden := setOf(session.Hidden)241 collapsed := setOf(session.Collapsed)242 expanded := setOf(session.Expanded)243 highlighted := setOf(session.Highlighted)244245 for _, section := range sections(model) {246 listed := LegendSection{247 Key: section.key,248 Title: section.title,249 Collapsed: collapsed[section.key],250 }251 for _, collection := range section.members {252 visible := !hidden[collection.ID]253 listed.Count += collection.Count254 listed.Shown = listed.Shown || visible255 row := LegendRow{256 ID: collection.ID,257 Title: collection.Title,258 Kind: collection.Kind,259 Count: collection.Count,260 Color: collectionColor(collection),261 Glyph: initials(collection.Title),262 Icon: collection.Icon,263 IconURL: iconAssetURL(base, collection.IconAsset),264 Visible: visible,265 Shapes: collection.Kind != semconv.GeometryPoint,266 Expanded: expanded[collection.ID],267 }268 if toggle, offered := labelToggle(collection, session); offered {269 row.Labels = &toggle270 }271 if row.Shapes {272 for _, entry := range orderedShapes(collection, shard) {273 row.Index = append(row.Index, IndexEntry{274 ID: entry.shape.ID,275 Title: entry.shape.Title,276 Color: colorFor(entry.shape.ID),277 Depth: entry.depth,278 Highlighted: highlighted[entry.shape.ID],279 Current: session.Focused == entry.shape.ID,280 Exclusive: soleHighlight(session, entry.shape.ID),281 })282 }283 }284 listed.Rows = append(listed.Rows, row)285 }286 out.Sections = append(out.Sections, listed)287 }288 out.Solo = soloChip(model, hidden)289 return out290}291292// labelPolicy is the ladder: the reader's override wins, then the producer's293// curated word, which format/semconv alone decides.294func labelPolicy(collection *collectionModel, session Session) string {295 if override, set := session.Labels[collection.ID]; set && override != "" {296 return override297 }298 return collection.Curated299}300301// labelToggle answers whether a row wears the control at all, and how it302// reads. Only a collection whose names can draw on the ground has one: an303// area, or a point collection whose curation granted it text. A plain pin row304// simply has no button to press.305func labelToggle(collection *collectionModel, session Session) (LabelToggle, bool) {306 glyph := ""307 switch {308 case collection.Kind == semconv.GeometryArea:309 glyph = glyphLabelBar310 case collection.Kind == semconv.GeometryPoint && collection.RenderAs == semconv.RenderAsText:311 glyph = glyphLabelTag312 default:313 return LabelToggle{}, false314 }315 speaking := labelPolicy(collection, session) == semconv.LabelAlways316 toggle := LabelToggle{317 Collection: collection.ID, Speaking: speaking,318 Policy: semconv.LabelAlways, Glyph: glyph,319 }320 if speaking {321 toggle.Action, toggle.Policy = "Hide labels", semconv.LabelQuiet322 toggle.Label = "Hide " + collection.Title + " labels"323 } else {324 toggle.Action = "Show labels"325 toggle.Label = "Show " + collection.Title + " labels"326 }327 return toggle, true328}329330// flipLabel is what pressing the toggle does: the policy flips to the other331// word, and if that word is what the producer curated anyway the override has332// nothing left to say and is dropped rather than stored.333func flipLabel(collection *collectionModel, session Session) string {334 flipped := semconv.LabelQuiet335 if labelPolicy(collection, session) != semconv.LabelAlways {336 flipped = semconv.LabelAlways337 }338 if flipped == collection.Curated {339 return ""340 }341 return flipped342}343344// ladder is the label ladder as it reads off the buttons: which collections345// are speaking and which are silent, sorted, over exactly the rows that wear346// a toggle. tests/island reads it back off the rendered legend the same way.347func ladder(model *worldModel, session Session) (speaking, silent []string) {348 if model == nil {349 return nil, nil350 }351 for _, collection := range model.Members {352 toggle, offered := labelToggle(collection, session)353 if !offered {354 continue355 }356 if toggle.Speaking {357 speaking = append(speaking, collection.ID)358 } else {359 silent = append(silent, collection.ID)360 }361 }362 sort.Strings(speaking)363 sort.Strings(silent)364 return speaking, silent365}366367// ---------------------------------------------------------------------------368// Sections369// ---------------------------------------------------------------------------370371type legendGroup struct {372 key string373 title string374 members []*collectionModel375}376377// sectionsKeyZones is the key of the viewer's own section: the one it makes378// for shape collections the producer left ungrouped. It is a legend key, not379// a payload word, which is why it is spelled once here.380const sectionsKeyZones = "zones"381382// sections buckets a world's collections the way the legend lists them:383// groups in order of first appearance, and an ungrouped shape collection384// filed under the viewer's own Zones section, which is unshifted to the front.385//386// Text is how a point collection draws, not what it is, so a collection of387// cities rendered as floating names sits in its own group beside the markers,388// wearing the toggle that flips it either way.389func sections(model *worldModel) []legendGroup {390 var groups []legendGroup391 at := map[string]int{}392 var zones []*collectionModel393 for _, collection := range model.Members {394 if collection.Kind != semconv.GeometryPoint && collection.Group == "" {395 zones = append(zones, collection)396 continue397 }398 key := "group-" + collection.Group399 if _, held := at[key]; !held {400 at[key] = len(groups)401 groups = append(groups, legendGroup{key: key, title: collection.Group})402 }403 groups[at[key]].members = append(groups[at[key]].members, collection)404 }405 if len(zones) > 0 {406 groups = append([]legendGroup{{key: sectionsKeyZones, title: "Zones", members: zones}}, groups...)407 }408 return groups409}410411// defaultCollapsed is the section set a world opens with. Every section412// opens the same way -- open -- because folding one kind of thing on the413// reader's behalf assumed a proportion of data no world promises. A reader414// folds what they are not using; a world does not guess for them.415func defaultCollapsed() []string { return nil }416417// unfoldableCollections is the set the unfold-all control opens: the418// ungrouped shape collections, whose feature indexes are the legend's long419// lists. It is not the set a world opens with -- a world opens with every420// index folded, because a category is a fact about the world and its hundred421// members are a request the reader makes.422func unfoldableCollections(model *worldModel) []string {423 var out []string424 if model == nil {425 return out426 }427 for _, collection := range model.Members {428 if collection.Kind != semconv.GeometryPoint && collection.Group == "" {429 out = append(out, collection.ID)430 }431 }432 return sortedIDs(out)433}434435// defaultHidden is what the payload itself asks to start put away.436func defaultHidden(model *worldModel) []string {437 var out []string438 if model == nil {439 return out440 }441 for _, collection := range model.Members {442 if collection.Hidden {443 out = append(out, collection.ID)444 }445 }446 return sortedIDs(out)447}448449// ---------------------------------------------------------------------------450// The feature index451// ---------------------------------------------------------------------------452453type indexed struct {454 shape *shapeModel455 depth int456}457458// orderedShapes is the parent-first order of the ground itself: children459// under their parent, siblings by title, and anything orphaned appended so460// nothing is lost to a broken parent link.461//462// The index holds the ground the reader is standing on and nothing else: a463// shape belonging to another layer of a split world is *elsewhere in the464// world* rather than filtered out, and a shape carrying no geometry the chart465// can draw was never on the map to be indexed. Both are the same two questions466// the map itself asks before drawing a shape (filter.go), asked here so the467// index and the map cannot list different ground.468func orderedShapes(collection *collectionModel, shard int64) []indexed {469 standing := make([]*shapeModel, 0, len(collection.Shapes))470 for _, shape := range collection.Shapes {471 if onShard(shape.Shard, shard) && shape.Drawn {472 standing = append(standing, shape)473 }474 }475 held := map[string]bool{}476 for _, shape := range standing {477 held[shape.ID] = true478 }479 children := map[string][]*shapeModel{}480 for _, shape := range standing {481 parent := shape.Parent482 if !held[parent] {483 parent = ""484 }485 children[parent] = append(children[parent], shape)486 }487 for _, group := range children {488 sort.SliceStable(group, func(i, j int) bool {489 return compareIndexTitles(group[i].Title, group[j].Title) < 0490 })491 }492493 var out []indexed494 seen := map[string]bool{}495 var append_ func(*shapeModel, int)496 append_ = func(shape *shapeModel, depth int) {497 if seen[shape.ID] {498 return499 }500 seen[shape.ID] = true501 out = append(out, indexed{shape: shape, depth: depth})502 for _, child := range children[shape.ID] {503 append_(child, depth+1)504 }505 }506 for _, shape := range children[""] {507 append_(shape, 0)508 }509 for _, shape := range standing {510 append_(shape, 0)511 }512 return out513}514515// ---------------------------------------------------------------------------516// Isolating517// ---------------------------------------------------------------------------518519// soloChip is the isolate readout, derived from the hide set rather than520// remembered, so it is right however the state was reached. Each domain speaks521// for itself and the chip reads them together: a region isolated among the522// shapes and a resource among the points is "Regions · Ore".523func soloChip(model *worldModel, hidden map[string]bool) string {524 var labels []string525 for _, domain := range []string{"zones", "features"} {526 var (527 onlyVisible *collectionModel528 visibleCount int529 hiddenCount int530 soleSection *legendGroup531 sectionsShowing int532 )533 groups := sections(model)534 for at := range groups {535 section := &groups[at]536 shown, held := 0, 0537 for _, collection := range section.members {538 if collection.Domain() != domain {539 continue540 }541 held++542 if hidden[collection.ID] {543 hiddenCount++544 continue545 }546 shown++547 visibleCount++548 onlyVisible = collection549 }550 if shown > 0 {551 sectionsShowing++552 soleSection = nil553 if shown == held {554 soleSection = section555 }556 }557 }558 if hiddenCount == 0 {559 continue560 }561 switch {562 case visibleCount == 1 && onlyVisible != nil:563 labels = append(labels, onlyVisible.Title)564 case visibleCount > 1 && sectionsShowing == 1 && soleSection != nil:565 labels = append(labels, soleSection.title)566 }567 }568 return strings.Join(labels, " · ")569}570571// soloDomains names the ground an isolate target may touch: the target572// collection's own domain, or every domain the target section holds.573func soloDomains(model *worldModel, section, collection string) map[string]bool {574 domains := map[string]bool{}575 for _, group := range sections(model) {576 for _, member := range group.members {577 if group.key == section || member.ID == collection {578 domains[member.Domain()] = true579 }580 }581 }582 return domains583}584585// isolate answers the hide set an isolate request produces. Asking to isolate586// what is already isolated means the reader is done with it, so the same587// control lets them back out -- of this domain alone. Everything else in the588// domain is hidden rather than remembered; showing everything is the single,589// obvious way back.590func isolate(model *worldModel, hidden map[string]bool, section, collection string) []string {591 domains := soloDomains(model, section, collection)592 out := map[string]bool{}593 for id, held := range hidden {594 out[id] = held595 }596 if isolated(model, hidden, section, collection) {597 for _, group := range sections(model) {598 for _, member := range group.members {599 if domains[member.Domain()] {600 delete(out, member.ID)601 }602 }603 }604 } else {605 for _, group := range sections(model) {606 wanted := group.key == section607 for _, member := range group.members {608 if !domains[member.Domain()] {609 continue610 }611 if wanted || member.ID == collection {612 delete(out, member.ID)613 } else {614 out[member.ID] = true615 }616 }617 }618 }619 return keysOf(out)620}621622// isolated is true when the target's own domain already shows exactly what623// isolating it would show. Other domains are none of the target's business.624func isolated(model *worldModel, hidden map[string]bool, section, collection string) bool {625 domains := soloDomains(model, section, collection)626 for _, group := range sections(model) {627 for _, member := range group.members {628 if !domains[member.Domain()] {629 continue630 }631 wanted := group.key == section || member.ID == collection632 if wanted == hidden[member.ID] {633 return false634 }635 }636 }637 return true638}639640// toggleSection is the section switch: one visible row anywhere in the section641// means pressing it puts the whole section away, and an empty section brings642// it all back.643func toggleSection(model *worldModel, hidden map[string]bool, key string) []string {644 out := map[string]bool{}645 for id, held := range hidden {646 out[id] = held647 }648 for _, group := range sections(model) {649 if group.key != key {650 continue651 }652 anyVisible := false653 for _, member := range group.members {654 if !hidden[member.ID] {655 anyVisible = true656 break657 }658 }659 for _, member := range group.members {660 if anyVisible {661 out[member.ID] = true662 } else {663 delete(out, member.ID)664 }665 }666 }667 return keysOf(out)668}669670// everyCollection is the whole hide set, for "hide everything".671func everyCollection(model *worldModel) []string {672 var out []string673 if model == nil {674 return out675 }676 for _, collection := range model.Members {677 out = append(out, collection.ID)678 }679 return sortedIDs(out)680}681682// everySection is every section key, for "fold everything".683func everySection(model *worldModel) []string {684 var out []string685 if model == nil {686 return out687 }688 for _, group := range sections(model) {689 out = append(out, group.key)690 }691 return sortedIDs(out)692}693694func setOf(values []string) map[string]bool {695 out := make(map[string]bool, len(values))696 for _, value := range values {697 out[value] = true698 }699 return out700}701702func keysOf(set map[string]bool) []string {703 out := make([]string, 0, len(set))704 for key, held := range set {705 if held {706 out = append(out, key)707 }708 }709 return sortedIDs(out)710}