diff --git a/src/main/frontend/components/graph.cljs b/src/main/frontend/components/graph.cljs index 7d38c1f..2e75033 100644 --- a/src/main/frontend/components/graph.cljs +++ b/src/main/frontend/components/graph.cljs @@ -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 diff --git a/src/main/frontend/extensions/graph/pixi/logic.cljs b/src/main/frontend/extensions/graph/pixi/logic.cljs index 35dfa4c..c8be163 100644 --- a/src/main/frontend/extensions/graph/pixi/logic.cljs +++ b/src/main/frontend/extensions/graph/pixi/logic.cljs @@ -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) diff --git a/src/main/frontend/worker/graph_view.cljs b/src/main/frontend/worker/graph_view.cljs index d39fc29..a202ccf 100644 --- a/src/main/frontend/worker/graph_view.cljs +++ b/src/main/frontend/worker/graph_view.cljs @@ -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}))) diff --git a/src/resources/dicts/en.edn b/src/resources/dicts/en.edn index 2cd6954..93675a7 100644 --- a/src/resources/dicts/en.edn +++ b/src/resources/dicts/en.edn @@ -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}." diff --git a/src/test/frontend/extensions/graph/pixi/logic_test.cljs b/src/test/frontend/extensions/graph/pixi/logic_test.cljs new file mode 100644 index 0000000..45a9aa3 --- /dev/null +++ b/src/test/frontend/extensions/graph/pixi/logic_test.cljs @@ -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))))))