1package app23import (4 "fmt"5 "sort"6 "strings"78 "github.com/FelineStateMachine/atlas/format/semconv"9)1011// The legend, decided.12//13// One tree holds every kind of collection: shape collections, text labels and14// pin groups are sections of the same list, each holding rows of the same15// shape. Everything the reference implementation worked out in the browser --16// which sections exist, what each row counts, which rows wear a label toggle17// and which way it reads, what the solo chip says -- is worked out here, and18// the template does no arithmetic and asks no questions (issue #5 §4.5).1920// The palette a collection without a colour of its own draws from. It is the21// identity's own wheel, carried with the CSS system: mid-tone hues bright22// enough to read over world tiles and muted enough not to shout over the23// neutral chrome.24var palette = [...]string{25 "#4fb3d5", "#c9924b", "#82b56a", "#c96a6a", "#9581cc",26 "#4bc9a9", "#d4b04a", "#6a92c9", "#b08a5a", "#8fb3a2",27}2829// colorFor is the one colour a feature wears wherever it is drawn, so the30// legend, the index and the map cannot disagree about what it looks like. The31// hash is the seam's FNV-1a identity rank, so native string IDs keep the same32// colour in every renderer and across builds.33func colorFor(id string) string {34 hash := uint32(2166136261)35 for _, value := range []byte(id) {36 hash ^= uint32(value)37 hash *= 1677761938 }39 return palette[hash%uint32(len(palette))]40}4142// collectionColor is the one colour a *collection* wears, and it is the seam's43// own ladder (render/chart/styles.ts, collectionColor) spelled in Go: the44// declared colour, then the older spelling of it, then the palette by the45// collection's place in the payload.46//47// It has to be the seam's ladder rather than the feature hash above, because48// the seam is what draws the map. A curated volume declares a colour and the49// two agreed anyway; an enriched one -- a city, whose collections arrive with50// no colour of their own -- fell through to two different fallbacks, and the51// reader saw a green swatch beside a blue pin.52func collectionColor(collection *collectionModel) string {53 if collection.Color != "" {54 return collection.Color55 }56 if collection.IconColor != "" {57 return collection.IconColor58 }59 return palette[((collection.Index%len(palette))+len(palette))%len(palette)]60}6162// iconAssetURL is where a collection's artwork lives, named exactly the way63// the seam names it (render/data/plane.ts, iconURL): every segment encoded,64// the separators left alone. Both surfaces have to spell one asset one way or65// the browser fetches it twice and the row and the pin disagree about which66// picture arrived.67func iconAssetURL(base, asset string) string {68 if base == "" || asset == "" {69 return ""70 }71 segments := strings.Split(asset, "/")72 for at, segment := range segments {73 segments[at] = encodeURIComponent(segment)74 }75 return base + "/" + strings.Join(segments, "/")76}7778// encodeURIComponent is the browser function of that name. Go's own79// url.PathEscape is a different set -- it escapes `(` and leaves `&` alone --80// and a URL that differs from the seam's by one byte is a second cache entry81// for the same picture.82func encodeURIComponent(value string) string {83 var out strings.Builder84 for _, b := range []byte(value) {85 switch {86 case b >= 'A' && b <= 'Z', b >= 'a' && b <= 'z', b >= '0' && b <= '9':87 out.WriteByte(b)88 case strings.IndexByte("-_.!~*'()", b) >= 0:89 out.WriteByte(b)90 default:91 out.WriteString(fmt.Sprintf("%%%02X", b))92 }93 }94 return out.String()95}9697// initials is the two-letter stand-in a collection with no icon wears.98func initials(title string) string {99 out := make([]string, 0, 2)100 for _, word := range strings.Fields(title) {101 out = append(out, string([]rune(word)[:1]))102 if len(out) == 2 {103 break104 }105 }106 return strings.Join(out, "")107}108109// LegendView is the whole sidebar: the tree, the counts under it, and the110// words the toolbar says about the state it is in.111type LegendView struct {112 Sections []LegendSection113114 // Search is the reader's query, echoed back so the field survives a115 // swap holding what they typed.116 Search string117118 // Solo is what the isolate chip says, or empty when nothing is119 // isolated. Derived rather than remembered, so it is right however the120 // state was reached -- including by switching rows off one at a time.121 Solo string122123 // Count is the sidebar footer's readout: what the map is drawing.124 Count string125}126127// LegendSection is one folded group of rows.128type LegendSection struct {129 Key string130 Title string131 Count int132 Collapsed bool133134 // Shown is whether the section's switch reads on, which it does while135 // any one of its rows is visible.136 Shown bool137 Rows []LegendRow138}139140// LegendRow is one collection.141type LegendRow struct {142 ID string143 Title string144 Kind string145 Count int146 Color string147148 // Glyph is the two-letter stand-in a point row wears when its149 // collection carries no icon asset.150 Glyph string151 Icon string152153 // IconURL is the artwork itself, where a collection carries any, and154 // empty where it does not. The row draws one or the other and never155 // both: the reference set `--pin-icon` and the `has-source-icon` class156 // on the icon cell and cleared its text, or wrote the initials into it157 // (frontend/src/theme.js, applyCategoryGlyph). The choice is made here158 // so the template only spells it.159 IconURL string160161 Visible bool162163 // Labels is the label-policy toggle, present only where a collection164 // draws names on the ground: an area, or a point collection whose165 // curation drew it as text. A row without the capability has no button166 // to press, and the column stands empty so every row still lines up.167 Labels *LabelToggle168169 // Index is the feature index under a shape row: present, and rendered170 // hidden, whether or not the row is unfolded, so anything reaching for171 // a feature by name finds it without unfolding first.172 Index []IndexEntry173 Expanded bool174 Shapes bool175}176177// LabelToggle is one row's label-policy control. The word it says is what178// pressing it would do, not what is true now.179type LabelToggle struct {180 Collection string181 Speaking bool182 Action string // "Hide labels" or "Show labels"183 Label string // the spoken label, which carries the collection's name184 Policy string // the policy pressing it would set185186 // Glyph is which mark the button wears, because the two kinds of row187 // that wear one are not asking the same question. An area's names are188 // drawn *on* the ground and the toggle is whether they speak unasked,189 // which the reference drew as a bar and a stem. A point collection190 // curated as text has no marker to fall back to -- its names are the191 // whole of how it draws -- so the toggle is whether the collection is a192 // row of tags at all, and the reference drew a luggage tag. It is a word193 // rather than a boolean because the template renders a glyph per name194 // and a third kind would be a third name, not a nested `if`.195 Glyph string // glyphLabelBar or glyphLabelTag196}197198// The two marks a label toggle wears. They are the reference's own199// (frontend/src/legend.js), spelled here so the template names them rather200// than deciding between them.201const (202 glyphLabelBar = "bar"203 glyphLabelTag = "tag"204)205206// IndexEntry is one feature under an unfolded shape row.207type IndexEntry struct {208 ID string209 Title string210 Color string211 Depth int212 Highlighted bool213 Current bool214215 // Exclusive is whether this feature is the whole of what is highlighted,216 // which is the state the row's exclusive control wears. It is not a217 // second highlight flag: a lone highlight is exclusive whether it was218 // reached by the control or by highlighting one feature and taking the219 // others away, and the control says what pressing it would do -- press an220 // exclusive one again and the highlights are gone.221 Exclusive bool222}223224// legend builds the tree for one world under one session, seen through one225// lens: a split world offers one layer at a time, and the feature index lists226// the layer the reader is standing on.227func legend(model *worldModel, session Session, shown visibility, lens *payloadLens, base string) LegendView {228 out := LegendView{Search: session.Search, Count: shown.FooterText}229 if model == nil {230 return out231 }232 shard := int64(0)233 if lens != nil {234 shard = int64(lens.Shard)235 }236 hidden := setOf(session.Hidden)237 collapsed := setOf(session.Collapsed)238 expanded := setOf(session.Expanded)239 highlighted := setOf(session.Highlighted)240241 for _, section := range sections(model) {242 listed := LegendSection{243 Key: section.key,244 Title: section.title,245 Collapsed: collapsed[section.key],246 }247 for _, collection := range section.members {248 visible := !hidden[collection.ID]249 listed.Count += collection.Count250 listed.Shown = listed.Shown || visible251 row := LegendRow{252 ID: collection.ID,253 Title: collection.Title,254 Kind: collection.Kind,255 Count: collection.Count,256 Color: collectionColor(collection),257 Glyph: initials(collection.Title),258 Icon: collection.Icon,259 IconURL: iconAssetURL(base, collection.IconAsset),260 Visible: visible,261 Shapes: collection.Kind != semconv.GeometryPoint,262 Expanded: expanded[collection.ID],263 }264 if toggle, offered := labelToggle(collection, session); offered {265 row.Labels = &toggle266 }267 if row.Shapes {268 for _, entry := range orderedShapes(collection, shard) {269 row.Index = append(row.Index, IndexEntry{270 ID: entry.shape.ID,271 Title: entry.shape.Title,272 Color: colorFor(entry.shape.ID),273 Depth: entry.depth,274 Highlighted: highlighted[entry.shape.ID],275 Current: session.Focused == entry.shape.ID,276 Exclusive: soleHighlight(session, entry.shape.ID),277 })278 }279 }280 listed.Rows = append(listed.Rows, row)281 }282 out.Sections = append(out.Sections, listed)283 }284 out.Solo = soloChip(model, hidden)285 return out286}287288// labelPolicy is the ladder: the reader's override wins, then the producer's289// curated word, which format/semconv alone decides.290func labelPolicy(collection *collectionModel, session Session) string {291 if override, set := session.Labels[collection.ID]; set && override != "" {292 return override293 }294 return collection.Curated295}296297// labelToggle answers whether a row wears the control at all, and how it298// reads. Only a collection whose names can draw on the ground has one: an299// area, or a point collection whose curation granted it text. A plain pin row300// simply has no button to press.301func labelToggle(collection *collectionModel, session Session) (LabelToggle, bool) {302 glyph := ""303 switch {304 case collection.Kind == semconv.GeometryArea:305 glyph = glyphLabelBar306 case collection.Kind == semconv.GeometryPoint && collection.RenderAs == semconv.RenderAsText:307 glyph = glyphLabelTag308 default:309 return LabelToggle{}, false310 }311 speaking := labelPolicy(collection, session) == semconv.LabelAlways312 toggle := LabelToggle{313 Collection: collection.ID, Speaking: speaking,314 Policy: semconv.LabelAlways, Glyph: glyph,315 }316 if speaking {317 toggle.Action, toggle.Policy = "Hide labels", semconv.LabelQuiet318 toggle.Label = "Hide " + collection.Title + " labels"319 } else {320 toggle.Action = "Show labels"321 toggle.Label = "Show " + collection.Title + " labels"322 }323 return toggle, true324}325326// flipLabel is what pressing the toggle does: the policy flips to the other327// word, and if that word is what the producer curated anyway the override has328// nothing left to say and is dropped rather than stored.329func flipLabel(collection *collectionModel, session Session) string {330 flipped := semconv.LabelQuiet331 if labelPolicy(collection, session) != semconv.LabelAlways {332 flipped = semconv.LabelAlways333 }334 if flipped == collection.Curated {335 return ""336 }337 return flipped338}339340// ladder is the label ladder as it reads off the buttons: which collections341// are speaking and which are silent, sorted, over exactly the rows that wear342// a toggle. tests/island reads it back off the rendered legend the same way.343func ladder(model *worldModel, session Session) (speaking, silent []string) {344 if model == nil {345 return nil, nil346 }347 for _, collection := range model.Members {348 toggle, offered := labelToggle(collection, session)349 if !offered {350 continue351 }352 if toggle.Speaking {353 speaking = append(speaking, collection.ID)354 } else {355 silent = append(silent, collection.ID)356 }357 }358 sort.Strings(speaking)359 sort.Strings(silent)360 return speaking, silent361}362363// ---------------------------------------------------------------------------364// Sections365// ---------------------------------------------------------------------------366367type legendGroup struct {368 key string369 title string370 members []*collectionModel371}372373// sectionsKeyZones is the key of the viewer's own section: the one it makes374// for shape collections the producer left ungrouped. It is a legend key, not375// a payload word, which is why it is spelled once here.376const sectionsKeyZones = "zones"377378// sections buckets a world's collections the way the legend lists them:379// groups in order of first appearance, and an ungrouped shape collection380// filed under the viewer's own Zones section, which is unshifted to the front.381//382// Text is how a point collection draws, not what it is, so a collection of383// cities rendered as floating names sits in its own group beside the markers,384// wearing the toggle that flips it either way.385func sections(model *worldModel) []legendGroup {386 var groups []legendGroup387 at := map[string]int{}388 var zones []*collectionModel389 for _, collection := range model.Members {390 if collection.Kind != semconv.GeometryPoint && collection.Group == "" {391 zones = append(zones, collection)392 continue393 }394 key := "group-" + collection.Group395 if _, held := at[key]; !held {396 at[key] = len(groups)397 groups = append(groups, legendGroup{key: key, title: collection.Group})398 }399 groups[at[key]].members = append(groups[at[key]].members, collection)400 }401 if len(zones) > 0 {402 groups = append([]legendGroup{{key: sectionsKeyZones, title: "Zones", members: zones}}, groups...)403 }404 return groups405}406407// defaultCollapsed is the section set a world opens with. Every section408// opens the same way -- open -- because folding one kind of thing on the409// reader's behalf assumed a proportion of data no world promises. A reader410// folds what they are not using; a world does not guess for them.411func defaultCollapsed() []string { return nil }412413// unfoldableCollections is the set the unfold-all control opens: the414// ungrouped shape collections, whose feature indexes are the legend's long415// lists. It is not the set a world opens with -- a world opens with every416// index folded, because a category is a fact about the world and its hundred417// members are a request the reader makes.418func unfoldableCollections(model *worldModel) []string {419 var out []string420 if model == nil {421 return out422 }423 for _, collection := range model.Members {424 if collection.Kind != semconv.GeometryPoint && collection.Group == "" {425 out = append(out, collection.ID)426 }427 }428 return sortedIDs(out)429}430431// defaultHidden is what the payload itself asks to start put away.432func defaultHidden(model *worldModel) []string {433 var out []string434 if model == nil {435 return out436 }437 for _, collection := range model.Members {438 if collection.Hidden {439 out = append(out, collection.ID)440 }441 }442 return sortedIDs(out)443}444445// ---------------------------------------------------------------------------446// The feature index447// ---------------------------------------------------------------------------448449type indexed struct {450 shape *shapeModel451 depth int452}453454// orderedShapes is the parent-first order of the ground itself: children455// under their parent, siblings by title, and anything orphaned appended so456// nothing is lost to a broken parent link.457//458// The index holds the ground the reader is standing on and nothing else: a459// shape belonging to another layer of a split world is *elsewhere in the460// world* rather than filtered out, and a shape carrying no geometry the chart461// can draw was never on the map to be indexed. Both are the same two questions462// the map itself asks before drawing a shape (filter.go), asked here so the463// index and the map cannot list different ground.464func orderedShapes(collection *collectionModel, shard int64) []indexed {465 standing := make([]*shapeModel, 0, len(collection.Shapes))466 for _, shape := range collection.Shapes {467 if onShard(shape.Shard, shard) && shape.Drawn {468 standing = append(standing, shape)469 }470 }471 held := map[string]bool{}472 for _, shape := range standing {473 held[shape.ID] = true474 }475 children := map[string][]*shapeModel{}476 for _, shape := range standing {477 parent := shape.Parent478 if !held[parent] {479 parent = ""480 }481 children[parent] = append(children[parent], shape)482 }483 for _, group := range children {484 sort.SliceStable(group, func(i, j int) bool {485 return compareIndexTitles(group[i].Title, group[j].Title) < 0486 })487 }488489 var out []indexed490 seen := map[string]bool{}491 var append_ func(*shapeModel, int)492 append_ = func(shape *shapeModel, depth int) {493 if seen[shape.ID] {494 return495 }496 seen[shape.ID] = true497 out = append(out, indexed{shape: shape, depth: depth})498 for _, child := range children[shape.ID] {499 append_(child, depth+1)500 }501 }502 for _, shape := range children[""] {503 append_(shape, 0)504 }505 for _, shape := range standing {506 append_(shape, 0)507 }508 return out509}510511// ---------------------------------------------------------------------------512// Isolating513// ---------------------------------------------------------------------------514515// soloChip is the isolate readout, derived from the hide set rather than516// remembered, so it is right however the state was reached. Each domain speaks517// for itself and the chip reads them together: a region isolated among the518// shapes and a resource among the points is "Regions · Ore".519func soloChip(model *worldModel, hidden map[string]bool) string {520 var labels []string521 for _, domain := range []string{"zones", "features"} {522 var (523 onlyVisible *collectionModel524 visibleCount int525 hiddenCount int526 soleSection *legendGroup527 sectionsShowing int528 )529 groups := sections(model)530 for at := range groups {531 section := &groups[at]532 shown, held := 0, 0533 for _, collection := range section.members {534 if collection.Domain() != domain {535 continue536 }537 held++538 if hidden[collection.ID] {539 hiddenCount++540 continue541 }542 shown++543 visibleCount++544 onlyVisible = collection545 }546 if shown > 0 {547 sectionsShowing++548 soleSection = nil549 if shown == held {550 soleSection = section551 }552 }553 }554 if hiddenCount == 0 {555 continue556 }557 switch {558 case visibleCount == 1 && onlyVisible != nil:559 labels = append(labels, onlyVisible.Title)560 case visibleCount > 1 && sectionsShowing == 1 && soleSection != nil:561 labels = append(labels, soleSection.title)562 }563 }564 return strings.Join(labels, " · ")565}566567// soloDomains names the ground an isolate target may touch: the target568// collection's own domain, or every domain the target section holds.569func soloDomains(model *worldModel, section, collection string) map[string]bool {570 domains := map[string]bool{}571 for _, group := range sections(model) {572 for _, member := range group.members {573 if group.key == section || member.ID == collection {574 domains[member.Domain()] = true575 }576 }577 }578 return domains579}580581// isolate answers the hide set an isolate request produces. Asking to isolate582// what is already isolated means the reader is done with it, so the same583// control lets them back out -- of this domain alone. Everything else in the584// domain is hidden rather than remembered; showing everything is the single,585// obvious way back.586func isolate(model *worldModel, hidden map[string]bool, section, collection string) []string {587 domains := soloDomains(model, section, collection)588 out := map[string]bool{}589 for id, held := range hidden {590 out[id] = held591 }592 if isolated(model, hidden, section, collection) {593 for _, group := range sections(model) {594 for _, member := range group.members {595 if domains[member.Domain()] {596 delete(out, member.ID)597 }598 }599 }600 } else {601 for _, group := range sections(model) {602 wanted := group.key == section603 for _, member := range group.members {604 if !domains[member.Domain()] {605 continue606 }607 if wanted || member.ID == collection {608 delete(out, member.ID)609 } else {610 out[member.ID] = true611 }612 }613 }614 }615 return keysOf(out)616}617618// isolated is true when the target's own domain already shows exactly what619// isolating it would show. Other domains are none of the target's business.620func isolated(model *worldModel, hidden map[string]bool, section, collection string) bool {621 domains := soloDomains(model, section, collection)622 for _, group := range sections(model) {623 for _, member := range group.members {624 if !domains[member.Domain()] {625 continue626 }627 wanted := group.key == section || member.ID == collection628 if wanted == hidden[member.ID] {629 return false630 }631 }632 }633 return true634}635636// toggleSection is the section switch: one visible row anywhere in the section637// means pressing it puts the whole section away, and an empty section brings638// it all back.639func toggleSection(model *worldModel, hidden map[string]bool, key string) []string {640 out := map[string]bool{}641 for id, held := range hidden {642 out[id] = held643 }644 for _, group := range sections(model) {645 if group.key != key {646 continue647 }648 anyVisible := false649 for _, member := range group.members {650 if !hidden[member.ID] {651 anyVisible = true652 break653 }654 }655 for _, member := range group.members {656 if anyVisible {657 out[member.ID] = true658 } else {659 delete(out, member.ID)660 }661 }662 }663 return keysOf(out)664}665666// everyCollection is the whole hide set, for "hide everything".667func everyCollection(model *worldModel) []string {668 var out []string669 if model == nil {670 return out671 }672 for _, collection := range model.Members {673 out = append(out, collection.ID)674 }675 return sortedIDs(out)676}677678// everySection is every section key, for "fold everything".679func everySection(model *worldModel) []string {680 var out []string681 if model == nil {682 return out683 }684 for _, group := range sections(model) {685 out = append(out, group.key)686 }687 return sortedIDs(out)688}689690func setOf(values []string) map[string]bool {691 out := make(map[string]bool, len(values))692 for _, value := range values {693 out[value] = true694 }695 return out696}697698func keysOf(set map[string]bool) []string {699 out := make([]string, 0, len(set))700 for key, held := range set {701 if held {702 out = append(out, key)703 }704 }705 return sortedIDs(out)706}