diff --git a/README.md b/README.md index 2bb426b..befdbc6 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,16 @@ Works well in Chrome and Firefox; not tested in Internet Exploder. ![what it should look like](resources/public/images/example.png) +## Intended uses + +This is a component for a console, typically one controlling a technical or scientific instrument. It is +by design flexible and configurable. Minimum value, maximum value, warning value and number of gradations +shown are all configurable, as are (obviously) styles. + +A cursor will be shown if the value of *setpoint* is between *min-value* and *max-value*; *setpoint* is a dynamic value which is watched during the run. While the *model* value is within +- *tolerance* of the *setpoint* value, a class *target-class* is set on the meter indicating an on-target status (by default the frame goes green). + +A red-zone may be shown if a *warn-value* is set which is between *min-value* and *max-value*. If such a *warn-value* is set, then if the current value (*model*) exceeds *warn-value*, a class *warning-class* is set on the meter indicating a warning status (by default the frame goes maroon). + ## Development Mode ### Run application: diff --git a/resources/public/css/swinging-needle-meter.css b/resources/public/css/swinging-needle-meter.css index 8dc6ad4..26f1909 100644 --- a/resources/public/css/swinging-needle-meter.css +++ b/resources/public/css/swinging-needle-meter.css @@ -19,6 +19,16 @@ stroke: #444444; } +.snm-gradation path { + stroke: black; + stroke-width: 1; +} + +.snm-gradation text { + font-size: 50%; + font-weight: lighter; +} + .snm-hub { fill: #444444; } @@ -50,13 +60,14 @@ stroke-width: 15; } +.snm-target .snm-frame { + stroke: green; +} + .snm-value { text-align: center; } -.snm-warning { - fill: none; - stroke-width: 10; - stroke-linejoin: round; - stroke: red; +.snm-warning .snm-frame { + stroke: maroon; } diff --git a/resources/public/images/example.png b/resources/public/images/example.png index d46afad..a4e54d6 100644 Binary files a/resources/public/images/example.png and b/resources/public/images/example.png differ diff --git a/resources/public/images/example.xcf b/resources/public/images/example.xcf index b4f10b5..845079a 100644 Binary files a/resources/public/images/example.xcf and b/resources/public/images/example.xcf differ diff --git a/src/cljs/swinging_needle_meter/swinging_needle_meter.cljs b/src/cljs/swinging_needle_meter/swinging_needle_meter.cljs index 6d661c6..54beb1b 100644 --- a/src/cljs/swinging_needle_meter/swinging_needle_meter.cljs +++ b/src/cljs/swinging_needle_meter/swinging_needle_meter.cljs @@ -1,5 +1,6 @@ (ns swinging-needle-meter.swinging-needle-meter - (:require [re-com.core :refer [h-box v-box box gap line label title slider checkbox p]] + (:require [clojure.string :as string] + [re-com.core :refer [h-box v-box box gap line label title slider checkbox p]] [re-com.box :refer [flex-child-style]] [re-com.util :refer [deref-or-value]] [re-com.validate :refer [number-or-string? css-style? html-attr? validate-args-macro]] @@ -30,7 +31,7 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; ------------------------------------------------------------------------------------ -;; Component: swinging-needle +;; Component: swinging-needle-meter ;; ------------------------------------------------------------------------------------ ;;; It seems the defaults given here are just documentation; the defaults @@ -48,10 +49,14 @@ :validate-fn number? :description "the minimum value model can take"} {:name :max-value :required false :type "double" :default 100 :validate-fn number? :description "the maximum value model can take"} + {:name :warn-value :required false :type "double" :default 80 + :validate-fn number? :description "the maximum safe value model can take"} + {:name :tolerance :required false :type "double" :default 3 + :validate-fn number? :description "the amount by which model can differ from setpoint and still be considered acceptable"} {:name :class :required false :type "string" :validate-fn string? :description "CSS class names, space separated, for the top-level SVG element"} - {:name :alarm-class :required false :type "string" - :validate-fn string? :description "CSS class names, space separated, for the cursor"} + {:name :alarm-class :required false :type "string" :default "snm-warning" + :validate-fn string? :description "CSS class names, space separated, applied to the frame in an alarm condition"} {:name :cursor-class :required false :type "string" :default "snm-cursor" :validate-fn string? :description "CSS class names, space separated, for the cursor"} {:name :frame-class :required false :type "string" :default "snm-frame" @@ -64,10 +69,14 @@ :validate-fn string? :description "CSS class names, space separated, for the scale"} {:name :redzone-class :required false :type "string" :default "snm-redzone" :validate-fn string? :description "CSS class names, space separated, for the redzone"} + {:name :target-class :required false :type "string" :default "snm-target" + :validate-fn string? :description "CSS class names, space separated, , applied to the frame in an on-target condition"} {:name :unit :required false :type "string" :validate-fn string? :description "Unit to show after the value"} {:name :id :required false :type "string" :default "meter" :validate-fn string? :description "Element id for this instance of the meter"} + {:name :gradations :reduired false :type "integer" :default 5 + :validate-fn integer? :description "Number of gradations to show on the scale, not counting the point."} {:name :style :required false :type "CSS style map" :validate-fn css-style? :description "CSS styles to add or override"} {:name :attr :required false :type "HTML attr map" @@ -81,6 +90,10 @@ ;; from the left end of the scale to right end, in degrees. (def full-scale-deflection 140) +;; ultimately this should be resizeable, and radius should be a function of +;; size... +(def scale-radius 75) + (defn deflection "Return the deflection of a needle given this `value` on the range `min-value`...`max-value`." @@ -89,33 +102,97 @@ deflection (/ value range) zero-offset (/ (- 0 min-value) range) limited (min (max (+ zero-offset deflection) 0) 1)] + (js/console.log (str "zero-offset: " zero-offset)) (* (- limited 0.5) full-scale-deflection))) +(defn polar-to-cartesian + "Return, as a map with keys :x. :y, the cartesian coordinates at the point + `radius` distance at `theta` (degrees) angle from a point at + cartesian coordinates `cx`, `cy`." + [cx cy radius theta] + (let + [in-radians (/ (* (- theta 90) (aget js/Math "PI")) 180.0)] + {:x (+ cx (* radius (.cos js/Math in-radians))) + :y (+ cy (* radius (.sin js/Math in-radians)))})) + +(defn describe-arc + "Return as a string an SVG path definition describing an arc centred + at `cx`, cy` starting at `start-angle` and ending at `end-angle` (both + angles in degrees)." + [cx cy radius start-angle end-angle] + (let + [start (polar-to-cartesian cx cy radius start-angle) + end (polar-to-cartesian cx cy radius end-angle) + large-arc? (if (<= (- end-angle start-angle) 180) 0 1) + sweep (if (> end-angle start-angle) 1 0)] + (string/join " " ["M" (:x start) (:y start) "A" radius radius 0 large-arc? sweep (:x end) (:y end)]))) + + +(defn as-label + "If this arg is a floating point number, format it to a reasonable width; else return it." + [arg] + (if + (and (number? arg) (not (integer? arg))) + (.toFixed arg 2) + arg)) + + +(defn gradation + "Return as a string an SVG path definition describing a radial stroke from a center + at `cx`, cy` starting at `min-radius` and extending to `max-radius`." + [cx cy min-radius max-radius angle label] + (let + [start (polar-to-cartesian cx cy min-radius angle) + mid (polar-to-cartesian cx cy (+ min-radius + (* (- max-radius min-radius) 0.333)) + angle) + end (polar-to-cartesian cx cy max-radius angle)] + [:g {:class "snm-gradation"} + [:path {:d (string/join " " ["M" (:x mid) (:y mid) "L" (:x end) (:y end)])}] + [:text {:text-anchor "middle" + :x (:x start) + :y (:y start) + :transform (string/join " " ["rotate(" angle (:x start) (:y start) ")"])} (as-label label)]])) + (defn swinging-needle-meter "Render an SVG swinging needle meter" - [& {:keys [model setpoint width height min-value max-value class alarm-class cursor-class frame-class hub-class needle-class scale-class redzone-class unit id style attr] + [& {:keys [model setpoint width height min-value max-value warn-value tolerance class gradations alarm-class cursor-class frame-class hub-class needle-class redzone-class scale-class target-class unit id style attr] :or {width "100%" height "100%" min-value 0 max-value 100 + warn-value 80 + tolerance 3 + gradations 5 + alarm-class "snm-warning" cursor-class "snm-cursor" frame-class "snm-frame" hub-class "snm-hub" needle-class "snm-needle" scale-class "snm-scale" redzone-class "snm-redzone" + target-class "snm-target" id "meter"} :as args}] {:pre [(validate-args-macro swinging-needle-args-desc args "swinging-needle")]} (let [model (deref-or-value model) setpoint (deref-or-value setpoint) - current-value (str model (if unit " ") unit)] + mid-point-deflection (/ full-scale-deflection 2) + ;; if warn-value is greater than max-value, we don't want a red-zone at all. + red-zone-deflection (if + (< warn-value max-value) + (* full-scale-deflection (/ warn-value max-value)) + full-scale-deflection)] [box :align :start :child [:div (merge - {:class (str "swinging-needle " class) + {:class (str "swinging-needle " class " " (str + (if (< min-value model warn-value) "" + (str " " alarm-class)) + (if (and (> setpoint min-value)(< (abs (- model setpoint)) tolerance)) + (str " " target-class) ""))) :style (merge (flex-child-style "none") {:width width :height height} style)} @@ -131,37 +208,19 @@ :id id :class (str "snm-meter " class)} [:text - {:xml:space "preserve" - :x "-75.5" - :y "50" - :id (str id "-min-value") - :class "snm-limit" - :transform "matrix(0.2398013,-0.97082199,0.97082199,0.2398013,0,0)"}[:tspan min-value]] - [:text - {:xml:space "preserve" - :x "102" - :y "-102" - :id (str id "-max-value") - :class "snm-limit" - :transform "matrix(0.26614433,0.96393319,-0.96393319,0.26614433,0,0)"}[:tspan max-value]] - [:text - {:xml:space "preserve" - ;; 4.5 here is a real fudge. It's roughly half the width in SVG units of a single character; - ;; it's intended to keep the visible text roughly in the middle of the meter. - :x (str (- 80 (* (count current-value) 4.5))) - :y "60" + {:text-anchor "middle" + :x 80 + :y 70 :width "100" :id (str id "-current-value") - :class "snm-value"}[:tspan current-value]] + :class "snm-value"}[:tspan (str (as-label model) (if unit " ") unit)]] [:path {:class scale-class :id (str id "-scale") - :d "m 11.85914,76.864488 c 0,0 14.34545,-53.795412 68.140856,-53.795412 53.795424,0 68.140864,53.795412 68.140864,53.795412"}] + :d (describe-arc 80 100 scale-radius (- 0 mid-point-deflection) mid-point-deflection)}] [:path {:class redzone-class :id (str id "-redzone") - :d "m 137.74738,54.878869 c 0,0 3.02675,3.620416 6.3911,11.14347 3.36435,7.523055 4.20612,11.198095 4.20612,11.198095"}] - [:rect {:class (str frame-class (if (< min-value model max-value) "" (str " " alarm-class))) - :id (str id "-frame") - :x "5" :y "5" :height "100" :width "150"}] + :d (describe-arc 80 100 scale-radius (- red-zone-deflection mid-point-deflection) mid-point-deflection)}] + [:path {:class cursor-class :id (str id "-cursor") :d "M 80,20 80,100" @@ -171,11 +230,20 @@ :id (str id "-needle") :d "M 80,20 80,100" :transform (str "rotate( " (deflection model min-value max-value) ", 80, 100)") }] + (apply vector (cons :g (map #(gradation 80 100 60 82 + (- (* % + (/ full-scale-deflection gradations)) + mid-point-deflection) + (+ min-value + (* + (/ + (- max-value min-value) + gradations) %))) + (range 0 (+ gradations 1))))) + [:rect {:class frame-class + :id (str id "-frame") + :x "5" :y "5" :height "100" :width "150"}] [:circle {:class hub-class :id (str id "-hub") :r "10" :cx "80" :cy "100"}]] - ;;; Useful for debugging: - (str "value: " model "; min: " min-value - "; max: " max-value - "; deflection: " (int (deflection model min-value max-value))) ]])) diff --git a/src/cljs/swinging_needle_meter/views.cljs b/src/cljs/swinging_needle_meter/views.cljs index 21118a4..729d191 100644 --- a/src/cljs/swinging_needle_meter/views.cljs +++ b/src/cljs/swinging_needle_meter/views.cljs @@ -6,14 +6,13 @@ [reagent.core :as reagent])) ;; ------------------------------------------------------------------------------------ -;; Demo: swinging-needle +;; Demo: swinging-needle-meter ;; ------------------------------------------------------------------------------------ (defn swinging-needle-demo [] (let [value (reagent/atom 75) - setpoint (reagent/atom 75) - striped? (reagent/atom false)] + setpoint (reagent/atom 75)] (fn [] [v-box @@ -27,13 +26,27 @@ :width "450px" :children [[title2 "Notes"] [status-text "Wildly experimental"] - [p "An SVG swinging needle meter."] + [p "An SVG swinging needle meter intended to be useful in dashboards."] [p "Note that the cursor will vanish if the setpoint is null or is less than or equal to min-value; this is intentional."] [p "Note that if the value of model is lower then min-value or greater than max-value, it will be limited as it would be on a mechanical meter."] - [p "You can hide the redzone by setting its style to the style 'snm-scale'"] - [p - "TODO: You can't adjust the position of the start of the red-zone; "] + [p "You can hide the redzone by setting its style to the style 'snm-scale', or by setting 'warn-value' equal to 'max-value'."] + + [title2 "Behaviour"] + + [p "min-value and max-value must be numbers; max-value must be greater than min-value. + The default behaviour is of a swinging needle meter with the needle deflection proportional + to the value of the model (also a number) expressed as a proportion of the difference between + min-value and max-value."] + + [p "A red-zone can be introduced by setting a value for warn-value between min-value and max-value. Additionally, if + the value of model exceeds warn-value the class alarm-class will be set on the meter indicating a warning state."] + + [p "A cursor can be shown by setting the value of set-point between min-value and max-value. A tolerance value can be specified + by setting a value for tolerance. If the difference between the model value and the setpoint value is less than the tolerance + value, the class target-class will be set on the meter to indicate an on-target status. The setpoint value, like the model value, + may change dynamically at run-time."] + [args-table swinging-needle-args-desc]]] [v-box :gap "10px" @@ -43,10 +56,13 @@ :children [[swinging-needle-meter :model value :setpoint setpoint -;; :unit "Mw" + :unit "Mw" ;; :min-value 20 +;; :warn-value 35 ;; :max-value 40 -;; :alarm-class "snm-warning" +;; :max-value (aget js/Math "PI") + :tolerance 2 + :alarm-class "snm-warning" :width "350px"] [title :level :level3 :label "Parameters"] [h-box