|
| 1 | +^{:kindly/hide-code true |
| 2 | + :clay {:title "The Algebra of Data" |
| 3 | + :quarto {:type :post |
| 4 | + :author [:timothypratley] |
| 5 | + :draft true |
| 6 | + :date "2025-12-29" |
| 7 | + :description "Exploring algebraic operators for generic data" |
| 8 | + :category :data-visualization |
| 9 | + :tags [:datavis] |
| 10 | + :keywords [:datavis]}}} |
| 11 | +(ns data-visualization.aog.algebra-of-data) |
| 12 | + |
| 13 | +;; Inspired by [the algebra of graphics](../aog_in_clojure_part1.html). |
| 14 | + |
| 15 | +;; ## Axioms |
| 16 | + |
| 17 | +;; We shall take it as given that we are interested in combining sequence of maps: |
| 18 | + |
| 19 | +[{:theme "dark"} {:font "serif"}] |
| 20 | + |
| 21 | +;; AoG calls the maps "layers", but in this article I'll call them "configs". |
| 22 | + |
| 23 | +;; ## Transitive operators |
| 24 | + |
| 25 | +;; One way to combine configs is to concatenate them: |
| 26 | + |
| 27 | +(def add |
| 28 | + (comp vec concat)) |
| 29 | + |
| 30 | +(add [{:theme "dark"}] |
| 31 | + [{:font "serif"}]) |
| 32 | + |
| 33 | +;; Another thing we can do is a cartesian merge: |
| 34 | + |
| 35 | +(defn cross-merge |
| 36 | + ;; Given 2 sequences, merge all combinations |
| 37 | + ([xs ys] |
| 38 | + (vec (for [x xs |
| 39 | + y ys] |
| 40 | + (merge x y)))) |
| 41 | + ;; Extended to work with multiple arguments |
| 42 | + ([xs ys & rest] |
| 43 | + (reduce cross-merge (cross-merge xs ys) rest)) |
| 44 | + ([xs] xs) |
| 45 | + ([] [])) |
| 46 | + |
| 47 | +;; The standard case of cross-merging 2 configs |
| 48 | + |
| 49 | +(cross-merge [{:theme "dark"}] |
| 50 | + [{:font "serif"}]) |
| 51 | + |
| 52 | +;; Extended to 3 configs |
| 53 | + |
| 54 | +(cross-merge [{:theme "dark"}] |
| 55 | + [{:font "serif"}] |
| 56 | + [{:x :customer :y :order-count}]) |
| 57 | + |
| 58 | +;; ## Why is this interesting? |
| 59 | + |
| 60 | +;; In short we can specify a larger structure with overlapping concerns concisely |
| 61 | + |
| 62 | +(def a [{:theme "dark" :color "black"}]) |
| 63 | +(def b [{:theme "light" :color "white"}]) |
| 64 | +(def c [{:font "serif" :size 12}]) |
| 65 | +(def d [{:font "sans-serif" :size 14}]) |
| 66 | + |
| 67 | +(cross-merge (add a b) (add c d)) |
| 68 | + |
| 69 | +;; We gained some expressive power. |
| 70 | + |
| 71 | +;; ## Constructors |
| 72 | + |
| 73 | +;; Given we like configs due to their combinatorial expressiveness, |
| 74 | +;; we may introduce some constructors to help us prepare configs concisely. |
| 75 | + |
| 76 | +(defn theme [name color] |
| 77 | + [{:theme name :color color}]) |
| 78 | + |
| 79 | +(theme "dark" "black") |
| 80 | + |
| 81 | +(defn font [name color] |
| 82 | + [{:font name :size color}]) |
| 83 | + |
| 84 | +(font "serif" 12) |
| 85 | + |
| 86 | +(defn dims [x y] |
| 87 | + [{:x x :y y}]) |
| 88 | + |
| 89 | +(dims :customers :orders) |
| 90 | + |
| 91 | +;; Now we can start combining our constructors and operators |
| 92 | + |
| 93 | +(cross-merge (add (theme "dark" "black") |
| 94 | + (font "serif" 12)) |
| 95 | + (dims :customers :orders)) |
| 96 | + |
| 97 | +;; ## Threading |
| 98 | + |
| 99 | +;; Rather than constructors, we could define "methods" |
| 100 | +;; that accept configs, and cross-merge them with constructed configs. |
| 101 | + |
| 102 | +(defn dims* [configs x y] |
| 103 | + (cross-merge configs [{:x x :y y}])) |
| 104 | + |
| 105 | +(defn theme* [configs name color] |
| 106 | + (cross-merge configs [{:theme name :color color}])) |
| 107 | + |
| 108 | +(defn font* [configs name color] |
| 109 | + (cross-merge configs [{:font name :size color}])) |
| 110 | + |
| 111 | +(-> (theme "dark" "black") |
| 112 | + (font* "serif" 12)) |
| 113 | + |
| 114 | +;; A key collision implies addition |
| 115 | +;; TODO: explain this idea in more detail, |
| 116 | +;; TLDR: threading can represent almost everything |
| 117 | + |
| 118 | +;; ## Unification |
| 119 | + |
| 120 | +;; The most obvious thing we can do with "configs" is merge them together. |
| 121 | + |
| 122 | +(def configs |
| 123 | + (cross-merge (add (theme "dark" "black") |
| 124 | + (font "serif" 12)) |
| 125 | + (add (dims :customers :orders) |
| 126 | + (dims :products :orders)))) |
| 127 | + |
| 128 | +configs |
| 129 | + |
| 130 | +(apply merge configs) |
| 131 | + |
| 132 | +;; But doing so loses information (specifically additions). |
| 133 | +;; Rather we might wish to detect conflicts where they occur. |
| 134 | + |
| 135 | +(apply merge-with list configs) |
| 136 | + |
| 137 | +;; Convert config map values into sets so merging can unify |
| 138 | +;; multiple possible values per key without duplicates. |
| 139 | +;; This is a simple form of "unification": collapsing alternatives. |
| 140 | +(defn normalize-to-sets [m] |
| 141 | + (update-vals m hash-set)) |
| 142 | + |
| 143 | +;; Merge a sequence of config maps, unifying values as sets |
| 144 | +;; (flat, unique per key). |
| 145 | +(defn merge-as-sets [configs] |
| 146 | + (apply merge-with clojure.set/union |
| 147 | + (map normalize-to-sets configs))) |
| 148 | + |
| 149 | +(merge-as-sets configs) |
| 150 | + |
| 151 | +;; Why is this interesting? |
| 152 | +;; Well in a chart for example we will have some properties |
| 153 | +;; that are global, and some that are "layered". |
| 154 | +;; This just demonstrates one process to convert an algebraic representation |
| 155 | +;; into a structured representation. |
| 156 | + |
| 157 | +;; ## Conclusion |
| 158 | + |
| 159 | +;; I think these ideas are more broadly applicable than just graphics. |
| 160 | +;; The only domain specific part about them is defining useful constructors, |
| 161 | +;; and the unification behaviors. |
0 commit comments