(ns frontend.extensions.graph.pixi.logic (:require ["@emoji-mart/data" :as emoji-data] ["d3-force" :as d3-force] [clojure.set :as set] [clojure.string :as string] [goog.object :as gobj])) (defn next-visibility-state [{:keys [label-visible?]} scale {:keys [show-label-scale hide-label-scale]}] (let [detail-expanded? true label-visible? (cond (and (not label-visible?) (>= scale show-label-scale)) true (and label-visible? (<= scale hide-label-scale)) false :else label-visible?)] {:detail-expanded? detail-expanded? :label-visible? label-visible?})) (defn node-source-id [node] (or (:source-id node) (:id node))) (defn visual-node-id [tag-id node-id] (str "tag-group:" tag-id ":node:" node-id)) (defn- node-priority [pinned-node-ids node] [(if (contains? pinned-node-ids (:id node)) 0 1) (if (= "tag" (:kind node)) 0 1) (- (or (:degree node) 0)) (str (or (node-source-id node) ""))]) (defn- visible-node? [node {:keys [min-x min-y max-x max-y]}] (and (string? (:label node)) (not (string/blank? (:label node))) (<= min-x (:x node) max-x) (<= min-y (:y node) max-y))) (defn- occupancy-key [node {:keys [scale x y]} screen-cell-width screen-cell-height] (let [screen-x (+ (* (:x node) scale) x) screen-y (+ (* (:y node) scale) y) cx (js/Math.floor (/ screen-x screen-cell-width)) cy (js/Math.floor (/ screen-y screen-cell-height))] (str cx ":" cy))) (defn select-label-node-ids [nodes {:keys [viewport transform screen-cell-width screen-cell-height max-labels pinned-node-ids] :or {screen-cell-width 132 screen-cell-height 24 max-labels 180}}] (let [pinned-node-ids (set pinned-node-ids)] (loop [remaining (sort-by (partial node-priority pinned-node-ids) (filter #(visible-node? % viewport) nodes)) occupied #{} selected []] (if (or (empty? remaining) (>= (count selected) max-labels)) selected (let [node (first remaining) k (occupancy-key node transform screen-cell-width screen-cell-height)] (if (contains? occupied k) (recur (rest remaining) occupied selected) (recur (rest remaining) (conj occupied k) (conj selected (:id node))))))))) (defn select-scoped-label-node-ids [nodes opts] (select-label-node-ids nodes opts)) (defn label-display-text [label hovered?] (let [label (or label "") max-prefix-length (if hovered? 96 19)] (if (> (count label) max-prefix-length) (str (subs label 0 max-prefix-length) "...") label))) (defn renderer-init-options [device-pixel-ratio] {:backgroundAlpha 0 :antialias true :autoDensity true :resolution (min 2 (or device-pixel-ratio 1)) :powerPreference "high-performance"}) (def graph-target-fps 120) (defn apply-graph-ticker-frame-rate! [^js ticker] (set! (.-maxFPS ticker) graph-target-fps) ticker) (defn fps-overlay-position ([width height text-width text-height] (fps-overlay-position width height text-width text-height nil)) ([width height text-width text-height {:keys [margin padding-x padding-y] :or {margin 12 padding-x 8 padding-y 4}}] {:x (max margin (- (or width 0) margin padding-x (or text-width 0))) :y (max margin (- (or height 0) margin padding-y (or text-height 0)))})) (defn fps-overlay-enabled? [dev?] (true? dev?)) (defn readable-edge-label-angle [from-x from-y to-x to-y] (let [angle (js/Math.atan2 (- to-y from-y) (- to-x from-x))] (cond (> angle (/ js/Math.PI 2)) (- angle js/Math.PI) (< angle (/ js/Math.PI -2)) (+ angle js/Math.PI) :else angle))) (defn edge-render-runs [links show-arrows?] (let [links (second (reduce (fn [[seen result] {:keys [source target] :as link}] (let [endpoint [source target]] (if (contains? seen endpoint) [seen result] [(conj seen endpoint) (conj result link)]))) [#{} []] links)) directed-endpoints (set (map (juxt :source :target) links))] (mapv (fn [{:keys [source target] :as link}] (let [reciprocal? (contains? directed-endpoints [target source]) parallel-offset (if reciprocal? 1 0)] (assoc link :show-arrow? (boolean show-arrows?) :parallel-offset parallel-offset))) links))) (defn- emoji-native [id] (when (string? id) (when-let [emoji (some-> (gobj/get emoji-data "emojis") (gobj/get id))] (when-let [skins (gobj/get emoji "skins")] (some-> (aget skins 0) (gobj/get "native")))))) (defn icon-display-text [icon] (cond (and (string? icon) (not (string/blank? icon))) icon (map? icon) (let [type (or (:type icon) (get icon "type")) id (or (:id icon) (get icon "id"))] (when (contains? #{:emoji "emoji"} type) (emoji-native id))) :else nil)) (defn connected-drag-weights [neighbor-map start-id {:keys [max-depth max-nodes decay min-weight] :or {max-depth 6 max-nodes 1200 decay 0.72 min-weight 0.18}}] (loop [queue (conj cljs.core/PersistentQueue.EMPTY [start-id 0]) depths {start-id 0}] (if (or (empty? queue) (>= (count depths) max-nodes)) (reduce-kv (fn [m node-id depth] (assoc m node-id (max min-weight (js/Math.pow decay depth)))) {} depths) (let [[node-id depth] (peek queue) queue (pop queue)] (if (>= depth max-depth) (recur queue depths) (let [[queue depths] (reduce (fn [[queue* depths*] neighbor-id] (if (contains? depths* neighbor-id) [queue* depths*] [(conj queue* [neighbor-id (inc depth)]) (assoc depths* neighbor-id (inc depth))])) [queue depths] (get neighbor-map node-id []))] (recur queue depths))))))) (defn merge-node-positions [layout-by-id positions-by-id] (reduce-kv (fn [m node-id position] (if-let [node (get m node-id)] (assoc m node-id (merge node position)) m)) layout-by-id positions-by-id)) (defn current-layout-by-id [layout-by-id preview-layout-by-id] (or preview-layout-by-id layout-by-id)) (defn update-highlighted-node-ids [selected-ids node-id remove?] (if remove? (disj (set selected-ids) node-id) (conj (set selected-ids) node-id))) (defn- connected-node-ids [selected-ids neighbor-map depth] (let [depth (-> (or depth 1) (max 1) (min 5))] (loop [queue (into cljs.core/PersistentQueue.EMPTY (map #(vector % 0) selected-ids)) seen selected-ids] (if (empty? queue) seen (let [[node-id current-depth] (peek queue) queue (pop queue)] (if (>= current-depth depth) (recur queue seen) (let [[queue seen] (reduce (fn [[queue* seen*] neighbor-id] (if (contains? seen* neighbor-id) [queue* seen*] [(conj queue* [neighbor-id (inc current-depth)]) (conj seen* neighbor-id)])) [queue seen] (get neighbor-map node-id []))] (recur queue seen)))))))) (defn highlight-state ([selected-ids neighbor-map] (highlight-state selected-ids neighbor-map 1)) ([selected-ids neighbor-map depth] (let [selected-ids (set selected-ids) active-ids (connected-node-ids selected-ids neighbor-map depth) connected-ids (set/difference active-ids selected-ids)] {:selected-ids selected-ids :connected-ids connected-ids :active-ids active-ids :select-mode? (seq selected-ids)}))) (defn highlight-visible-links [links {:keys [active-ids select-mode?]}] (if select-mode? (let [active-ids (set active-ids)] (filter (fn [{:keys [source target]}] (and (contains? active-ids source) (contains? active-ids target))) links)) [])) (defn node-emphasis [{:keys [selected-ids connected-ids preview-ids hovered-id select-mode? tag-focus-mode? tag-focus-ids]} node-id] (cond (contains? selected-ids node-id) :selected (= hovered-id node-id) :hovered (contains? connected-ids node-id) :connected (contains? preview-ids node-id) :preview (and tag-focus-mode? (contains? tag-focus-ids node-id)) :preview tag-focus-mode? :background select-mode? :dimmed :else :normal)) (def double-click-ms 320) (def min-zoom-scale 0.05) (def max-zoom-scale 3.6) (def linked-label-visible-scale 2.1) (declare normalize-view-mode) (defn clamp-zoom-scale [scale] (-> scale (max min-zoom-scale) (min max-zoom-scale))) (defn wheel-zoom-transform [{:keys [world-x world-y scale]} {:keys [screen-x screen-y delta-y locked?]}] (when-not locked? (let [current-scale (or scale 1) factor (if (pos? delta-y) 0.9 1.1) next-scale (clamp-zoom-scale (* current-scale factor))] {:x (- screen-x (* world-x next-scale)) :y (- screen-y (* world-y next-scale)) :scale next-scale}))) (defn node-click-action [previous-click node-id {:keys [selected? open?]} now] (cond open? {:action :open :next-click nil} (and (= node-id (:node-id previous-click)) (<= (- now (:time previous-click)) double-click-ms)) {:action :open :next-click nil} selected? {:action :unhighlight :next-click {:node-id node-id :time now}} :else {:action :highlight :next-click {:node-id node-id :time now}})) (defn- label-state [target-alpha update? hovered-only? include-select-scope? selected-only? active-only?] (cond-> {:target-alpha target-alpha :update? update? :hovered-only? hovered-only?} include-select-scope? (assoc :selected-only? selected-only? :active-only? active-only?))) (defn label-render-state ([hovered-node-id visibility-state label-alpha] (label-render-state hovered-node-id #{} #{} visibility-state label-alpha false)) ([hovered-node-id active-node-ids {:keys [label-visible?]} label-alpha] (label-render-state hovered-node-id active-node-ids active-node-ids {:label-visible? label-visible?} label-alpha true)) ([hovered-node-id selected-node-ids active-node-ids visibility-state label-alpha] (label-render-state hovered-node-id selected-node-ids active-node-ids visibility-state label-alpha true)) ([hovered-node-id selected-node-ids active-node-ids {:keys [label-visible? linked-label-visible?]} _ include-select-scope?] (let [linked-label-visible? (if (some? linked-label-visible?) linked-label-visible? label-visible?) select-mode? (seq selected-node-ids) active-mode? (seq active-node-ids) active-labels-only? (boolean select-mode?)] (cond hovered-node-id (label-state 1.0 true (not (or label-visible? active-mode?)) include-select-scope? false (or active-labels-only? (and active-mode? linked-label-visible?))) label-visible? (label-state 1.0 true false include-select-scope? false (or active-labels-only? (and active-mode? linked-label-visible?))) select-mode? (label-state 1.0 true false include-select-scope? false true) :else (label-state 0.0 false true include-select-scope? false false))))) (defn focus-labels-only? [focused-node-ids _visibility-state] (boolean (seq (set focused-node-ids)))) (defn focused-label-node? [focused-node-ids node] (contains? (set focused-node-ids) (:id node))) (defn tag-related-node-ids [tag-ids neighbor-map tag-id?] (let [tag-ids (set tag-ids) tag-id? (set tag-id?) object-ids (->> tag-ids (mapcat #(get neighbor-map %)) (remove tag-id?) set) crossed-tag-ids (->> object-ids (mapcat #(get neighbor-map %)) (filter tag-id?) set)] (set/union tag-ids object-ids crossed-tag-ids))) (defn tag-focus-active-level? [focus-level] (= :objects focus-level)) (defn tag-focus-active-node-ids [_visible-tag-ids focus-level focused-tag-id visible-object-ids crossed-tag-ids] (case focus-level :objects (if focused-tag-id (set/union #{focused-tag-id} (set visible-object-ids) (set crossed-tag-ids)) #{}) (if focused-tag-id #{focused-tag-id} #{}))) (defn tag-focus-display-node-ids ([visible-tag-ids focus-level focused-tag-id visible-object-ids crossed-tag-ids] (let [visible-tag-ids (set visible-tag-ids)] (case focus-level :objects (if focused-tag-id (set/union visible-tag-ids (set visible-object-ids) (set crossed-tag-ids)) visible-tag-ids) visible-tag-ids))) ([visible-tag-ids focus-level focused-tag-id visible-object-ids crossed-tag-ids _background-object-ids] (tag-focus-active-node-ids visible-tag-ids focus-level focused-tag-id visible-object-ids crossed-tag-ids))) (defn tag-focus-context-node-ids [_visible-tag-ids focus-level focused-tag-id visible-object-ids crossed-tag-ids background-node-ids] (if (and (= :objects focus-level) focused-tag-id) (tag-focus-active-node-ids nil focus-level focused-tag-id visible-object-ids crossed-tag-ids) (set background-node-ids))) (defn layout-bounds [nodes] (when (seq nodes) (reduce (fn [bounds {:keys [x y radius]}] (let [radius (or radius 0)] {:min-x (min (:min-x bounds) (- x radius)) :min-y (min (:min-y bounds) (- y radius)) :max-x (max (:max-x bounds) (+ x radius)) :max-y (max (:max-y bounds) (+ y radius))})) (let [{:keys [x y radius]} (first nodes) radius (or radius 0)] {:min-x (- x radius) :min-y (- y radius) :max-x (+ x radius) :max-y (+ y radius)}) (rest nodes)))) (defn fit-transform [nodes width height {:keys [padding max-scale] :or {padding 80 max-scale 1.0}}] (if-let [{:keys [min-x min-y max-x max-y]} (layout-bounds nodes)] (let [graph-width (max 1 (- max-x min-x)) graph-height (max 1 (- max-y min-y)) available-width (max 1 (- width (* 2 padding))) available-height (max 1 (- height (* 2 padding))) scale (clamp-zoom-scale (min max-scale (/ available-width graph-width) (/ available-height graph-height))) center-x (/ (+ min-x max-x) 2) center-y (/ (+ min-y max-y) 2)] {:scale scale :x (- (/ width 2) (* center-x scale)) :y (- (/ height 2) (* center-y scale))}) {:scale 1.0 :x (/ width 2) :y (/ height 2)})) (defn- normalize-view-mode [view-mode] (case view-mode :all-pages :all-pages :page :page :temporal :temporal :tags-and-objects)) (defn labels-visible-by-default? [view-mode] (= :page (normalize-view-mode view-mode))) (defn- color->int [hex-color] (js/parseInt (subs hex-color 1) 16)) (defn- tag-node-color [dark?] (if dark? "#8F78B8" "#B9A5E6")) (defn- node-color [kind dark?] (case kind "tag" (tag-node-color dark?) "property" (if dark? "#A08D85" "#817068") (if dark? "#858D98" "#E6E6E6"))) (defn- build-degree-map [links] (persistent! (reduce (fn [m {:keys [source target]}] (let [source-degree (inc (or (get m source) 0)) target-degree (inc (or (get m target) 0))] (-> m (assoc! source source-degree) (assoc! target target-degree)))) (transient {}) links))) (defn- keep-links-with-nodes [links node-id-set] (persistent! (reduce (fn [result {:keys [source target] :as link}] (if (and source target (contains? node-id-set source) (contains? node-id-set target)) (conj! result link) result)) (transient []) links))) (defn- build-fast-degree-map [nodes links] (let [node-count (count nodes) link-count (count links) node-id-set (js/Set.) degree (js/Map.)] (loop [idx 0] (when (< idx node-count) (.add node-id-set (:id (nth nodes idx))) (recur (inc idx)))) (loop [idx 0] (when (< idx link-count) (let [{:keys [source target]} (nth links idx)] (when (and source target (.has node-id-set source) (.has node-id-set target)) (.set degree source (inc (or (.get degree source) 0))) (.set degree target (inc (or (.get degree target) 0))))) (recur (inc idx)))) degree)) (defn display-links [links layouted-nodes] (let [node-id-set (set (map :id layouted-nodes)) tag-id-set (->> layouted-nodes (filter #(= "tag" (:kind %))) (map node-source-id) set) display-id-by-source-and-cluster (reduce (fn [m node] (if-let [cluster-id (:cluster-id node)] (assoc m [(node-source-id node) cluster-id] (:id node)) m)) {} layouted-nodes)] (into [] (keep (fn [{:keys [source target] :as link}] (cond (contains? tag-id-set target) (when-let [source-id (or (get display-id-by-source-and-cluster [source target]) (when (contains? node-id-set source) source))] (assoc link :source source-id :target target)) (contains? tag-id-set source) (when-let [target-id (or (get display-id-by-source-and-cluster [target source]) (when (contains? node-id-set target) target))] (assoc link :source source :target target-id)) (and (contains? node-id-set source) (contains? node-id-set target)) link))) links))) (def tag-view-non-tag-radius 4.2) (defn- node-radius ([kind degree] (node-radius kind degree false false)) ([kind degree grid-object?] (node-radius kind degree grid-object? false)) ([kind degree grid-object? tags-view?] (let [base (case kind "tag" 7.4 "object" 4.4 "journal" 4.4 3.8) radius (+ base (min (if (= kind "tag") 15.0 12.0) (* (if (= kind "tag") 4.1 3.4) (js/Math.sqrt (double degree)))))] (if (and tags-view? (not= "tag" kind)) tag-view-non-tag-radius (if grid-object? (-> radius (min 5.8) (max 3.2)) radius))))) (defn- decorate-node ([node degree dark? x y] (decorate-node node degree dark? x y nil)) ([node degree dark? x y {:keys [tags-view?]}] (let [kind (:kind node) d (get degree (node-source-id node) 0) color (or (:color node) (node-color kind dark?))] (assoc node :x x :y y :radius (node-radius kind d (:grid-object? node) tags-view?) :degree d :color color :color-int (color->int color))))) (defn- decorate-large-page-node [node x y d radius color-int] (assoc node :x x :y y :radius radius :degree d :color-int color-int)) (defn- simulation-node [node degree idx] (let [d (get degree (node-source-id node) 0) result #js {:id (:id node) :idx idx :kind (:kind node) :degree d :radius (or (:radius node) (node-radius (:kind node) d))}] (when (number? (:x node)) (set! (.-x ^js result) (:x node))) (when (number? (:y node)) (set! (.-y ^js result) (:y node))) (when (:root? node) (set! (.-fx ^js result) 0) (set! (.-fy ^js result) 0)) result)) (defn- simulation-link [{:keys [source target]}] #js {:source source :target target}) (def large-graph-fast-layout-threshold 10000) (def all-pages-fast-layout-threshold 2500) (def large-graph-draw-edge-limit 8000) (def large-graph-render-node-limit 12000) (def all-pages-large-graph-draw-edge-limit 3600) (def all-pages-large-graph-render-node-limit 2200) (def regular-graph-draw-edge-limit 28000) (def ^:private tag-force-node-limit 900) (defn layout-mode [node-count view-mode] (let [view-mode (normalize-view-mode view-mode) threshold (if (= view-mode :all-pages) all-pages-fast-layout-threshold large-graph-fast-layout-threshold)] (if (>= node-count threshold) :fast :force))) (defn- large-graph-threshold [view-mode] (if (= (normalize-view-mode view-mode) :all-pages) all-pages-fast-layout-threshold large-graph-fast-layout-threshold)) (defn draw-edge-limit [node-count link-count view-mode] (min link-count (cond (and (= (normalize-view-mode view-mode) :all-pages) (>= node-count (large-graph-threshold view-mode))) all-pages-large-graph-draw-edge-limit (>= node-count (large-graph-threshold view-mode)) large-graph-draw-edge-limit :else regular-graph-draw-edge-limit))) (defn render-node-limit [node-count view-mode] (min node-count (cond (and (= (normalize-view-mode view-mode) :all-pages) (>= node-count (large-graph-threshold view-mode))) all-pages-large-graph-render-node-limit (>= node-count (large-graph-threshold view-mode)) large-graph-render-node-limit :else node-count))) (defn- point-cross [o a b] (- (* (- (:x a) (:x o)) (- (:y b) (:y o))) (* (- (:y a) (:y o)) (- (:x b) (:x o))))) (defn- hull-half [points] (reduce (fn [h point] (let [h (loop [h h] (if (and (>= (count h) 2) (not (pos? (point-cross (nth h (- (count h) 2)) (peek h) point)))) (recur (pop h)) h))] (conj h point))) [] points)) (defn- convex-hull [points] (let [points (->> points (distinct) (sort-by (juxt :x :y)) vec)] (if (< (count points) 3) points (let [lower (hull-half points) upper (hull-half (rseq points))] (vec (concat (butlast lower) (butlast upper))))))) (defn- cluster-boundary-points [nodes center-x center-y] (let [points (mapcat (fn [{:keys [x y radius]}] (let [padding (+ (or radius 0) 34) diagonal (* padding 0.72)] [{:x (+ x padding) :y y} {:x (+ x diagonal) :y (+ y diagonal)} {:x x :y (+ y padding)} {:x (- x diagonal) :y (+ y diagonal)} {:x (- x padding) :y y} {:x (- x diagonal) :y (- y diagonal)} {:x x :y (- y padding)} {:x (+ x diagonal) :y (- y diagonal)}])) nodes) hull (convex-hull points)] (when (>= (count hull) 3) (->> hull (map (fn [{:keys [x y]}] (let [dx (- x center-x) dy (- y center-y) distance (max 1 (js/Math.sqrt (+ (* dx dx) (* dy dy)))) soften 16] {:x (+ x (* (/ dx distance) soften)) :y (+ y (* (/ dy distance) soften))}))) vec)))) (def ^:private tag-cluster-color-palette ["#3B82F6" "#10B981" "#F59E0B" "#F43F5E" "#8B5CF6" "#06B6D4" "#84CC16" "#F97316" "#14B8A6" "#A855F7" "#64748B" "#EC4899"]) (def ^:private grid-group-object-limit 36) (defn- tag-title-color-int [title] (let [title (str (or title "")) hash (reduce (fn [result idx] (mod (+ (* result 31) (.charCodeAt title idx)) 2147483647)) 0 (range (count title)))] (color->int (nth tag-cluster-color-palette (mod hash (count tag-cluster-color-palette)))))) (defn- grid-layout-enabled? [opts] (true? (:grid-layout? opts))) (defn tag-cluster-backgrounds ([nodes view-mode] (tag-cluster-backgrounds nodes view-mode {})) ([nodes view-mode {:keys [grid-layout?]}] (if (= :tags-and-objects (normalize-view-mode view-mode)) (->> nodes (filter :cluster-id) (group-by :cluster-id) (map (fn [[cluster-id nodes]] (let [bounds (reduce (fn [bounds {:keys [x y radius]}] (let [radius (or radius 0)] {:min-x (min (:min-x bounds) (- x radius)) :min-y (min (:min-y bounds) (- y radius)) :max-x (max (:max-x bounds) (+ x radius)) :max-y (max (:max-y bounds) (+ y radius))})) {:min-x js/Infinity :min-y js/Infinity :max-x js/-Infinity :max-y js/-Infinity} nodes) tag-node (some #(when (and (= "tag" (:kind %)) (= cluster-id (:id %))) %) nodes) center-x (if (and grid-layout? tag-node) (:x tag-node) (/ (+ (:min-x bounds) (:max-x bounds)) 2)) center-y (if (and grid-layout? tag-node) (:y tag-node) (/ (+ (:min-y bounds) (:max-y bounds)) 2)) radius (->> nodes (map (fn [{:keys [x y radius]}] (let [dx (- x center-x) dy (- y center-y)] (+ (js/Math.sqrt (+ (* dx dx) (* dy dy))) (or radius 0) 44)))) (apply max 84))] {:id cluster-id :x center-x :y center-y :radius radius :points (cluster-boundary-points nodes center-x center-y) :color-int (tag-title-color-int (:label tag-node cluster-id))}))) (sort-by :id) vec) []))) (defn- link-distance [view-mode value] (if (number? value) value (case view-mode :tags-and-objects 58 :page 118 82))) (defn- charge-strength [view-mode] (case view-mode :tags-and-objects -95 :page -180 -140)) (defn- y-strength [view-mode] (if (= view-mode :tags-and-objects) 0.018 0.0)) (defn layout-tick-count [node-count view-mode] (let [view-mode (normalize-view-mode view-mode)] (cond (<= node-count 120) 160 (<= node-count 400) (if (= view-mode :tags-and-objects) 130 110) (<= node-count 900) (if (= view-mode :tags-and-objects) 110 90) (and (= view-mode :tags-and-objects) (< node-count large-graph-fast-layout-threshold)) 3 :else (if (= view-mode :tags-and-objects) 90 70)))) (defn- phyllotaxis-layout-nodes [nodes degree dark?] (let [golden-angle (* js/Math.PI (- 3 (js/Math.sqrt 5))) spacing 18 [linked unlinked] (reduce (fn [[linked unlinked] node] (if (pos? (get degree (:id node) 0)) [(conj linked node) unlinked] [linked (conj unlinked node)])) [[] []] nodes)] (->> (into linked unlinked) (map-indexed (fn [idx node] (let [rank (inc idx) angle (* idx golden-angle) radius (* spacing (js/Math.sqrt rank)) x (* radius (js/Math.cos angle)) y (* radius (js/Math.sin angle))] (decorate-node node degree dark? x y)))) vec))) (defn- linked-node? [degree node] (pos? (get degree (node-source-id node) 0))) (defn- split-linked-nodes [nodes degree] (reduce (fn [[linked isolated] node] (if (linked-node? degree node) [(conj linked node) isolated] [linked (conj isolated node)])) [[] []] nodes)) (defn- all-pages-node-priority [degree node] [(if (= "tag" (:kind node)) 0 1) (- (get degree (node-source-id node) 0)) (str (or (:id node) ""))]) (defn- graph-center [nodes] (if-let [{:keys [min-x min-y max-x max-y]} (layout-bounds nodes)] {:x (/ (+ min-x max-x) 2) :y (/ (+ min-y max-y) 2) :radius (/ (max (- max-x min-x) (- max-y min-y)) 2)} {:x 0 :y 0 :radius 0})) (defn- place-isolated-ring-nodes [nodes degree dark? start-radius] (let [node-gap 42 ring-gap 58 sorted-nodes (sort-by #(str (:id %)) nodes)] (loop [remaining sorted-nodes ring 0 result []] (if (empty? remaining) (vec result) (let [radius (+ start-radius (* ring ring-gap)) capacity (max 14 (js/Math.floor (/ (* 2 js/Math.PI radius) node-gap))) ring-nodes (take capacity remaining) angle-offset (* ring 0.41)] (recur (drop capacity remaining) (inc ring) (into result (map-indexed (fn [idx node] (let [angle (+ angle-offset (* idx (/ (* 2 js/Math.PI) (max 1 (count ring-nodes))))) x (* radius (js/Math.cos angle)) y (* radius (js/Math.sin angle))] (decorate-node node degree dark? x y))) ring-nodes)))))))) (defn- all-pages-layout-nodes [nodes degree dark?] (let [[linked isolated] (split-linked-nodes nodes degree) golden-angle (* js/Math.PI (- 3 (js/Math.sqrt 5))) linked (if (>= (count nodes) all-pages-fast-layout-threshold) linked (sort-by #(all-pages-node-priority degree %) linked)) linked-nodes (mapv (fn [idx node] (let [rank (inc idx) angle (* idx golden-angle) radius (* 15 (js/Math.sqrt rank)) x (* radius (js/Math.cos angle)) y (* radius (js/Math.sin angle))] (decorate-node node degree dark? x y))) (range) linked) {:keys [radius]} (graph-center linked-nodes) isolated-start-radius (+ (max 180 radius) 96)] (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) columns (max 1 (js/Math.ceil (js/Math.sqrt node-count))) spacing 24 origin-x (/ (* (dec columns) spacing) 2) color-int (color->int (node-color "page" dark?)) radius-by-degree (js/Map.)] (loop [idx 0 col 0 row 0 result (transient [])] (if (= idx node-count) (persistent! result) (let [node (nth nodes idx) x (- (* col spacing) origin-x) y (* row spacing) layouted-node (if degree (let [d (or (.get ^js degree (:id node)) 0) cached-radius (.get radius-by-degree d) radius (if (some? cached-radius) cached-radius (let [radius (node-radius (:kind node) d)] (.set radius-by-degree d radius) radius))] (decorate-large-page-node node x y d radius color-int)) (decorate-large-page-node node x y 0 3.8 color-int)) next-idx (inc idx) next-col (inc col)] (recur next-idx (if (= next-col columns) 0 next-col) (if (= next-col columns) (inc row) row) (conj! result layouted-node))))))) (defn- stabilize-all-pages-layout [nodes degree dark?] (let [[linked isolated] (split-linked-nodes nodes degree)] (if-not (seq linked) (all-pages-layout-nodes nodes degree dark?) (let [{center-x :x center-y :y radius :radius} (graph-center linked) linked-nodes (mapv (fn [node] (assoc node :x (- (:x node) center-x) :y (- (:y node) center-y))) linked) isolated-start-radius (+ (max 180 radius) 96)] (vec (concat linked-nodes (place-isolated-ring-nodes isolated degree dark? isolated-start-radius))))))) (defn- root-node-id [nodes] (some (fn [node] (when (:root? node) (:id node))) nodes)) (defn- graph-depths [links root-id] (let [neighbor-map (reduce (fn [m {:keys [source target]}] (-> m (update source (fnil conj #{}) target) (update target (fnil conj #{}) source))) {} links)] (loop [queue (conj cljs.core/PersistentQueue.EMPTY [root-id 0]) depths {root-id 0}] (if (empty? queue) depths (let [[node-id depth] (peek queue) queue (pop queue) [queue depths] (reduce (fn [[queue* depths*] neighbor-id] (if (contains? depths* neighbor-id) [queue* depths*] [(conj queue* [neighbor-id (inc depth)]) (assoc depths* neighbor-id (inc depth))])) [queue depths] (get neighbor-map node-id #{}))] (recur queue depths)))))) (defn- page-layout-nodes [nodes links degree dark?] (if-let [root-id (root-node-id nodes)] (let [depths (graph-depths links root-id) golden-angle (* js/Math.PI (- 3 (js/Math.sqrt 5))) nodes-by-depth (->> nodes (remove #(= root-id (:id %))) (group-by #(min 3 (get depths (:id %) 3))))] (vec (cons (decorate-node (some #(when (= root-id (:id %)) %) nodes) degree dark? 0 0) (mapcat (fn [depth] (let [ring-nodes (sort-by (juxt #(- (get degree (node-source-id %) 0)) #(str (:id %))) (get nodes-by-depth depth)) count* (max 1 (count ring-nodes)) radius (+ 150 (* 128 (dec depth)) (* 10 (js/Math.sqrt count*)))] (map-indexed (fn [idx node] (let [angle (+ (* idx (/ (* 2 js/Math.PI) count*)) (* depth 0.37) (* idx golden-angle 0.08)) x (* radius (js/Math.cos angle)) y (* radius (js/Math.sin angle))] (decorate-node node degree dark? x y))) ring-nodes))) (sort (keys nodes-by-depth)))))) (phyllotaxis-layout-nodes nodes degree dark?))) (defn- tag-links-by-node-id [links tag-ids] (reduce (fn [m {:keys [source target]}] (cond (contains? tag-ids target) (update m source (fnil conj []) target) (contains? tag-ids source) (update m target (fnil conj []) source) :else m)) {} links)) (defn- linked-tag-ids [object-links tag-ids object-id] (filterv tag-ids (get object-links object-id))) (defn- group-spacing [_tag-count max-object-count] (let [object-count (min grid-group-object-limit (max 1 max-object-count)) group-radius (+ 54 (* 10 (js/Math.sqrt object-count)) 48) center-distance (+ (* 2 group-radius) 28)] (max 210 center-distance))) (defn- tag-grid-position [idx tag-count spacing] (let [columns (max 1 (js/Math.ceil (js/Math.sqrt tag-count))) rows (js/Math.ceil (/ tag-count columns)) col (mod idx columns) row (js/Math.floor (/ idx columns)) x (- (* col spacing) (/ (* (dec columns) spacing) 2)) y (- (* row spacing) (/ (* (dec rows) spacing) 2))] [x y])) (defn- clustered-tags-force-layout-nodes [nodes links degree dark?] (let [tag-ids (->> nodes (filter #(= "tag" (:kind %))) (map :id) set) tags (vec (filter #(contains? tag-ids (:id %)) nodes)) objects (vec (remove #(contains? tag-ids (:id %)) nodes)) object-links (tag-links-by-node-id links tag-ids) tag-count (max 1 (count tags)) cluster-radius (max 360 (* 72 (js/Math.sqrt tag-count))) golden-angle (* js/Math.PI (- 3 (js/Math.sqrt 5))) tag-position-by-id (into {} (map-indexed (fn [idx tag] (let [angle (/ (* idx 2 js/Math.PI) tag-count) x (* cluster-radius (js/Math.cos angle)) y (* cluster-radius (js/Math.sin angle))] [(:id tag) [x y]]))) tags) tag-nodes (mapv (fn [tag] (let [[x y] (get tag-position-by-id (:id tag) [0 0])] (decorate-node (assoc tag :cluster-id (:id tag) :cluster-root? true :cluster-x x :cluster-y y) degree dark? x y {:tags-view? true}))) tags)] (vec (concat tag-nodes (loop [idx 0 counters {} result (transient [])] (if (= idx (count objects)) (persistent! result) (let [object (nth objects idx) linked-tag-id (some tag-ids (get object-links (:id object))) cluster-idx (get counters linked-tag-id 0) counters (if linked-tag-id (update counters linked-tag-id (fnil inc 0)) counters) [cx cy] (get tag-position-by-id linked-tag-id [0 0]) angle (+ (* idx 0.11) (* cluster-idx golden-angle)) radius (if linked-tag-id (+ 58 (* 10 (js/Math.sqrt (inc cluster-idx)))) (+ 160 (* 20 (js/Math.sqrt (inc idx))))) x (+ cx (* radius (js/Math.cos angle))) y (+ cy (* radius (js/Math.sin angle))) node (decorate-node (cond-> object linked-tag-id (assoc :cluster-id linked-tag-id :cluster-x cx :cluster-y cy)) degree dark? x y {:tags-view? true})] (recur (inc idx) counters (conj! result node))))))))) (defn- clustered-tags-grid-layout-nodes [nodes links degree dark?] (let [tag-ids (->> nodes (filter #(= "tag" (:kind %))) (map :id) set) tags (filter #(contains? tag-ids (:id %)) nodes) objects (remove #(contains? tag-ids (:id %)) nodes) object-links (tag-links-by-node-id links tag-ids) tag-count (max 1 (count tags)) object-count-by-tag (reduce (fn [counts object] (reduce (fn [counts tag-id] (update counts tag-id (fnil inc 0))) counts (linked-tag-ids object-links tag-ids (:id object)))) {} objects) max-object-count (if (seq object-count-by-tag) (apply max (vals object-count-by-tag)) 0) spacing (group-spacing tag-count max-object-count) golden-angle (* js/Math.PI (- 3 (js/Math.sqrt 5))) tag-position-by-id (into {} (map-indexed (fn [idx tag] [(:id tag) (tag-grid-position idx tag-count spacing)])) tags) counters* (atom {})] (vec (concat (map (fn [tag] (let [[x y] (get tag-position-by-id (:id tag) [0 0])] (decorate-node (assoc tag :cluster-id (:id tag) :cluster-root? true :cluster-x x :cluster-y y) degree dark? x y {:tags-view? true}))) tags) (mapcat (fn [idx object] (let [object-tag-ids (linked-tag-ids object-links tag-ids (:id object)) group-ids (if (seq object-tag-ids) object-tag-ids [nil])] (map (fn [linked-tag-id] (let [cluster-idx (get @counters* linked-tag-id 0) _ (when linked-tag-id (swap! counters* update linked-tag-id (fnil inc 0))) [cx cy] (get tag-position-by-id linked-tag-id [0 0]) angle (+ (* idx 0.11) (* cluster-idx golden-angle)) radius (if linked-tag-id (+ 48 (* 10 (js/Math.sqrt (inc cluster-idx)))) (+ 160 (* 20 (js/Math.sqrt (inc idx))))) x (+ cx (* radius (js/Math.cos angle))) y (+ cy (* radius (js/Math.sin angle)))] (decorate-node (cond-> object linked-tag-id (assoc :id (visual-node-id linked-tag-id (:id object)) :source-id (:id object) :grid-object? true :cluster-id linked-tag-id :cluster-x cx :cluster-y cy)) degree dark? x y {:tags-view? true}))) group-ids))) (range) objects))))) (defn- clustered-tags-layout-nodes [nodes links degree dark? opts] (if (grid-layout-enabled? opts) (clustered-tags-grid-layout-nodes nodes links degree dark?) (clustered-tags-force-layout-nodes nodes links degree dark?))) (defn- bounded-tag-force-nodes [nodes] (if (<= (count nodes) tag-force-node-limit) nodes (let [tags (filter #(= "tag" (:kind %)) nodes) object-limit (max 0 (- tag-force-node-limit (count tags)))] (vec (concat tags (take object-limit (remove #(= "tag" (:kind %)) nodes))))))) (defn- links-between-node-ids [links node-ids] (keep (fn [{:keys [source target] :as link}] (when (and (contains? node-ids source) (contains? node-ids target)) link)) links)) (defn- merge-bounded-tag-force-layout [nodes force-layouted-nodes] (let [node-map (into {} (map (juxt :id identity) nodes)) force-by-id (into {} (map (juxt :id identity) force-layouted-nodes)) cluster-delta-by-id (reduce (fn [m {:keys [id x y kind]}] (if (and (= "tag" kind) (contains? node-map id)) (let [original (get node-map id)] (assoc m id {:dx (- x (:x original)) :dy (- y (:y original)) :x x :y y})) m)) {} force-layouted-nodes)] (mapv (fn [{:keys [id cluster-id] :as node}] (let [node (or (get force-by-id id) node)] (if-let [{:keys [dx dy x y]} (get cluster-delta-by-id cluster-id)] (cond-> (assoc node :cluster-x x :cluster-y y) (not (contains? force-by-id id)) (assoc :x (+ (:x node) dx) :y (+ (:y node) dy))) node))) nodes))) (defn- fast-layout-nodes [nodes links view-mode degree dark? opts] (cond (= view-mode :page) (page-layout-nodes nodes links degree dark?) (and (= view-mode :tags-and-objects) (some #(= "tag" (:kind %)) nodes)) (clustered-tags-layout-nodes nodes links degree dark? opts) (= view-mode :all-pages) (all-pages-layout-nodes nodes degree dark?) :else (phyllotaxis-layout-nodes nodes degree dark?))) (defn- force-seed-nodes [nodes links view-mode degree dark? opts] (cond (= view-mode :page) (page-layout-nodes nodes links degree dark?) (and (or (= view-mode :tags-and-objects) (= view-mode :all-pages)) (> (count nodes) 900) (= :force (layout-mode (count nodes) view-mode))) (fast-layout-nodes nodes links view-mode degree dark? opts) :else nodes)) (defn layout-nodes ([nodes links view-mode dark?] (layout-nodes nodes links view-mode dark? {})) ([nodes links view-mode dark? opts] (let [view-mode (normalize-view-mode view-mode) mode (layout-mode (count nodes) view-mode) fast-all-pages? (and (= :fast mode) (= view-mode :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) grid-layout? (grid-layout-enabled? opts)] (cond (= :fast mode) (fast-layout-nodes nodes filtered-links view-mode degree dark? opts) :else (let [tags-mode? (and (= view-mode :tags-and-objects) (some #(= "tag" (:kind %)) nodes)) nodes (if tags-mode? (clustered-tags-layout-nodes nodes filtered-links degree dark? opts) (force-seed-nodes nodes filtered-links view-mode degree dark? opts))] (if (and tags-mode? grid-layout?) nodes (let [bounded-tag-force? (and tags-mode? (> (count nodes) tag-force-node-limit)) force-nodes (if bounded-tag-force? (bounded-tag-force-nodes nodes) nodes) force-node-ids (when bounded-tag-force? (set (map :id force-nodes))) force-links (if bounded-tag-force? (links-between-node-ids filtered-links force-node-ids) filtered-links) simulation-nodes (->> force-nodes (map-indexed #(simulation-node %2 degree %1)) (into-array)) simulation-links (->> force-links (map simulation-link) (into-array)) link-force (-> (d3-force/forceLink simulation-links) (.id (fn [^js node] (.-id node))) (.distance (link-distance view-mode (:link-distance opts))) (.strength 0.82)) collide-force (-> (d3-force/forceCollide) (.radius (fn [^js node] (+ 10 (.-radius node)))) (.strength 0.86) (.iterations 2)) y-force (d3-force/forceY 0) _ (.strength y-force (y-strength view-mode)) simulation (cond-> (-> (d3-force/forceSimulation simulation-nodes) (.force "link" link-force) (.force "charge" (-> (d3-force/forceManyBody) (.strength (charge-strength view-mode)) (.distanceMax 420))) (.force "collision" collide-force) (.force "y" y-force)) (or (not= view-mode :tags-and-objects) (not grid-layout?)) (.force "center" (d3-force/forceCenter 0 0))) _ (when (= view-mode :tags-and-objects) (.force simulation "cluster-x" (-> (d3-force/forceX (fn [^js node] (or (:cluster-x (nth force-nodes (.-idx node))) 0))) (.strength 0.16))) (.force simulation "cluster-y" (-> (d3-force/forceY (fn [^js node] (or (:cluster-y (nth force-nodes (.-idx node))) 0))) (.strength 0.16)))) _ (.stop simulation) ticks (layout-tick-count (count nodes) view-mode)] (dotimes [_ ticks] (.tick simulation)) (let [layouted-nodes (->> simulation-nodes (map (fn [^js sim-node] (let [node (nth force-nodes (.-idx sim-node))] (decorate-node node degree dark? (.-x sim-node) (.-y sim-node) {:tags-view? tags-mode?})))) vec) layouted-nodes (if bounded-tag-force? (merge-bounded-tag-force-layout nodes layouted-nodes) layouted-nodes)] (if (= view-mode :all-pages) (stabilize-all-pages-layout layouted-nodes degree dark?) layouted-nodes)))))))))))