aboutsummaryrefslogtreecommitdiff
path: root/docs/prototypes/gallery-widget.el
blob: 61cd78a464a5e365a7957339f59c068873409ecf (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
;;; gallery-widget.el --- svg.el renderers for the panel widget gallery -*- lexical-binding: t; -*-

;;; Commentary:
;; Proof-of-concept Emacs target for the panel widget gallery.  The web
;; gallery (panel-widget-gallery.html) is the visual spec; tokens.json is
;; the shared source of truth, and gen_tokens.py emits tokens.el (the
;; `gallery-tokens' alist this file reads).  Same tokens, same geometry,
;; different renderer — that's the reuse model: shared values and shared
;; SVG shapes, per-target code.
;;
;; The first widget is the needle gauge (gallery card 10): a semicircular
;; arc in the wash color, three steel ticks at -60/0/+60 degrees, an amber
;; needle with a soft glow (a real feGaussianBlur underlay, which librsvg —
;; Emacs's own SVG renderer — rasterizes), a gold hub, and a value readout.
;;
;; Usage:
;;   (gallery-widget-needle-gauge 72)              ; => svg dom object
;;   (gallery-widget-svg-string (gallery-widget-needle-gauge 72))
;;   (gallery-widget-write-svg (gallery-widget-needle-gauge 72) "/tmp/g.svg")
;; In a live Emacs buffer:
;;   (insert-image (svg-image (gallery-widget-needle-gauge 72)))

;;; Code:

(require 'svg)
(require 'dom)

(load (expand-file-name "tokens.el"
                        (file-name-directory (or load-file-name buffer-file-name)))
      nil t)

(defun gallery-widget-token (name)
  "Return the color/value string for token NAME from `gallery-tokens'.
Signal an error for an unknown NAME rather than silently returning nil —
a typo'd token must fail loudly, not render black."
  (or (cdr (assq name gallery-tokens))
      (error "Unknown gallery token: %s" name)))

;;; --- needle gauge (gallery card 10) ---

(defconst gallery-widget--gauge-min-deg -60.0
  "Needle angle at value 0, degrees from vertical (negative = left).")
(defconst gallery-widget--gauge-max-deg 60.0
  "Needle angle at value 100.")

(defun gallery-widget--needle-angle (value)
  "Map VALUE (0-100, clamped) onto the gauge's needle angle in degrees."
  (unless (numberp value)
    (error "Gauge value must be a number, got %S" value))
  (let ((v (min 100.0 (max 0.0 (float value)))))
    (+ gallery-widget--gauge-min-deg
       (* (/ v 100.0)
          (- gallery-widget--gauge-max-deg gallery-widget--gauge-min-deg)))))

(defun gallery-widget--polar (cx cy radius angle-deg)
  "Point at RADIUS from (CX . CY) at ANGLE-DEG from vertical, as (X . Y).
Positive angles swing right, matching the gauge's needle travel."
  (let ((rad (* float-pi (/ angle-deg 180.0))))
    (cons (+ cx (* radius (sin rad)))
          (- cy (* radius (cos rad))))))

(defun gallery-widget--fmt (n)
  "Format coordinate N with two decimals for stable, readable SVG output."
  (format "%.2f" n))

(defun gallery-widget--node (svg tag &rest attrs)
  "Append a TAG element with ATTRS plist to SVG and return it."
  (let ((node (dom-node tag
                        (cl-loop for (k v) on attrs by #'cddr
                                 collect (cons (intern (substring (symbol-name k) 1))
                                               v)))))
    (svg--append svg node)
    node))

(defun gallery-widget-needle-gauge (value)
  "Render the gallery's needle gauge at VALUE (0-100) as an svg.el object.
Geometry mirrors the web card: 96px-wide semicircular dial, pivot at its
bottom center, 40px needle sweeping -60..+60 degrees, readout below."
  (let* ((w 96) (h 64) (cx 48.0) (cy 48.0)
         (angle (gallery-widget--needle-angle value))
         (tip (gallery-widget--polar cx cy 40.0 angle))
         (svg (svg-create w h :viewBox (format "0 0 %d %d" w h))))
    ;; glow filter: the SVG equivalent of the web card's box-shadow bloom
    (svg--append
     svg
     (dom-node 'defs nil
               (dom-node 'filter
                         '((id . "gw-glow") (x . "-75%") (y . "-75%")
                           (width . "250%") (height . "250%"))
                         (dom-node 'feGaussianBlur
                                   '((in . "SourceGraphic")
                                     (stdDeviation . "2"))))))
    ;; arc: 2px wash stroke, centerline radius 47 (96px circle, 2px border)
    (gallery-widget--node svg 'path
                          :d "M 1 48 A 47 47 0 0 1 95 48"
                          :fill "none"
                          :stroke (gallery-widget-token 'wash)
                          :stroke-width "2")
    ;; ticks: 8px steel radials at -60 / 0 / +60
    (dolist (deg '(-60.0 0.0 60.0))
      (let ((outer (gallery-widget--polar cx cy 47.0 deg))
            (inner (gallery-widget--polar cx cy 39.0 deg)))
        (gallery-widget--node svg 'line
                              :class "tick"
                              :x1 (gallery-widget--fmt (car inner))
                              :y1 (gallery-widget--fmt (cdr inner))
                              :x2 (gallery-widget--fmt (car outer))
                              :y2 (gallery-widget--fmt (cdr outer))
                              :stroke (gallery-widget-token 'steel)
                              :stroke-width "1.5")))
    ;; needle glow underlay (blurred, translucent), then the crisp needle
    (gallery-widget--node svg 'line
                          :class "needle-glow"
                          :x1 (gallery-widget--fmt cx)
                          :y1 (gallery-widget--fmt cy)
                          :x2 (gallery-widget--fmt (car tip))
                          :y2 (gallery-widget--fmt (cdr tip))
                          :stroke (gallery-widget-token 'gold-hi)
                          :stroke-width "4"
                          :stroke-linecap "round"
                          :opacity "0.55"
                          :filter "url(#gw-glow)")
    (gallery-widget--node svg 'line
                          :class "needle"
                          :x1 (gallery-widget--fmt cx)
                          :y1 (gallery-widget--fmt cy)
                          :x2 (gallery-widget--fmt (car tip))
                          :y2 (gallery-widget--fmt (cdr tip))
                          :stroke (gallery-widget-token 'gold-hi)
                          :stroke-width "2"
                          :stroke-linecap "round")
    ;; hub: half-dome on the dial's bottom edge (the web card clips the
    ;; lower half of its 8px circle; here the dome is drawn directly)
    (gallery-widget--node svg 'path
                          :class "hub"
                          :d "M 44 48 A 4 4 0 0 1 52 48 Z"
                          :fill (gallery-widget-token 'gold))
    ;; value readout, matching the web card's cream bold figure
    (svg-text svg (format "%d%%" (round value))
              :x 48 :y 62
              :fill (gallery-widget-token 'cream)
              ;; the token's CSS font stack uses double quotes, which would
              ;; break the XML attribute — SVG accepts single-quoted names
              :font-family (replace-regexp-in-string
                            "\"" "'" (gallery-widget-token 'mono))
              :font-size "12"
              :font-weight "700"
              :text-anchor "middle")
    svg))

;;; --- output helpers ---

(defun gallery-widget-svg-string (svg)
  "Return SVG (an svg.el dom object) serialized as an XML string."
  (with-temp-buffer
    (svg-print svg)
    (buffer-string)))

(defun gallery-widget-write-svg (svg file)
  "Write SVG to FILE and return FILE."
  (with-temp-file file
    (svg-print svg))
  file)

(provide 'gallery-widget)
;;; gallery-widget.el ends here