Plotting Datoms: Queries as Visual Mappings

A query-first approach makes relational data available for visualization.
Author
Affiliation
Published

January 8, 2026

Keywords

datavis, algebra, datascript, queries, datoms

Most plotting libraries operate on dataframes analogous to a table with rows and columns. But what if our plots operated on a Datomic style database of facts instead?

DataScript is an in-memory database of datoms (entity-attribute-value triples). A plot query can select exactly the facts we want to visualize, binding them to visual channels. The query itself becomes the mapping: which attributes become x, y, color, or relationships.

(require '[datascript.core :as d])

Why query-based plotting? Databases are relational. Entities can have arbitrary attributes and relationships to other entities. Queries let us express things that don’t fit in a single table: joins across entities, aggregations, derived relationships. A query-first approach makes relational data available for visualization.

First: some tiny datoms to play with.

(def penguins
  [{:species "Adelie" :bill_length 29.1 :bill_depth 18.7 :sex "MALE"}
   {:species "Adelie" :bill_length 33.5 :bill_depth 15.4 :sex "FEMALE"}
   {:species "Chinstrap" :bill_length 43.5 :bill_depth 17.9 :sex "FEMALE"}
   {:species "Gentoo" :bill_length 47.3 :bill_depth 13.8 :sex "MALE"}])
(def penguin-db
  (let [conn (d/create-conn)]
    (d/transact! conn penguins)
    @conn))

Think of the database as many tiny facts. Each fact says: “entity has attribute value”.

penguin-db
#datascript/DB {:schema nil,
                :datoms
                [[1 :bill_depth 18.7 536870913]
                 [1 :bill_length 29.1 536870913]
                 [1 :sex "MALE" 536870913]
                 [1 :species "Adelie" 536870913]
                 [2 :bill_depth 15.4 536870913]
                 [2 :bill_length 33.5 536870913]
                 [2 :sex "FEMALE" 536870913]
                 [2 :species "Adelie" 536870913]
                 [3 :bill_depth 17.9 536870913]
                 [3 :bill_length 43.5 536870913]
                 [3 :sex "FEMALE" 536870913]
                 [3 :species "Chinstrap" 536870913]
                 [4 :bill_depth 13.8 536870913]
                 [4 :bill_length 47.3 536870913]
                 [4 :sex "MALE" 536870913]
                 [4 :species "Gentoo" 536870913]]}

How to plot a database:

(def default-palette
  ["#2563eb" "#f97316" "#10b981" "#a855f7" "#ef4444" "#14b8a6" "#f59e0b" "#6b7280"])
(defn color-scale
  "Given a sequence of category values,
   assign consistent colors from the default palette."
  [categories]
  (let [domain (distinct categories)
        colors (cycle default-palette)]
    (into {} (map vector domain colors))))
(defn plot-basic [g]
  (let [{:keys [db query geometry]} (g 1)
        results (vec (d/q query db))]
    (for [geom geometry]
      (case geom
        :point (let [color-map (color-scale (map last results))]
                 (for [[x y color] results]
                   [:circle {:r 2, :cx x, :cy y
                             :fill (get color-map color "gray")}]))
        :line (for [[x1 y1 x2 y2] results]
                [:line {:x1 x1, :y1 y1, :x2 x2, :y2 y2}])))))

To specify a plot, we provide a query

(def bill-scatter
  [:graphic {:db penguin-db
             :query '{:find [?x ?y ?color]
                      :where [[?e :species ?color]
                              [?e :bill_length ?x]
                              [?e :bill_depth ?y]]}
             :geometry [:point]}])

The coordinates fall out of the query bindings; geometry only chooses how to render them:

Wrap it in a tiny SVG viewport to see the result:

^:kind/hiccup
[:svg {:width "100%" :height "300"
       :viewBox "0 0 50 50"
       :xmlns   "http://www.w3.org/2000/svg"}
 [:g {:stroke "gray", :fill "none"}
  (plot-basic bill-scatter)]]

We can also query for relationships between entities. For example, pairs of penguins from the same species:

(def same-species-relationships
  [:graphic {:db penguin-db
             :query '{:find [?x1 ?y1 ?x2 ?y2]
                      :where [[?e1 :species ?s]
                              [?e2 :species ?s]
                              [?e1 :bill_length ?x1]
                              [?e1 :bill_depth ?y1]
                              [?e2 :bill_length ?x2]
                              [?e2 :bill_depth ?y2]
                              [(not= ?e1 ?e2)]]}
             :geometry [:line]}])

This small example shows that the mapping lives in the query. Queries can bind points and relationships between entities. Each geometry expects a specific binding shape (points: [x y color], lines: [x1 y1 x2 y2]), so the query and geometry must agree on that contract. The novelty here is expressiveness: a relational query can yield edges, something dataframe workflows don’t model directly.

^:kind/hiccup
[:svg {:width "100%" :height "300"
       :viewBox "0 0 50 50"
       :xmlns   "http://www.w3.org/2000/svg"}
 [:g {:stroke "gray", :fill "none"}
  (plot-basic bill-scatter)
  (plot-basic same-species-relationships)]]

Plots as queries let us say more. Points and edges can be defined by a query. The novelty in this example is that relationships can be a first‑class things to draw.

source: src/data_visualization/aog/datomframes.clj