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 / filter.go
filter.go 10.2K
1package app23import (4 "sort"5 "strconv"6 "strings"78 "github.com/FelineStateMachine/atlas/format/semconv"9)1011// One answer to what the reader is looking at.12//13// Three surfaces used to work this out for themselves: the canvas drew what14// survived every filter, the footer counted what the legend allowed, and the15// dock listed what the search left. So highlighting one district could cull16// sixty features off the map while the panel beside it went on offering all17// sixty. Every surface asks here now, once, on the server, and a filter that18// lands on one of them lands on all of them.1920// dockListLimit is how many rows the dock lists. Every feature still draws on21// the canvas; the list is a shortlist, and it says when it is one.22const dockListLimit = 1002324// visibility is what one session leaves standing.25type visibility struct {26 // Points and Shapes are what the map is drawing, by kind.27 Points []*pointModel28 Shapes []*shapeModel2930 // Drawn is the two counted together: one number, every kind.31 Drawn int3233 // Eligible is the point features the filters admit, which is the34 // count the seam's diagnostics publish as eligibleLocations35 // (render/diagnostics.ts).36 Eligible int3738 // Listable is the part a list can name, alphabetically.39 Listable []ListEntry4041 // Filtered says whether anything but the reader's own search is42 // narrowing the list -- a collection put away, ground highlighted, a43 // cell holding the view -- so a count that drops always has its reason44 // on screen beside it.45 Filtered bool4647 FooterText string48 DockText string49 DockFlag string50 DockFlagTitle string51 DockNote string52 Empty string53}5455// ListEntry is one row of the dock: a point or a shape, told apart by which56// identity it carries, because they are jumped to by different routes.57type ListEntry struct {58 ID string59 Title string60 Kind string61 Collection string62 Group string63 Color string64 Glyph string65 Icon string66 Selected bool67}6869// visible works out what the world's filters leave standing.70//71// The order of the questions is the reference implementation's, and each one72// is a different kind of narrowing:73//74// - the collection is put away, which takes its features off every surface;75// - the search does not match, which takes a point off the map and out of76// the list but never takes ground out from under it;77// - ground is highlighted, which asks a conjunctive question of every point;78// - a shard other than the lens's holds the feature, which means it is79// elsewhere in the world rather than filtered out.80func visible(model *worldModel, session Session, lens *payloadLens) visibility {81 out := visibility{}82 if model == nil {83 out.FooterText, out.DockText = "No features shown", "0 features"84 out.Empty = "Nothing is shown. Bring a collection back to list its features."85 return out86 }87 hidden := setOf(session.Hidden)88 query := strings.ToLower(strings.TrimSpace(session.Search))89 highlights := highlightGroups(model, session)90 shard := int64(0)91 if lens != nil {92 shard = int64(lens.Shard)93 }94 // The held cell narrows exactly the way a highlight does, and it spares95 // the same two answers: the feature the reader has open, and one they are96 // searching for by name.97 cell := heldCell(session, model, lens)9899 for _, pin := range model.Points {100 if !onShard(pin.Shard, shard) || hidden[pin.Collection.ID] {101 continue102 }103 searched := query != "" && strings.Contains(strings.ToLower(pin.Title), query)104 if query != "" && !searched {105 continue106 }107 // A highlight asks a conjunctive question of every point. Two108 // answers are exempt from it: the point the reader has open, and109 // a point they are searching for by name -- both were asked for110 // rather than merely drawn.111 if len(highlights) > 0 && !passesHighlights(highlights, pin.X, pin.Y) &&112 pin.ID != session.Selected && !searched {113 continue114 }115 if cell != nil && !cell(pin.X, pin.Y) &&116 pin.ID != session.Selected && !searched {117 continue118 }119 out.Points = append(out.Points, pin)120 }121 out.Eligible = len(out.Points)122123 // Ground is drawn while its collection is shown. Highlighting narrows124 // which points stand, not which ground is drawn, and so does a held cell:125 // descending into a cell is a question about what is standing in it, and126 // the ground it is standing on goes on being the ground. The shard is the127 // one question ground answers the same way a point does -- a shape on128 // another lens's layer is elsewhere in the world, not filtered out, so it129 // is neither drawn nor counted nor listed.130 for _, shape := range model.Shapes {131 if !onShard(shape.Shard, shard) || hidden[shape.Collection.ID] || !shape.Drawn {132 continue133 }134 out.Shapes = append(out.Shapes, shape)135 }136 out.Drawn = len(out.Points) + len(out.Shapes)137138 for _, pin := range out.Points {139 out.Listable = append(out.Listable, ListEntry{140 ID: pin.ID,141 Title: pin.Title,142 Kind: semconv.GeometryPoint,143 Collection: pin.Collection.Title,144 Group: pin.Collection.Group,145 Color: collectionColor(pin.Collection),146 Glyph: initials(pin.Collection.Title),147 Icon: pin.Collection.Icon,148 Selected: pin.ID == session.Selected,149 })150 }151 // A shape answers the search here rather than on the canvas -- searching152 // for a place has never taken the ground out from under it -- and one the153 // archive left untitled is on the map without being in any index of it.154 for _, shape := range out.Shapes {155 if shape.Title == "" {156 continue157 }158 if query != "" && !strings.Contains(strings.ToLower(shape.Title), query) {159 continue160 }161 out.Listable = append(out.Listable, ListEntry{162 ID: shape.ID,163 Title: shape.Title,164 Kind: shape.Collection.Kind,165 Collection: shape.Collection.Title,166 Color: colorFor(shape.ID),167 Selected: shape.ID == session.Selected,168 })169 }170 sort.SliceStable(out.Listable, func(i, j int) bool {171 return compareTitles(out.Listable[i].Title, out.Listable[j].Title) < 0172 })173174 // The chip says "filtered" when something narrowed, which for a grid is175 // the held cell having an answer rather than the session having an176 // address: a system this world cannot divide narrows nothing, and a page177 // that says it is filtered while showing everything is the same178 // disagreement in words instead of counts.179 out.Filtered = len(session.Hidden) > 0 || len(session.Highlighted) > 0 || cell != nil180 out.FooterText = countText(out.Drawn)181 out.DockText = countText(len(out.Listable))182 switch {183 case session.Search != "":184 out.DockFlag = "“" + session.Search + "”"185 case out.Filtered:186 out.DockFlag = "filtered"187 // The chip's word is pinned by tests/island; the tooltip is where188 // what narrows gets named — a reader once lost every pin to five189 // highlights nobody could see.190 var parts []string191 if n := len(session.Hidden); n > 0 {192 parts = append(parts, strconv.Itoa(n)+" hidden")193 }194 if n := len(session.Highlighted); n > 0 {195 parts = append(parts, strconv.Itoa(n)+" highlighted")196 }197 if cell != nil {198 parts = append(parts, "cell "+session.Grid.Cell+" held")199 }200 out.DockFlagTitle = strings.Join(parts, " · ")201 }202 if held := len(out.Listable); held > dockListLimit {203 out.DockNote = "First " + strconv.Itoa(dockListLimit) + " of " +204 strconv.Itoa(held) + " — search or filter to narrow the list."205 }206 switch {207 case len(out.Listable) > 0:208 case session.Search != "":209 out.Empty = "No visible features match."210 case len(session.Highlighted) > 0:211 out.Empty = "Nothing stands inside the highlighted ground."212 default:213 out.Empty = "Nothing is shown. Bring a collection back to list its features."214 }215 return out216}217218// Rows is the shortlist the dock actually renders.219func (v visibility) Rows() []ListEntry {220 if len(v.Listable) > dockListLimit {221 return v.Listable[:dockListLimit]222 }223 return v.Listable224}225226func countText(n int) string {227 word := " features"228 if n == 1 {229 word = " feature"230 }231 return grouped(n) + word232}233234// grouped writes a count the way the chrome has always written one: thousands235// separated by commas, so a reader takes in "2,048" at a glance rather than236// counting digits. Every count on the page goes through here.237func grouped(n int) string {238 digits := strconv.Itoa(n)239 sign := ""240 if strings.HasPrefix(digits, "-") {241 sign, digits = "-", digits[1:]242 }243 var out []byte244 for i, digit := range []byte(digits) {245 if i > 0 && (len(digits)-i)%3 == 0 {246 out = append(out, ',')247 }248 out = append(out, digit)249 }250 return sign + string(out)251}252253// onShard answers whether a feature belongs to the shard the lens is showing.254// A map split into layers offers one at a time, and anything belonging to255// another layer is elsewhere in the world rather than merely filtered out.256func onShard(feature, lens int64) bool {257 return lens == 0 || feature == 0 || feature == lens258}259260// ---------------------------------------------------------------------------261// AND across, OR within262// ---------------------------------------------------------------------------263264// highlightGroups buckets the highlighted features under their collections,265// which is the shape the filter thinks in: each bucket a question, its members266// the acceptable answers.267//268// Two districts highlighted widens the question; a district and a subwatershed269// narrows it to the ground they share.270func highlightGroups(model *worldModel, session Session) [][]*shapeModel {271 if len(session.Highlighted) == 0 {272 return nil273 }274 at := map[string]int{}275 var groups [][]*shapeModel276 for _, id := range sortedIDs(session.Highlighted) {277 shape, held := model.ShapeByID[id]278 if !held {279 continue280 }281 key := shape.Collection.ID282 if _, seen := at[key]; !seen {283 at[key] = len(groups)284 groups = append(groups, nil)285 }286 groups[at[key]] = append(groups[at[key]], shape)287 }288 return groups289}290291// passesHighlights answers whether a coordinate survives the highlights: it292// must lie inside at least one highlighted feature of every collection holding293// any. Within a collection the features are alternatives; across collections294// they are conditions.295func passesHighlights(groups [][]*shapeModel, x, y float64) bool {296 for _, group := range groups {297 inside := false298 for _, shape := range group {299 if shape.contains(x, y) {300 inside = true301 break302 }303 }304 if !inside {305 return false306 }307 }308 return true309}