Fase B5: temporal/linear graph view mode

Adds a third :temporal view mode alongside :tags-and-objects and
:all-pages. The backend reuses build-all-pages-graph's node/link shape
unchanged (every node already carries :block/created-at); only the
client-side pixi layout differs, arranging nodes along a fixed
chronological timeline (oldest first, staggered across a few lanes)
instead of running the force simulation, following the existing
grid-layout fixed-position precedent.
This commit is contained in:
2026-08-26 19:03:21 +02:00
parent b67f7e9f78
commit d59aec181e
5 changed files with 90 additions and 26 deletions

View File

@@ -35,8 +35,9 @@
(defn- valid-view-mode
[view-mode]
(if (= view-mode :all-pages)
:all-pages
(case view-mode
:all-pages :all-pages
:temporal :temporal
:tags-and-objects))
(defn- clamp-number
@@ -335,7 +336,13 @@
:class "graph-mode-tab"}
(view-mode-tab-label
(t :graph/view-mode-all-pages)
(and loading? (= view-mode :all-pages))))))}))
(and loading? (= view-mode :all-pages))))
(shui/tabs-trigger
{:value "temporal"
:class "graph-mode-tab"}
(view-mode-tab-label
(t :graph/view-mode-temporal)
(and loading? (= view-mode :temporal))))))}))
(defn- tags-group
[settings set-settings! available-tags selected-tag-ids tag-query set-tag-query!]
@@ -430,34 +437,37 @@
link-distance (valid-link-distance (:link-distance settings))
grid-layout? (true? (:grid-layout? settings))
show-journals? (not (false? (:show-journals? settings)))
depth-disabled? (depth-control-disabled? selected-nodes)]
depth-disabled? (depth-control-disabled? selected-nodes)
temporal? (= view-mode :temporal)]
(settings-group
{:settings settings
:set-settings! set-settings!
:id :layout
:title (t :graph/layout)
:meta (t :graph/layout-force)
:meta (if temporal? (t :graph/layout-temporal) (t :graph/layout-force))
:children [:div.graph-layout-controls
[:div.graph-layout-stats
[:span (t :graph/node-count (count (:nodes graph-data)))]
[:span (t :graph/link-count (count (:links graph-data)))]]
(layout-slider
{:label (t :graph/layout-depth)
:value depth
:min 1
:max 5
:step 1
:disabled? depth-disabled?
:on-change #(set-settings! (fn [settings]
(assoc settings :depth (valid-depth %))))})
(layout-slider
{:label (t :graph/layout-link-distance)
:value link-distance
:min 36
:max 180
:step 6
:on-change #(set-settings! (fn [settings]
(assoc settings :link-distance (valid-link-distance %))))})
(when-not temporal?
[:<>
(layout-slider
{:label (t :graph/layout-depth)
:value depth
:min 1
:max 5
:step 1
:disabled? depth-disabled?
:on-change #(set-settings! (fn [settings]
(assoc settings :depth (valid-depth %))))})
(layout-slider
{:label (t :graph/layout-link-distance)
:value link-distance
:min 36
:max 180
:step 6
:on-change #(set-settings! (fn [settings]
(assoc settings :link-distance (valid-link-distance %))))})])
(when (= view-mode :tags-and-objects)
[:<>
(layout-toggle

View File

@@ -483,6 +483,7 @@
(case view-mode
:all-pages :all-pages
:page :page
:temporal :temporal
:tags-and-objects))
(defn labels-visible-by-default?
@@ -1000,6 +1001,26 @@
(vec (concat linked-nodes
(place-isolated-ring-nodes isolated degree dark? isolated-start-radius)))))
(def ^:private temporal-lane-count 6)
(def ^:private temporal-node-gap 46)
(def ^:private temporal-lane-gap 30)
(defn- temporal-layout-nodes
"Lays out nodes along a fixed, linear timeline ordered by :block/created-at
(oldest first), instead of running the force simulation. Nodes without a
timestamp sort first. Staggered across a few lanes on the y-axis so nodes
created close together don't fully overlap."
[nodes degree dark?]
(->> nodes
(sort-by (fn [node] [(or (:block/created-at node) 0) (str (:id node))]))
(map-indexed
(fn [idx node]
(let [lane (mod idx temporal-lane-count)
x (* idx temporal-node-gap)
y (* lane temporal-lane-gap)]
(decorate-node node degree dark? x y))))
vec))
(defn- all-pages-fast-layout-nodes
[nodes degree dark?]
(let [node-count (count nodes)
@@ -1383,8 +1404,17 @@
mode (layout-mode (count nodes) view-mode)
fast-all-pages? (and (= :fast mode)
(= view-mode :all-pages))]
(if fast-all-pages?
(cond
(= view-mode :temporal)
(let [node-id-set (set (map :id nodes))
filtered-links (keep-links-with-nodes links node-id-set)
degree (build-degree-map filtered-links)]
(temporal-layout-nodes nodes degree dark?))
fast-all-pages?
(all-pages-fast-layout-nodes nodes (build-fast-degree-map nodes links) dark?)
:else
(let [node-id-set (set (map :id nodes))
filtered-links (keep-links-with-nodes links node-id-set)
degree (build-degree-map filtered-links)

View File

@@ -48,8 +48,9 @@
(defn- normalize-view-mode
[view-mode]
(if (= view-mode :all-pages)
:all-pages
(case view-mode
:all-pages :all-pages
:temporal :temporal
:tags-and-objects))
(def ^:private large-all-pages-fast-threshold 10000)
@@ -923,7 +924,11 @@
result (case view-mode
:tags-and-objects
(build-tags-and-objects-graph db)
:all-pages
(:all-pages :temporal)
;; :temporal reuses the same node/link shape as :all-pages
;; (every node already carries :block/created-at); only the
;; client-side layout differs, arranging nodes on a fixed
;; timeline instead of running the force simulation.
(build-all-pages-graph db opts))]
(assoc result :meta {:view-mode view-mode})))

View File

@@ -750,6 +750,7 @@
:graph/layout-force "Force graph"
:graph/layout-grid-layout "Grid layout"
:graph/layout-link-distance "Link distance"
:graph/layout-temporal "Timeline"
:graph/layout-show-journals "Show journals"
:graph/leave-action "Leave this graph"
:graph/leave-confirm-desc "Are you sure you want to leave this graph?"
@@ -787,6 +788,7 @@
:graph/view-mode "View mode"
:graph/view-mode-all-pages "All pages"
:graph/view-mode-tags "Tags"
:graph/view-mode-temporal "Timeline"
:graph.diagnostics/checksum-failed-error "Failed to compute graph checksum diagnostics."
:graph.diagnostics/checksum-recomputed-success "Checksum recomputed. Recomputed: {1}, local: {2}, remote: {3}. Downloaded {4}.edn with {5} blocks and checksum attributes {6}."

View File

@@ -0,0 +1,17 @@
(ns frontend.extensions.graph.pixi.logic-test
(:require [cljs.test :refer [deftest is testing]]
[frontend.extensions.graph.pixi.logic :as logic]))
(deftest temporal-view-mode-lays-out-nodes-on-a-fixed-timeline
(let [nodes [{:id "a" :kind "page" :block/created-at 300}
{:id "b" :kind "page" :block/created-at 100}
{:id "c" :kind "page" :block/created-at 200}]
links []
result (logic/layout-nodes nodes links :temporal false)
by-id (into {} (map (juxt :id identity) result))]
(testing "nodes are ordered chronologically along the x-axis, oldest first"
(is (< (:x (by-id "b")) (:x (by-id "c")) (:x (by-id "a")))))
(testing "every node gets a fixed position and radius, no force simulation"
(is (every? #(and (number? (:x %)) (number? (:y %)) (number? (:radius %))) result)))
(testing "re-running layout for the same input is deterministic"
(is (= result (logic/layout-nodes nodes links :temporal false))))))