diff --git a/build.clj b/build.clj index e7ae07b..84f447e 100644 --- a/build.clj +++ b/build.clj @@ -22,7 +22,8 @@ (ns build (:require [clojure.pprint :as pprint] - [clojure.tools.build.api :as build-api])) + [clojure.tools.build.api :as build-api] + [shadow.cljs.devtools.api :as shadow])) ;; --------------------------------------------------------- ;; Project configuration @@ -66,6 +67,8 @@ (build-api/copy-dir {:src-dirs ["src" "resources"] :target-dir class-directory}) + (shadow/release :app) + (build-api/compile-clj {:basis project-basis :class-dir class-directory :src-dirs ["src"]}) diff --git a/bun.lockb b/bun.lockb index e4ca7ee..cffb42a 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/cljs/tools/ifs/parts/core.cljs b/cljs/tools/ifs/parts/core.cljs index dfaa64f..a02c27c 100644 --- a/cljs/tools/ifs/parts/core.cljs +++ b/cljs/tools/ifs/parts/core.cljs @@ -1,67 +1,32 @@ (ns tools.ifs.parts.core (:require - ["d3" :as d3] - ["htmx.org" :default htmx])) + ["reactflow" :refer [Background Controls ReactFlow ReactFlowProvider]] + [uix.core :refer [$ defui]] + [uix.dom])) -(def node-data - [{:type "exile"} - {:type "exile"} - {:type "exile"} - {:type "manager"} - {:type "firefighter"} - {:type "firefighter"}]) +(def initial-nodes + [{:id "1" :data {:label "1"} :position {:x 250 :y 25}} + {:id "2" :data {:label "2"} :position {:x 250 :y 125}}]) -(defn drag-start [event _d] - (js/console.log "Drag started") - (let [subject (d3/select (.-subject event))] - (.raise subject))) +(def initial-edges + [{:id "e1-2" :source "1" :target "2"}]) -(defn drag [event _d] - (js/console.log "Dragging" event _d) - (let [x (.-x event) - y (.-y event) - subject (d3/select (.-subject event))] - (-> subject - (.attr "x" 10) - (.attr "y" 10)))) +(defui flow-diagram [] + (let [[nodes set-nodes] (uix.core/use-state initial-nodes) + [edges set-edges] (uix.core/use-state initial-edges)] + ($ ReactFlowProvider + ($ :div {:style {:width "100%" :height "600px"}} + ($ ReactFlow + {:nodes (clj->js nodes) + :edges (clj->js edges) + :fitView true}) + ($ Background) + ($ Controls))))) -(defn drag-end [_event _d] - (js/console.log "Drag ended")) - -(defn create-visualization [el] - (js/console.log "Creating visualization" el) - (-> d3 - (.select el) - (.append "svg") - (.attr "width" 600) - (.attr "height" 400))) - -(defn load-nodes [svg data] - (let [width 600 - height 400 - drag-behavior (-> d3 - (.drag) - (.on "start" drag-start) - (.on "drag" drag) - (.on "end" drag-end))] - (doseq [node data] - (let [x (rand-int width) - y (rand-int height) - type (:type node) - img-path (str "/images/nodes/" type ".svg")] - (-> svg - (.append "image") - (.attr "xlink:href" img-path) - (.attr "x" x) - (.attr "y" y) - (.attr "width" 50) - (.attr "height" 50) - (.attr "class" type) - (.call drag-behavior)))))) +(defonce root + (uix.dom/create-root (js/document.getElementById "root"))) (defn ^:export init [] - (.on htmx "htmx:load" - (fn [_event] - (js/console.log "htmx loaded!") - (let [svg (create-visualization (.getElementById js/document "chart"))] - (load-nodes svg node-data))))) + (uix.dom/render-root + ($ flow-diagram) + root)) diff --git a/deps.edn b/deps.edn index 95576f8..a06a432 100644 --- a/deps.edn +++ b/deps.edn @@ -1,6 +1,6 @@ {;; --------------------------------------------------------- :paths - ["src" "resources" "cljs"] + ["src" "resources"] ;; --------------------------------------------------------- ;; --------------------------------------------------------- @@ -35,11 +35,7 @@ ;; Hiccup is a library for representing HTML in Clojure. It uses vectors to ;; represent elements, and maps to represent an element's attributes. ;; https://github.com/weavejester/hiccup - hiccup/hiccup {:mvn/version "2.0.0-RC3"} - ;; - ;; Clojurescript - ;; https://github.com/clojure/clojurescript - org.clojure/clojurescript {:mvn/version "1.11.132"}} + hiccup/hiccup {:mvn/version "2.0.0-RC3"}} ;; --------------------------------------------------------- :aliases @@ -54,6 +50,23 @@ :exec-args {:name "Clojure"}} ;; ------------ + ;; ------------ + ;; Frontend + :frontend + {:extra-paths ["cljs"] + :extra-deps + {org.clojure/clojurescript {:mvn/version "1.11.132"} + com.pitch/uix.core {:mvn/version "1.3.1"} + com.pitch/uix.dom {:mvn/version "1.3.1"}}} + + :shadow-cljs + {:extra-paths ["dev/cljs"] + :extra-deps + {thheller/shadow-cljs {:mvn/version "2.28.15"} + cider/cider-nrepl {:mvn/version "0.51.1"} + refactor-nrepl/refactor-nrepl {:mvn/version "3.10.0"} + binaryage/devtools {:mvn/version "1.0.7"}}} + ;; ------------ ;; Add libraries and paths to support additional test tools :test/env diff --git a/package.json b/package.json index f84c806..702a886 100644 --- a/package.json +++ b/package.json @@ -2,9 +2,12 @@ "dependencies": { "d3": "^7.9.0", "htmx.org": "^2.0.2", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "reactflow": "^11.11.4", "shadow-cljs": "^2.28.15" }, "scripts": { - "watch": "shadow-cljs watch app" + "watch": "bunx shadow-cljs watch app" } } diff --git a/resources/public/css/flow.css b/resources/public/css/flow.css new file mode 100644 index 0000000..a087c28 --- /dev/null +++ b/resources/public/css/flow.css @@ -0,0 +1,408 @@ +/* From: https://unpkg.com/reactflow@11.11.4/dist/style.css */ + +/* this gets exported as style.css and can be used for the default theming */ +/* these are the necessary styles for React Flow, they get used by base.css and style.css */ +.react-flow { + direction: ltr; +} +.react-flow__container { + position: absolute; + width: 100%; + height: 100%; + top: 0; + left: 0; +} +.react-flow__pane { + z-index: 1; + cursor: -webkit-grab; + cursor: grab; +} +.react-flow__pane.selection { + cursor: pointer; + } +.react-flow__pane.dragging { + cursor: -webkit-grabbing; + cursor: grabbing; + } +.react-flow__viewport { + transform-origin: 0 0; + z-index: 2; + pointer-events: none; +} +.react-flow__renderer { + z-index: 4; +} +.react-flow__selection { + z-index: 6; +} +.react-flow__nodesselection-rect:focus, +.react-flow__nodesselection-rect:focus-visible { + outline: none; +} +.react-flow .react-flow__edges { + pointer-events: none; + overflow: visible; +} +.react-flow__edge-path, +.react-flow__connection-path { + stroke: #b1b1b7; + stroke-width: 1; + fill: none; +} +.react-flow__edge { + pointer-events: visibleStroke; + cursor: pointer; +} +.react-flow__edge.animated path { + stroke-dasharray: 5; + -webkit-animation: dashdraw 0.5s linear infinite; + animation: dashdraw 0.5s linear infinite; + } +.react-flow__edge.animated path.react-flow__edge-interaction { + stroke-dasharray: none; + -webkit-animation: none; + animation: none; + } +.react-flow__edge.inactive { + pointer-events: none; + } +.react-flow__edge.selected, + .react-flow__edge:focus, + .react-flow__edge:focus-visible { + outline: none; + } +.react-flow__edge.selected .react-flow__edge-path, + .react-flow__edge:focus .react-flow__edge-path, + .react-flow__edge:focus-visible .react-flow__edge-path { + stroke: #555; + } +.react-flow__edge-textwrapper { + pointer-events: all; + } +.react-flow__edge-textbg { + fill: white; + } +.react-flow__edge .react-flow__edge-text { + pointer-events: none; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + } +.react-flow__connection { + pointer-events: none; +} +.react-flow__connection .animated { + stroke-dasharray: 5; + -webkit-animation: dashdraw 0.5s linear infinite; + animation: dashdraw 0.5s linear infinite; + } +.react-flow__connectionline { + z-index: 1001; +} +.react-flow__nodes { + pointer-events: none; + transform-origin: 0 0; +} +.react-flow__node { + position: absolute; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + pointer-events: all; + transform-origin: 0 0; + box-sizing: border-box; + cursor: -webkit-grab; + cursor: grab; +} +.react-flow__node.dragging { + cursor: -webkit-grabbing; + cursor: grabbing; + } +.react-flow__nodesselection { + z-index: 3; + transform-origin: left top; + pointer-events: none; +} +.react-flow__nodesselection-rect { + position: absolute; + pointer-events: all; + cursor: -webkit-grab; + cursor: grab; + } +.react-flow__handle { + position: absolute; + pointer-events: none; + min-width: 5px; + min-height: 5px; + width: 6px; + height: 6px; + background: #1a192b; + border: 1px solid white; + border-radius: 100%; +} +.react-flow__handle.connectionindicator { + pointer-events: all; + cursor: crosshair; + } +.react-flow__handle-bottom { + top: auto; + left: 50%; + bottom: -4px; + transform: translate(-50%, 0); + } +.react-flow__handle-top { + left: 50%; + top: -4px; + transform: translate(-50%, 0); + } +.react-flow__handle-left { + top: 50%; + left: -4px; + transform: translate(0, -50%); + } +.react-flow__handle-right { + right: -4px; + top: 50%; + transform: translate(0, -50%); + } +.react-flow__edgeupdater { + cursor: move; + pointer-events: all; +} +.react-flow__panel { + position: absolute; + z-index: 5; + margin: 15px; +} +.react-flow__panel.top { + top: 0; + } +.react-flow__panel.bottom { + bottom: 0; + } +.react-flow__panel.left { + left: 0; + } +.react-flow__panel.right { + right: 0; + } +.react-flow__panel.center { + left: 50%; + transform: translateX(-50%); + } +.react-flow__attribution { + font-size: 10px; + background: rgba(255, 255, 255, 0.5); + padding: 2px 3px; + margin: 0; +} +.react-flow__attribution a { + text-decoration: none; + color: #999; + } +@-webkit-keyframes dashdraw { + from { + stroke-dashoffset: 10; + } +} +@keyframes dashdraw { + from { + stroke-dashoffset: 10; + } +} +.react-flow__edgelabel-renderer { + position: absolute; + width: 100%; + height: 100%; + pointer-events: none; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; +} +.react-flow__edge.updating .react-flow__edge-path { + stroke: #777; + } +.react-flow__edge-text { + font-size: 10px; + } +.react-flow__node.selectable:focus, + .react-flow__node.selectable:focus-visible { + outline: none; + } +.react-flow__node-default, +.react-flow__node-input, +.react-flow__node-output, +.react-flow__node-group { + padding: 10px; + border-radius: 3px; + width: 150px; + font-size: 12px; + color: #222; + text-align: center; + border-width: 1px; + border-style: solid; + border-color: #1a192b; + background-color: white; +} +.react-flow__node-default.selectable:hover, .react-flow__node-input.selectable:hover, .react-flow__node-output.selectable:hover, .react-flow__node-group.selectable:hover { + box-shadow: 0 1px 4px 1px rgba(0, 0, 0, 0.08); + } +.react-flow__node-default.selectable.selected, + .react-flow__node-default.selectable:focus, + .react-flow__node-default.selectable:focus-visible, + .react-flow__node-input.selectable.selected, + .react-flow__node-input.selectable:focus, + .react-flow__node-input.selectable:focus-visible, + .react-flow__node-output.selectable.selected, + .react-flow__node-output.selectable:focus, + .react-flow__node-output.selectable:focus-visible, + .react-flow__node-group.selectable.selected, + .react-flow__node-group.selectable:focus, + .react-flow__node-group.selectable:focus-visible { + box-shadow: 0 0 0 0.5px #1a192b; + } +.react-flow__node-group { + background-color: rgba(240, 240, 240, 0.25); +} +.react-flow__nodesselection-rect, +.react-flow__selection { + background: rgba(0, 89, 220, 0.08); + border: 1px dotted rgba(0, 89, 220, 0.8); +} +.react-flow__nodesselection-rect:focus, + .react-flow__nodesselection-rect:focus-visible, + .react-flow__selection:focus, + .react-flow__selection:focus-visible { + outline: none; + } +.react-flow__controls { + box-shadow: 0 0 2px 1px rgba(0, 0, 0, 0.08); +} +.react-flow__controls-button { + border: none; + background: #fefefe; + border-bottom: 1px solid #eee; + box-sizing: content-box; + display: flex; + justify-content: center; + align-items: center; + width: 16px; + height: 16px; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + padding: 5px; + } +.react-flow__controls-button:hover { + background: #f4f4f4; + } +.react-flow__controls-button svg { + width: 100%; + max-width: 12px; + max-height: 12px; + } +.react-flow__controls-button:disabled { + pointer-events: none; + } +.react-flow__controls-button:disabled svg { + fill-opacity: 0.4; + } +.react-flow__minimap { + background-color: #fff; +} +.react-flow__minimap svg { + display: block; +} +.react-flow__resize-control { + position: absolute; +} +.react-flow__resize-control.left, +.react-flow__resize-control.right { + cursor: ew-resize; +} +.react-flow__resize-control.top, +.react-flow__resize-control.bottom { + cursor: ns-resize; +} +.react-flow__resize-control.top.left, +.react-flow__resize-control.bottom.right { + cursor: nwse-resize; +} +.react-flow__resize-control.bottom.left, +.react-flow__resize-control.top.right { + cursor: nesw-resize; +} +/* handle styles */ +.react-flow__resize-control.handle { + width: 4px; + height: 4px; + border: 1px solid #fff; + border-radius: 1px; + background-color: #3367d9; + transform: translate(-50%, -50%); +} +.react-flow__resize-control.handle.left { + left: 0; + top: 50%; +} +.react-flow__resize-control.handle.right { + left: 100%; + top: 50%; +} +.react-flow__resize-control.handle.top { + left: 50%; + top: 0; +} +.react-flow__resize-control.handle.bottom { + left: 50%; + top: 100%; +} +.react-flow__resize-control.handle.top.left { + left: 0; +} +.react-flow__resize-control.handle.bottom.left { + left: 0; +} +.react-flow__resize-control.handle.top.right { + left: 100%; +} +.react-flow__resize-control.handle.bottom.right { + left: 100%; +} +/* line styles */ +.react-flow__resize-control.line { + border-color: #3367d9; + border-width: 0; + border-style: solid; +} +.react-flow__resize-control.line.left, +.react-flow__resize-control.line.right { + width: 1px; + transform: translate(-50%, 0); + top: 0; + height: 100%; +} +.react-flow__resize-control.line.left { + left: 0; + border-left-width: 1px; +} +.react-flow__resize-control.line.right { + left: 100%; + border-right-width: 1px; +} +.react-flow__resize-control.line.top, +.react-flow__resize-control.line.bottom { + height: 1px; + transform: translate(0, -50%); + left: 0; + width: 100%; +} +.react-flow__resize-control.line.top { + top: 0; + border-top-width: 1px; +} +.react-flow__resize-control.line.bottom { + border-bottom-width: 1px; + top: 100%; +} diff --git a/shadow-cljs.edn b/shadow-cljs.edn index cab4678..68f3880 100644 --- a/shadow-cljs.edn +++ b/shadow-cljs.edn @@ -1,9 +1,5 @@ -{:source-paths - ["cljs"] - - :dependencies - [[cider/cider-nrepl "0.51.1"] - [refactor-nrepl/refactor-nrepl "3.10.0"]] +{:deps + {:aliases [:shadow-cljs :frontend]} :nrepl {:middleware [cider.nrepl/cider-middleware diff --git a/src/tools/ifs/parts.clj b/src/tools/ifs/parts.clj index cda6285..fa0ad8b 100644 --- a/src/tools/ifs/parts.clj +++ b/src/tools/ifs/parts.clj @@ -34,6 +34,7 @@ (ring/ring-handler (ring/router [["/" {:get {:handler #(pages/home-page %)}}] + ["/system" {:get {:handler #(pages/system-graph %)}}] ["/up" {:get {:handler (fn [_] {:status 200 :body "OK"})}}] ["/waitlist-signup" {:post {:handler #(waitlist/signup %)}}]] diff --git a/src/tools/ifs/parts/layouts/main.clj b/src/tools/ifs/parts/layouts/main.clj index 6606090..4d33576 100644 --- a/src/tools/ifs/parts/layouts/main.clj +++ b/src/tools/ifs/parts/layouts/main.clj @@ -16,14 +16,15 @@ [:link {:rel "apple-touch-icon" :href "/images/icons/favicon.png"}] [:title (str title " — Parts")] [:link {:rel "stylesheet" :href "/css/style.css"}] + [:link {:rel "stylesheet" :href "/css/flow.css"}] [:link {:rel "preconnect" :href "https://fonts.googleapis.com"}] [:link {:rel "preconnect" :href "https://fonts.gstatic.com" :crossorigin true}] [:link {:rel "stylesheet" :href "https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&display=swap"}] - [:script {:src "/js/main.js"}] [:script {:defer true :data-domain "parts.ifs.tools" :src "https://plausible.io/js/script.outbound-links.tagged-events.js"}]] [:body (header) content - (footer)])) + (footer) + [:script {:src "/js/main.js"}]])) diff --git a/src/tools/ifs/parts/pages.clj b/src/tools/ifs/parts/pages.clj index 31ea984..fe50611 100644 --- a/src/tools/ifs/parts/pages.clj +++ b/src/tools/ifs/parts/pages.clj @@ -11,8 +11,10 @@ (response/response (html (layout "System" - [:div [:h2 "System"]] - [:div#chart])))) + [:section.container + [:div.content + [:div [:h2 "System"]] + [:div#root]]])))) (defn home-page "Page rendered for GET /"