├── README.md
├── TODO.txt
├── demo.html
└── www
├── adapter.js
├── arena.js
├── fonts
├── inter-italic.var.woff2
├── inter-roman.var.woff2
├── opensans-italic.var.woff2
├── opensans-italic.woff2
├── opensans-regular.woff2
└── opensans.var.woff2
├── glue.js
├── icons
├── bootstrap-icons.woff2
├── fa-brands-400.woff2
├── fa-regular-400.woff2
├── fa-solid-900.woff2
├── ionicons.woff2
├── la-brands-400.woff2
├── la-regular-400.woff2
├── la-solid-900.woff2
├── material-icons-outlined.woff2
├── material-icons-round.woff2
├── material-icons-sharp.woff2
├── materialdesignicons.woff2
├── remixicon.woff2
└── tabler-icons.woff2
├── ui.js
├── ui_charts.js
├── ui_grid.js
├── ui_grid0.js
├── ui_grid_temp.js
├── ui_nav.js
├── ui_nav_todo.js
├── ui_validation.js
└── webrtc.js
/README.md:
--------------------------------------------------------------------------------
1 | NOTE: This is in the works! Check out the [demo] to see how we're progressing.
2 |
3 | [demo]: https://allegory-software.github.io/canvas-ui/demo.html
4 |
5 | # :computer_mouse: Canvas UI
6 |
7 | UI library in JavaScript: canvas-drawn, no dependencies, IMGUI API,
8 | built-in remote screen sharing.
9 |
10 | ## Highlights
11 |
12 | * virtual editable tree-grid:
13 | * scrolls 1 million records @ 60 fps
14 | * loads, sorts and filters 100K records instantly
15 | * grouping by multiple columns
16 | * bulk offline changes
17 | * master-detail with client-side and server-side detail filtering
18 | * data-bound widgets for data entry: editbox, dropdown, etc.
19 | * split-pane layouting widgets: tabs list and splitter.
20 | * p2p screen-sharing, needs only 2-5 Mbps to get 60 fps on a relatively dense UI.
21 | * UI designer for templates.
22 | * flex layouting.
23 | * popup positioning.
24 | * z-layering.
25 | * styling system for colors and spacing better than CSS.
26 | * animations better than CSS.
27 | * IMGUI, so stateless, no DOM updating or diff'ing.
28 | * no dependencies, no build system, small, hackable code base.
29 | * possible to add new layouting algorithms.
30 |
31 | ## Why canvas-drawn?
32 |
33 | Sidestepping the DOM and CSS allows fixing all the [problems with the web][1],
34 | at once, and opens up opportunities to create better models for UIs in general.
35 | For instance, the IMGUI approach is reactive by design, but implementing a
36 | reactive system on top of the DOM is complicated, slow and full of trade-offs.
37 | Composable, smart styling is also harder with CSS than with JS where anything
38 | is dynamic (eg. the text color is inverted automatically based on background
39 | lightness). Remote screen sharing would also be hard to implement efficiently
40 | on top of a DOM, but it comes almost for free with the command array model.
41 | Virtual widgets like a data grid that has to display a million rows is harder
42 | to implement by moving DOM elements around when you could just draw the visible
43 | row range at an offset.
44 |
45 | [1]: https://github.com/allegory-software/x-widgets/blob/main/WHY-WEB-SUCKS.md
46 |
47 | ## Limitations of canvas-drawn
48 |
49 | With canvas, you have to reimplement the web from scratch: layouting, styling,
50 | animations, box model, etc. Most of these are easy and you can even
51 | do a better job with not a lot of code. But some are infeasible because
52 | of missing APIs. Text APIs in particular are very crude
53 | in the browser, so things like BiDi (UAX#9), Unicode line breaking (UAX#14),
54 | dictionary-based word wrapping, underlines that break under letter stems,
55 | all those things are hard, and we'll just have to do without.
56 |
--------------------------------------------------------------------------------
/TODO.txt:
--------------------------------------------------------------------------------
1 | LEGEND
2 | ------------------------------------------------------------------------------
3 | * means done
4 | - means TODO
5 |
6 |
7 | REFACTORINGS FROM IMTUI
8 | ------------------------------------------------------------------------------
9 | S -> BOX_CT_S
10 | NEXT_EXT_I -> BOX_CT_NEXT_EXT_I
11 | box_widget box_hit -> use hit_box
12 |
13 |
14 | WIDGET INTER-DEPENDENCY PROBLEM
15 | ------------------------------------------------------------------------------
16 | - solve the IMGUI achilles heel: widgets depending on the state of
17 | other widgets that appear later in the frame.
18 |
19 | * cmd record buffers:
20 | * use case: update widget state before the widget appears in the frame
21 | * alt: keepalive update callback.
22 | * use case: dynamic child order in flexbox.
23 | * alt: TODO: `order` attribute for flex children.
24 | * use case: dynamic draw order.
25 | * alt: layers, but they only work with popups currently.
26 | * con: they break when the recording contains command index refs inside.
27 | * fix: use relative indexes.
28 | * but then we have to know to have all refs inside the recording.
29 | * fix: use ct stack instead of storing ct_i.
30 | * forced re-layouting without redrawing with ui.relayout():
31 | * pro: solves the later-in-the-frame problem by not solving it.
32 | * con: doubles the layout time so we can't do it on mouse move or animations.
33 | * keepalive update callbacks:
34 | * pro: solves the later-in-the-frame problem.
35 | * con: must split updating and building phases in code, which prevents
36 | the updating part to create local state for the building part to use.
37 |
38 |
39 | BUGS UI
40 | ------------------------------------------------------------------------------
41 | - force-scroll with popups double-translate bug (repro: h-scroll a grid and
42 | drag a gcol back to the col header)
43 | - text hit clip (repro: hover to the right of the first grid gcol)
44 |
45 | TODO GRID
46 | ------------------------------------------------------------------------------
47 |
48 | TODO UI
49 | ------------------------------------------------------------------------------
50 | - scroll-to-view-rect cascade to all scrollboxes
51 |
52 | - remove scopes and automatic container scopes and replace them with
53 | begin_font() end_font()
54 | begin_bold() end_bold()
55 | - careful with begin_rec()
56 | - careful with frames
57 |
58 |
59 | BUGS
60 | ------------------------------------------------------------------------------
61 | - set_cursor() must be set in draw? hittest? where?
62 | - toolbox constrain bug
63 | - toolbox resize bug
64 |
65 | - bb on text
66 | - bb on scrollbox?
67 | - negative margins?
68 |
69 | - filter out available APIs and state for each phase
70 | - draw phase should not be able to access state
71 | - etc.
72 | - make drawing phase stateless
73 | - empty context in drawing phase!
74 |
75 | ------------------------------------------------------------------------------
76 |
77 | GOALS
78 |
79 | * drag & drop UI designer
80 | - templates with conditionals, repeats and sub-templates
81 | - template-based widgets
82 | * IMGUI with popups and layouting
83 | * remote screen sharing
84 | * with input routing
85 | * with client-side themes
86 |
87 | IMGUI
88 |
89 | - drawing
90 | * global z-index
91 | * local z-index
92 | * text
93 | * bg-color
94 | * border
95 | * box-shadow
96 | * images (incl. svg)
97 | - layouting
98 | * min width & height
99 | * flexbox fr stretch
100 | * flexbox min-w/h grow
101 | * flexbox align
102 | * flexbox gap
103 | * stack
104 | * padding
105 | * margin
106 | * text min-w/h
107 | * popup layouting
108 | * word-wrapping
109 | - flex direction
110 | - flex item order
111 | - grid fr stretch
112 | - grid min-w/h grow
113 | - grid align
114 | - grid gap
115 | - transition animations
116 | - mouse
117 | * hit-test rect
118 | * hit state
119 | * hit parents
120 | * capturing
121 | * capture state
122 | * drag & drop
123 | - keyboard
124 | * focused state
125 | - tab focusing
126 | - event bubbling
127 | - text selection
128 | - drag-select with mouse
129 | - text(area) input
130 | * put it behind the canvas with correct width & height
131 | - route keyboard events to it
132 | * redraw it on canvas with offset and selection
133 | - reuse a single global input object for all inputs
134 |
135 | CHALLENGES
136 |
137 | * input on a canvas
138 | - textarea on a canvas
139 | * webgl2 canvas on a 2d canvas
140 | * word wrapping for horizontal text
141 | - rich text on a canvas (inline box model)
142 |
143 | PROS vs WEB
144 | - stateless i.e. reactive / IMGUI
145 | - but with layouting and z-index and no frame lag
146 | - simpler and far more productive layouting and styling models
147 | - global z-index, allowing for:
148 | - relatively-positioned but painted-last popups
149 | - painted-last focus ring
150 | - transition animations for in-layout objects, allowing for:
151 | - animated element moving, drag/drop and selection under/overlays
152 | - streamable and replayable rendering, for sreen-sharing and remote interaction
153 | - fast and memory-efficient with low gc pressure between frames.
154 | - complex widgets built with a combination of box-stacking and custom-drawing.
155 |
156 | CONS vs WEB
157 | - must reimplement:
158 | * scrollbox
159 | - text selection
160 | - clipboard ops
161 | * word-wrapping
162 | - inline layouting with:
163 | - u, b, i, strike, sub, sup, h1..6, p, br, hr, float
164 | - no fancy typography:
165 | - no smart word-wrapping (Thai, auto-hyphenation)
166 | - no BiDi
167 | - no smart underline (interrupted by letter descenders)
168 |
--------------------------------------------------------------------------------
/demo.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
20 |
30 |
31 |
1131 |
1132 |
1133 |
--------------------------------------------------------------------------------
/www/arena.js:
--------------------------------------------------------------------------------
1 |
2 | // arena for small (< 100 elements) arrays, maps and sets to keep gc low.
3 | // note: for larger arrays the heap is far more efficient, esp. if all the
4 | // elements have the same type.
5 |
6 | arena = function() {
7 |
8 | let arena = {}
9 | let a = []
10 | let alen = 0
11 |
12 | arena.clear = function() {
13 | for (let i = 0; i < alen; i++)
14 | a[i] = undefined
15 | alen = 0
16 | }
17 |
18 | arena.add = function(v) {
19 | a[alen++] = v
20 | return alen-1
21 | }
22 |
23 | arena.get = function(i) {
24 | return a[i]
25 | }
26 |
27 | arena.arr = function(len, est_len) {
28 | let i = alen
29 | let cap = nextpow2(max(len, est_len ?? 0))
30 | this.add(len)
31 | this.add(cap)
32 | for (let j = 0; j < cap; j++)
33 | this.add(undefined)
34 | return i
35 | }
36 |
37 | arena.arr_len = function(i) { return a[i] }
38 | arena.arr_capacity = function(i) { return a[i+1] }
39 |
40 | arena.arr_setlen = function(i, len, est_len) {
41 | let len0 = a[i]
42 | let cap0 = a[i+1]
43 | if (len > cap0) {
44 | let i0 = i
45 | if (i0+2+len0 == alen) { // last in arena, expand arena
46 | let cap = nextpow2(max(len, est_len ?? 0))
47 | alen += cap - cap0 // expand arena
48 | a[i+1] = cap // update capacity
49 | } else {
50 | i = this.arr(len, est_len) // realloc
51 | for (let j = 0; j < len0; j++) // copy contents
52 | a[i+2+j] = a[i0+2+j]
53 | return i
54 | }
55 | } else if (len < len0) { // shrink
56 | for (let j = 1 + len0; j > len; j--) // clear surplus
57 | a[i+j] = undefined
58 | }
59 | a[i] = len
60 | return i
61 | }
62 |
63 | arena.arr_expand = function(i, n) {
64 | return this.arr_setlen(i, this.arr_len(i) + n)
65 | }
66 |
67 | arena.arr_get = function(i, at) {
68 | return a[i+2+at]
69 | }
70 |
71 | arena.arr_find = function(i, v) {
72 | let n = this.arr_len(i)
73 | if (n > 50) pr('arr_find slow', n)
74 | for (let j = 0; j < n; j++)
75 | if (a[i+2+j] === v)
76 | return j
77 | }
78 |
79 | let default_cmp = function(a, b) { return a - b; }
80 |
81 | arena.arr_binsearch = function(i, v, cmp) {
82 | cmp ??= default_cmp
83 | let m = 0
84 | let n = this.arr_len(i) - 1
85 | while (m <= n) {
86 | let k = (n + m) >> 1
87 | let r = cmp(v, this.arr_get(i, k))
88 | if (r > 0) {
89 | m = k + 1
90 | } else if(r < 0) {
91 | n = k - 1
92 | } else {
93 | return k
94 | }
95 | }
96 | return ~m
97 | }
98 |
99 | function partition_sort(low, high, cmp) {
100 | if (low >= high) return
101 |
102 | cmp ??= default_cmp
103 | let pivot = a[high]
104 | let left = low
105 |
106 | for (let right = low; right < high; right++) {
107 | if (cmp(a[right], pivot) < 0) {
108 | [a[left], a[right]] = [a[right], a[left]] // swap
109 | left++
110 | }
111 | }
112 | [a[left], a[high]] = [a[high], a[left]] // swap pivot into place
113 |
114 | partition_sort(low, left - 1, cmp)
115 | partition_sort(left + 1, high, cmp)
116 | }
117 | arena.arr_sort = function(i, cmp) {
118 | partition_sort(i+2, i+2+this.arr_len(i)-1, cmp)
119 | }
120 |
121 | arena.arr_set = function(i, at, v) {
122 | assert(at >= 0, 'invalid index ', at)
123 | assert(at < this.arr_len(i), 'invalid index ', at)
124 | a[i+2+at] = v
125 | }
126 |
127 | arena.arr_push = function(i, ...args) {
128 | let len0 = this.arr_len(i)
129 | i = this.arr_expand(i, args.length)
130 | for (let j = 0; j < args.length; j++)
131 | this.arr_set(i, len0 + j, args[j])
132 | return i
133 | }
134 |
135 | arena.map = function(est_len) {
136 | return this.arr(0, (est_len ?? 0) * 2)
137 | }
138 |
139 | arena.map_size = function(i) {
140 | return this.arr_len(i) / 2
141 | }
142 |
143 | arena.map_find = function(i, k) {
144 | let n = this.arr_len(i)
145 | if (n > 100) pr('map_find slow', n)
146 | for (let j = 0; j < n; j += 2) {
147 | if (this.arr_get(i, j) === k)
148 | return j
149 | }
150 | }
151 |
152 | arena.map_get = function(i, k) {
153 | let j = this.map_find(i, k)
154 | if (j == null) return
155 | return this.arr_get(i, j+1)
156 | }
157 |
158 | arena.map_set = function(i, k, v) {
159 | let j = this.map_find(i, k)
160 | if (j != null) {
161 | this.arr_set(i, j+1, v)
162 | } else {
163 | i = this.arr_expand(i, 2)
164 | let n = this.arr_len(i)
165 | this.arr_set(i, n-2, k)
166 | this.arr_set(i, n-1, v)
167 | }
168 | return i
169 | }
170 |
171 | arena.map_keys = function*(i) {
172 | let n = this.arr_len(i)
173 | for (let j = 0; j < n; j += 2)
174 | yield this.arr_get(i, j)
175 | }
176 |
177 | arena.map_values = function*(i) {
178 | let n = this.arr_len(i)
179 | for (let j = 0; j < n; j += 2)
180 | yield this.arr_get(i, j+1)
181 | }
182 |
183 | arena.map_entries = function*(i) {
184 | let n = this.arr_len(i)
185 | for (let j = 0; j < n; j += 2)
186 | yield [this.arr_get(i, j), this.arr_get(i, j+1)]
187 | }
188 |
189 | arena.set = function(est_len) {
190 | return this.arr(0, est_len)
191 | }
192 |
193 | arena.set_has = function(i, v) {
194 | return this.arr_find(i, v) != null
195 | }
196 |
197 | arena.set_add = function(i, v) {
198 | return this.set_has(i, v) ? i : this.arr_push(i, v)
199 | }
200 |
201 | arena.set_size = function(i) {
202 | return this.arr_len(i)
203 | }
204 |
205 | return arena
206 | }
207 |
208 | arena_test = function() {
209 |
210 | ar = arena()
211 |
212 | // simple values
213 | let t = {}
214 | let ti = ar.add(t)
215 | assert(ar.get(ti) == t)
216 |
217 | // dyn arrays
218 | a = ar.arr(5, 10)
219 | assert(ar.arr_len(a) == 5)
220 | for (let i = 0; i < 5; i++)
221 | ar.arr_set(a, i, i)
222 | for (let i = 100; i >= 1; i--)
223 | a = ar.arr_push(a, i)
224 | ar.arr_sort(a)
225 | for (let i = 1, n = ar.arr_len(a); i < n; i++)
226 | assert(ar.arr_get(a, i-1) <= ar.arr_get(a, i))
227 | assert(ar.arr_len(a) == 5 + 100)
228 | assert(ar.arr_capacity(a) == nextpow2(100))
229 | assert(a == 1) // not reallocated
230 | assert(ar.arr_binsearch(a, 50) == 54)
231 |
232 | // maps
233 | m = ar.map()
234 | m = ar.map_set(m, 'a', 3)
235 | m = ar.map_set(m, 'b', 5)
236 | m = ar.map_set(m, 'a', 7)
237 | assert(ar.map_size(m) == 2)
238 | {
239 | let a = []
240 | let b = ['a', 7, 'b', 5]
241 | for (let [k, v] of ar.map_entries(m)) a.push(k, v)
242 | for (let i = 0; i < 4; i++) assert(a[i] == b[i])
243 | }
244 |
245 | // sets
246 | s = ar.set()
247 | s = ar.set_add(s, 'a')
248 | s = ar.set_add(s, 'b')
249 | s = ar.set_add(s, 'a')
250 | assert( ar.set_size(s) == 2)
251 | assert( ar.set_has(s, 'a'))
252 | assert( ar.set_has(s, 'b'))
253 | assert(!ar.set_has(s, 'c'))
254 |
255 | pr('arena tests passed')
256 | }
257 |
258 | if (0)
259 | arena_test()
260 |
261 | function benchmark() {
262 | let n = 1000 // array size; 4mil/sec on i3-4170
263 | let a = []; for (let i = 0; i < n; i++) a[i] = i
264 | let m = map(); for (let i = 0; i < n; i++) m.set(i, i)
265 | let v = n - 2
266 |
267 | console.time('arr')
268 | assert(a.indexOf(v) == v) // O(n)
269 | console.timeEnd('arr')
270 |
271 | console.time('map')
272 | assert(m.get(v) == v) // O(1)
273 | console.timeEnd('map')
274 | }
275 |
276 | if (0)
277 | for (let i = 1; i <= 3; i++)
278 | benchmark()
279 |
280 |
--------------------------------------------------------------------------------
/www/fonts/inter-italic.var.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/allegory-software/canvas-ui/47ee22f298de43720ca2c97ec433f6cbe57783d2/www/fonts/inter-italic.var.woff2
--------------------------------------------------------------------------------
/www/fonts/inter-roman.var.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/allegory-software/canvas-ui/47ee22f298de43720ca2c97ec433f6cbe57783d2/www/fonts/inter-roman.var.woff2
--------------------------------------------------------------------------------
/www/fonts/opensans-italic.var.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/allegory-software/canvas-ui/47ee22f298de43720ca2c97ec433f6cbe57783d2/www/fonts/opensans-italic.var.woff2
--------------------------------------------------------------------------------
/www/fonts/opensans-italic.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/allegory-software/canvas-ui/47ee22f298de43720ca2c97ec433f6cbe57783d2/www/fonts/opensans-italic.woff2
--------------------------------------------------------------------------------
/www/fonts/opensans-regular.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/allegory-software/canvas-ui/47ee22f298de43720ca2c97ec433f6cbe57783d2/www/fonts/opensans-regular.woff2
--------------------------------------------------------------------------------
/www/fonts/opensans.var.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/allegory-software/canvas-ui/47ee22f298de43720ca2c97ec433f6cbe57783d2/www/fonts/opensans.var.woff2
--------------------------------------------------------------------------------
/www/icons/bootstrap-icons.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/allegory-software/canvas-ui/47ee22f298de43720ca2c97ec433f6cbe57783d2/www/icons/bootstrap-icons.woff2
--------------------------------------------------------------------------------
/www/icons/fa-brands-400.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/allegory-software/canvas-ui/47ee22f298de43720ca2c97ec433f6cbe57783d2/www/icons/fa-brands-400.woff2
--------------------------------------------------------------------------------
/www/icons/fa-regular-400.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/allegory-software/canvas-ui/47ee22f298de43720ca2c97ec433f6cbe57783d2/www/icons/fa-regular-400.woff2
--------------------------------------------------------------------------------
/www/icons/fa-solid-900.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/allegory-software/canvas-ui/47ee22f298de43720ca2c97ec433f6cbe57783d2/www/icons/fa-solid-900.woff2
--------------------------------------------------------------------------------
/www/icons/ionicons.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/allegory-software/canvas-ui/47ee22f298de43720ca2c97ec433f6cbe57783d2/www/icons/ionicons.woff2
--------------------------------------------------------------------------------
/www/icons/la-brands-400.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/allegory-software/canvas-ui/47ee22f298de43720ca2c97ec433f6cbe57783d2/www/icons/la-brands-400.woff2
--------------------------------------------------------------------------------
/www/icons/la-regular-400.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/allegory-software/canvas-ui/47ee22f298de43720ca2c97ec433f6cbe57783d2/www/icons/la-regular-400.woff2
--------------------------------------------------------------------------------
/www/icons/la-solid-900.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/allegory-software/canvas-ui/47ee22f298de43720ca2c97ec433f6cbe57783d2/www/icons/la-solid-900.woff2
--------------------------------------------------------------------------------
/www/icons/material-icons-outlined.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/allegory-software/canvas-ui/47ee22f298de43720ca2c97ec433f6cbe57783d2/www/icons/material-icons-outlined.woff2
--------------------------------------------------------------------------------
/www/icons/material-icons-round.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/allegory-software/canvas-ui/47ee22f298de43720ca2c97ec433f6cbe57783d2/www/icons/material-icons-round.woff2
--------------------------------------------------------------------------------
/www/icons/material-icons-sharp.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/allegory-software/canvas-ui/47ee22f298de43720ca2c97ec433f6cbe57783d2/www/icons/material-icons-sharp.woff2
--------------------------------------------------------------------------------
/www/icons/materialdesignicons.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/allegory-software/canvas-ui/47ee22f298de43720ca2c97ec433f6cbe57783d2/www/icons/materialdesignicons.woff2
--------------------------------------------------------------------------------
/www/icons/remixicon.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/allegory-software/canvas-ui/47ee22f298de43720ca2c97ec433f6cbe57783d2/www/icons/remixicon.woff2
--------------------------------------------------------------------------------
/www/icons/tabler-icons.woff2:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/allegory-software/canvas-ui/47ee22f298de43720ca2c97ec433f6cbe57783d2/www/icons/tabler-icons.woff2
--------------------------------------------------------------------------------
/www/ui_charts.js:
--------------------------------------------------------------------------------
1 | /*
2 |
3 | Chart widgets.
4 | Written by Cosmin Apreutesei. Public Domain.
5 |
6 | You must load first:
7 |
8 | nav.js
9 |
10 | WIDGETS
11 |
12 |
13 |
14 | SHAPES
15 |
16 | stack
17 | pie
18 | line line_dots area area_dots
19 | column
20 | bar
21 |
22 | */
23 |
24 | (function () {
25 | "use strict"
26 | let G = window
27 |
28 |
29 | css('.chart', 'S shrinks p clip v')
30 |
31 | css('.chart-header', '', `
32 | padding-left: 40px;
33 | `)
34 |
35 | css('.chart-split', 'S h')
36 |
37 | css('.chart-view', 'S rel h')
38 |
39 | css('.chart-legend', 'g-h arrow gap-y-05 gap-x', `
40 | grid-template-columns: 1em 1fr;
41 | align-content: end;
42 | margin-left: 2em;
43 | `)
44 |
45 | css('.chart-legend-bullet', 'round', `
46 | width : .8em;
47 | height: .8em;
48 | `)
49 |
50 | css('.chart-legend-percent', 'bold self-h-r')
51 |
52 | css('.chart-canvas', 'abs', `
53 | top: 0;
54 | left: 0;
55 | `)
56 |
57 | css('.chart-tooltip', 'click-through')
58 |
59 | css('.chart-tooltip > .tooltip-body', 'tight')
60 |
61 | css('.chart-tooltip-label', 'g-h gap-x-2', `
62 | grid-template-columns: repeat(2, auto);
63 | justify-items: start;
64 | `)
65 |
66 | css('.chart-stack', 'S v')
67 | css('.chart-stack-slice-ct', 'rel h')
68 |
69 | css('.chart-stack-slice', 'm-r', `
70 | width: 50px;
71 | `)
72 |
73 | css('.chart[shape=pie] .chart-split', 'h-c')
74 |
75 | css('.chart[shape=pie] .chart-view', '', `
76 | max-width : 15em;
77 | min-height: 15em;
78 | `)
79 |
80 | css('.chart[shape=pie] .chart-legend', '', `
81 | grid-template-columns: 1em 5fr 1fr;
82 | align-content: center;
83 | `)
84 |
85 | css('.chart[shape=lines] .chart-view', '', `
86 | min-height: 10em;
87 | `)
88 |
89 | css('.chart[shape=lines] .chart-legend', 'm-l-2 m-b-4')
90 |
91 | css('.chart-pie', 'abs')
92 |
93 | css('.chart-pie-selected', 'abs round')
94 |
95 | css('.chart-pie-percents', 'abs click-through', `
96 | top: 0;
97 | left: 0;
98 | `)
99 |
100 | css('.chart-pie-label', 'abs on-dark')
101 |
102 | G.chart = component('chart', 'Input', function(e) {
103 |
104 | e.class('chart unframe')
105 | e.make_disablable()
106 |
107 | // data binding -----------------------------------------------------------
108 |
109 | make_nav_data_widget_extend_before(e)
110 | e.make_nav_data_widget()
111 |
112 | let nav
113 | e.on('bind_nav', function(nav1, on) {
114 | nav = on ? nav1 : null
115 | update_model()
116 | })
117 |
118 | function nav_changed(nav1) {
119 | if (nav1 != nav) return
120 | update_model()
121 | }
122 |
123 | e.listen('reset' , nav_changed)
124 | e.listen('rows_changed' , nav_changed)
125 | e.listen('cell_state_changed' , nav_changed)
126 | e.listen('col_vals_changed' , nav_changed)
127 | e.listen('col_attr_changed' , nav_changed)
128 |
129 | function update_view() {
130 | e.update()
131 | }
132 |
133 | e.listen('layout_changed', update_view)
134 | e.on('resize', update_view)
135 |
136 | // config -----------------------------------------------------------------
137 |
138 | e.prop('split_cols' , {type: 'col'})
139 | e.prop('group_cols' , {type: 'col'})
140 | e.prop('sum_cols' , {type: 'col'})
141 | e.prop('min_sum' , {type: 'number'})
142 | e.prop('max_sum' , {type: 'number'})
143 | e.prop('sum_step' , {type: 'number'})
144 | e.prop('min_val' , {type: 'number'})
145 | e.prop('max_val' , {type: 'number'})
146 | e.prop('other_threshold', {type: 'number', default: .05, decimals: null})
147 | e.prop('other_text' , {default: 'Other', slot: 'lang'})
148 | e.prop('shape', {
149 | type: 'enum',
150 | enum_values: ['pie', 'stack', 'line', 'line_dots', 'area', 'area_dots',
151 | 'column', 'bar', 'stackbar', 'bubble', 'scatter'],
152 | default: 'pie',
153 | })
154 | e.prop('nolegend', {type: 'bool', default: false})
155 | e.prop('text', {slot: 'lang'})
156 |
157 | e.set_split_cols = update_model
158 | e.set_group_cols = update_model
159 | e.set_sum_cols = update_model
160 | e.set_min_val = update_model
161 | e.set_max_val = update_model
162 | e.set_min_sum = update_model
163 | e.set_max_sum = update_model
164 | e.set_other_threshold = update_model
165 | e.set_other_text = update_model
166 |
167 | // model ------------------------------------------------------------------
168 |
169 | function compute_step_and_range(wanted_n, min_sum, max_sum, scale_base, scales, decimals) {
170 | scale_base = scale_base || 10
171 | scales = scales || [1, 2, 2.5, 5]
172 | let d = max_sum - min_sum
173 | let min_scale_exp = floor((d ? logbase(d, scale_base) : 0) - 2)
174 | let max_scale_exp = floor((d ? logbase(d, scale_base) : 0) + 2)
175 | let n0, step
176 | let step_multiple = decimals != null ? 10**(-decimals) : null
177 | for (let scale_exp = min_scale_exp; scale_exp <= max_scale_exp; scale_exp++) {
178 | for (let scale of scales) {
179 | let step1 = scale_base ** scale_exp * scale
180 | let n = d / step1
181 | if (n0 == null || abs(n - wanted_n) < n0) {
182 | if (step_multiple == null || floor(step1 / step_multiple) == step1 / step_multiple) {
183 | n0 = n
184 | step = step1
185 | }
186 | }
187 | }
188 | }
189 | min_sum = floor (min_sum / step) * step
190 | max_sum = ceil (max_sum / step) * step
191 | return [step, min_sum, max_sum]
192 | }
193 |
194 | function compute_sums(sum_def, row_group) {
195 | let agg = sum_def.agg
196 | let fi = sum_def.field.val_index
197 | let n
198 | let i = 0
199 | if (agg == 'sum' || agg == 'avg') {
200 | n = 0
201 | for (let row of row_group)
202 | if (row[fi] != null) {
203 | n += row[fi]
204 | i++
205 | }
206 | if (i && agg == 'avg')
207 | n /= i
208 | } else if (agg == 'min') {
209 | n = 1/0
210 | for (let row of row_group)
211 | if (row[fi] != null) {
212 | n = min(n, row[fi])
213 | i++
214 | }
215 | } else if (agg == 'max') {
216 | n = -1/0
217 | for (let row of row_group)
218 | if (row[fi] != null) {
219 | n = max(n, row[fi])
220 | i++
221 | }
222 | }
223 | if (i)
224 | row_group.sum = n
225 | }
226 |
227 | let sum_defs, group_cols, all_split_groups
228 |
229 | function reset_model() {
230 | sum_defs = null
231 | group_cols = null
232 | all_split_groups = null
233 | }
234 |
235 | function try_update_model() {
236 |
237 | if (!nav) return
238 | if (e.sum_cols == null) return
239 | if (e.group_cols == null) return
240 |
241 | // parse `sum_cols`: `COL1[/AVG|MIN|MAX|SUM][..COL2]`.
242 | // the `..` operator ties two line graphs together into a closed shape.
243 | sum_defs = []
244 | let tied_back = false
245 | for (let col of e.sum_cols.replaceAll('..', '.. ').words()) {
246 | let tied = col.includes('..')
247 | col = col.replace('..', '')
248 | let agg = 'avg'; col.replace(/\/[^\/]+$/, k => { agg = k; return '' })
249 | let fld = nav.optfld(col)
250 | if (fld) // it's enough for one sum_col to be valid.
251 | sum_defs.push({field: fld, tied: tied, tied_back: tied_back, agg: agg})
252 | tied_back = fld ? tied : false
253 | }
254 | if (!sum_defs.length)
255 | return
256 |
257 | group_cols = words(e.group_cols).map(s => s.match(/^[^/]+/)[0])
258 | if (!group_cols.length)
259 | return
260 |
261 | if (!nav.rows.length)
262 | return true
263 |
264 | // group rows and compute the sums on each group.
265 | // all_split_groups : [split_group1, ...] split groups (cgs) => superimposed graphs.
266 | // split_group : [row_group1, ...] row groups (xgs) => one graph of sum points.
267 | // row_group : [row1, ...] each row group => one sum point.
268 | all_split_groups = []
269 | for (let sum_def of sum_defs) {
270 |
271 | let split_groups = nav.row_groups({
272 | col_groups : catany('>', e.split_cols, e.group_cols),
273 | rows : nav.rows,
274 | })
275 |
276 | if (!e.split_cols)
277 | split_groups = [split_groups]
278 |
279 | for (let split_group of split_groups) {
280 |
281 | for (let row_group of split_group)
282 | compute_sums(sum_def, row_group)
283 |
284 | split_group.tied_back = sum_def.tied_back
285 | split_group.tied = sum_def.tied
286 |
287 | split_group.sum_def = sum_def
288 | }
289 |
290 | all_split_groups.extend(split_groups)
291 | }
292 |
293 | e.g = all_split_groups // for inspecting the model
294 |
295 | return true
296 | }
297 |
298 | function update_model() {
299 | reset_model()
300 | if (!try_update_model())
301 | reset_model()
302 | e.update({model: true})
303 | }
304 |
305 | // compute label for a sum point.
306 | function sum_label(cls, label, sum) {
307 | let sum_fld = sum_defs[0].field
308 | let a = []
309 | if (label)
310 | a.push(label)
311 | a.push(nav.draw_val(null, sum_fld, sum))
312 | return a.join_nodes(tag('br'), cls && div({class: cls}))
313 | }
314 |
315 | function val_text(val) {
316 | let val_fld = nav.fld(group_cols[0]) // TODO: only works for single-col groups!
317 | return nav.draw_val(null, val_fld, val)
318 | }
319 |
320 | function pie_slices() {
321 |
322 | if (!all_split_groups)
323 | return
324 |
325 | let groups = all_split_groups[0]
326 |
327 | let slices = []
328 | slices.total = 0
329 | for (let group of groups) {
330 | let slice = {}
331 | let sum = group.sum
332 | slice.sum = sum
333 | slice.label = group.label
334 | slices.push(slice)
335 | slices.total += sum
336 | slices.key_cols = group.key_cols
337 | }
338 |
339 | // sum small slices into a single "other" slice.
340 | let big_slices = []
341 | let other_slice
342 | for (let slice of slices) {
343 | slice.size = slice.sum / slices.total
344 | if (slice.size < e.other_threshold) {
345 | other_slice = other_slice || {sum: 0}
346 | other_slice.sum += slice.sum
347 | } else
348 | big_slices.push(slice)
349 | }
350 | if (other_slice) {
351 | other_slice.size = other_slice.sum / slices.total
352 | other_slice.label = e.other_text
353 | big_slices.push(other_slice)
354 | }
355 |
356 | for (let slice of big_slices)
357 | slice.percent = (slice.size * 100).dec(0) + '%'
358 |
359 | big_slices.key_cols_label = nav.fldlabels(slices.key_cols).join_nodes(' / ')
360 | big_slices.sum_field = groups.sum_def.field
361 |
362 | return big_slices
363 | }
364 |
365 | // view -------------------------------------------------------------------
366 |
367 | e.header = div({class: 'chart-header'})
368 | e.view = div({class: 'chart-view'})
369 | e.legend = div({class: 'chart-legend'})
370 | e.add(e.header, div({class: 'chart-split'}, e.view, e.legend))
371 |
372 | e.set_nolegend = function(v) {
373 | e.legend.hide(v)
374 | }
375 |
376 | // shape dispatcher
377 |
378 | let renderer = obj() // {shape->cons()}
379 | let do_update
380 | let do_measure
381 | let do_position
382 | let pointermove = noop
383 |
384 | let shape
385 | e.on_update(function(opt) {
386 | e.header.set(e.text)
387 | if (shape != e.shape) {
388 | if (tt) {
389 | tt.del()
390 | tt = null
391 | }
392 | pointermove = noop
393 | shape = e.shape
394 | let init_renderer = renderer[shape]
395 | if (init_renderer)
396 | init_renderer()
397 | }
398 | if (do_update)
399 | do_update(opt)
400 | })
401 |
402 | let view_w, view_h, view_css, font
403 |
404 | e.on_measure(function() {
405 | let r = e.view.rect()
406 | view_w = floor(r.w)
407 | view_h = floor(r.h)
408 | view_css = e.view.css()
409 | font = view_css['font-size'] + ' ' + view_css.font
410 | if (do_measure)
411 | do_measure()
412 | })
413 |
414 | e.on_position(function() {
415 | if (do_position)
416 | do_position()
417 | })
418 |
419 | e.on('pointermove' , function(...args) { pointermove(...args) })
420 | e.on('pointerleave', function(...args) { pointermove(...args) })
421 |
422 | // common functionality
423 |
424 | function split_color(i, n, alpha) {
425 | return hsl_to_rgb(((i / n) * 360 - 120) % 360, .8, .6, alpha)
426 | }
427 |
428 | let legend_hit_slice
429 |
430 | function update_legend_slices(slices) {
431 | let i = 0
432 | e.legend.clear()
433 | if (e.nolegend)
434 | return
435 | for (let slice of slices) {
436 | let color = split_color(i, slices.length)
437 | let bullet = div({
438 | class: 'chart-legend-bullet',
439 | style: 'background-color: '+color,
440 | })
441 | let label = div({class: 'chart-legend-label'}, slice.label)
442 | let percent = div({class: 'chart-legend-percent'}, slice.percent)
443 | e.legend.add(bullet, label, percent)
444 | label.on('pointerenter', function() {
445 | legend_hit_slice = slice
446 | e.update()
447 | })
448 | label.on('pointerleave', function() {
449 | legend_hit_slice = null
450 | e.update()
451 | })
452 | i++
453 | }
454 | }
455 |
456 | let legend_hit_cg
457 |
458 | function update_legend_split_groups() {
459 | let cgi = 0
460 | e.legend.clear()
461 | if (e.nolegend)
462 | return
463 | let groups = all_split_groups
464 | for (let cg of groups) {
465 | let color = split_color(cgi, groups.length)
466 | let bullet = div({
467 | class: 'chart-legend-bullet',
468 | style: 'background-color: '+color,
469 | })
470 | let label = div({class: 'chart-legend-label'}, TC(cg.label))
471 | e.legend.add(bullet, label)
472 | label.on('pointerenter', function() {
473 | legend_hit_cg = cg
474 | e.update()
475 | })
476 | label.on('pointerleave', function() {
477 | legend_hit_cg = null
478 | e.update()
479 | })
480 | cgi++
481 | }
482 | }
483 |
484 | renderer.stack = function() {
485 |
486 | let slices = pie_slices()
487 | if (!slices)
488 | return
489 |
490 | let stack = div({class: 'chart-stack'})
491 | e.view.add(stack)
492 |
493 | do_update = function(opt) {
494 | if (opt.model) {
495 | stack.clear()
496 | e.legend.clear()
497 | if (!all_split_groups)
498 | return
499 | let i = 0
500 | for (let slice of slices) {
501 | let cdiv = div({class: 'chart-stack-slice'})
502 | let sdiv = div({class: 'chart-stack-slice-ct'}, cdiv,
503 | slice.label, unsafe_html(' - '), slice.percent)
504 | sdiv.style.flex = slice.size
505 | cdiv.style['background-color'] = split_color(i, slices.length)
506 | stack.add(sdiv)
507 | i++
508 | }
509 | }
510 | }
511 |
512 | }
513 |
514 | let tt
515 |
516 | renderer.pie = function() {
517 |
518 | let slices = pie_slices()
519 | if (!slices)
520 | return
521 |
522 | let pie = svg({class: 'chart-pie', viewBox: '-1.1 -1.1 2.2 2.2'})
523 | let percent_divs = div({class: 'chart-pie-percents'})
524 | e.attr('shape', 'pie')
525 | e.view.add(pie, percent_divs)
526 |
527 | let hit_slice
528 |
529 | pie.on('pointermove', function(ev, mx, my) {
530 | let path = ev.target
531 | hit_slice = path.slice
532 | e.update({tooltip: true})
533 | })
534 |
535 | pie.on('pointerleave', function(ev, mx, my) {
536 | hit_slice = null
537 | e.update({tooltip: true})
538 | })
539 |
540 | do_update = function(opt) {
541 | if (opt.model) {
542 |
543 | // create pie.
544 | let angle = 0
545 | let i = 0
546 | pie.clear()
547 | for (let slice of slices) {
548 | let arclen = slice.size * 360
549 | let color = split_color(i, slices.length)
550 |
551 | let slice_path = pie.path({
552 | d: 'M 0 0 '
553 | + svg_arc_path(0, 0, 1, angle + arclen - 90, angle - 90, 'L')
554 | + ' Z',
555 | fill: color,
556 | stroke: 'white',
557 | ['stroke-width']: 1,
558 | ['vector-effect']: 'non-scaling-stroke',
559 | })
560 | slice_path.slice = slice
561 |
562 | let selection_path = pie.path({
563 | d: svg_arc_path(0, 0, 1.05, angle + arclen - 90, angle - 90, 'M'),
564 | fill: 'none',
565 | stroke: split_color(i, slices.length, .6),
566 | ['stroke-width']: .08,
567 | })
568 | slice.selection_path = selection_path
569 |
570 | // compute the percent div's unscaled position inside the pie.
571 | let center_angle = angle + arclen / 2
572 | ;[slice.percent_div_x, slice.percent_div_y] =
573 | point_around(0, 0, .65, -(center_angle - 90)*rad)
574 |
575 | ;[slice.center_x, slice.center_y] =
576 | point_around(0, 0, .5, -(center_angle - 90)*rad)
577 |
578 | angle += arclen
579 | i++
580 | }
581 |
582 | // create percent divs.
583 | percent_divs.clear()
584 | for (let slice of slices) {
585 | if (slice.size > 0.05) {
586 | slice.percent_div = div({class: 'chart-pie-label'}, slice.percent)
587 | percent_divs.add(slice.percent_div)
588 | }
589 | }
590 |
591 | update_legend_slices(slices)
592 | }
593 |
594 | if (opt.tooltip) {
595 | if (hit_slice) {
596 | if (!tt) {
597 | tt = tooltip({
598 | target : pie,
599 | align : 'center',
600 | side : 'inner-center',
601 | kind : 'info',
602 | classes : 'chart-tooltip',
603 | })
604 | e.view.add(tt)
605 | tt.key_cols_div = div()
606 | tt.sum_cols_div = div()
607 | tt.key_div = div({style: 'justify-self: end'})
608 | tt.sum_div = div({style: 'justify-self: end'})
609 | tt.text = div({class: 'chart-tooltip-label'},
610 | tt.key_cols_div, tt.key_div,
611 | tt.sum_cols_div, tt.sum_div
612 | )
613 | }
614 |
615 | let s = nav.draw_val(null, slices.sum_field, hit_slice.sum)
616 |
617 | tt.key_cols_div.set(slices.key_cols_label)
618 | tt.sum_cols_div.set(slices.sum_field.label)
619 | tt.key_div.set(TC(hit_slice.label))
620 | tt.sum_div.set([s, ' (', tag('b', 0, hit_slice.percent), ')'])
621 |
622 | tt.update({show: true})
623 | } else if (tt) {
624 | tt.update({show: false})
625 | }
626 | }
627 |
628 | }
629 |
630 | do_measure = function() {
631 | // measure percent divs
632 | for (let slice of slices) {
633 | if (slice.percent_div) {
634 | slice.percent_div_w = slice.percent_div.cw
635 | slice.percent_div_h = slice.percent_div.ch
636 | }
637 | }
638 | }
639 |
640 | do_position = function() {
641 |
642 | let r = min(view_w, view_h) / 2
643 | let x = view_w / 2 - r
644 | let y = view_h / 2 - r
645 |
646 | // size and position the pie
647 | pie.w = r * 2
648 | pie.h = r * 2
649 | pie.style.left = px(x)
650 | pie.style.top = px(y)
651 |
652 | // position percent divs
653 | let i = 0
654 | for (let slice of slices) {
655 | if (slice.percent_div) {
656 | slice.percent_div.x = x + r + slice.percent_div_x * r - slice.percent_div_w / 2
657 | slice.percent_div.y = y + r + slice.percent_div_y * r - slice.percent_div_h / 2
658 | }
659 | slice.selection_path.style.display = 'none'
660 | i++
661 | }
662 |
663 | if (hit_slice || legend_hit_slice) {
664 | let sel_path = (hit_slice || legend_hit_slice).selection_path
665 | sel_path.style.display = 'block'
666 | }
667 |
668 | if (hit_slice) {
669 | tt.popup_ox = hit_slice.center_x * r
670 | tt.popup_oy = hit_slice.center_y * r
671 | }
672 |
673 | }
674 | }
675 |
676 | function renderer_line_or_columns(columns, rotate, dots, area) {
677 |
678 | let ct = resizeable_canvas_container()
679 | let cx = ct.context
680 |
681 | e.view.set(ct)
682 |
683 | e.attr('shape', 'lines')
684 |
685 | let line_h; {
686 | let m = cx.measureText('M')
687 | line_h = (m.actualBoundingBoxAscent - m.actualBoundingBoxDescent) * 1.5
688 | }
689 |
690 | // paddings to make room for axis markers.
691 | let px1 = 40
692 | let px2 = 10
693 | let py1 = round(rotate ? line_h + 5 : 10)
694 | let py2 = line_h * 1.5
695 |
696 | let bar_rect
697 | let hit_mx, hit_my
698 | let hit_cg, hit_xg
699 | let hit_x, hit_y, hit_h
700 |
701 | function hit_test_columns(mx, my) {
702 | let cgi = 0
703 | for (let cg of all_split_groups) {
704 | for (let xg of cg) {
705 | if (xg.visible) {
706 | let [x, y, w, h] = bar_rect(cgi, xg)
707 | if (mx >= x && mx <= x + w) {
708 | hit_cg = cg
709 | hit_xg = xg
710 | hit_x = x + w / 2
711 | hit_y = y
712 | hit_h = h
713 | legend_hit_cg = null
714 | return true
715 | }
716 | }
717 | }
718 | cgi++
719 | }
720 | }
721 |
722 | function hit_test_dots(mx, my) {
723 | let max_d2 = 16**2
724 | let min_d2 = 1/0
725 | for (let cg of all_split_groups) {
726 | for (let xg of cg) {
727 | if (xg.visible) {
728 | let dx = abs(mx - xg.x)
729 | let dy = abs(my - xg.y)
730 | let d2 = dx**2 + (all_split_groups.length > 1 ? dy**2 : 0)
731 | if (d2 <= min(min_d2, max_d2)) {
732 | min_d2 = d2
733 | hit_cg = cg
734 | hit_xg = xg
735 | hit_x = hit_xg.x
736 | hit_y = hit_xg.y
737 | hit_h = 0
738 | legend_hit_cg = null
739 | }
740 | }
741 | }
742 | }
743 | return !!hit_cg
744 | }
745 |
746 | pointermove = function(ev, mx, my) {
747 | if (!all_split_groups)
748 | return
749 | let r = ct.rect()
750 | mx -= r.x + px1
751 | my -= r.y + py1
752 | if (rotate)
753 | [mx, my] = [my, mx]
754 | hit_mx = mx
755 | hit_my = my
756 | hit_x = null
757 | hit_y = null
758 | hit_cg = null
759 | hit_xg = null
760 | let hit = columns ? hit_test_columns(mx, my) : hit_test_dots(mx, my)
761 | if (hit) {
762 |
763 | if (!tt) {
764 | tt = tooltip({
765 | align : 'center',
766 | kind : 'info',
767 | classes : 'chart-tooltip',
768 | })
769 | e.view.add(tt)
770 | tt.spl_cols_div = div()
771 | tt.key_cols_div = div()
772 | tt.sum_cols_div = div()
773 | tt.spl_div = div({style: 'justify-self: end'})
774 | tt.key_div = div({style: 'justify-self: end'})
775 | tt.sum_div = div({style: 'justify-self: end'})
776 | tt.text = div({class: 'chart-tooltip-label'},
777 | tt.spl_cols_div, tt.spl_div,
778 | tt.key_cols_div, tt.key_div,
779 | tt.sum_cols_div, tt.sum_div
780 | )
781 | }
782 | tt.side = rotate ? 'right' : 'top'
783 |
784 | let spl_flds = nav.flds(hit_cg.key_cols)
785 | let key_flds = nav.flds(hit_xg.key_cols)
786 | let spl_cols = spl_flds.map(f => f.label).join_nodes(' / ')
787 | let key_cols = key_flds.map(f => f.label).join_nodes(' / ')
788 |
789 | let sum_fld = hit_cg.sum_def.field
790 | let s = nav.draw_val(null, sum_fld, hit_xg.sum)
791 |
792 | tt.spl_cols_div.set(spl_cols)
793 | tt.key_cols_div.set(key_cols)
794 | tt.spl_div.set(hit_cg.label)
795 | tt.key_div.set(TC(hit_xg.label))
796 | tt.sum_cols_div.set(sum_fld.label)
797 | tt.sum_div.set(s)
798 |
799 | let p1x = hit_x
800 | let p1y = hit_y
801 | let p2x = hit_x
802 | let p2y = hit_y + hit_h
803 | let p3x = hit_x
804 | let p3y = hit_y
805 | let p4x = hit_x
806 | let p4y = hit_y + hit_h
807 | let x1 = min(p1x, p2x, p3x, p4x)
808 | let y1 = min(p1y, p2y, p3y, p4y)
809 | let x2 = max(p1x, p2x, p3x, p4x)
810 | let y2 = max(p1y, p2y, p3y, p4y)
811 | if (rotate) {
812 | ;[x1, y1] = [y1, x1]
813 | ;[x2, y2] = [y2, x2]
814 | ;[x2, x1] = [x1, x2]
815 | x1 = view_w - x1 - px1 - px2
816 | x2 = view_w - x2 - px1 - px2
817 | }
818 | tt.popup_x1 = x1 + px1
819 | tt.popup_y1 = y1 + py1
820 | tt.popup_x2 = x2 + px1
821 | tt.popup_y2 = y2 + py1
822 | tt.update({show: true})
823 | } else if (tt) {
824 | tt.update({show: false})
825 | }
826 | e.update()
827 | }
828 |
829 | do_update = function(opt) {
830 | if (opt.model)
831 | update_legend_split_groups()
832 | ct.update()
833 | }
834 | do_measure = null
835 | do_position = null
836 |
837 | let xgs = map()
838 | ct.on_redraw(function(cx, view_w, view_h) {
839 |
840 | cx.font = font
841 |
842 | let discrete = columns || e.min_val == null || e.max_val == null
843 |
844 | let w = view_w - (px1 + px2)
845 | let h = view_h - (py1 + py2)
846 |
847 | cx.translate(px1, py1)
848 |
849 | if (rotate) {
850 | cx.translate(w, 0)
851 | cx.rotate(90*rad)
852 | ;[w, h] = [h, w]
853 | }
854 |
855 | // compute vertical (sum) and horizontal (val) ranges.
856 | // also create a map of descrete x-values for drawing as x-axis labels.
857 | xgs.clear() // {x_key -> xg}
858 | let min_val = 0
859 | let max_val = 1
860 | let val_step
861 | let min_sum = 0
862 | let max_sum = 1
863 | let sum_step = e.sum_step || 1
864 | if (all_split_groups) {
865 | min_val = 1/0
866 | max_val = -1/0
867 | min_sum = 1/0
868 | max_sum = -1/0
869 | let user_min_val = e.min_val ?? -1/0
870 | let user_max_val = e.max_val ?? 1/0
871 | for (let cg of all_split_groups) {
872 | for (let xg of cg) {
873 | let sum = xg.sum
874 | let key_flds = nav.flds(xg.key_cols)
875 | let val = xg.key_vals[0]
876 | min_sum = min(min_sum, sum)
877 | max_sum = max(max_sum, sum)
878 | min_val = min(min_val, val)
879 | max_val = max(max_val, val)
880 | xg.visible = val >= user_min_val && val <= user_max_val
881 | xgs.set(val, xg)
882 | }
883 | }
884 | }
885 |
886 | // clip/stretch ranges to given fixed values.
887 | min_val = e.min_val ?? min_val
888 | max_val = e.max_val ?? max_val
889 | min_sum = e.min_sum ?? min_sum
890 | max_sum = e.max_sum ?? max_sum
891 |
892 | if (columns && all_split_groups) {
893 | let val_unit = (max_val - min_val) / xgs.size
894 | min_val -= val_unit / 2
895 | max_val += val_unit / 2
896 | }
897 |
898 | // compute min, max and step of y-axis markers so that 1) the step is
899 | // on a module and the spacing between lines is the closest to an ideal.
900 | if (sum_defs) {
901 | let y_spacing = rotate ? 80 : 40 // wanted space between y-axis markers
902 | let target_n = round(h / y_spacing) // wanted number of y-axis markers
903 | let fld = sum_defs[0].field
904 | ;[sum_step, min_sum, max_sum] =
905 | compute_step_and_range(target_n, min_sum, max_sum,
906 | fld.scale_base, fld.scales, fld.decimals)
907 | }
908 |
909 | let min_elem_w = 60
910 |
911 | // compute min, max and step of x-axis markers so that 1) the step is
912 | // on a module and the spacing between markers is the closest to an ideal.
913 | if (!discrete) {
914 | let max_n = max(1, floor(w / min_elem_w)) // max number of elements
915 | ;[val_step, min_val, max_val] = compute_step_and_range(max_n, min_val, max_val)
916 | }
917 |
918 | // compute polygon's points.
919 | if (all_split_groups)
920 | for (let cg of all_split_groups) {
921 | let xgi = 0
922 | let y0 = h - py2
923 | for (let xg of cg) {
924 | let key_flds = nav.flds(xg.key_cols)
925 | let val = xg.key_vals[0]
926 | xg.x = round(lerp(val, min_val, max_val, 0, w))
927 | xg.y = round(lerp(xg.sum, min_sum, max_sum, h - py2, 0))
928 | if (xg.y != xg.y) // NaN (x/0 from lerp)
929 | xg.y = xg.sum
930 | xgi++
931 | }
932 | }
933 |
934 | // draw x-axis labels & reference lines.
935 |
936 | let ref_line_color = view_css.prop('--border-light')
937 | let label_color = view_css.prop('--fg-label')
938 | cx.fillStyle = label_color
939 | cx.strokeStyle = ref_line_color
940 |
941 | function draw_xaxis_label(xg_x, text, is_first, is_last) {
942 | let m = cx.measureText(text)
943 | cx.save()
944 | if (rotate) {
945 | let text_h = m.actualBoundingBoxAscent - m.actualBoundingBoxDescent
946 | let x = round(xg_x + text_h / 2)
947 | let y = h + m.width
948 | cx.translate(x, y)
949 | cx.rotate(-90*rad)
950 | } else {
951 | let x = is_first ? xg_x : is_last ? xg_x - m.width : xg_x - m.width / 2
952 | let y = round(h)
953 | cx.translate(x, y)
954 | }
955 | cx.fillText(text, 0, 0)
956 | cx.restore()
957 | // draw x-axis center line marker.
958 | cx.beginPath()
959 | cx.moveTo(xg_x + .5, h - py2 + 0.5)
960 | cx.lineTo(xg_x + .5, h - py2 + 4.5)
961 | cx.stroke()
962 | }
963 |
964 | // draw x-axis labels.
965 |
966 | if (discrete) {
967 | let i = 0
968 | let n = xgs.size
969 | let xg0
970 | for (let xg of xgs.values()) {
971 | if (xg.visible && (!xg0 || (xg.x - xg0.x) >= min_elem_w)) {
972 | // TODO: draw the element as a html overlay
973 | let text = isnode(xg.label) ? xg.label.textContent : xg.label
974 | draw_xaxis_label(xg.x, text, i == 0, i == n-1)
975 | xg0 = xg
976 | }
977 | i++
978 | }
979 | } else {
980 | for (let val = min_val; val <= max_val; val += val_step) {
981 | let text = val_text(val)
982 | let x = round(lerp(val, min_val, max_val, 0, w))
983 | draw_xaxis_label(x, text, val == min_val, !(val + val_step <= max_val))
984 | }
985 | }
986 |
987 | // draw y-axis labels & reference lines.
988 |
989 | if (sum_defs)
990 | for (let sum = min_sum; sum <= max_sum; sum += sum_step) {
991 | // draw y-axis label.
992 | let y = round(lerp(sum, min_sum, max_sum, h - py2, 0))
993 | let s = sum_label(null, null, sum)
994 | s = isnode(s) ? s.textContent : s
995 | let m = cx.measureText(s)
996 | let text_h = m.actualBoundingBoxAscent - m.actualBoundingBoxDescent
997 | cx.save()
998 | if (rotate) {
999 | let px = -5
1000 | let py = round(y + m.width / 2)
1001 | cx.translate(px, py)
1002 | cx.rotate(-90*rad)
1003 | } else {
1004 | let px = -m.width - 10
1005 | let py = round(y + text_h / 2)
1006 | cx.translate(px, py)
1007 | }
1008 | cx.fillText(s, 0, 0)
1009 | cx.restore()
1010 | // draw y-axis strike-through line marker.
1011 | cx.strokeStyle = ref_line_color
1012 | cx.beginPath()
1013 | cx.moveTo(0 + .5, y - .5)
1014 | cx.lineTo(w + .5, y - .5)
1015 | cx.stroke()
1016 | }
1017 |
1018 | // draw the axis.
1019 | cx.strokeStyle = ref_line_color
1020 | cx.beginPath()
1021 | // y-axis
1022 | cx.moveTo(.5, round(lerp(min_sum, min_sum, max_sum, h - py2, 0)) + .5)
1023 | cx.lineTo(.5, round(lerp(max_sum, min_sum, max_sum, h - py2, 0)) + .5)
1024 | // x-axis
1025 | cx.moveTo(round(lerp(min_val, min_val, max_val, 0, w)) + .5, round(h - py2) - .5)
1026 | cx.lineTo(round(lerp(max_val, min_val, max_val, 0, w)) + .5, round(h - py2) - .5)
1027 | cx.stroke()
1028 |
1029 | let cn = all_split_groups.length
1030 | let bar_w = round(w / (xgs.size - 1) / cn / 3)
1031 | let half_w = round((bar_w * cn + 2 * (cn - 1)) / 2)
1032 | bar_rect = function(cgi, xg) {
1033 | return [
1034 | xg.x + cgi * (bar_w + 2) - half_w,
1035 | xg.y,
1036 | bar_w,
1037 | h - py2 - xg.y
1038 | ]
1039 | }
1040 |
1041 | // draw the chart lines or columns.
1042 |
1043 | if (all_split_groups) {
1044 |
1045 | if (hit_mx < 0 || hit_mx > w)
1046 | hit_mx = null
1047 |
1048 | cx.rect(0, 0, w, h)
1049 |
1050 | let cgi = 0
1051 | for (let cg of all_split_groups) {
1052 |
1053 | let color = split_color(cgi, all_split_groups.length)
1054 |
1055 | if (columns) {
1056 |
1057 | cx.fillStyle = color
1058 | for (let xg of cg) {
1059 | let [x, y, w, h] = bar_rect(cgi, xg)
1060 | cx.beginPath()
1061 | cx.rect(x, y, w, h)
1062 | cx.fill()
1063 | }
1064 |
1065 | } else {
1066 |
1067 | // draw the line.
1068 |
1069 | cx.lineWidth = cg == legend_hit_cg ? 2 : 1
1070 |
1071 | if (!cg.tied_back)
1072 | cx.beginPath()
1073 |
1074 | let x0, x
1075 | for (let xg of (cg.tied_back ? cg.reverse() : cg)) {
1076 | x = xg.x
1077 | if (x0 == null && !cg.tied_back) {
1078 | x0 = x
1079 | cx.moveTo(x + .5, xg.y + .5)
1080 | } else {
1081 | cx.lineTo(x + .5, xg.y + .5)
1082 | }
1083 | }
1084 |
1085 | if (area && !cg.tied && !cg.tied_back) {
1086 | cx.lineTo(x + .5, h - py2 + .5)
1087 | cx.lineTo(x0 + .5, h - py2 + .5)
1088 | cx.closePath()
1089 | }
1090 |
1091 | if (area && !cg.tied) {
1092 | cx.fillStyle = split_color(cgi, all_split_groups.length,
1093 | legend_hit_cg == cg ? .7 : .5)
1094 | cx.fill()
1095 | }
1096 |
1097 | cx.strokeStyle = color
1098 | cx.stroke()
1099 |
1100 | cx.fillStyle = cx.strokeStyle
1101 | let xg_x0 = 0
1102 | let xg_y0 = 0
1103 | for (let xg of cg) {
1104 |
1105 | // draw a dot on each line cusp.
1106 | if (dots) {
1107 | cx.beginPath()
1108 | cx.arc(xg.x, xg.y, 3, 0, 2*PI)
1109 | cx.fill()
1110 | }
1111 |
1112 | // draw a dot where intersecting the extrapolator line.
1113 | if (hit_mx != null && !hit_cg) {
1114 | if (hit_mx >= xg_x0 && hit_mx <= xg.x) {
1115 | cx.fillStyle = cx.strokeStyle
1116 | cx.beginPath()
1117 | let y = lerp(hit_mx, xg_x0, xg.x, xg_y0, xg.y)
1118 | cx.arc(hit_mx, y, 3, 0, 2*PI)
1119 | cx.fill()
1120 | }
1121 | xg_x0 = xg.x
1122 | xg_y0 = xg.y
1123 | }
1124 |
1125 | }
1126 |
1127 | }
1128 |
1129 | cgi++
1130 | }
1131 |
1132 | if (hit_cg) {
1133 | // draw the hit line.
1134 | cx.beginPath()
1135 | cx.moveTo(hit_x + .5, .5)
1136 | cx.lineTo(hit_x + .5, h - py2)
1137 | cx.strokeStyle = label_color
1138 | cx.setLineDash([3, 2])
1139 | cx.stroke()
1140 | cx.setLineDash(empty_array)
1141 | } else if (hit_mx != null) {
1142 | // draw the extrapolator line.
1143 | cx.beginPath()
1144 | cx.moveTo(hit_mx + .5, .5)
1145 | cx.lineTo(hit_mx + .5, h - py2)
1146 | cx.strokeStyle = label_color
1147 | cx.setLineDash([3, 2])
1148 | cx.stroke()
1149 | cx.setLineDash(empty_array)
1150 | }
1151 |
1152 | }
1153 |
1154 | })
1155 |
1156 | }
1157 |
1158 | renderer.lines = () => renderer_line_or_columns()
1159 | renderer.lines_dots = () => renderer_line_or_columns(false, false, true)
1160 | renderer.areas = () => renderer_line_or_columns(false, false, false, true)
1161 | renderer.areas_dots = () => renderer_line_or_columns(false, false, true , true)
1162 | renderer.columns = () => renderer_line_or_columns(true)
1163 | renderer.bars = () => renderer_line_or_columns(true , true)
1164 |
1165 | })
1166 |
1167 |
1168 | }()) // module function
1169 |
--------------------------------------------------------------------------------
/www/ui_grid.js:
--------------------------------------------------------------------------------
1 |
2 | (function () {
3 | "use strict"
4 | const G = window
5 | const ui = G.ui
6 |
7 | const {
8 | pr,
9 | isobject,
10 | round, min, max, floor,
11 | } = glue
12 |
13 | const {
14 | cx,
15 | } = ui
16 |
17 | ui.widget('treegrid_indent', {
18 | create: function(cmd, indent, state) {
19 | return ui.cmd(cmd, ui.ct_i(), indent, state)
20 | },
21 | draw: function(a, i) {
22 | let ct_i = a[i+0]
23 | let indent = a[i+1]
24 | let state = a[i+2]
25 | let x = a[ct_i+0]
26 | let y = a[ct_i+1]
27 | let w = a[ct_i+2]
28 | let h = a[ct_i+3]
29 | cx.fillStyle = 'red'
30 | cx.beginPath()
31 | cx.rect(x, y, w, h)
32 | cx.fill()
33 | },
34 | })
35 |
36 | function init(id, e) {
37 |
38 | e.id = id // for errors
39 |
40 | e.cell_border_v_width = 0
41 | e.cell_border_h_width = 1
42 |
43 | e.auto_expand = false // expand cells instead of scrollboxing them
44 | e.group_bar_visible = 'auto' // auto | always | no
45 |
46 | let horiz = true
47 |
48 | // context-sensitive thus set on each frame
49 | let font_size
50 | let line_height
51 | let cells_w
52 | let cell_h
53 | let header_h
54 | let gcol_w
55 | let gcol_h
56 | let gcol_gap
57 | // cells-view-height-sensitive thus set on frame callback
58 | let page_row_count = 1
59 |
60 | // mouse state
61 | let drag_state, dx, dy, cs
62 | let gcol_mover
63 | let hit_zone // sort_icon, col_divider, col, gcol, cell
64 | let drag_op // col_move, col_group, row_move
65 | let hit_ri // row index
66 | let hit_fi // field index
67 | let hit_indent
68 | let row_move_state
69 |
70 | function reset_mouse_state() {
71 | hit_zone = null
72 | hit_ri = null
73 | hit_fi = null
74 | hit_indent = null
75 | drag_state = null
76 | dx = null
77 | dy = null
78 | cs = null
79 | drag_op = null
80 | gcol_mover = null
81 | row_move_state = null
82 | }
83 |
84 | // keyboard state
85 | let focused, shift, ctrl
86 | let keydown = key => focused && ui.keydown(key)
87 |
88 | // utils
89 |
90 | function field_has_indent(field) {
91 | return horiz && field == e.tree_field
92 | }
93 |
94 | function indent_offset(indent) {
95 | return floor(font_size * 1.5 + (font_size * 1.2) * indent)
96 | }
97 |
98 | function row_indent(row) {
99 | return row.depth ?? 0
100 | }
101 |
102 | function draw_cell_at(a, row, field, ri, fi, x, y, w, h, draw_stage) {
103 |
104 | let input_val = e.cell_input_val(row, field)
105 |
106 | // static geometry
107 | let bx = e.cell_border_v_width
108 | let by = e.cell_border_h_width
109 |
110 | // state
111 | let grid_focused = ui.focused(id)
112 | let row_focused = e.focused_row == row
113 | let cell_focused = row_focused && (!e.can_focus_cells || field == e.focused_field)
114 | let disabled = e.is_cell_disabled(row, field)
115 | let is_new = row.is_new
116 | let cell_invalid = e.cell_has_errors(row, field)
117 | let modified = e.cell_modified(row, field)
118 | let is_null = input_val == null
119 | let is_empty = input_val === ''
120 | let sel_fields = e.selected_rows.get(row)
121 | let selected = (isobject(sel_fields) ? sel_fields.has(field) : sel_fields) || false
122 | let editing = !!e.editor && cell_focused
123 | let hovering = hit_zone == 'cell' && hit_ri == ri && hit_fi == fi
124 | let full_width = !draw_stage && ((row_focused && field == e.focused_field) || hovering)
125 |
126 | let indent_x = 0
127 | let collapsed
128 | let has_children
129 | if (field_has_indent(field)) {
130 | indent_x = indent_offset(row_indent(row))
131 | has_children = (row.child_rows?.length ?? 0) > 0
132 | if (has_children)
133 | collapsed = !!row.collapsed
134 | let s = row_move_state
135 | if (s) {
136 | // show minus sign on adopting parent.
137 | if (row == s.hit_parent_row && collapsed == null)
138 | collapsed = false
139 |
140 | // shift indent on moving rows so it gets under the adopting parent.
141 | if (draw_stage == 'row_move')
142 | indent_x += s.hit_indent_x - s.indent_x
143 | }
144 | }
145 |
146 | // background & text color
147 | // drawing a background is slow, so we avoid it when we can.
148 | let bg, bgs
149 |
150 | if (draw_stage == 'col_move' || draw_stage == 'row_move')
151 | bg = 'bg2'
152 | else if (draw_stage == 'col_group')
153 | bg = 'bg0'
154 | if (editing) {
155 | bg = 'item'
156 | bgs = grid_focused ? 'item-focused focused' : 'item-focused'
157 | } else if (cell_invalid) {
158 | bg = 'item'
159 | bgs = grid_focused && cell_focused ? 'item-error item-focused' : 'item-error'
160 | } else if (cell_focused) {
161 | bg = 'item'
162 | if (selected)
163 | bgs = grid_focused
164 | ? 'item-focused item-selected focused'
165 | : 'item-focused item-selected'
166 | else
167 | bgs = grid_focused
168 | ? 'item-focused focused'
169 | : 'item-selected'
170 | } else if (selected) {
171 | bg = 'item'
172 | bgs = grid_focused ? 'item-selected focused' : 'item-selected'
173 | } else if (is_new) {
174 | bg = 'item'
175 | bgs = modified ? 'new modified' : 'new'
176 | } else if (modified) {
177 | bg = 'item'
178 | bgs = 'modified'
179 | } else if (row_focused) {
180 | bg = 'row'
181 | bgs = grid_focused ? 'item-focused focused' : 'item-focused'
182 | }
183 | if (!bg) {
184 | if (row.is_group_row)
185 | bg = null
186 | else if ((ri & 1) == 0)
187 | bg = 'alt'
188 | else if (full_width)
189 | bg = 'bg'
190 | }
191 |
192 | let fg
193 | if (draw_stage == 'col_group')
194 | fg = 'faint'
195 | else if (is_null || is_empty || disabled)
196 | fg = 'label'
197 | else
198 | fg = 'text'
199 |
200 | // drawing
201 | let sp2 = ui.sp2()
202 |
203 | ui.m(x, y, 0, 0)
204 | ui.stack('', 0, 'l', 't', w, h)
205 | ui.bb(bg, bgs, draw_stage == 'col_move' ? 'lrb' : 'b', 'light')
206 | ui.color(fg)
207 | if (has_children) {
208 | ui.p(indent_x - sp2, 0, sp2, 0)
209 | ui.scope()
210 | ui.font('fas')
211 | ui.text('', collapsed ? '\uf0fe' : '\uf146')
212 | // ui.treegrid_indent(indent_x)
213 | ui.end_scope()
214 | }
215 | ui.p(sp2 + indent_x, 0, sp2, 0)
216 | e.draw_val(row, field, input_val, true, full_width)
217 | ui.p(0)
218 | ui.end_stack()
219 |
220 | }
221 |
222 | let cell_rect
223 | {
224 | let r = [0, 0, 0, 0]
225 | cell_rect = function(ri, fi) {
226 | let field = e.fields[fi]
227 | let ry = ri * cell_h
228 | let x = field._x
229 | let y = ry
230 | let w = field._w
231 | let h = cell_h
232 | r[0] = x
233 | r[1] = y
234 | r[2] = w
235 | r[3] = h
236 | return r
237 | }
238 | }
239 |
240 | function draw_cell(a, ri, fi, draw_stage) {
241 | let [x, y, w, h] = cell_rect(ri, fi)
242 | let row = e.rows[ri]
243 | let field = e.fields[fi]
244 | draw_cell_at(a, row, field, ri, fi, x, y, w, h, draw_stage)
245 | }
246 |
247 | function draw_cells_range(a, x0, y0, rows, ri1, ri2, fi1, fi2, draw_stage) {
248 |
249 | let hit_cell, foc_cell, foc_ri, foc_fi
250 |
251 | if (!draw_stage) {
252 |
253 | foc_ri = e.focused_row_index
254 | foc_fi = e.focused_field_index
255 |
256 | hit_cell = hit_zone == 'cell'
257 | && hit_ri >= ri1 && hit_ri <= ri2
258 | && hit_fi >= fi1 && hit_fi <= fi2
259 |
260 | foc_cell = foc_ri != null && foc_fi != null
261 |
262 | // when foc_cell and hit_cell are the same, don't draw them twice.
263 | if (foc_cell && hit_cell && hit_ri == foc_ri && hit_fi == foc_fi)
264 | foc_cell = null
265 |
266 | }
267 | let skip_moving_col = drag_op == 'col_move' && draw_stage == 'col'
268 |
269 | for (let ri = ri1; ri < ri2; ri++) {
270 |
271 | let row = rows[ri]
272 |
273 | let rx = x0
274 | let ry = ri * cell_h
275 | let rw = cells_w
276 | let rh = cell_h
277 |
278 | let row_is_foc = foc_cell && foc_ri == ri
279 | let row_is_hit = hit_cell && hit_ri == ri
280 |
281 | for (let fi = fi1; fi < fi2; fi++) {
282 | if (skip_moving_col && hit_fi == fi)
283 | continue
284 | if (row_is_hit && hit_fi == fi)
285 | continue
286 | if (row_is_foc && foc_fi == fi)
287 | continue
288 |
289 | let field = e.fields[fi]
290 | let x = field._x
291 | let y = ry
292 | let w = field._w
293 | let h = rh
294 |
295 | draw_cell_at(a, row, field, ri, fi, x, y, w, h, draw_stage)
296 | }
297 |
298 | if (row.removed)
299 | draw_row_strike_line(row, ri, rx, ry, rw, rh, draw_stage)
300 |
301 | }
302 |
303 | if (foc_cell && foc_ri >= ri1 && foc_ri <= ri2 && foc_fi >= fi1 && foc_fi <= fi2) {
304 | draw_cell(a, foc_ri, foc_fi, draw_stage)
305 | }
306 |
307 | // hit_cell can overlap foc_cell, so we draw it after it.
308 | if (hit_cell && hit_ri >= ri1 && hit_ri <= ri2 && hit_fi >= fi1 && hit_fi <= fi2) {
309 | draw_cell(a, hit_ri, hit_fi, draw_stage)
310 | }
311 |
312 | }
313 |
314 | let h_sb_i // cmd record index of header scrollbox
315 |
316 | function on_cellview_frame(a, _i, x, y, w, h, vx, vy, vw, vh) {
317 |
318 | page_row_count = floor(vh / cell_h)
319 |
320 | let sx = vx - x
321 | let sy = vy - y
322 |
323 | // scroll the header scrollbox to match the scroll offset of the cell view.
324 | // TODO: make this work!
325 | ui.force_scroll(a, h_sb_i, sx, 0)
326 |
327 | // find the visible row range
328 |
329 | let rn // number of rows fully or partially in the viewport.
330 | let ri1, ri2 // visible row range.
331 | if (horiz) {
332 | ri1 = floor(sy / cell_h)
333 | } else {
334 | ri1 = floor(sx / cell_w)
335 | }
336 | rn = floor(vh / cell_h) + 2 // 2 is right, think it!
337 | ri2 = ri1 + rn
338 | ri1 = max(0, min(ri1, e.rows.length - 1))
339 | ri2 = max(0, min(ri2, e.rows.length))
340 |
341 | // find the visible field range
342 |
343 | let fi1, fi2 // visible field range.
344 | for (let field of e.fields) {
345 | let fx = field._x + x
346 | let fw = field._w
347 | if (fi1 == null && fx + fw >= vx)
348 | fi1 = field.index
349 | if (fi2 == null && fx > vx + vw)
350 | fi2 = field.index
351 | }
352 | fi2 = fi2 ?? e.fields.length
353 |
354 | let bx = e.cell_border_v_width
355 | let by = e.cell_border_h_width
356 |
357 | // draw cells
358 |
359 | ui.stack(id+'.cells')
360 | ui.measure(id+'.cells')
361 |
362 | x = bx + sx
363 | y = by + sy
364 |
365 | if (drag_op == 'row_move') {
366 | // draw fixed rows first and moving rows above them.
367 | let s = row_move_state
368 | draw_cells_range(a, x, y, e.rows, s.vri1, s.vri2, fi1, fi2, 'row')
369 | draw_cells_range(s.rows, s.move_vri1, s.move_vri2, fi1, fi2, 'row_move')
370 | } else if (drag_op == 'col_move' || drag_op == 'col_group') {
371 | // draw fixed cols first and moving cols above them.
372 | draw_cells_range(a, x, y, e.rows, ri1, ri2, fi1, fi2, 'col')
373 | draw_cells_range(a, x, y, e.rows, ri1, ri2, hit_fi, hit_fi + 1, drag_op)
374 | } else {
375 | draw_cells_range(a, x, y, e.rows, ri1, ri2, fi1, fi2)
376 | }
377 |
378 | ui.end_stack()
379 |
380 | }
381 |
382 | e.scroll_to_cell = function(ri, fi) {
383 | let [x, y, w, h] = cell_rect(ri, fi)
384 | ui.scroll_to_view(id+'.cells_scrollbox', x, y, w, h)
385 | }
386 |
387 | // render grid ------------------------------------------------------------
388 |
389 | function group_bar_h() {
390 | let sp2 = ui.sp2()
391 | let levels = e.groups.col_groups.length-1
392 | return 2 * sp2 + gcol_h + levels * sp2 + 2
393 | }
394 |
395 | e.render = function(fr, align, valign, min_w, min_h) {
396 |
397 | // set layout vars
398 |
399 | let sp = ui.sp1()
400 | let sp2 = ui.sp2()
401 | font_size = ui.get_font_size()
402 | line_height = font_size * 1.5
403 | cell_h = round(line_height + 2 * sp + e.cell_border_h_width)
404 | header_h = cell_h
405 | gcol_w = 80 // group-bar column width
406 | gcol_h = line_height + sp
407 | gcol_gap = 1
408 |
409 | // set keyboard state
410 |
411 | focused = ui.focused(id)
412 | shift = ui.key('shift')
413 | ctrl = ui.key('control')
414 |
415 | // check mouse state ---------------------------------------------------
416 |
417 | reset_mouse_state()
418 |
419 | // hover or click on sort icons from colum header
420 | for (let field of e.fields) {
421 | let icon_id = id+'.sort_icon.'+field.name
422 | ;[drag_state] = ui.drag(icon_id)
423 | if (drag_state) {
424 | hit_zone = 'sort_icon'
425 | hit_fi = field.index
426 | ui.set_cursor('pointer')
427 | if (drag_state == 'drag')
428 | e.set_order_by_dir(field, 'toggle', shift)
429 | break
430 | }
431 | }
432 |
433 | // hover or drag on on column header
434 | if (!hit_zone) {
435 | ;[drag_state, dx, dy, cs] = ui.drag(id+'.header')
436 | if (drag_state == 'hover' || drag_state == 'drag') {
437 | let x0 = ui.state(id+'.header').get('x')
438 | for (let field of e.fields) {
439 | let x = field._x + x0
440 | let w = field._w
441 | if (ui.mx >= x + w - 5 && ui.mx <= x + w + 5) {
442 | hit_zone = 'col_divider'
443 | hit_fi = field.index
444 | break
445 | } else if (ui.mx >= x && ui.mx <= x + w) {
446 | hit_zone = 'col'
447 | hit_fi = field.index
448 | if (drag_state == 'drag')
449 | cs.set('dx', ui.mx - x0 - field._x)
450 | break
451 | }
452 | }
453 | cs.set('zone', hit_zone)
454 | cs.set('field_index', hit_fi)
455 | } else if (drag_state) {
456 | hit_zone = cs.get('zone')
457 | hit_fi = cs.get('field_index')
458 | drag_op = cs.get('op')
459 | }
460 | }
461 |
462 | // column resize
463 | if (hit_zone == 'col_divider') {
464 | let field = e.fields[hit_fi]
465 | if (drag_state == 'drag') {
466 | cs.set('w0', field.w)
467 | drag_state == 'dragging'
468 | }
469 | if (drag_state == 'dragging') {
470 | field.w = clamp(cs.get('w0') + dx, field.min_w, field.max_w)
471 | }
472 | ui.set_cursor('ew-resize')
473 | }
474 |
475 | // column drag horizontally => start column move
476 | if (hit_zone == 'col' && !drag_op && drag_state == 'dragging'
477 | && abs(dx) > 10
478 | && e.fields[hit_fi].movable
479 | ) {
480 |
481 | let mover = ui.live_move_mixin()
482 |
483 | mover.movable_element_size = function(fi) {
484 | let [x, y, w, h] = cell_rect(0, fi)
485 | return horiz ? e.fields[fi]._w : h
486 | }
487 |
488 | mover.set_movable_element_pos = function(fi, x, moving) {
489 | e.fields[fi]._x = x
490 | }
491 |
492 | mover.move_element_start(hit_fi, 1,
493 | e.fields[0].is_group_field ? 1 : 0, e.fields.length)
494 |
495 | drag_op = 'col_move'
496 | cs.set('op', drag_op)
497 | cs.set('mover', mover)
498 | }
499 |
500 | // column move
501 | if (drag_op == 'col_move') {
502 |
503 | let mover = cs.get('mover')
504 |
505 | let x0 = ui.state(id+'.header').get('x')
506 | let mx = ui.mx - x0 - cs.get('dx')
507 |
508 | mover.move_element_update(horiz ? mx : my)
509 | e.scroll_to_cell(hit_ri ?? 0, hit_fi)
510 |
511 | if (drag_state == 'drop') {
512 | let over_fi = mover.move_element_stop() // sets x of moved element.
513 | e.move_field(hit_fi, over_fi)
514 |
515 | // reset drag state but preserve hover state
516 | drag_op = null
517 | }
518 |
519 | }
520 |
521 | // drag column vertically towards group-bar => column move to group
522 | let col_group_start
523 | if (hit_zone == 'col' && !drag_op && drag_state == 'dragging'
524 | && (ui.hovers(id+'.group_bar') || -dy > 10)
525 | && e.fields[hit_fi].groupable
526 | ) {
527 | col_group_start = true
528 | drag_op = 'col_group'
529 | cs.set('op', drag_op)
530 | }
531 |
532 | // hover or drag group-bar column
533 | let hit_gcol
534 | if (!hit_zone) {
535 | for (let col of e.groups.cols || empty_array) {
536 | // hit sort icon
537 | let icon_id = id+'.sort_icon.'+col
538 | ;[drag_state, dx, dy, cs] = ui.drag(icon_id)
539 | if (drag_state) {
540 | hit_zone = 'sort_icon'
541 | hit_gcol = col
542 | ui.set_cursor('pointer')
543 | if (drag_state == 'drag')
544 | e.set_order_by_dir(col, 'toggle', shift)
545 | break
546 | }
547 | // hit group column
548 | let col_id = id+'.gcol.'+col
549 | ;[drag_state, dx, dy, cs] = ui.drag(col_id)
550 | if (drag_state) {
551 | hit_zone = 'gcol'
552 | hit_gcol = col
553 | break
554 | }
555 | }
556 | }
557 |
558 | if (drag_op == 'col_group')
559 | hit_gcol = e.fields[hit_fi].name
560 |
561 | // move group-bar column OR drag header column over the group-bar
562 | if (hit_zone == 'gcol' || drag_op == 'col_group') {
563 |
564 | let gcol_move_start = hit_zone == 'gcol' && drag_state == 'drag'
565 | let start = gcol_move_start || col_group_start
566 | let mover
567 |
568 | if (start) {
569 |
570 | gcol_mover = ui.live_move_mixin()
571 | mover = gcol_mover
572 | cs.set('mover', gcol_mover)
573 |
574 | mover.cols = [...(e.groups.cols || empty_array)]
575 | mover.range_defs = assign({}, e.groups.range_defs)
576 |
577 | if (gcol_move_start) {
578 |
579 | mover.col_def = mover.range_defs[hit_gcol]
580 |
581 | let x = mover.col_def.index * (gcol_w + 1)
582 | let y = mover.col_def.group_level * sp2
583 | mover.x0 = x
584 | mover.y0 = y
585 |
586 | } else if (col_group_start) {
587 |
588 | mover.cols.push(hit_gcol)
589 | mover.col_def = {
590 | index: mover.cols.length-1,
591 | group_level: e.groups.cols.length ? e.groups.col_groups.length : 0,
592 | }
593 | mover.range_defs[hit_gcol] = mover.col_def
594 |
595 | let field = e.fld(hit_gcol)
596 | let hs = ui.state(id+'.header')
597 | let hx = hs.get('x')
598 | let hy = hs.get('y')
599 | let group_bar_was_visible = !(e.group_bar_visible == 'auto' && !e.groups.cols.length)
600 | mover.x0 = field._x - sp2
601 | mover.y0 = group_bar_was_visible ? group_bar_h() : 0
602 |
603 | }
604 |
605 | mover.xs = [] // col_index -> col_x
606 | mover.is = [] // col_index -> col_visual_index
607 | mover.movable_element_size = function() {
608 | return gcol_w + gcol_gap
609 | }
610 | mover.set_movable_element_pos = function(i, x, moving, vi) {
611 | this.xs[i] = x
612 | if (vi != null)
613 | this.is[i] = vi
614 | }
615 | mover.move_element_start(mover.col_def.index, 1, 0, mover.cols.length)
616 |
617 | // compute the allowed level ranges that the dragged column is
618 | // allowed to move vertically in each horizontal position that
619 | // it finds itself in (since it can move in both directions).
620 | // these ranges remain fixed while the col is moving.
621 | mover.levels = []
622 | mover.min_levels = []
623 | mover.max_levels = []
624 | let last_level = 0
625 | let i = 0
626 | for (let col of mover.cols) {
627 | let def = mover.range_defs[col]
628 | let level = def.group_level
629 | mover.levels [i] = level
630 | mover.min_levels[i] = level
631 | mover.max_levels[i] = level
632 | if (level != last_level) {
633 | mover.min_levels[i]--
634 | if (i > 0)
635 | mover.max_levels[i-1]++
636 | } else if (i > 0 && i == mover.cols.length-1) {
637 | mover.max_levels[i]++
638 | }
639 | last_level = level
640 | i++
641 | }
642 |
643 | } else {
644 | gcol_mover = cs.get('mover')
645 | mover = gcol_mover
646 | }
647 |
648 | if (drag_state != 'hover') {
649 |
650 | // drag column over the group-by header or over the grid's column header.
651 | mover.move_element_update(mover.x0 + dx)
652 | let vi = mover.is[mover.col_def.index]
653 | let level = mover.levels[vi]
654 | let min_level = mover.min_levels[vi]
655 | let max_level = mover.max_levels[vi]
656 | let mx = mover.x0 + dx
657 | let my = mover.y0 + dy
658 | level = clamp(round(my / sp2), min_level, max_level)
659 | let min_y = min_level * sp2 - ui.sp4()
660 | let max_y = max_level * sp2 + ui.sp4()
661 | let min_x = (-0.5) * (gcol_w + gcol_gap)
662 | let max_x = 1/0
663 | mover.drop_level = null
664 | mover.drop_pos = null
665 | if (
666 | my >= min_y && my <= max_y &&
667 | mx >= min_x && mx <= max_x &&
668 | ui.hovers(id+'.group_bar')
669 | ) { // move
670 | mover.drop_level = level
671 | } else {
672 | mover.move_element_update(null)
673 | if (drag_op != 'col_group') { // put back in grid
674 | mover.move_element_update(null)
675 | if (ui.hovers(id+'.header')) {
676 | let hx = ui.state(id+'.header').get('x')
677 | for (let field of e.fields) {
678 | let x = field._x + hx
679 | let w = field._w
680 | let d = w / 2
681 | let is_last = field.index == e.fields.length-1
682 | if (ui.mx >= x && ui.mx <= x + d && !field.is_group_field) {
683 | mover.drop_pos = field.index
684 | break
685 | } else if (ui.mx >= x + w - d && (is_last || ui.mx <= x + w)) {
686 | mover.drop_pos = field.index+1
687 | break
688 | }
689 | }
690 | }
691 | }
692 | }
693 |
694 | if (drag_state == 'drop') {
695 |
696 | if (mover.drop_level != null) { // move it between other group columns
697 |
698 | // create a temp array with the dragged column moved to its new position.
699 | let over_i = mover.move_element_stop()
700 | let a = []
701 | for (let col of mover.cols) {
702 | let def = mover.range_defs[col]
703 | let vi = mover.is[def.index]
704 | let level = col == hit_gcol ? mover.drop_level : mover.levels[vi]
705 | a.push([col, level])
706 | }
707 | array_move(a, mover.col_def.index, 1, over_i, true)
708 |
709 | // format group-bar cols and set it.
710 | let t = []
711 | let last_level = a[0][1] // can be -1..1 from dragging
712 | let i = 0
713 | for (let [col, level] of a) {
714 | if (level != last_level)
715 | t.push(' > ')
716 | t.push(col)
717 | let def = mover.range_defs[col]
718 | if (def.offset != null) t.push('/', def.offset)
719 | if (def.unit != null) t.push('/', def.unit)
720 | if (def.freq != null) t.push('/', def.freq)
721 | t.push(' ')
722 | last_level = level
723 | i++
724 | }
725 | e.update({group_by: t.join('')})
726 |
727 | } else if (mover.drop_pos != null) { // put it back in grid
728 |
729 | e.ungroup_col(hit_gcol, mover.drop_pos)
730 |
731 | }
732 |
733 | // reset drag state but preserve hover state
734 | gcol_mover = null
735 | drag_op = null
736 |
737 | }
738 |
739 | }
740 |
741 | }
742 |
743 | if (drag_op == 'col_group')
744 | ui.set_cursor('grabbing')
745 | else if (drag_op == 'col_move')
746 | ui.set_cursor('grabbing')
747 |
748 | // layout fields and compute cell grid size
749 |
750 | cells_w = 0
751 | for (let field of e.fields) {
752 | let w = clamp(field.w, field.min_w, field.max_w)
753 | let cw = w + 2 * sp2
754 | if (drag_op != 'col_move')
755 | field._x = cells_w
756 | field._w = cw
757 | cells_w += cw
758 | }
759 |
760 | // check hover/drag on cell view
761 |
762 | if (!hit_zone) {
763 | ;[drag_state, dx, dy] = ui.drag(id+'.cells')
764 | if (drag_state == 'hover' || drag_state == 'drag') {
765 | let s = ui.state(id+'.cells')
766 | let x0 = s.get('x')
767 | let y0 = s.get('y')
768 | hit_ri = floor((ui.my - y0) / cell_h)
769 | let row = e.rows[hit_ri]
770 | for (let fi = 0; fi < e.fields.length; fi++) {
771 | let [x1, y1, w, h] = cell_rect(hit_ri, fi)
772 | let hit_dx = ui.mx - x1 - x0
773 | let hit_dy = ui.my - y1 - y0
774 | if (hit_dx >= 0 && hit_dx <= w) {
775 | let field = e.fields[fi]
776 | hit_zone = 'cell'
777 | hit_fi = fi
778 | hit_indent = false
779 | if (row && field_has_indent(field)) {
780 | let has_children = (row.child_rows?.length ?? 0) > 0
781 | if (has_children) {
782 | let indent_x = indent_offset(row_indent(row))
783 | hit_indent = hit_dx <= indent_x
784 | }
785 | }
786 | break
787 | }
788 | }
789 | }
790 | }
791 |
792 | if (drag_state == 'drag' && hit_zone == 'cell') {
793 |
794 | ui.focus(id)
795 |
796 | let row = e.rows[hit_ri]
797 | let field = e.fields[hit_fi]
798 |
799 | if (hit_indent)
800 | e.toggle_collapsed(row, shift)
801 |
802 | let already_on_it =
803 | hit_ri == e.focused_row_index &&
804 | hit_fi == e.focused_field_index
805 |
806 | let click =
807 | !e.enter_edit_on_click
808 | && !e.stay_in_edit_mode
809 | && !e.editor
810 | && e.cell_clickable(row, field)
811 |
812 | if (e.focus_cell(hit_ri, hit_fi, 0, 0, {
813 | must_not_move_col: true,
814 | must_not_move_row: true,
815 | enter_edit: !hit_indent
816 | && !ctrl && !shift
817 | && ((e.enter_edit_on_click || click)
818 | || (e.enter_edit_on_click_focused && already_on_it)),
819 | focus_editor: true,
820 | focus_non_editable_if_not_found: true,
821 | editor_state: click ? 'click' : 'select_all',
822 | expand_selection: shift,
823 | invert_selection: ctrl,
824 | input: e,
825 | })) {
826 | // TODO:
827 | //drag_op = 'row_move'
828 | }
829 |
830 | }
831 |
832 | // process keyboard input ----------------------------------------------
833 |
834 | if (ui.key_events.length)
835 | if ((function() {
836 |
837 | let left_arrow = horiz ? 'arrowleft' : 'arrowup'
838 | let right_arrow = horiz ? 'arrowright' : 'arrowdown'
839 | let up_arrow = !horiz ? 'arrowleft' : 'arrowup'
840 | let down_arrow = !horiz ? 'arrowright' : 'arrowdown'
841 |
842 | // same-row field navigation.
843 | if (keydown(left_arrow) || keydown(right_arrow)) {
844 |
845 | let cols = keydown(left_arrow) ? -1 : 1
846 |
847 | let move = !e.editor
848 | || (e.auto_jump_cells && !shift && (!horiz || ctrl)
849 | && (!horiz
850 | || !e.editor.editor_state
851 | || ctrl
852 | && (e.editor.editor_state(cols < 0 ? 'left' : 'right')
853 | || e.editor.editor_state('all_selected'))
854 | ))
855 |
856 | if (move)
857 | if (e.focus_next_cell(cols, {
858 | editor_state: horiz
859 | ? (((e.editor && e.editor.editor_state) ? e.editor.editor_state('all_selected') : ctrl)
860 | ? 'select_all'
861 | : cols > 0 ? 'left' : 'right')
862 | : 'select_all',
863 | expand_selection: shift,
864 | input: e,
865 | }))
866 | return false
867 |
868 | }
869 |
870 | // Tab/Shift+Tab cell navigation.
871 | if (keydown('tab') && e.tab_navigation) {
872 |
873 | let cols = shift ? -1 : 1
874 |
875 | if (e.focus_next_cell(cols, {
876 | auto_advance_row: true,
877 | editor_state: cols > 0 ? 'left' : 'right',
878 | input: e,
879 | }))
880 | return false
881 |
882 | }
883 |
884 | // insert with the arrow down key on the last focusable row.
885 | if (keydown(down_arrow) && !shift) {
886 | if (!e.save_on_add_row) { // not really compatible behavior...
887 | if (e.is_last_row_focused() && e.can_actually_add_rows()) {
888 | if (e.insert_rows(1, {
889 | input: e,
890 | focus_it: true,
891 | })) {
892 | return false
893 | }
894 | }
895 | }
896 | }
897 |
898 | // remove last row with the arrow up key if not edited.
899 | if (keydown(up_arrow)) {
900 | if (e.is_last_row_focused() && e.focused_row) {
901 | let row = e.focused_row
902 | if (row.is_new && !e.is_row_user_modified(row)) {
903 | let editing = !!e.editor
904 | if (e.remove_row(row, {input: e, refocus: true})) {
905 | if (editing)
906 | e.enter_edit('select_all')
907 | return false
908 | }
909 | }
910 | }
911 | }
912 |
913 | // row navigation.
914 | let rows
915 | if (keydown(up_arrow )) rows = -1
916 | else if (keydown(down_arrow)) rows = 1
917 | else if (keydown('pageup' )) rows = -(ctrl ? 1/0 : page_row_count)
918 | else if (keydown('pagedown')) rows = (ctrl ? 1/0 : page_row_count)
919 | else if (keydown('home' )) rows = -1/0
920 | else if (keydown('end' )) rows = 1/0
921 | if (rows) {
922 |
923 | let move = !e.editor
924 | || (e.auto_jump_cells && !shift
925 | && (horiz
926 | || !e.editor.editor_state
927 | || (ctrl
928 | && (e.editor.editor_state(rows < 0 ? 'left' : 'right')
929 | || e.editor.editor_state('all_selected')))
930 | ))
931 |
932 | if (move)
933 | if (e.focus_cell(true, true, rows, 0, {
934 | editor_state: e.editor && e.editor.editor_state
935 | && (horiz ? e.editor.editor_state() : 'select_all'),
936 | expand_selection: shift,
937 | input: e,
938 | }))
939 | return false
940 |
941 | }
942 |
943 | // F2: enter edit mode
944 | if (!e.editor && keydown('f2')) {
945 | e.enter_edit('select_all')
946 | return false
947 | }
948 |
949 | // Enter: toggle edit mode, and navigate on exit
950 | if (keydown('enter')) {
951 | if (e.quicksearch_text) {
952 | e.quicksearch(e.quicksearch_text, e.focused_row, shift ? -1 : 1)
953 | return false
954 | } else if (e.hasclass('picker')) {
955 | e.pick_val()
956 | return false
957 | } else if (!e.editor) {
958 | e.enter_edit('click')
959 | return false
960 | } else {
961 | if (e.advance_on_enter == 'next_row')
962 | e.focus_cell(true, true, 1, 0, {
963 | input: e,
964 | enter_edit: e.stay_in_edit_mode,
965 | editor_state: 'select_all',
966 | must_move: true,
967 | })
968 | else if (e.advance_on_enter == 'next_cell')
969 | e.focus_next_cell(shift ? -1 : 1, {
970 | input: e,
971 | enter_edit: e.stay_in_edit_mode,
972 | editor_state: 'select_all',
973 | must_move: true,
974 | })
975 | else if (e.exit_edit_on_enter)
976 | e.exit_edit()
977 | return false
978 | }
979 | }
980 |
981 | // Esc: exit edit mode.
982 | if (keydown('escape')) {
983 | if (e.quicksearch_text) {
984 | e.quicksearch('')
985 | return false
986 | }
987 | if (e.editor) {
988 | if (e.exit_edit_on_escape) {
989 | e.exit_edit()
990 | e.focus()
991 | return false
992 | }
993 | } else if (e.focused_row && e.focused_field) {
994 | let row = e.focused_row
995 | if (row.is_new && !e.is_row_user_modified(row, true))
996 | e.remove_row(row, {input: e, refocus: true})
997 | else
998 | e.revert_cell(row, e.focused_field)
999 | return false
1000 | }
1001 | }
1002 |
1003 | // insert key: insert row
1004 | if (keydown('insert')) {
1005 | let insert_arg = 1 // add one row
1006 |
1007 | if (ctrl && e.focused_row) { // add a row filled with focused row's values
1008 | let row = e.serialize_row_vals(e.focused_row)
1009 | e.pk_fields.map((f) => delete row[f.name])
1010 | insert_arg = [row]
1011 | }
1012 |
1013 | if (e.insert_rows(insert_arg, {
1014 | input: e,
1015 | at_focused_row: true,
1016 | focus_it: true,
1017 | })) {
1018 | return false
1019 | }
1020 | }
1021 |
1022 | if (keydown('delete')) {
1023 |
1024 | if (e.editor && e.editor.input_val == null)
1025 | e.exit_edit({cancel: true})
1026 |
1027 | // delete: toggle-delete selected rows
1028 | if (!ctrl && !e.editor && e.remove_selected_rows({
1029 | input: e, refocus: true, toggle: true, confirm: true
1030 | }))
1031 | return false
1032 |
1033 | // ctrl_delete: set selected cells to null.
1034 | if (ctrl) {
1035 | e.set_null_selected_cells({input: e})
1036 | return false
1037 | }
1038 |
1039 | }
1040 |
1041 | if (!e.editor && keydown(' ') && !e.quicksearch_text) {
1042 | if (e.focused_row && (!e.can_focus_cells || e.focused_field == e.tree_field))
1043 | e.toggle_collapsed(e.focused_row, shift)
1044 | else if (e.focused_row && e.focused_field && e.cell_clickable(e.focused_row, e.focused_field))
1045 | e.enter_edit('click')
1046 | return false
1047 | }
1048 |
1049 | if (!e.editor && ctrl && keydown('a')) {
1050 | e.select_all_cells()
1051 | return false
1052 | }
1053 |
1054 | if (!e.editor && keydown('backspace')) {
1055 | if (e.quicksearch_text)
1056 | e.quicksearch(e.quicksearch_text.slice(0, -1), e.focused_row)
1057 | return false
1058 | }
1059 |
1060 | if (ctrl && keydown('s')) {
1061 | e.save()
1062 | return false
1063 | }
1064 |
1065 | if (ctrl && !e.editor) {
1066 | if (keydown('c')) {
1067 | let row = e.focused_row
1068 | let fld = e.focused_field
1069 | if (row && fld)
1070 | copy_to_clipboard(e.cell_text_val(row, fld))
1071 | return false
1072 | } else if (keydown('x')) {
1073 |
1074 | return false
1075 | } else if (keydown('v')) {
1076 |
1077 | return false
1078 | }
1079 | }
1080 |
1081 | // TODO:
1082 | // printable characters: enter quick edit mode.
1083 | function keypress(c) {
1084 | if (e.quick_edit) {
1085 | if (!e.editor && e.focused_row && e.focused_field) {
1086 | e.enter_edit('select_all')
1087 | let v = e.focused_field.from_text(c)
1088 | e.set_cell_val(e.focused_row, e.focused_field, v)
1089 | return false
1090 | }
1091 | } else if (!e.editor) {
1092 | e.quicksearch(e.quicksearch_text + c, e.focused_row)
1093 | return false
1094 | }
1095 | }
1096 |
1097 | })() === false) // if (ui.key_events.length) ...
1098 | ui.capture_keys()
1099 |
1100 | // draw ----------------------------------------------------------------
1101 |
1102 | ui.v(fr, 0, align, valign, min_w, min_h)
1103 |
1104 | // group-by bar
1105 |
1106 | if (e.group_bar_visible == 'always'
1107 | || e.group_bar_visible == 'auto' && e.groups.cols.length
1108 | || drag_op == 'col_group'
1109 | ) {
1110 |
1111 | let group_bar_i = ui.sb(id+'.group_bar', 0, 'hide', 'hide', 's', 't', null, group_bar_h())
1112 | ui.bb('bg2', null, 'b', 'light')
1113 |
1114 | let mover = gcol_mover
1115 |
1116 | for (let col of (mover ?? e.groups).cols ?? empty_array) {
1117 |
1118 | let def = (mover ?? e.groups).range_defs[col]
1119 | let x = def.index * (gcol_w + 1)
1120 | let y = def.group_level * sp2
1121 | let w = gcol_w
1122 | let h = gcol_h
1123 |
1124 | if (mover) {
1125 | let vi = mover.is[def.index]
1126 | let level = col == hit_gcol ? mover.drop_level : mover.levels[vi]
1127 | x = mover.xs[def.index]
1128 | y = level * sp2
1129 | if (col == hit_gcol) {
1130 | if (mover.drop_level == null) {
1131 | // dragging outside the columns area
1132 | x = mover.x0 + dx
1133 | y = mover.y0 + dy
1134 | } else {
1135 | let place_x = vi * (w + 1)
1136 | ui.m(sp2 + place_x - 1, sp2 + y - 1, 0, 0)
1137 | ui.stack('', 0, 'l', 't', w + 2, h + 2)
1138 | ui.border(1, 'marker', null, 0, 'dashes')
1139 | ui.end_stack()
1140 | x = mover.x0 + dx
1141 | y = mover.y0 + dy
1142 | }
1143 | }
1144 | }
1145 |
1146 | if (mover && col == hit_gcol) {
1147 | ui.popup(id+'.moving_gcol_popup', 'handle', group_bar_i, 'il', '[', 0, 0)
1148 | ui.nohit()
1149 | }
1150 |
1151 | let col_id = id+'.gcol.'+col
1152 | let field = e.fld(col)
1153 | ui.m(sp2 + x, sp2 + y, 0, 0)
1154 | ui.stack(col_id, 1, 'l', 't', w, h)
1155 | ui.bb('bg1',
1156 | hit_gcol == col && (
1157 | drag_state == 'hover' && 'hover' ||
1158 | drag_state == 'drop' && 'hover' ||
1159 | drag_state == 'dragging' && (hit_zone != 'sort_icon' ? 'active' : 'hover') ||
1160 | drag_state == 'drag' && (hit_zone != 'sort_icon' ? 'active' : 'hover')) || null,
1161 | 1, 'intense')
1162 | ui.p(sp2, ui.sp075())
1163 | ui.h(1, sp)
1164 |
1165 | ui.text('', field.label, 1, 'l', 'c', 0)
1166 |
1167 | // sort icon
1168 | let icon_id = id+'.sort_icon.'+col
1169 | if (field.sortable) {
1170 | ui.scope()
1171 | ui.font('fas')
1172 | ui.color(field.sort_dir ? 'label' : 'faint',
1173 | (hit_zone == 'sort_icon' && hit_gcol == col) ? 'hover' : null)
1174 | let pri = field.sort_priority
1175 | ui.text(icon_id,
1176 | field.sort_dir == 'asc' && (pri ? '\uf176' : '\uf176') ||
1177 | field.sort_dir && (pri ? '\uf175' : '\uf175') ||
1178 | '\uf07d'
1179 | , 0)
1180 | ui.end_scope()
1181 | }
1182 | ui.end_h()
1183 | ui.end_stack()
1184 |
1185 | if (mover && col == hit_gcol)
1186 | ui.end_popup()
1187 |
1188 | }
1189 |
1190 | ui.end_sb()
1191 |
1192 | }
1193 |
1194 | // column header
1195 |
1196 | function draw_header_cell(field, noclip) {
1197 | ui.m(field._x, 0, 0, 0)
1198 | ui.p(sp2, 0)
1199 | ui.h(0, sp, 'l', 't', field._w - 2 * sp2, header_h)
1200 |
1201 | let col_move = drag_op == 'col_move' && hit_fi == field.index
1202 | let col_group = drag_op == 'col_group' && hit_gcol == field.name
1203 |
1204 | ui.bb(
1205 | col_move ? 'bg2' : col_group ? 'bg0' : 'bg1', null,
1206 | col_move ? 'blr' : 'br', 'intense')
1207 |
1208 | if (col_group)
1209 | ui.color('faint')
1210 |
1211 | let max_min_w = noclip ? null : max(0,
1212 | field._w
1213 | - 2 * sp2
1214 | - (field.sortable ? 2 * sp2 : 0)
1215 | )
1216 | let pri = field.sort_priority
1217 |
1218 | if (field.align != 'right')
1219 | ui.text('', field.label, 1, field.align, 'c', max_min_w)
1220 |
1221 | let icon_id = id+'.sort_icon.'+field.name
1222 |
1223 | if (field.sortable) {
1224 | ui.scope()
1225 |
1226 | ui.font('fas')
1227 | if (!col_group)
1228 | ui.color(field.sort_dir ? 'label' : 'faint',
1229 | (hit_zone == 'sort_icon' && hit_fi == field.index) ? 'hover' : null)
1230 |
1231 | ui.text(icon_id,
1232 | field.sort_dir == 'asc' && (pri ? '\uf176' : '\uf176') ||
1233 | field.sort_dir && (pri ? '\uf175' : '\uf175') ||
1234 | '\uf07d'
1235 | , 0)
1236 |
1237 | ui.end_scope()
1238 | }
1239 |
1240 | if (field.align == 'right')
1241 | ui.text('', field.label, 1, field.align, 'c', max_min_w)
1242 |
1243 | ui.end_h()
1244 | }
1245 |
1246 | h_sb_i = ui.scrollbox(id+'.header', 0, e.auto_expand ? 'contain' : 'hide', 'contain')
1247 |
1248 | ui.stack(id+'.header')
1249 | ui.measure(id+'.header')
1250 |
1251 | let col_move = drag_op == 'col_move'
1252 |
1253 | ui.bb('bg1', null, 'b', 'intense')
1254 | for (let field of e.fields) {
1255 | if (col_move && hit_fi === field.index)
1256 | continue
1257 | draw_header_cell(field)
1258 | }
1259 | if (col_move) {
1260 | let field = e.fields[hit_fi]
1261 | draw_header_cell(field)
1262 | }
1263 |
1264 | // group column drop arrows
1265 |
1266 | if (gcol_mover?.drop_pos != null) {
1267 | let x = e.fields[gcol_mover.drop_pos]?._x ?? cells_w
1268 | ui.ml(x)
1269 | ui.stack('', 0, 'l', 't', 0, header_h)
1270 | ui.popup('', 'overlay', null, 't', 'c')
1271 | ui.scope()
1272 | ui.color('marker')
1273 | ui.font('fas')
1274 | ui.text('', '\uf063') // arrow-down
1275 | ui.end_scope()
1276 | ui.end_popup()
1277 | ui.popup('', 'overlay', null, 'b', 'c')
1278 | ui.scope()
1279 | ui.color('marker')
1280 | ui.font('fas')
1281 | ui.text('', '\uf062') // arrow-up
1282 | ui.end_scope()
1283 | ui.end_popup()
1284 | ui.end_stack()
1285 | }
1286 |
1287 | ui.end_stack()
1288 |
1289 | ui.end_scrollbox()
1290 |
1291 | // cells frame
1292 |
1293 | let cells_h = e.rows.length * cell_h
1294 | let overflow = e.auto_expand ? 'contain' : 'auto'
1295 | ui.scrollbox(id+'.cells_scrollbox', 1, overflow, overflow, 's', 's')
1296 | ui.frame(noop, on_cellview_frame, 0, 'l', 't', cells_w, cells_h)
1297 | ui.end_scrollbox()
1298 |
1299 | ui.end_v()
1300 |
1301 | }
1302 |
1303 | }
1304 |
1305 | ui.grid = function(id, opt, fr, align, valign, min_w, min_h) {
1306 |
1307 | ui.keepalive(id)
1308 | let s = ui.state(id)
1309 | let nav = s.get('nav')
1310 | if (!nav) {
1311 | nav = ui.nav(opt, s)
1312 | ui.on_free(id, () => nav.free())
1313 | init(id, nav)
1314 | s.set('nav', nav)
1315 | }
1316 | nav.render(fr, align, valign, min_w, min_h)
1317 |
1318 | }
1319 |
1320 | }()) // module function
1321 |
--------------------------------------------------------------------------------
/www/ui_grid_temp.js:
--------------------------------------------------------------------------------
1 |
2 |
3 | for (let ri = ri1; ri < ri2; ri++) {
4 | let row = rows[ri]
5 | if (row.errors && row.errors.failed) {
6 | let ry = ri * e.cell_h
7 | draw_row_invalid_border(row, ri, rx, ry, rw, rh, draw_stage)
8 | }
9 | }
10 |
11 | if (draw_cell_w != null) {
12 | let [x, y, w, h] = cell_rect(hit_ri, hit_fi, draw_stage)
13 | x = draw_cell_x
14 | w = draw_cell_w
15 | draw_hover_outline(x, y, w, h)
16 | }
17 |
18 |
19 |
20 |
21 | if (!editing) {
22 |
23 | cx.save()
24 |
25 | // clip
26 | cx.beginPath()
27 | cx.translate(px, py)
28 | let cw = w - px - px
29 | let ch = h - py - py
30 | cx.cw = cw
31 | cx.ch = ch
32 | cx.rect(0, 0, cw, ch)
33 | cx.clip()
34 |
35 | let y = round(ch / 2)
36 |
37 | // tree node sign
38 | if (collapsed != null) {
39 | cx.fillStyle = selected ? fg : e.bg_focused_selected
40 | cx.font = cx.icon_font
41 | let x = indent_x - e.font_size - 4
42 | cx.textBaseline = 'middle'
43 | cx.fillText(collapsed ? '\uf0fe' : '\uf146', x, y)
44 | }
45 |
46 | // text
47 | cx.translate(indent_x, 0)
48 | cx.fg_text = fg
49 | cx.quicksearch_len = cell_focused && e.quicksearch_text.length || 0
50 | e.draw_val(row, field, input_val, cx)
51 |
52 | cx.restore()
53 | }
54 |
55 | cx.restore()
56 |
57 | if (ri != null && focused)
58 | update_editor(
59 | horiz ? null : xy,
60 | !horiz ? null : xy, hit_indent)
61 | }
62 |
63 |
64 | cells_w = bx + col_x
65 |
66 | // prevent cells_w shrinking while col resizing to prevent scroll_x changes.
67 | if (col_resizing && !e.auto_expand)
68 | cells_w = max(cells_w, last_cells_w)
69 |
70 | vrn = floor(cells_view_h / cell_h) + 2 // 2 is right, think it!
71 |
72 | function measure_cell_width(row, field) {
73 | cx.measure = true
74 | e.draw_cell(row, field, cx)
75 | cx.measure = false
76 | return cx.measured_width
77 | }
78 |
79 | function draw_cell_border(cx, w, h, bx, by, color_x, color_y, draw_stage) {
80 | let bw = w - .5
81 | let bh = h - .5
82 | let zz = -.5
83 | if (by && color_y != null) { // horizontals
84 | cx.beginPath()
85 | cx.lineWidth = by
86 | cx.strokeStyle = color_y
87 |
88 | // top line
89 | if (!horiz && draw_stage == 'moving_cols') {
90 | cx.moveTo(zz, zz)
91 | cx.lineTo(bw, zz)
92 | }
93 | // bottom line
94 | cx.moveTo(zz, bh)
95 | cx.lineTo(bw, bh)
96 |
97 | cx.stroke()
98 | }
99 | if (bx && color_x != null) { // verticals
100 | cx.beginPath()
101 | cx.lineWidth = bx
102 | cx.strokeStyle = color_x
103 |
104 | // left line
105 | if (horiz && draw_stage == 'moving_cols') {
106 | cx.moveTo(zz, zz)
107 | cx.lineTo(zz, bh)
108 | }
109 | // right line
110 | cx.moveTo(bw, zz)
111 | cx.lineTo(bw, bh)
112 |
113 | cx.stroke()
114 | }
115 | }
116 |
117 |
118 | function draw_hover_outline(x, y, w, h) {
119 |
120 | let bx = e.cell_border_v_width
121 | let by = e.cell_border_h_width
122 | let bw = w - .5
123 | let bh = h - .5
124 |
125 | cx.save()
126 | cx.translate(x, y)
127 |
128 | cx.lineWidth = bx || by
129 |
130 | // add a background to override the borders.
131 | cx.strokeStyle = e.bg
132 | cx.setLineDash(empty_array)
133 | cx.beginPath()
134 | cx.rect(-.5, -.5, bw + .5, bh + .5)
135 | cx.stroke()
136 |
137 | // draw the actual hover outline.
138 | cx.strokeStyle = e.fg
139 | cx.setLineDash([1, 3])
140 | cx.beginPath()
141 | cx.rect(-.5, -.5, bw + .5, bh + .5)
142 | cx.stroke()
143 |
144 | cx.restore()
145 | }
146 |
147 |
148 |
149 | // grid view -----------------------------------------------------------------
150 |
151 | function grid_view(nav) {
152 |
153 | let e = {}
154 |
155 | let horiz = true
156 | e.horiz = true
157 |
158 | e.rowset = nav.rowset
159 | e.fields = nav.fields
160 | e.rows = nav.rows
161 |
162 | e.cell_border_v_width = 0
163 | e.cell_border_h_width = 1
164 | e.line_height = 22
165 |
166 | e.padding_x = round(ui.spx_input() * ui.get_font_size())
167 | e.padding_y = round(ui.spy_input() * ui.get_font_size())
168 |
169 | e.cell_h = e.cell_h ?? round(e.line_height + 2 * e.padding_y + e.cell_border_h_width)
170 | e.header_h = e.header_h ?? round(e.line_height + 2 * e.padding_y + e.cell_border_h_width)
171 |
172 | e.header_w = 120 // vert-grid only
173 | e.cell_w = 120 // vert-grid only
174 | e.auto_cols_w = false // horiz-grid only
175 | e.auto_expand = false
176 |
177 | // keyboard behavior
178 | e.auto_jump_cells = true // jump to next/prev cell on caret limits with Ctrl.
179 | e.tab_navigation = false // disabled as it prevents jumping out of the grid.
180 | e.advance_on_enter = 'next_row' // false|'next_row'|'next_cell'
181 | e.exit_edit_on_escape = true
182 | e.exit_edit_on_enter = true
183 | e.quick_edit = false // quick edit (vs. quick-search) when pressing a key
184 |
185 | // mouse behavior
186 | e.can_reorder_fields = true
187 | e.enter_edit_on_click = false
188 | e.enter_edit_on_click_focused = false
189 | e.enter_edit_on_dblclick = true
190 | e.focus_cell_on_click_header = false
191 | e.can_change_parent = true
192 |
193 | // context menu features
194 | e.enable_context_menu = true
195 | e.can_change_header_visibility = true
196 | e.can_change_filters_visibility = true
197 | e.can_change_fields_visibility = true
198 |
199 | // view-size-derived state ------------------------------------------------
200 |
201 | let cells_w, cells_h // cell grid dimensions.
202 | let grid_w, grid_h // grid dimensions.
203 | let cells_view_w, cells_view_h // cell viewport dimensions inside scrollbars.
204 | let cells_view_overflow_x, cells_view_overflow_y // cells viewport overflow setting.
205 | let header_x, header_y
206 | let header_w, header_h, filters_h // header viewport dimensions.
207 | let hcell_h // header cell height.
208 | let vrn // how many rows are fully or partially in the viewport.
209 | let page_row_count // how many rows in a page for pgup/pgdown navigation.
210 |
211 | let min_cols_w
212 |
213 | e.measure_header = function(axis) {
214 | if (horiz) {
215 | if (!axis) {
216 |
217 | let col_resizing = hit_state == 'col_resizing'
218 |
219 | min_cols_w = 0
220 |
221 | if (e.auto_expand)
222 | for (let field of e.fields)
223 | min_cols_w += col_resizing ? field._w : min(max(field.w, field.min_w), field.max_w)
224 |
225 | let bx = e.cell_border_v_width
226 | let min_cells_w = bx + min_cols_w
227 |
228 | // prevent cells_w shrinking while col resizing to prevent scroll_x changes.
229 | if (col_resizing && !e.auto_expand)
230 | min_cells_w = max(min_cells_w, cells_w)
231 |
232 | cells_w = min_cells_w
233 |
234 | return min_cells_w
235 |
236 | } else {
237 |
238 | header_h = 36
239 |
240 | return header_h
241 |
242 | }
243 | } else {
244 |
245 | }
246 | return 0
247 | }
248 |
249 | e.measure_cells = function(axis) {
250 | if (horiz) {
251 | if (!axis) {
252 | return cells_w // computed in measure_header()
253 | } else {
254 | let by = e.cell_border_h_width
255 | cells_h = by + e.cell_h * e.rows.length
256 | return cells_h
257 | }
258 | } else {
259 |
260 | }
261 | return 0
262 | }
263 |
264 | e.position_header = function(axis, sx, sw) {
265 | if (!axis) {
266 | header_x = sx
267 | header_w = sw
268 | } else {
269 | header_y = sx
270 | header_h = sw
271 | }
272 | }
273 |
274 | e.position_cells = function(axis, sx, sw) {
275 |
276 | if (horiz) {
277 | if (!axis) {
278 |
279 | let total_free_w = 0
280 | let cw = min_cols_w
281 | if (auto_cols_w) {
282 | cw = cells_view_w - bx
283 | total_free_w = max(0, cw - min_cols_w)
284 | }
285 |
286 | let col_x = 0
287 | for (let field of e.fields) {
288 |
289 | let min_col_w = col_resizing ? field._w : max(field.min_w, field.w)
290 | let max_col_w = col_resizing ? field._w : field.max_w
291 | let free_w = total_free_w * (min_col_w / min_cols_w)
292 | let col_w = min(floor(min_col_w + free_w), max_col_w)
293 | if (field == e.fields.at(-1)) {
294 | let remaining_w = cw - col_x
295 | if (total_free_w > 0)
296 | // set width exactly to prevent showing the horizontal scrollbar.
297 | col_w = remaining_w
298 | else
299 | // stretch last col to include leftovers from rounding.
300 | col_w = max(col_w, remaining_w)
301 | }
302 |
303 | field._y = 0
304 | field._x = col_x
305 | field._w = col_w
306 |
307 | col_x += col_w
308 | }
309 |
310 | cells_w = bx + col_x
311 |
312 | // prevent cells_w shrinking while col resizing to prevent scroll_x changes.
313 | if (col_resizing && !e.auto_expand)
314 | cells_w = max(cells_w, last_cells_w)
315 |
316 | page_row_count = floor(cells_view_h / e.cell_h)
317 | vrn = floor(cells_view_h / e.cell_h) + 2 // 2 is right, think it!
318 |
319 | } else {
320 |
321 | }
322 | }
323 | }
324 |
325 | e.scroll_cells = function(sx, sy) {
326 | scroll_x = sx
327 | scroll_y = sy
328 | }
329 |
330 | e.measure_sizes = function(cw, ch) {
331 | filters_h = 0
332 | if (e.auto_expand) {
333 | grid_w = 0
334 | grid_h = 0
335 | scroll_x = 0
336 | scroll_y = 0
337 | } else {
338 | grid_w = cw
339 | grid_h = ch
340 | scroll_x = e.cells_view.scrollLeft
341 | scroll_y = e.cells_view.scrollTop
342 | }
343 | }
344 |
345 | e.update_sizes = function() {
346 |
347 | if (hit_state == 'col_moving')
348 | return
349 |
350 | let col_resizing = hit_state == 'col_resizing'
351 | let bx = e.cell_border_v_width
352 | let by = e.cell_border_h_width
353 |
354 | let auto_cols_w =
355 | horiz
356 | && !e.auto_expand
357 | && e.auto_cols_w
358 | && (!e.rowset || e.rowset.auto_cols_w != false)
359 | && !col_resizing
360 |
361 | cells_view_overflow_x = e.auto_expand ? 'hidden' : 'auto'
362 | cells_view_overflow_y = e.auto_expand ? 'hidden' : (auto_cols_w ? 'scroll' : 'auto')
363 |
364 | if (horiz) {
365 |
366 | let last_cells_w = cells_w
367 |
368 | hcell_h = e.header_h
369 | header_h = e.header_h
370 |
371 | let min_cols_w = 0
372 | for (let field of e.fields)
373 | min_cols_w += col_resizing ? field._w : min(max(field.w, field.min_w), field.max_w)
374 |
375 | cells_h = by + e.cell_h * e.rows.length
376 |
377 | let min_cells_w = bx + min_cols_w
378 |
379 | // prevent cells_w shrinking while col resizing to prevent scroll_x changes.
380 | if (col_resizing && !e.auto_expand)
381 | min_cells_w = max(min_cells_w, last_cells_w)
382 |
383 | if (e.auto_expand) {
384 | cells_view_w = min_cells_w
385 | cells_view_h = cells_h
386 | } else {
387 | cells_view_w = grid_w // before vscrollbar.
388 | cells_view_h = grid_h - header_h - filters_h // before hscrollbar.
389 | }
390 |
391 | header_w = cells_view_w // before vscrollbar
392 |
393 | let total_free_w = 0
394 | let cw = min_cols_w
395 | if (auto_cols_w) {
396 | cw = cells_view_w - bx
397 | total_free_w = max(0, cw - min_cols_w)
398 | }
399 |
400 | let col_x = 0
401 | for (let field of e.fields) {
402 |
403 | let min_col_w = col_resizing ? field._w : max(field.min_w, field.w)
404 | let max_col_w = col_resizing ? field._w : field.max_w
405 | let free_w = total_free_w * (min_col_w / min_cols_w)
406 | let col_w = min(floor(min_col_w + free_w), max_col_w)
407 | if (field == e.fields.at(-1)) {
408 | let remaining_w = cw - col_x
409 | if (total_free_w > 0)
410 | // set width exactly to prevent showing the horizontal scrollbar.
411 | col_w = remaining_w
412 | else
413 | // stretch last col to include leftovers from rounding.
414 | col_w = max(col_w, remaining_w)
415 | }
416 |
417 | field._y = 0
418 | field._x = col_x
419 | field._w = col_w
420 |
421 | col_x += col_w
422 | }
423 |
424 | cells_w = bx + col_x
425 |
426 | // prevent cells_w shrinking while col resizing to prevent scroll_x changes.
427 | if (col_resizing && !e.auto_expand)
428 | cells_w = max(cells_w, last_cells_w)
429 |
430 | page_row_count = floor(cells_view_h / e.cell_h)
431 | vrn = floor(cells_view_h / e.cell_h) + 2 // 2 is right, think it!
432 |
433 | } else {
434 |
435 | hcell_h = e.cell_h
436 | header_w = min(e.header_w, grid_w - 20)
437 | header_w = max(header_w, 20)
438 |
439 | for (let fi = 0; fi < e.fields.length; fi++) {
440 | let field = e.fields[fi]
441 | let [x, y, w] = cell_rel_rect(fi)
442 | field._x = x
443 | field._y = y
444 | field._w = w
445 | }
446 |
447 | cells_w = bx + e.cell_w * e.rows.length
448 | cells_h = by + e.cell_h * e.fields.length
449 |
450 | if (e.auto_expand) {
451 | cells_view_w = cells_w
452 | cells_view_h = cells_h
453 | } else {
454 | cells_view_w = grid_w - header_w // before vscrollbar.
455 | cells_view_h = grid_h // before hscrollbar.
456 | }
457 |
458 | header_h = min(e.cell_h * e.fields.length, cells_view_h) // before hscrollbar.
459 |
460 | ;[cells_view_w, cells_view_h] =
461 | scrollbox_client_dimensions(
462 | cells_w,
463 | cells_h,
464 | cells_view_w,
465 | cells_view_h,
466 | cells_view_overflow_x,
467 | cells_view_overflow_y
468 | )
469 |
470 | page_row_count = floor(cells_view_w / e.cell_w)
471 | vrn = floor(cells_view_w / e.cell_w) + 2 // 2 is right, think it!
472 |
473 | }
474 |
475 | vrn = min(vrn, e.rows.length)
476 |
477 | e.update_scroll(scroll_x, scroll_y)
478 | }
479 |
480 | // view-scroll-derived state ----------------------------------------------
481 |
482 | let scroll_x, scroll_y // current scroll offsets.
483 | let vri1, vri2 // visible row range.
484 |
485 | // NOTE: keep this raf-friendly, i.e. don't measure the DOM in here!
486 | e.update_scroll = function(sx, sy) {
487 | sx = horiz ? sx : clamp(sx, 0, max(0, cells_w - cells_view_w))
488 | sy = !horiz ? sy : clamp(sy, 0, max(0, cells_h - cells_view_h))
489 | if (horiz) {
490 | vri1 = floor(sy / e.cell_h)
491 | } else {
492 | vri1 = floor(sx / e.cell_w)
493 | }
494 | vri2 = vri1 + vrn
495 | vri1 = max(0, min(vri1, e.rows.length - 1))
496 | vri2 = max(0, min(vri2, e.rows.length))
497 | scroll_x = sx
498 | scroll_y = sy
499 |
500 | // hack because we don't get pointermove events on scroll when
501 | // the mouse doesn't move but the div beneath the mouse pointer does.
502 | if (hit_state == 'cell') {
503 | hit_state = null
504 | ht_cell(null, hit_mx, hit_my)
505 | }
506 | }
507 |
508 | // mouse-derived state ----------------------------------------------------
509 |
510 | let hit_state // this affects both rendering and behavior in many ways.
511 | let hit_mx, hit_my // last mouse coords, needed on scroll event.
512 | let hit_target // last mouse target, needed on click events.
513 | let hit_dx, hit_dy // mouse coords relative to the dragged object.
514 | let hit_ri, hit_fi, hit_indent // the hit cell and whether the cell indent was hit.
515 | let row_move_state // additional state when row moving
516 |
517 | let row_rect
518 | {
519 | let r = [0, 0, 0, 0] // x, y, w, h
520 | row_rect = function(ri, draw_stage) {
521 | let s = row_move_state
522 | if (horiz) {
523 | r[0] = 0
524 | if (s) {
525 | if (draw_stage == 'moving_rows') {
526 | r[1] = s.x + ri * e.cell_h
527 | } else {
528 | r[1] = s.xs[ri]
529 | }
530 | } else {
531 | r[1] = ri * e.cell_h
532 | }
533 | r[2] = cells_w
534 | r[3] = e.cell_h
535 | } else {
536 | r[1] = 0
537 | if (s) {
538 | if (draw_stage == 'moving_rows') {
539 | r[0] = s.x + ri * e.cell_w
540 | } else {
541 | r[0] = s.xs[ri]
542 | }
543 | } else {
544 | r[0] = ri * e.cell_w
545 | }
546 | r[2] = e.cell_w
547 | r[3] = cells_h
548 | }
549 | return r
550 | }
551 | }
552 |
553 | let cell_rel_rect
554 | {
555 | let r = [0, 0, 0, 0] // x, y, w, h
556 | cell_rel_rect = function(fi) {
557 | let s = row_move_state
558 | if (horiz) {
559 | r[0] = e.fields[fi]._x
560 | r[1] = 0
561 | r[2] = e.fields[fi]._w
562 | r[3] = e.cell_h
563 | } else {
564 | r[0] = 0
565 | r[1] = hit_state == 'col_moving' ? e.fields[fi]._x : fi * e.cell_h
566 | r[2] = e.cell_w
567 | r[3] = e.cell_h
568 | }
569 | return r
570 | }
571 | }
572 |
573 | function cell_rect(ri, fi, draw_stage) {
574 | let [rx, ry] = row_rect(ri, draw_stage)
575 | let r = cell_rel_rect(fi)
576 | r[0] += rx
577 | r[1] += ry
578 | return r
579 | }
580 |
581 | function hcell_rect(fi) {
582 | let r = cell_rel_rect(fi)
583 | if (!horiz)
584 | r[2] = header_w
585 | r[3] = hcell_h
586 | return r
587 | }
588 |
589 | function cells_rect(ri1, fi1, ri2, fi2, draw_stage) {
590 | let [x1, y1, w1, h1] = cell_rect(ri1, fi1, draw_stage)
591 | let [x2, y2, w2, h2] = cell_rect(ri2, fi2, draw_stage)
592 | let x = min(x1, x2)
593 | let y = min(y1, y2)
594 | let w = max(x1, x2) - x
595 | let h = max(y1, y2) - y
596 | return [x, y, w, h]
597 | }
598 |
599 | function field_has_indent(field) {
600 | return horiz && field == e.tree_field
601 | }
602 |
603 | function row_indent(row) {
604 | return row.parent_rows ? row.parent_rows.length : 0
605 | }
606 |
607 | e.scroll_to_cell = function(ri, fi) {
608 | if (ri == null)
609 | return
610 | let [x, y, w, h] = cell_rect(ri, fi || 0)
611 | e.cells_view.scroll_to_view_rect(null, null, x, y, w, h)
612 | }
613 |
614 | function row_visible_rect(row) { // relative to cells
615 | let bx = e.cell_border_v_width
616 | let by = e.cell_border_h_width
617 | let ri = e.row_index(row)
618 | let [x, y, w, h] = row_rect(ri)
619 | return clip_rect(x+bx, y+by, w, h, scroll_x, scroll_y, cells_view_w, cells_view_h)
620 | }
621 |
622 | function cell_visible_rect(row, field) { // relative to cells
623 | let bx = e.cell_border_v_width
624 | let by = e.cell_border_h_width
625 | let ri = e.row_index(row)
626 | let fi = e.field_index(field)
627 | let [x, y, w, h] = cell_rect(ri, fi)
628 | return clip_rect(x+bx, y+by, w, h, scroll_x, scroll_y, cells_view_w, cells_view_h)
629 | }
630 |
631 | // rendering --------------------------------------------------------------
632 |
633 | function update_cx(cx) {
634 | cx.font_size = e.font_size
635 | cx.line_height = e.line_height
636 | cx.text_font = e.text_font
637 | cx.icon_font = e.icon_font
638 | cx.bg_search = e.bg_search
639 | cx.fg_search = e.fg_search
640 | cx.fg_dim = e.fg_dim
641 | }
642 |
643 | function draw_cell_border(cx, w, h, bx, by, color_x, color_y, draw_stage) {
644 | let bw = w - .5
645 | let bh = h - .5
646 | let zz = -.5
647 | if (by && color_y != null) { // horizontals
648 | cx.beginPath()
649 | cx.lineWidth = by
650 | cx.strokeStyle = color_y
651 |
652 | // top line
653 | if (!horiz && draw_stage == 'moving_cols') {
654 | cx.moveTo(zz, zz)
655 | cx.lineTo(bw, zz)
656 | }
657 | // bottom line
658 | cx.moveTo(zz, bh)
659 | cx.lineTo(bw, bh)
660 |
661 | cx.stroke()
662 | }
663 | if (bx && color_x != null) { // verticals
664 | cx.beginPath()
665 | cx.lineWidth = bx
666 | cx.strokeStyle = color_x
667 |
668 | // left line
669 | if (horiz && draw_stage == 'moving_cols') {
670 | cx.moveTo(zz, zz)
671 | cx.lineTo(zz, bh)
672 | }
673 | // right line
674 | cx.moveTo(bw, zz)
675 | cx.lineTo(bw, bh)
676 |
677 | cx.stroke()
678 | }
679 | }
680 |
681 | function draw_hcell_at(field, fi, x0, y0, w, h, draw_stage) {
682 |
683 | // static geometry
684 | let px = e.padding_x
685 | let py = e.padding_y
686 | let bx = e.cell_border_v_width
687 | let by = e.cell_border_h_width
688 |
689 | cx.save()
690 |
691 | cx.translate(x0, y0)
692 |
693 | // border
694 | draw_cell_border(cx, w, h, bx, by,
695 | e.hcell_border_v_color,
696 | e.hcell_border_h_color,
697 | draw_stage)
698 |
699 | // background
700 | let bg = draw_stage == 'moving_cols' ? e.bg_moving : e.bg_header
701 | if (bg) {
702 | cx.beginPath()
703 | cx.fillStyle = bg
704 | cx.rect(0, 0, w-bx, h-by)
705 | cx.fill()
706 | }
707 |
708 | // order sign
709 | let dir = field.sort_dir
710 | if (dir != null) {
711 | let pri = field.sort_priority
712 | let asc = horiz ? 'up' : 'left'
713 | let desc = horiz ? 'down' : 'right'
714 | let right = horiz && field.align == 'right'
715 | let icon_char = fontawesome_char('fa-angle'+(pri?'-double':'')+'-'+(dir== 'asc'?asc:desc))
716 | cx.font = e.header_icon_font
717 | let x = right ? 2*px : w - 2*px
718 | let y = round(h / 2)
719 | let iw = e.font_size * 1.5
720 | cx.textAlign = right ? 'left' : 'right'
721 | cx.textBaseline = 'middle'
722 | cx.fillStyle = e.fg_header
723 | cx.fillText(icon_char, x, y)
724 | w -= iw
725 | if (right)
726 | cx.translate(iw, 0)
727 | }
728 |
729 | // clip
730 | cx.beginPath()
731 | cx.translate(px, py)
732 | let cw = w - 2*px
733 | let ch = h - 2*py
734 | cx.rect(0, 0, cw, ch)
735 | cx.clip()
736 |
737 | // text
738 | let x = 0
739 | let y = round(ch / 2)
740 | if (horiz)
741 | if (field.align == 'right')
742 | x = cw
743 | else if (field.align == 'center')
744 | x = cw / 2
745 |
746 | cx.font = e.header_text_font
747 | cx.textAlign = horiz ? field.align : 'left'
748 | cx.textBaseline = 'middle'
749 | cx.fillStyle = e.fg_header
750 | cx.fillText(field.label, x, y)
751 |
752 | cx.restore()
753 |
754 | }
755 |
756 | function draw_hcells_range(fi1, fi2, draw_stage) {
757 | cx.save()
758 | let bx = e.cell_border_v_width
759 | let by = e.cell_border_h_width
760 | cx.translate(header_x, header_y)
761 | cx.translate(horiz ? -scroll_x + bx : 0, horiz ? 0 : -scroll_y + by)
762 | let skip_moving_col = hit_state == 'col_moving' && draw_stage == 'non_moving_cols'
763 | for (let fi = fi1; fi < fi2; fi++) {
764 | if (skip_moving_col && hit_fi == fi)
765 | continue
766 | let field = e.fields[fi]
767 | let [x, y, w, h] = hcell_rect(fi)
768 | draw_hcell_at(field, fi, x, y, w, h, draw_stage)
769 | }
770 | cx.restore()
771 | }
772 |
773 | function indent_offset(indent) {
774 | return floor(e.font_size * 1.5 + (e.font_size * 1.2) * indent)
775 | }
776 |
777 | function measure_cell_width(row, field) {
778 | cx.measure = true
779 | e.draw_cell(row, field, cx)
780 | cx.measure = false
781 | return cx.measured_width
782 | }
783 |
784 | let draw_cell_x
785 | let draw_cell_w
786 | function draw_cell_at(row, ri, fi, x, y, w, h, draw_stage) {
787 |
788 | let field = e.fields[fi]
789 | let input_val = e.cell_input_val(row, field)
790 |
791 | // static geometry
792 | let bx = e.cell_border_v_width
793 | let by = e.cell_border_h_width
794 | let px = e.padding_x + bx
795 | let py = e.padding_y + by
796 |
797 | // state
798 | let grid_focused = e.focused
799 | let row_focused = e.focused_row == row
800 | let cell_focused = row_focused && (!e.can_focus_cells || field == e.focused_field)
801 | let disabled = e.is_cell_disabled(row, field)
802 | let is_new = row.is_new
803 | let cell_invalid = e.cell_has_errors(row, field)
804 | let modified = e.cell_modified(row, field)
805 | let is_null = input_val == null
806 | let is_empty = input_val === ''
807 | let sel_fields = e.selected_rows.get(row)
808 | let selected = (isobject(sel_fields) ? sel_fields.has(field) : sel_fields) || false
809 | let editing = !!e.editor && cell_focused
810 | let hovering = hit_state == 'cell' && hit_ri == ri && hit_fi == fi
811 | let full_width = !draw_stage && ((row_focused && field == e.focused_field) || hovering)
812 |
813 | // geometry
814 | if (full_width) {
815 | let w1 = max(w, measure_cell_width(row, field) + 2*px)
816 | if (field.align == 'right')
817 | x -= (w1 - w)
818 | else if (field.align == 'center')
819 | x -= round((w1 - w) / 2)
820 | w = w1
821 | }
822 |
823 | let indent_x = 0
824 | let collapsed
825 | if (field_has_indent(field)) {
826 | indent_x = indent_offset(row_indent(row))
827 | let has_children = row.child_rows.length > 0
828 | if (has_children)
829 | collapsed = !!row.collapsed
830 | let s = row_move_state
831 | if (s) {
832 | // show minus sign on adopting parent.
833 | if (row == s.hit_parent_row && collapsed == null)
834 | collapsed = false
835 |
836 | // shift indent on moving rows so it gets under the adopting parent.
837 | if (draw_stage == 'moving_rows')
838 | indent_x += s.hit_indent_x - s.indent_x
839 | }
840 | }
841 |
842 | // background & text color
843 | // drawing a background is slow, so we avoid it when we can.
844 | let bg = (draw_stage == 'moving_cols' || draw_stage == 'moving_rows')
845 | && e.bg_moving
846 |
847 | let fg = e.fg
848 |
849 | if (editing)
850 | bg = grid_focused ? e.row_bg_focused : e.row_bg_unfocused
851 | else if (cell_invalid)
852 | if (grid_focused && cell_focused) {
853 | bg = e.bg_focused_invalid
854 | fg = e.fg_error
855 | } else {
856 | bg = e.bg_error
857 | fg = e.fg_error
858 | }
859 | else if (cell_focused)
860 | if (selected)
861 | if (grid_focused) {
862 | bg = e.bg_focused_selected
863 | fg = e.fg_selected
864 | } else {
865 | bg = e.bg_unfocused_selected
866 | fg = e.fg_unfocused_selected
867 | }
868 | else if (grid_focused)
869 | bg = e.bg_focused
870 | else
871 | bg = e.bg_unselected
872 | else if (selected) {
873 | if (grid_focused)
874 | bg = e.bg_selected
875 | else
876 | bg = e.bg_unfocused
877 | fg = e.fg_selected
878 | } else if (is_new)
879 | if (modified)
880 | bg = e.bg_new_modified
881 | else
882 | bg = e.bg_new
883 | else if (modified)
884 | bg = e.bg_modified
885 | else if (row_focused)
886 | if (grid_focused)
887 | bg = e.row_bg_focused
888 | else
889 | bg = e.row_bg_unfocused
890 |
891 | if (!bg)
892 | if ((ri & 1) == 0)
893 | bg = e.bg_alt
894 | else if (full_width)
895 | bg = e.bg
896 |
897 | if (is_null || is_empty || disabled)
898 | fg = e.fg_dim
899 |
900 | // drawing
901 |
902 | cx.save()
903 | cx.translate(x, y)
904 |
905 | // background
906 | if (bg) {
907 | cx.beginPath()
908 | cx.fillStyle = bg
909 | cx.rect(0, 0, w, h)
910 | cx.fill()
911 | }
912 |
913 | // border
914 | draw_cell_border(cx, w, h, bx, by,
915 | e.cell_border_v_color,
916 | e.cell_border_h_color,
917 | draw_stage)
918 |
919 | if (!editing) {
920 |
921 | cx.save()
922 |
923 | // clip
924 | cx.beginPath()
925 | cx.translate(px, py)
926 | let cw = w - px - px
927 | let ch = h - py - py
928 | cx.cw = cw
929 | cx.ch = ch
930 | cx.rect(0, 0, cw, ch)
931 | cx.clip()
932 |
933 | let y = round(ch / 2)
934 |
935 | // tree node sign
936 | if (collapsed != null) {
937 | cx.fillStyle = selected ? fg : e.bg_focused_selected
938 | cx.font = cx.icon_font
939 | let x = indent_x - e.font_size - 4
940 | cx.textBaseline = 'middle'
941 | cx.fillText(collapsed ? '\uf0fe' : '\uf146', x, y)
942 | }
943 |
944 | // text
945 | cx.translate(indent_x, 0)
946 | cx.fg_text = fg
947 | cx.quicksearch_len = cell_focused && e.quicksearch_text.length || 0
948 | e.draw_val(row, field, input_val, cx)
949 |
950 | cx.restore()
951 | }
952 |
953 | cx.restore()
954 |
955 | // TODO:
956 | //if (ri != null && focused)
957 | // update_editor(
958 | // horiz ? null : xy,
959 | // !horiz ? null : xy, hit_indent)
960 |
961 | draw_cell_x = x
962 | draw_cell_w = w
963 | }
964 |
965 | function draw_hover_outline(x, y, w, h) {
966 |
967 | let bx = e.cell_border_v_width
968 | let by = e.cell_border_h_width
969 | let bw = w - .5
970 | let bh = h - .5
971 |
972 | cx.save()
973 | cx.translate(x, y)
974 |
975 | cx.lineWidth = bx || by
976 |
977 | // add a background to override the borders.
978 | cx.strokeStyle = e.bg
979 | cx.setLineDash(empty_array)
980 | cx.beginPath()
981 | cx.rect(-.5, -.5, bw + .5, bh + .5)
982 | cx.stroke()
983 |
984 | // draw the actual hover outline.
985 | cx.strokeStyle = e.fg
986 | cx.setLineDash([1, 3])
987 | cx.beginPath()
988 | cx.rect(-.5, -.5, bw + .5, bh + .5)
989 | cx.stroke()
990 |
991 | cx.restore()
992 | }
993 |
994 | function draw_row_strike_line(row, ri,x, y, w, h, draw_stage) {
995 | cx.save()
996 | cx.strokeStyle = e.fg
997 | cx.beginPath()
998 | if (horiz) {
999 | cx.translate(x, y + h / 2 + .5)
1000 | cx.moveTo(0, 0)
1001 | cx.lineTo(w, 0)
1002 | cx.stroke()
1003 | } else {
1004 | cx.translate(x + w / 2, y + .5)
1005 | cx.moveTo(0, 0)
1006 | cx.lineTo(0, h)
1007 | cx.stroke()
1008 | }
1009 | cx.restore()
1010 | }
1011 |
1012 | function draw_row_invalid_border(row, ri,x, y, w, h, draw_stage) {
1013 | cx.lineWidth = 1
1014 | cx.strokeStyle = e.bg_error
1015 | cx.beginPath()
1016 | cx.rect(x - .5, y -.5, w - horiz, h - (1 - horiz))
1017 | cx.stroke()
1018 | }
1019 |
1020 | function draw_cell(ri, fi, draw_stage) {
1021 | let [x, y, w, h] = cell_rect(ri, fi, draw_stage)
1022 | let row = e.rows[ri]
1023 | draw_cell_at(row, ri, fi, x, y, w, h, draw_stage)
1024 | }
1025 |
1026 | function draw_cells_range(rows, ri1, ri2, fi1, fi2, draw_stage) {
1027 | cx.save()
1028 | let bx = e.cell_border_v_width
1029 | let by = e.cell_border_h_width
1030 | cx.translate(-scroll_x + bx, -scroll_y + by)
1031 |
1032 | let hit_cell, foc_cell, foc_ri, foc_fi
1033 | if (!draw_stage) {
1034 |
1035 | foc_ri = e.focused_row_index
1036 | foc_fi = e.focused_field_index
1037 |
1038 | hit_cell = hit_state == 'cell'
1039 | && hit_ri >= ri1 && hit_ri <= ri2
1040 | && hit_fi >= fi1 && hit_fi <= fi2
1041 |
1042 | foc_cell = foc_ri != null && foc_fi != null
1043 |
1044 | // when foc_cell and hit_cell are the same, don't draw them twice.
1045 | if (foc_cell && hit_cell && hit_ri == foc_ri && hit_fi == foc_fi)
1046 | foc_cell = null
1047 |
1048 | }
1049 | let skip_moving_col = hit_state == 'col_moving' && draw_stage == 'non_moving_cols'
1050 |
1051 | for (let ri = ri1; ri < ri2; ri++) {
1052 |
1053 | let row = rows[ri]
1054 | let [rx, ry, rw, rh] = row_rect(ri, draw_stage)
1055 |
1056 | let foc_cell_now = foc_cell && foc_ri == ri
1057 | let hit_cell_now = hit_cell && hit_ri == ri
1058 |
1059 | for (let fi = fi1; fi < fi2; fi++) {
1060 | if (skip_moving_col && hit_fi == fi)
1061 | continue
1062 | if (hit_cell_now && hit_fi == fi)
1063 | continue
1064 | if (foc_cell_now && foc_fi == fi)
1065 | continue
1066 |
1067 | let [x, y, w, h] = cell_rel_rect(fi)
1068 | draw_cell_at(row, ri, fi, rx + x, ry + y, w, h, draw_stage)
1069 | }
1070 |
1071 | if (row.removed)
1072 | draw_row_strike_line(row, ri, rx, ry, rw, rh, draw_stage)
1073 | }
1074 |
1075 | if (foc_cell && foc_ri >= ri1 && foc_ri <= ri2 && foc_fi >= fi1 && foc_fi <= fi2)
1076 | draw_cell(foc_ri, foc_fi, draw_stage)
1077 |
1078 | // hit_cell can overlap foc_cell, so we draw it after it.
1079 | draw_cell_x = null
1080 | draw_cell_w = null
1081 | if (hit_cell && hit_ri >= ri1 && hit_ri <= ri2 && hit_fi >= fi1 && hit_fi <= fi2)
1082 | draw_cell(hit_ri, hit_fi, draw_stage)
1083 |
1084 | for (let ri = ri1; ri < ri2; ri++) {
1085 | let row = rows[ri]
1086 | if (row.errors && row.errors.failed) {
1087 | let [rx, ry, rw, rh] = row_rect(ri, draw_stage)
1088 | draw_row_invalid_border(row, ri, rx, ry, rw, rh, draw_stage)
1089 | }
1090 | }
1091 |
1092 | if (draw_cell_w != null) {
1093 | let [x, y, w, h] = cell_rect(hit_ri, hit_fi, draw_stage)
1094 | x = draw_cell_x
1095 | w = draw_cell_w
1096 | draw_hover_outline(x, y, w, h)
1097 | }
1098 |
1099 | cx.restore()
1100 | }
1101 |
1102 | e.draw_header = function() {
1103 |
1104 | /*
1105 | e.cells.w = max(1, cells_w) // need at least 1px to show scrollbar.
1106 | e.cells.h = max(1, cells_h) // need at least 1px to show scrollbar.
1107 |
1108 | e.cells_view.w = e.auto_expand ? cells_view_w : null
1109 | e.cells_view.h = e.auto_expand ? cells_view_h : null
1110 |
1111 | e.cells_view.style['overflow-x'] = cells_view_overflow_x
1112 | e.cells_view.style['overflow-y'] = cells_view_overflow_y
1113 |
1114 | e.cells_canvas.x = scroll_x
1115 | e.cells_canvas.y = scroll_y
1116 |
1117 | e.header.show(e.header_visible)
1118 |
1119 | e.header.w = horiz ? null : header_w
1120 | e.header.h = header_h + filters_h
1121 |
1122 | e.filters_bar.x = -scroll_x
1123 | e.filters_bar.y = header_h
1124 | e.filters_bar.h = filters_h
1125 |
1126 | e.cells_canvas .resize(cells_view_w, cells_view_h, 200, 200)
1127 | e.header_canvas.resize(header_w, header_h, 200, horiz ? 1 : 200)
1128 | */
1129 |
1130 | if (hit_state == 'row_moving') { // draw fixed rows first and moving rows above them.
1131 | draw_hcells_range(0, e.fields.length)
1132 | } else if (hit_state == 'col_moving') { // draw fixed cols first and moving cols above them.
1133 | draw_hcells_range(0 , e.fields.length, 'non_moving_cols')
1134 | draw_hcells_range(hit_fi, hit_fi + 1 , 'moving_cols')
1135 | } else {
1136 | draw_hcells_range(0, e.fields.length)
1137 | }
1138 |
1139 | }
1140 |
1141 | e.draw_cells = function() {
1142 |
1143 | for (let field of e.fields)
1144 | if (field.filter_input)
1145 | field.filter_input.position()
1146 |
1147 | if (hit_state == 'row_moving') { // draw fixed rows first and moving rows above them.
1148 | let s = row_move_state
1149 | draw_cells_range(e.rows, s.vri1, s.vri2 , 0, e.fields.length, 'non_moving_rows')
1150 | draw_cells_range(s.rows, s.move_vri1, s.move_vri2, 0, e.fields.length, 'moving_rows')
1151 | } else if (hit_state == 'col_moving') { // draw fixed cols first and moving cols above them.
1152 | draw_cells_range (e.rows, vri1, vri2, 0 , e.fields.length, 'non_moving_cols')
1153 | draw_cells_range (e.rows, vri1, vri2, hit_fi, hit_fi + 1 , 'moving_cols')
1154 | } else {
1155 | draw_cells_range(e.rows, vri1, vri2, 0, e.fields.length)
1156 | }
1157 |
1158 | }
1159 |
1160 | return e
1161 | }
1162 |
1163 | // header --------------------------------------------------------------------
1164 |
1165 | let header = {}
1166 |
1167 | header.create = function(cmd, view) {
1168 | return ui.cmd(cmd, view)
1169 | }
1170 |
1171 | header.measure = function(a, i, axis) {
1172 | let view = a[i]
1173 | let min_w = view.measure_header(axis)
1174 | add_ct_min_wh(a, axis, min_w, 0)
1175 | }
1176 |
1177 | header.position = function(a, i, axis, sx, sw) {
1178 | let view = a[i]
1179 | view.position_header(axis, sx, sw)
1180 | }
1181 |
1182 | header.draw = function(a, i) {
1183 | let view = a[i]
1184 | view.draw_header()
1185 | }
1186 |
1187 | ui.widget('grid_header', header)
1188 |
1189 |
1190 |
--------------------------------------------------------------------------------
/www/ui_nav_todo.js:
--------------------------------------------------------------------------------
1 |
2 | // tabname ----------------------------------------------------------------
3 |
4 | /*
5 | e.prop('tabname_template', {slot: 'lang', default: '{0}'})
6 |
7 | let tabname
8 | e.prop('tabname', {slot: 'lang'})
9 |
10 | e.set_tabname(
11 | function() {
12 | if (tabname)
13 | return tabname
14 | // TODO: use param nav's selected_rows_tabname()
15 | let view = rowset_name || 'Nav'
16 | return subst(e.tabname_template, view)
17 | }, function(s) {
18 | tabname = s
19 | e.announce('tabname_changed')
20 | })
21 |
22 | e.row_tabname = function(row) {
23 | return e.draw_row(row, div())
24 | }
25 |
26 | e.selected_rows_tabname = function() {
27 | if (!e.selected_rows)
28 | return S('no_rows_selected', 'No rows selected')
29 | let caps = []
30 | for (let [row, sel_fields] of e.selected_rows)
31 | caps.push(e.row_tabname(row))
32 | return caps.join(', ')
33 | }
34 | */
35 |
36 |
37 |
38 | /* view-less nav -------------------------------------------------------------
39 |
40 | */
41 |
42 | ui.bare_nav = function(e) {
43 | e.class('bare-nav')
44 | e.make_nav_widget()
45 | }
46 |
47 | /* ---------------------------------------------------------------------------
48 |
49 | Widget that has a nav as its data model. The nav can be either external,
50 | internal, or missing (in which case the widget is disabled).
51 |
52 | config props:
53 | nav_id nav
54 | nav
55 | rowset
56 | rowset_name
57 | fires:
58 | ^bind_nav(nav, on)
59 |
60 | */
61 |
62 | // TODO: make interpreting the inner html a separate init step!
63 |
64 | G.make_nav_data_widget_extend_before = function(e) {
65 |
66 | let nav
67 | let rowset = e.$1(':scope>rowset')
68 | if (rowset) {
69 | nav = bare_nav({}, rowset)
70 | } else {
71 | nav = e.$1(':scope>nav')
72 | if (nav) {
73 | nav.init_component()
74 | nav.del()
75 | }
76 | }
77 | if (nav) {
78 | e.on_bind(function(on) {
79 | if (on)
80 | head.add(nav)
81 | else
82 | nav.del()
83 | e._html_nav = on ? nav : null
84 | })
85 | }
86 |
87 | }
88 |
89 | ui.make_nav_data_widget = function() {
90 |
91 | let e = this
92 |
93 | let nav
94 |
95 | function bind_nav(on) {
96 | if (!nav)
97 | return
98 | if (nav._internal) {
99 | if (on) {
100 | head.add(nav)
101 | e.fire('bind_nav', nav, true)
102 | } else {
103 | e.fire('bind_nav', nav, false)
104 | nav.del()
105 | }
106 | }
107 | }
108 |
109 | // NOTE: internal nav takes priority to external nav. This decision is
110 | // arbitrary, but also more stable (external nav can go anytime).
111 | function get_nav() {
112 | if (e.rowset_name || e.rowset) { // internal
113 | if (nav && nav._internal
114 | && ((e.rowset_name && nav.rowset_name == e.rowset_name) ||
115 | (e.rowset && nav.rowset == e.rowset))) // same internal
116 | return nav
117 | return bare_nav({
118 | rowset_name : e.rowset_name,
119 | rowset : e.rowset,
120 | _internal : true,
121 | })
122 | }
123 | return e.nav || e._html_nav // external
124 | }
125 |
126 | function update_nav() {
127 | let nav1 = get_nav()
128 | if (nav != nav1) {
129 | bind_nav(false)
130 | nav = nav1
131 | bind_nav(true)
132 | }
133 | e.ready = !!(!e.nav_based || nav)
134 | }
135 |
136 | e.on_bind(update_nav)
137 |
138 | // external nav: referenced directly or by id.
139 | e.nav = null
140 | e.nav_id = null
141 |
142 | // internal nav: local rowset binding
143 | e.rowset = null
144 |
145 | // internal nav: remote rowset binding
146 | e.rowset_name = null
147 | e.set_rowset_name = update_nav
148 |
149 | e.property('nav_based', () => !!(e.nav_id || e.nav || e.rowset_name || e.rowset))
150 |
151 | e.ready = false
152 |
153 | }
154 |
155 | /* ---------------------------------------------------------------------------
156 |
157 | Widget that has a nav and a col from the nav as its data model, but doesn't
158 | depend on the focused row (see next mixin for that).
159 |
160 | config props:
161 | nav
162 | nav_id nav
163 | col
164 | state props:
165 | nav_based
166 | ready
167 | fires:
168 | ^bind_field(field, on)
169 |
170 | */
171 |
172 | ui.make_nav_col_widget = function() {
173 |
174 | let e = this
175 |
176 | let nav, field
177 |
178 | function bind_field(on) {
179 | if (on) {
180 | assert(!field)
181 | field = nav && nav.optfld(e.col) || null
182 | if (!field)
183 | return
184 | e.fire('bind_field', field, true)
185 | } else {
186 | if (!field)
187 | return
188 | e.fire('bind_field', field, false)
189 | field = null
190 | }
191 | }
192 |
193 | function update_nav(force) {
194 | let nav1 = e.nav
195 | if (nav1 != nav || force) {
196 | bind_field(false)
197 | nav = nav1
198 | bind_field(true)
199 | }
200 | e.ready = !!(!e.nav_based || field)
201 | }
202 |
203 | function update_field() {
204 | update_nav(true)
205 | }
206 |
207 | e.prop('col')
208 | e.set_col = update_field
209 |
210 | e.prop('nav')
211 | e.prop('nav_id')
212 | e.set_nav = update_nav
213 |
214 | e.listen('reset', function(reset_nav) {
215 | if (reset_nav != nav) return
216 | update_field()
217 | })
218 |
219 | e.property('nav_based', () => !!((e.nav_id || e.nav) && e.col))
220 |
221 | e.prop('ready')
222 |
223 | }
224 |
225 | /* ---------------------------------------------------------------------------
226 |
227 | Widget that has a nav cell or a range of two nav cells as its data model.
228 |
229 | config props:
230 | nav
231 | nav_id nav
232 | col[1,2]
233 | state props:
234 | nav_based
235 | ready
236 | row
237 | fires:
238 | ^bind_field(field, on)
239 |
240 | */
241 |
242 | function field_ready(field) {
243 | if (!field)
244 | return false
245 | if (field.lookup_nav_id || field.lookup_nav)
246 | if (!field.lookup_fields)
247 | return false
248 | return true
249 | }
250 |
251 | ui.make_nav_input_widget = function(field_props, range, field_range_props) {
252 |
253 | let e = this
254 |
255 | // nav binding ------------------------------------------------------------
256 |
257 | let nav, field, field1, field2
258 |
259 | field_props = field_props && wordset(field_props )
260 | field_range_props = field_range_props && wordset(field_range_props)
261 |
262 | function bind_field(field, col, input_widget, INPUT_VALUE, on) {
263 | if (on) {
264 | assert(!field)
265 | field = nav && nav.optfld(col) || null
266 | if (!field)
267 | return
268 | e.xoff()
269 | if (field_props)
270 | for (let k in field_props)
271 | if (field[k] !== undefined)
272 | input_widget.set_prop(k, field[k])
273 | if (field_range_props)
274 | for (let k in field_range_props)
275 | if (field[k] !== undefined)
276 | e.set_prop(k, field[k])
277 | e.xon()
278 | e.fire('bind_field', field, true)
279 | e[INPUT_VALUE] = e.get_input_val_for(field)
280 | } else {
281 | if (!field)
282 | return
283 | e.fire('bind_field', field, false)
284 | field = null
285 | }
286 | return field
287 | }
288 |
289 | function bind_fields(on) {
290 | if (range) {
291 | field1 = bind_field(field1, e.col1, e.input_widgets[0], 'input_value1', on)
292 | field2 = bind_field(field2, e.col2, e.input_widgets[1], 'input_value2', on)
293 | } else {
294 | field = bind_field(field, e.col, e, 'input_value', on)
295 | }
296 | }
297 |
298 | function update_ready() {
299 | e.ready = !!(!e.nav_based || (nav && (range
300 | ? field_ready(field1) && field_ready(field2)
301 | : field_ready(field))))
302 | e.disable('not_ready', !e.ready)
303 | }
304 |
305 | function update_nav(force) {
306 | let nav1 = e.nav
307 | if (nav1 != nav || force) {
308 | bind_fields(false)
309 | nav = nav1
310 | bind_fields(true)
311 | update_ready()
312 | }
313 | }
314 |
315 | function update_fields() {
316 | update_nav(true)
317 | }
318 |
319 | if (range) {
320 | e.prop('col1')
321 | e.prop('col2')
322 | e.set_col1 = update_fields
323 | e.set_col2 = update_fields
324 | } else {
325 | e.prop('col')
326 | e.set_col = update_fields
327 | }
328 |
329 | e.prop('nav')
330 | e.prop('nav_id')
331 | e.set_nav = update_nav
332 |
333 | e.property('nav_based', () => !!((e.nav_id || e.nav) && (range ? e.col1 && e.col2 : e.col)))
334 |
335 | e.prop('ready')
336 |
337 | e.listen('reset', function(reset_nav) {
338 | if (reset_nav != nav) return
339 | update_fields()
340 | })
341 |
342 | e.listen('col_vals_changed', function(changed_nav, changed_field) {
343 | if (!range && changed_field != field)
344 | return
345 | if (range && changed_field != field1 && changed_field != field2)
346 | return
347 | update_fields()
348 | })
349 |
350 | e.listen('field_changed', function(changed_field, k, v) {
351 | if (!range && changed_field != field)
352 | return
353 | if (range && changed_field != field1 && changed_field != field2)
354 | return
355 | if (field_props && field_props[k])
356 | for (let input_widget of e.input_widgets) {
357 | e.xoff()
358 | input_widget.set_prop(k, v)
359 | e.xon()
360 | }
361 | if (field_range_props && field_range_props[k]) {
362 | e.xoff()
363 | e.set_prop(k, v)
364 | e.xon()
365 | }
366 | })
367 |
368 | e.listen('label_find_target', function(label, f) {
369 | if (f == field || f == field1 || f == field2) {
370 | return e
371 | }
372 | })
373 |
374 | // state ------------------------------------------------------------------
375 |
376 | e.property('row', () => nav && nav.focused_row)
377 |
378 | e.get_input_val_for = function(field) {
379 | let row = e.row
380 | return row && field ? nav.cell_input_val(row, field) : null
381 | }
382 |
383 | function set_input_values(ev) {
384 | if (range) {
385 | e.set_prop('input_value1', e.get_input_val_for(field1), ev)
386 | e.set_prop('input_value2', e.get_input_val_for(field2), ev)
387 | } else {
388 | e.set_prop('input_value', e.get_input_val_for(field), ev)
389 | }
390 | }
391 |
392 | function set_cell_val_for(field, v, ev) {
393 | if (v === undefined)
394 | v = null
395 | let was_set
396 | if (field) {
397 | if (!e.row)
398 | if (nav && !nav.all_rows.length)
399 | if (nav.can_actually_change_val())
400 | nav.insert_rows(1, {focus_it: true})
401 | if (e.row) {
402 | nav.set_cell_val(e.row, field, v, ev)
403 | was_set = true
404 | }
405 | }
406 | }
407 |
408 | if (range) {
409 | e.set_cell_val1 = function(v, ev) { set_cell_val_for(field1, v, ev) }
410 | e.set_cell_val2 = function(v, ev) { set_cell_val_for(field2, v, ev) }
411 | } else {
412 | e.set_cell_val = function(v, ev) { set_cell_val_for(field , v, ev) }
413 | }
414 |
415 | e.listen('focused_row_cell_state_changed', function(te, row, f, changes, ev) {
416 | if (te != nav) return
417 | if (f != field && f != field1 && f != field2) return
418 | if (changes.input_val && !(ev && ev.target == e))
419 | set_input_values(ev)
420 | })
421 |
422 | e.listen('focused_row_changed', function(te, row, row0, ev) {
423 | if (te != nav) return
424 | set_input_values(ev)
425 | })
426 |
427 | // e.listen('col_vals_changed', update)
428 | // e.listen('col_info_changed', update)
429 |
430 | }
431 |
432 |
--------------------------------------------------------------------------------
/www/ui_validation.js:
--------------------------------------------------------------------------------
1 | /* validators & validation rules ---------------------------------------------
2 |
3 | We don't like abstractions around here but this one buys us many things:
4 |
5 | - validation rules are: reusable, composable, and easy to write logic for.
6 | - rules apply automatically, no need to specify which to apply where.
7 | - a validator can depend on, i.e. require that other rules pass first.
8 | - a validator can parse the input value so that subsequent rules operate
9 | on the parsed value, thus only having to parse the value once.
10 | - null values are filtered automatically.
11 | - result contains all the messages with `failed` and `checked` status on each.
12 | - it makes no garbage on re-validation so you can validate huge lists fast.
13 | - entire objects can be validated the same way simple values are, so it also
14 | works for validating ranges, db records, etc. as a unit.
15 | - it's not that much code for all of that.
16 |
17 | input:
18 | parent_validator
19 | output:
20 | rules
21 | triggered
22 | results
23 | value
24 | failed
25 | parse_failed
26 | first_failed_result
27 | methods:
28 | prop_changed(prop) -> needs_revalidation?
29 | validate([ev]) -> valid?
30 | effectively_failed()
31 |
32 | */
33 |
34 | (function () {
35 | "use strict"
36 | const G = window
37 | const ui = G.ui
38 |
39 | const {
40 | isstr, repl,
41 | property,
42 | assert,
43 | obj, map,
44 | wordset,
45 | empty_array,
46 | return_true,
47 | words,
48 | assign,
49 | announce,
50 | S,
51 | } = glue
52 |
53 |
54 | let global_rules = obj()
55 | G.validation_rules = global_rules
56 |
57 | let global_rule_props = obj()
58 |
59 | function fix_rule(rule) {
60 | rule.applies = rule.applies || return_true
61 | rule. props = wordset(rule. props)
62 | rule.vprops = wordset(rule.vprops)
63 | rule.requires = words(rule.requires) || empty_array
64 | }
65 |
66 | G.add_validation_rule = function(rule) {
67 | fix_rule(rule)
68 | global_rules[rule.name] = rule
69 | assign(global_rule_props, rule.props)
70 | announce('validation_rules_changed')
71 | }
72 |
73 | G.create_validator = function(e) {
74 |
75 | let rules_invalid = true
76 | let rules = []
77 | let own_rules = []
78 | let own_rule_props = obj()
79 | let rule_vprops = obj()
80 | let parse
81 | let results = []
82 | let checked = map()
83 |
84 | let validator = {
85 | results: results,
86 | rules: rules,
87 | triggered: false,
88 | }
89 |
90 | function add_rule(rule) {
91 | assert(checked.get(rule) !== false, 'validation rule require cycle: {0}', rule.name)
92 | if (checked.get(rule))
93 | return true
94 | if (!rule.applies(e))
95 | return
96 | checked.set(rule, false) // means checking...
97 | for (let req_rule_name of rule.requires) {
98 | if (!add_global_rule(req_rule_name)) {
99 | checked.set(rule, true)
100 | return true
101 | }
102 | }
103 | rules.push(rule)
104 | assign(rule_vprops, rule.vprops)
105 | checked.set(rule, true)
106 | if (!parse)
107 | parse = rule.parse
108 | return true
109 | }
110 |
111 | function add_global_rule(rule_name) {
112 | let rule = global_rules[rule_name]
113 | if (!rule) {
114 | warn('unknown validation rule', rule_name)
115 | return
116 | }
117 | return add_rule(rule)
118 | }
119 |
120 | validator.invalidate = function() {
121 | rules_invalid = true
122 | }
123 |
124 | validator.add_rule = function(rule) {
125 | fix_rule(rule)
126 | own_rules.push(rule)
127 | assign(own_rule_props, rule.props)
128 | rules_invalid = true
129 | }
130 |
131 | validator.prop_changed = function(prop) {
132 | if (!prop || global_rule_props[prop] || own_rule_props[prop]) {
133 | rules.clear()
134 | for (let k in rule_vprops)
135 | rule_vprops[k] = null
136 | checked.clear()
137 | rules_invalid = true
138 | return true
139 | }
140 | return rules_invalid || rule_vprops[prop] || !rules.len
141 | }
142 |
143 | function update_rules() {
144 | if (!rules_invalid)
145 | return
146 | for (let rule_name in global_rules)
147 | add_global_rule(rule_name)
148 | for (let rule of own_rules)
149 | add_rule(rule)
150 | rules_invalid = false
151 | }
152 |
153 | validator.parse = function(v) {
154 | update_rules()
155 | if (v == null) return null
156 | return parse ? parse(e, v) : v
157 | }
158 |
159 | validator.validate = function(v, announce_results) {
160 | announce_results = announce_results != false
161 | update_rules()
162 | let parse_failed
163 | for (let rule of rules) {
164 | if (parse_failed) {
165 | rule._failed = true
166 | continue // if parse failed, subsequent rules cannot run!
167 | }
168 | if (rule._failed)
169 | continue
170 | if (rule._checked)
171 | continue
172 | if (v == null && !rule.check_null)
173 | continue
174 | for (let req_rule_name of rule.requires) {
175 | if (global_rules[req_rule_name]._failed) {
176 | rule._failed = true
177 | continue
178 | }
179 | }
180 | let parse = rule.parse
181 | if (parse) {
182 | assert(parse_failed == null)
183 | v = parse(e, v)
184 | parse_failed = v === undefined
185 | }
186 | let failed = parse_failed || !rule.validate(e, v)
187 | rule._checked = true
188 | rule._failed = failed
189 | }
190 | results.len = rules.len
191 | this.failed = false
192 | this.first_failed_result = null
193 | for (let i = 0, n = rules.len; i < n; i++) {
194 | let rule = rules[i]
195 | let result = attr(results, i)
196 | result.checked = rule._checked || false
197 | result.failed = rule._failed || false
198 | result.rule = rule
199 | if (announce_results) {
200 | result.error = rule.error(e, v)
201 | result.rule_text = rule.rule (e)
202 | }
203 | if (rule._failed && !this.failed) {
204 | this.failed = true
205 | this.first_failed_result = result
206 | }
207 | // clean up scratch pad.
208 | rule._checked = null
209 | rule._failed = null
210 | }
211 | this.parse_failed = parse_failed
212 | this.value = repl(v, undefined, null)
213 | if (announce_results)
214 | e.announce('validate', this)
215 | this.triggered = true
216 | return !this.failed
217 | }
218 |
219 | property(validator, 'effectively_failed', function() {
220 | let e = this
221 | assert(e.triggered)
222 | if (e.failed)
223 | return true
224 | e = e.parent_validator
225 | if (!e)
226 | return false
227 | return e.effectively_failed
228 | })
229 |
230 | return validator
231 | }
232 |
233 | // NOTE: this must work with values that are unparsed and invalid!
234 | function field_value(e, v) {
235 | if (e.draw) return e.draw(v) ?? '' // field renders itself
236 | if (v == null) return S('null', 'null')
237 | if (isstr(v)) return v // string or failed to parse, show as is.
238 | if (e.to_text) return e.to_text(v)
239 | return str(v)
240 | }
241 |
242 | add_validation_rule({
243 | name : 'required',
244 | check_null: true,
245 | props : 'not_null required',
246 | vprops : 'input_value',
247 | applies : (e) => e.not_null || e.required,
248 | validate : (e, v) => v != null || e.default != null,
249 | error : (e, v) => S('validation_empty_error', '{0} is required', e.label),
250 | rule : (e) => S('validation_empty_rule' , '{0} cannot be empty', e.label),
251 | })
252 |
253 | // NOTE: empty string converts to `true` even when setting the value from JS!
254 | // This is so that a html attr without value becomes `true`.
255 | add_validation_rule({
256 | name : 'bool',
257 | vprops : 'input_value',
258 | applies : (e) => e.is_bool,
259 | parse : (e, v) => isbool(v) ? v : bool_attr(v),
260 | validate : (e, v) => isbool(v),
261 | error : (e, v) => S('validation_bool_error',
262 | '{0} is not a boolean' , e.label),
263 | rule : (e) => S('validation_bool_rule' ,
264 | '{0} must be a boolean', e.label),
265 | })
266 |
267 | add_validation_rule({
268 | name : 'number',
269 | vprops : 'input_value',
270 | applies : (e) => e.is_number,
271 | parse : (e, v) => isstr(v) ? num(v) : v,
272 | validate : (e, v) => isnum(v),
273 | error : (e, v) => S('validation_num_error',
274 | '{0} is not a number' , e.label),
275 | rule : (e) => S('validation_num_rule' ,
276 | '{0} must be a number', e.label),
277 | })
278 |
279 | function add_scalar_rules(type) {
280 |
281 | add_validation_rule({
282 | name : 'min_'+type,
283 | requires : type,
284 | props : 'min',
285 | vprops : 'input_value',
286 | applies : (e) => e.min != null,
287 | validate : (e, v) => v >= e.min,
288 | error : (e, v) => S('validation_min_error',
289 | '{0} is smaller than {1}', e.label, field_value(e, e.min)),
290 | rule : (e) => S('validation_min_rule',
291 | '{0} must be larger than or equal to {1}', e.label, field_value(e, e.min)),
292 | })
293 |
294 | add_validation_rule({
295 | name : 'max_'+type,
296 | requires : type,
297 | props : 'max',
298 | vprops : 'input_value',
299 | applies : (e) => e.max != null,
300 | validate : (e, v) => v <= e.max,
301 | error : (e, v) => S('validation_max_error',
302 | '{0} is larger than {1}', e.label, field_value(e, e.max)),
303 | rule : (e) => S('validation_max_rule',
304 | '{0} must be smaller than or equal to {1}', e.label, field_value(e, e.max)),
305 | })
306 |
307 | }
308 |
309 | add_scalar_rules('number')
310 |
311 | add_validation_rule({
312 | name : 'checked_value',
313 | props : 'checked_value unchecked_value',
314 | vprops : 'input_value',
315 | applies : (e) => e.checked_value !== undefined || e.unchecked_value !== undefined,
316 | validate : (e, v) => v == e.checked_value || v == e.unchecked_value,
317 | error : (e, v) => S('validation_checked_value_error',
318 | '{0} is not {1} or {2}' , e.label, e.checked_value, e.unchecked_value),
319 | rule : (e) => S('validation_checked_value_rule' ,
320 | '{0} must be {1} or {2}', e.label, e.checked_value, e.unchecked_value),
321 | })
322 |
323 | add_validation_rule({
324 | name : 'range_values_valid',
325 | vprops : 'invalid1 invalid2',
326 | applies : (e) => e.is_range,
327 | validate : (e, v) => !e.invalid1 && !e.invalid2,
328 | error : (e, v) => S('validation_range_values_valid_error', 'Range values are invalid'),
329 | rule : (e) => S('validation_range_values_valid_rule' , 'Range values must be valid'),
330 | })
331 |
332 | add_validation_rule({
333 | name : 'positive_range',
334 | vprops : 'value1 value2',
335 | applies : (e) => e.is_range,
336 | validate : (e, v) => e.value1 == null || e.value2 == null || e.value1 <= e.value2,
337 | error : (e, v) => S('validation_positive_range_error', 'Range is negative'),
338 | rule : (e) => S('validation_positive_range_rule' , 'Range must be positive'),
339 | })
340 |
341 | add_validation_rule({
342 | name : 'min_range',
343 | props : 'min_range',
344 | vprops : 'value1 value2',
345 | applies : (e) => e.is_range && e.range_type == 'number' && e.min_range != null,
346 | validate : (e, v) => e.value1 == null || e.value2 == null
347 | || e.value2 - e.value1 >= e.min_range,
348 | error : (e, v) => S('validation_min_range_error', 'Range is too small'),
349 | rule : (e) => S('validation_min_range_rule' ,
350 | 'Range must be larger than or equal to {0}', field_value(e, e.min_range)),
351 | })
352 |
353 | add_validation_rule({
354 | name : 'max_range',
355 | props : 'max_range',
356 | vprops : 'value1 value2',
357 | applies : (e) => e.is_range && e.range_type == 'number' && e.max_range != null,
358 | validate : (e, v) => e.value1 == null || e.value2 == null
359 | || e.value2 - e.value1 <= e.max_range,
360 | error : (e, v) => S('validation_max_range_error', 'Range is too large'),
361 | rule : (e) => S('validation_max_range_rule' ,
362 | 'Range must be smaller than or equal to {0}', field_value(e, e.max_range)),
363 | })
364 |
365 | add_validation_rule({
366 | name : 'min_len',
367 | props : 'min_len',
368 | vprops : 'input_value',
369 | applies : (e) => e.min_len != null,
370 | validate : (e, v) => v.len >= e.min_len,
371 | error : (e, v) => S('validation_min_len_error',
372 | '{0} too short', e.label),
373 | rule : (e) => S('validation_min_len_rule' ,
374 | '{0} must be at least {1} characters', e.label, e.min_len),
375 | })
376 |
377 | add_validation_rule({
378 | name : 'max_len',
379 | props : 'max_len',
380 | vprops : 'input_value',
381 | applies : (e) => e.max_len != null,
382 | validate : (e, v) => v.len <= e.max_len,
383 | error : (e, v) => S('validation_max_len_error',
384 | '{0} is too long', e.label),
385 | rule : (e) => S('validation_min_len_rule' ,
386 | '{0} must be at most {1} characters', e.label, e.max_len),
387 | })
388 |
389 | add_validation_rule({
390 | name : 'lower',
391 | props : 'conditions',
392 | vprops : 'input_value',
393 | applies : (e) => e.conditions && e.conditions.includes('lower'),
394 | validate : (e, v) => /[a-z]/.test(v),
395 | error : (e, v) => S('validation_lower_error',
396 | '{0} does not contain a lowercase letter', e.label),
397 | rule : (e) => S('validation_lower_rule' ,
398 | '{0} must contain at least one lowercase letter', e.label),
399 | })
400 |
401 | add_validation_rule({
402 | name : 'upper',
403 | props : 'conditions',
404 | vprops : 'input_value',
405 | applies : (e) => e.conditions && e.conditions.includes('upper'),
406 | validate : (e, v) => /[A-Z]/.test(v),
407 | error : (e, v) => S('validation_upper_error',
408 | '{0} does not contain a uppercase letter', e.label),
409 | rule : (e) => S('validation_upper_rule' ,
410 | '{0} must contain at least one uppercase letter', e.label),
411 | })
412 |
413 | add_validation_rule({
414 | name : 'digit',
415 | props : 'conditions',
416 | vprops : 'input_value',
417 | applies : (e) => e.conditions && e.conditions.includes('digit'),
418 | validate : (e, v) => /[0-9]/.test(v),
419 | error : (e, v) => S('validation_digit_error',
420 | '{0} does not contain a digit', e.label),
421 | rule : (e) => S('validation_digit_rule' ,
422 | '{0} must contain at least one digit', e.label),
423 | })
424 |
425 | add_validation_rule({
426 | name : 'symbol',
427 | props : 'conditions',
428 | vprops : 'input_value',
429 | applies : (e) => e.conditions && e.conditions.includes('symbol'),
430 | validate : (e, v) => /[^A-Za-z0-9]/.test(v),
431 | error : (e, v) => S('validation_symbol_error',
432 | '{0} does not contain a symbol', e.label),
433 | rule : (e) => S('validation_symbol_rule' ,
434 | '{0} must contain at least one symbol', e.label),
435 | })
436 |
437 | let pass_score_errors = [
438 | S('password_score_error_0', 'extremely easy to guess'),
439 | S('password_score_error_1', 'very easy to guess'),
440 | S('password_score_error_2', 'easy to guess'),
441 | S('password_score_error_3', 'not hard enough to guess'),
442 | ]
443 | let pass_score_rules = [
444 | S('password_score_rule_0', 'extremely easy to guess'),
445 | S('password_score_rule_1', 'very easy to guess'),
446 | S('password_score_rule_2', 'easy to guess'),
447 | S('password_score_rule_3', 'hard to guess'),
448 | S('password_score_rule_4', 'impossible to guess'),
449 | ]
450 | add_validation_rule({
451 | name : 'min_score',
452 | props : 'conditions min_score',
453 | vprops : 'input_value',
454 | applies : (e) => e.min_score != null
455 | && e.conditions && e.conditions.includes('min-score'),
456 | validate : (e, v) => (e.score ?? 0) >= e.min_score,
457 | error : (e, v) => S('validation_min_score_error',
458 | '{0} is {1}', e.label,
459 | pass_score_errors[e.score] || S('password_score_unknwon', '... wait...')),
460 | rule : (e) => S('validation_min_score_rule' ,
461 | '{0} must be {1}', e.label, pass_score_rules[e.min_score]),
462 | })
463 |
464 | add_validation_rule({
465 | name : 'time',
466 | vprops : 'input_value',
467 | applies : (e) => e.is_time,
468 | parse : (e, v) => parse_date(v, 'SQL', true, e.precision),
469 | validate : return_true,
470 | error : (e, v) => S('validation_time_error', '{0}: invalid date', e.label),
471 | rule : (e) => S('validation_time_rule', '{0} must be a valid date'),
472 | })
473 |
474 | add_scalar_rules('time')
475 |
476 | add_validation_rule({
477 | name : 'timeofday',
478 | vprops : 'input_value',
479 | applies : (e) => e.is_timeofday,
480 | parse : (e, v) => parse_timeofday(v, true, e.precision),
481 | validate : return_true,
482 | error : (e, v) => S('validation_timeofday_error',
483 | '{0}: invalid time of day', e.label),
484 | rule : (e) => S('validation_timeofday_rule',
485 | '{0} must be a valid time of day'),
486 | })
487 |
488 | add_scalar_rules('timeofday')
489 |
490 | add_validation_rule({
491 | name : 'date_min_range',
492 | props : 'date_min_range',
493 | vprops : 'value1 value2',
494 | applies : (e) => e.is_range && e.range_type == 'date' && e.min_range != null,
495 | validate : (e, v) => e.value1 == null || e.value2 == null
496 | || e.value2 - e.value1 >= e.min_range - 24 * 3600,
497 | error : (e, v) => S('validation_date_min_range_error', 'Range is too small'),
498 | rule : (e) => S('validation_date_min_range_rule' ,
499 | 'Range must be larger than or equal to {0}', field_value(e, e.min_range)),
500 | })
501 |
502 | add_validation_rule({
503 | name : 'date_max_range',
504 | props : 'date_max_range',
505 | vprops : 'value1 value2',
506 | applies : (e) => e.is_range && e.range_type == 'date' && e.max_range != null,
507 | validate : (e, v) => e.value1 == null || e.value2 == null
508 | || e.value2 - e.value1 <= e.max_range - 24 * 3600,
509 | error : (e, v) => S('validation_date_max_range_error', 'Range is too large'),
510 | rule : (e) => S('validation_date_max_range_rule' ,
511 | 'Range must be smaller than or equal to {0}', field_value(e, e.max_range)),
512 | })
513 |
514 | add_validation_rule({
515 | name : 'value_known',
516 | props : 'known_values',
517 | vprops : 'input_value',
518 | applies : (e) => !e.is_values && e.known_values,
519 | validate : (e, v) => e.known_values.has(v),
520 | error : (e, v) => S('validation_value_known_error',
521 | '{0}: unknown value {1}', e.label, field_value(e, v)),
522 | rule : (e) => S('validation_value_known_rule',
523 | '{0} must be a known value', e.label),
524 | })
525 |
526 | add_validation_rule({
527 | name : 'values',
528 | vprops : 'input_value',
529 | applies : (e) => e.is_values,
530 | parse : (e, v) => {
531 | v = isstr(v) ? (v.trim().starts('[') ? try_json_arg(v) : v.words()) : v
532 | return v.sort().uniq_sorted()
533 | },
534 | validate : return_true,
535 | error : (e, v) => S('validation_values_error',
536 | '{0}: invalid values list', e.label),
537 | rule : (e) => S('validation_values_rule',
538 | '{0} must be a valid values list', e.label),
539 | })
540 |
541 | function invalid_values(e, v) {
542 | if (v == null)
543 | return 'null'
544 | let a = []
545 | for (let s of v)
546 | if (!e.known_values.has(s))
547 | a.push(s)
548 | return a.join(', ')
549 | }
550 | add_validation_rule({
551 | name : 'values_known',
552 | props : 'known_values',
553 | vprops : 'input_value',
554 | requires : 'values',
555 | applies : (e) => e.known_values,
556 | validate : (e, v) => {
557 | for (let s of v)
558 | if (!e.known_values.has(s))
559 | return false
560 | return true
561 | },
562 | error : (e, v) => S('validation_values_known_error',
563 | '{0}: unknown values: {1}', e.label, invalid_values(e, v)),
564 | rule : (e) => S('validation_values_known_rule',
565 | '{0} must contain only known values', e.label),
566 | })
567 |
568 | }()) // module function
569 |
--------------------------------------------------------------------------------
/www/webrtc.js:
--------------------------------------------------------------------------------
1 | /*
2 |
3 | RTC wrapper
4 |
5 | */
6 |
7 | (function () {
8 | "use strict"
9 | let G = window
10 | let rtc = {}
11 | G.rtc = rtc
12 |
13 | let {
14 | min, max,
15 | set,
16 | debug, pr, clock, json, json_arg, noop,
17 | runafter, runevery,
18 | announce,
19 | assign,
20 | } = glue
21 |
22 | rtc.DEBUG = 1
23 | rtc.servers = {
24 | iceServers: [{
25 | // Google STUN server
26 | urls: 'stun:74.125.142.127:19302'
27 | }]
28 | }
29 |
30 | function rtc_debug(...args) {
31 | if (!rtc.DEBUG) return
32 | debug(this.signal_con.sid, this.type, ':', ...args)
33 | }
34 |
35 | rtc.offer = function(e) {
36 |
37 | e.type = 'offer'
38 | e.debug = rtc_debug
39 | e.open = false
40 | e.ready = false
41 | e.max_message_size = null
42 |
43 | e.connect = async function(to_sid) {
44 | if (e.open)
45 | return
46 | e.open = true
47 |
48 | e.signal_con.to_sid = to_sid
49 |
50 | e.con = new RTCPeerConnection(rtc.servers)
51 |
52 | e.chan = e.con.createDataChannel('chan', {ordered: true})
53 | e.chan.binaryType = 'arraybuffer'
54 |
55 | e.con.onicecandidate = function(ev) {
56 | if (!ev.candidate) return
57 | e.signal_con.signal('candidate', ev.candidate)
58 | }
59 |
60 | e.chan.onopen = function() {
61 | e.debug('chan open')
62 | e.max_message_size = e.con.sctp.maxMessageSize
63 | e.ready = true
64 | announce('rtc', e, 'ready')
65 | }
66 |
67 | e.chan.onclose = function() {
68 | e.debug('chan closed')
69 | e.chan = null
70 | e.close()
71 | }
72 |
73 | e.chan.onmessage = function(ev) {
74 | e.recv(ev.data)
75 | }
76 |
77 | let offer = await e.con.createOffer()
78 | await e.con.setLocalDescription(offer)
79 |
80 | e.signal_con.signal('offer', offer, to_sid)
81 |
82 | announce('rtc', e, 'open')
83 | }
84 |
85 | e.close = function() {
86 | if (!e.open)
87 | return
88 | e.open = false
89 | e.ready = false
90 | e.max_message_size = null
91 |
92 | if (e.chan) {
93 | e.chan.close()
94 | e.chan = null
95 | }
96 | if (e.con) {
97 | e.con.close()
98 | e.con = null
99 | }
100 |
101 | e.signal_con.signal('close')
102 | announce('rtc', e, 'close')
103 |
104 | e.signal_con.close()
105 | }
106 |
107 | e.signal_con = e.signal_server.connect({})
108 |
109 | e.signal_con.on_signal = function(k, v) {
110 | if (!e.open)
111 | return
112 | if (k == 'candidate') {
113 | e.debug('<- candidate', v)
114 | let c = new RTCIceCandidate(assign(v, {
115 | sdpMLineIndex: v.label ?? v.sdpMLineIndex,
116 | }))
117 | e.con.addIceCandidate(c)
118 | } else if (k == 'answer') {
119 | e.debug('<- answer', v)
120 | e.con.setRemoteDescription(v)
121 | }
122 | }
123 |
124 | e.send = function(s) {
125 | if (!e.ready)
126 | return
127 | let wait_bytes = e.chan.bufferedAmount
128 | if (wait_bytes > 64 * 1024)
129 | return
130 | e.chan.send(s)
131 | }
132 |
133 | return e
134 | }
135 |
136 | rtc.answer = function(e) {
137 |
138 | e.type = 'answer'
139 | e.debug = rtc_debug
140 | e.open = false
141 | e.ready = false
142 |
143 | e.connect = function(to_sid) {
144 | if (e.open)
145 | return
146 | e.open = true
147 |
148 | e.signal_con.to_sid = to_sid
149 |
150 | e.con = new RTCPeerConnection(rtc.servers)
151 |
152 | e.con.onicecandidate = function(ev) {
153 | if (!ev.candidate) return
154 | e.signal_con.signal('candidate', ev.candidate)
155 | }
156 |
157 | e.con.ondatachannel = function(ev) {
158 |
159 | e.debug('remote chan open')
160 |
161 | e.chan = ev.channel
162 | e.chan.binaryType = 'arraybuffer'
163 |
164 | e.chan.onclose = function() {
165 | e.debug('remote chan closed')
166 | e.chan = null
167 | e.close()
168 | }
169 |
170 | e.chan.onmessage = function(ev) {
171 | e.recv(ev.data)
172 | }
173 |
174 | e.ready = true
175 | announce('rtc', e, 'ready')
176 | }
177 |
178 | e.signal_con.signal('ready')
179 |
180 | announce('rtc', e, 'open')
181 | }
182 |
183 | e.close = function() {
184 | if (!e.open)
185 | return
186 | e.open = false
187 | e.ready = false
188 |
189 | if (e.chan) {
190 | e.chan.close()
191 | e.chan = null
192 | }
193 | if (e.con) {
194 | e.con.close()
195 | e.con = null
196 | }
197 |
198 | e.signal_con.signal('close')
199 | announce('rtc', e, 'close')
200 |
201 | e.signal_con.close()
202 | }
203 |
204 | e.signal_con = e.signal_server.connect({})
205 |
206 | e.signal_con.on_signal = function(k, v) {
207 | if (!e.open)
208 | return
209 | if (k == 'candidate') {
210 | e.debug('<- candidate', v)
211 | let c = new RTCIceCandidate(assign(v, {
212 | sdpMLineIndex: v.label ?? v.sdpMLineIndex,
213 | }))
214 | e.con.addIceCandidate(c)
215 | } else if (k == 'offer') {
216 | e.debug('<- offer', v)
217 | e.con.setRemoteDescription(v)
218 | runafter(0, async function() {
219 | let answer = await e.con.createAnswer()
220 | await e.con.setLocalDescription(answer)
221 | e.signal_con.signal('answer', answer)
222 | })
223 | }
224 | }
225 |
226 | e.send = function(s) {
227 | if (!e.ready)
228 | return
229 | let wait_bytes = e.chan.bufferedAmount
230 | if (wait_bytes > 64 * 1024)
231 | return
232 | e.chan.send(s)
233 | }
234 |
235 | return e
236 | }
237 |
238 | rtc.mock_signal_server = function() {
239 |
240 | let e = {}
241 |
242 | e.offers = {} // {id->offer}
243 | e.ready_con = {} // {id->con}
244 | e.offer_con = {} // {id->con}
245 |
246 | let {offers, ready_con, offer_con} = e
247 |
248 | e.connect = function(c) {
249 |
250 | c.on_signal = null
251 | c.candidates = set()
252 |
253 | let id = 'local'
254 |
255 | c.signal_candidates = function(id) {
256 | let c2 = c == offer_con[id] ? ready_con[id] : offer_con[id]
257 | if (!c2)
258 | return
259 | for (let candidate of c.candidates) {
260 | c2.on_signal('candidate', candidate)
261 | c.candidates.delete(candidate)
262 | }
263 | }
264 |
265 | c.signal = function(k, v) {
266 | if (k == 'offer') {
267 | let offer = v
268 | offers[id] = offer
269 | offer_con[id] = c
270 | let rc = ready_con[id]
271 | let oc = offer_con[id]
272 | if (rc) {
273 | rc.on_signal('offer', offer)
274 | oc.signal_candidates(id)
275 | }
276 | } else if (k == 'ready') {
277 | ready_con[id] = c
278 | let offer = offers[id]
279 | let oc = offer_con[id]
280 | if (offer) {
281 | c.on_signal('offer', offer)
282 | oc.signal_candidates(id)
283 | }
284 | } else if (k == 'answer') {
285 | let answer = v
286 | let oc = offer_con[id]
287 | let rc = ready_con[id]
288 | if (oc) {
289 | oc.on_signal('answer', answer)
290 | rc.signal_candidates(id)
291 | }
292 | } else if (k == 'candidate') {
293 | let candidate = v
294 | c.candidates.add(candidate)
295 | c.signal_candidates(id)
296 | } else if (k == 'close') {
297 | delete offers[id]
298 | delete ready_con[id]
299 | delete offer_con[id]
300 | }
301 | }
302 |
303 | return c
304 |
305 | }
306 |
307 | return e
308 | }
309 |
310 | rtc.demo_signal_server = function() {
311 |
312 | let e = {}
313 |
314 | e.connect = function(c) {
315 |
316 | c.on_signal = null
317 |
318 | c.signal = function(k, v) {
319 | assert(c.sid, 'connection to signal server not ready')
320 | let t = {sid: c.sid, k: k, v: v, to_sid: c.to_sid}
321 | post('/rtc_signal', t)
322 | }
323 |
324 | // poor man's bidi communication in lieu of websockets.
325 | // the server identifies our "connection" based on sid (session id).
326 | let es = new EventSource('/rtc_signal.events')
327 |
328 | es.onmessage = function(ev) {
329 | let t = json_arg(ev.data)
330 | if (t.sid)
331 | c.sid = t.sid
332 | else
333 | c.on_signal(t.k, t.v)
334 | }
335 |
336 | c.close = function() {
337 | es.close()
338 | es = null
339 | }
340 |
341 | return c
342 | }
343 |
344 | return e
345 | }
346 |
347 | }()) // module function
348 |
--------------------------------------------------------------------------------