├── 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 | --------------------------------------------------------------------------------