openvibers/openvibe.shared kit-data-web.js
kit-data-web.js
| 1 | /** |
| 2 | * openvibe-shared/kit-data-web.js — Kit 2's behaviour in the page (split from kit-web.js in 4.37.0): |
| 3 | * |
| 4 | * charts .ovk-chart-plot[data-ovk-chart]: the values under the pointer, a tap, or ← → Home End |
| 5 | * scatter labels a label that would cover another, or cross the right edge, is hidden or moved |
| 6 | * drop zones [data-ovk-drop]: drag and drop, paste, the camera on phones, the file's name and picture |
| 7 | * tool frame [data-ovk-clear] shows while its box has text and empties it; a[data-ovk-try] fills the box and runs |
| 8 | * the tool (its form's submit, else the [data-ovk-run] button); without this it is a ?name=value link |
| 9 | * |
| 10 | * It runs by itself when it loads, again on a boosted page swap (ov:boost:load), and for markup added later through |
| 11 | * OpenVibeKitData.enhance(container). Idempotent and safe on a page that uses none of it. Load it on a page that draws |
| 12 | * kit.chart, kit.drop or the tool frame (kit.steps, table, rank, meter and receipt need no script). |
| 13 | */ |
| 14 | (function (root) { |
| 15 | ; |
| 16 | function enhance(rootEl) { |
| 17 | if (typeof document === 'undefined') return; |
| 18 | const scope = rootEl || document; |
| 19 | const all = (sel) => Array.prototype.slice.call(scope.querySelectorAll ? scope.querySelectorAll(sel) : []); |
| 20 | // The mark has a name of its own (…Wired), apart from the data-ovk-* attribute that asks for the behaviour (4.55.0). |
| 21 | const once = (el, key) => { const k = `${key}Wired`; if (el.dataset[k]) return false; el.dataset[k] = '1'; return true; }; |
| 22 | // Charts (Kit 2): a rule, a dot per series and the values under the pointer or the arrow keys, on lines and areas. |
| 23 | all('.ovk-chart-plot[data-ovk-chart]').forEach((plot) => { |
| 24 | if (!once(plot, 'ovkChart')) return; |
| 25 | let data; try { data = JSON.parse(plot.getAttribute('data-ovk-chart')); } catch (e) { return; } |
| 26 | if (!data || !data.x || !data.x.length) return; |
| 27 | const rule = plot.querySelector('.ovk-chart-rule'), tip = plot.querySelector('.ovk-chart-tip'); |
| 28 | if (!rule || !tip) return; |
| 29 | let at = -1; |
| 30 | const show = (i) => { |
| 31 | at = Math.max(0, Math.min(data.x.length - 1, i)); |
| 32 | const [px, label] = data.x[at]; |
| 33 | rule.style.left = `${px}%`; rule.hidden = false; |
| 34 | rule.innerHTML = data.s.map((s) => (s.v[at] ? `<i class="ovk-chart-s${s.c}" style="bottom:${s.v[at][0]}%"></i>` : '')).join(''); |
| 35 | tip.textContent = ''; |
| 36 | const b = document.createElement('b'); b.textContent = label; tip.appendChild(b); |
| 37 | data.s.forEach((s) => { if (!s.v[at]) return; const line = document.createElement('span'); line.className = `ovk-chart-s${s.c}`; line.innerHTML = '<i></i>'; line.appendChild(document.createTextNode(`${s.n ? `${s.n} ` : ''}${s.v[at][1]}`)); tip.appendChild(line); }); |
| 38 | tip.hidden = false; |
| 39 | const w = plot.clientWidth, tw = tip.offsetWidth, x = (px / 100) * w; |
| 40 | tip.style.left = `${Math.max(0, Math.min(w - tw, x + 12 + tw > w ? x - tw - 12 : x + 12))}px`; |
| 41 | }; |
| 42 | const hide = () => { rule.hidden = true; tip.hidden = true; }; |
| 43 | const nearest = (clientX) => { |
| 44 | const r = plot.getBoundingClientRect(); const p = ((clientX - r.left) / r.width) * 100; |
| 45 | let best = 0; for (let i = 1; i < data.x.length; i++) if (Math.abs(data.x[i][0] - p) < Math.abs(data.x[best][0] - p)) best = i; |
| 46 | return best; |
| 47 | }; |
| 48 | plot.addEventListener('pointermove', (e) => show(nearest(e.clientX))); |
| 49 | plot.addEventListener('pointerdown', (e) => show(nearest(e.clientX))); |
| 50 | plot.addEventListener('pointerleave', hide); |
| 51 | plot.addEventListener('blur', hide); |
| 52 | plot.addEventListener('focus', () => show(at < 0 ? data.x.length - 1 : at)); |
| 53 | plot.addEventListener('keydown', (e) => { |
| 54 | const step = { ArrowLeft: -1, ArrowRight: 1, Home: -1e9, End: 1e9 }[e.key]; |
| 55 | if (step == null) { if (e.key === 'Escape') hide(); return; } |
| 56 | e.preventDefault(); show((at < 0 ? data.x.length - 1 : at) + step); |
| 57 | }); |
| 58 | }); |
| 59 | // Scatter labels (Kit 2): a label that would sit on one already shown is hidden (best points first); its dot still says it. |
| 60 | all('.ovk-chart--scatter .ovk-chart-plot').forEach((plot) => { |
| 61 | const names = Array.prototype.slice.call(plot.querySelectorAll('.ovk-chart-name')); |
| 62 | if (names.length < 2) return; |
| 63 | const place = () => { |
| 64 | names.forEach((n) => { n.classList.remove('is-hidden'); n.parentNode.classList.remove('is-flip'); }); |
| 65 | // A label that would run past the plot's right edge goes to the left of its dot. |
| 66 | const edge = plot.getBoundingClientRect().right + 4; |
| 67 | names.forEach((n) => { if (!n.parentNode.classList.contains('is-right') && n.getBoundingClientRect().right > edge) n.parentNode.classList.add('is-flip'); }); |
| 68 | const order = names.slice().sort((a, b) => (b.parentNode.classList.contains('is-best') - a.parentNode.classList.contains('is-best')) || (parseFloat(b.parentNode.style.bottom) - parseFloat(a.parentNode.style.bottom))); |
| 69 | const kept = []; |
| 70 | order.forEach((n) => { |
| 71 | const r = n.getBoundingClientRect(); |
| 72 | const hit = kept.some((k) => r.left < k.right + 4 && r.right + 4 > k.left && r.top < k.bottom + 2 && r.bottom + 2 > k.top); |
| 73 | if (hit) n.classList.add('is-hidden'); else kept.push(r); |
| 74 | }); |
| 75 | }; |
| 76 | if (!once(plot, 'ovkLabels')) return; |
| 77 | place(); |
| 78 | if (typeof ResizeObserver !== 'undefined') new ResizeObserver(place).observe(plot); |
| 79 | }); |
| 80 | // Drop zones (Kit 2): drag and drop, paste, the camera on phones, the chosen file's name and picture. |
| 81 | all('[data-ovk-drop]').forEach((box) => { |
| 82 | if (!once(box, 'ovkDrop')) return; |
| 83 | const input = box.querySelector('.ovk-drop-input'), zone = box.querySelector('.ovk-drop-zone'), name = box.querySelector('.ovk-drop-name'); |
| 84 | if (!input || !zone) return; |
| 85 | box.classList.add('is-js'); |
| 86 | let url = null; |
| 87 | const shown = () => { |
| 88 | const files = Array.prototype.slice.call(input.files || []); |
| 89 | box.classList.toggle('has-file', files.length > 0); |
| 90 | if (!name) return; |
| 91 | name.textContent = ''; |
| 92 | if (url) { URL.revokeObjectURL(url); url = null; } |
| 93 | if (!files.length) return; |
| 94 | if (files.length === 1 && /^image\//.test(files[0].type) && window.URL) { |
| 95 | const img = document.createElement('img'); url = URL.createObjectURL(files[0]); img.src = url; img.alt = ''; name.appendChild(img); |
| 96 | } |
| 97 | name.appendChild(document.createTextNode(files.length === 1 ? files[0].name : `${files.length} files`)); |
| 98 | }; |
| 99 | const take = (list) => { |
| 100 | if (!list || !list.length || typeof DataTransfer === 'undefined') return; |
| 101 | const dt = new DataTransfer(); |
| 102 | Array.prototype.slice.call(list, 0, input.multiple ? list.length : 1).forEach((f) => dt.items.add(f)); |
| 103 | input.files = dt.files; |
| 104 | input.dispatchEvent(new Event('change', { bubbles: true })); |
| 105 | }; |
| 106 | input.addEventListener('change', () => { |
| 107 | shown(); |
| 108 | if (box.hasAttribute('data-ovk-auto') && input.files && input.files.length && input.form) (input.form.requestSubmit ? input.form.requestSubmit() : input.form.submit()); |
| 109 | }); |
| 110 | ['dragenter', 'dragover'].forEach((t) => zone.addEventListener(t, (e) => { e.preventDefault(); box.classList.add('is-over'); })); |
| 111 | ['dragleave', 'dragend'].forEach((t) => zone.addEventListener(t, () => box.classList.remove('is-over'))); |
| 112 | zone.addEventListener('drop', (e) => { e.preventDefault(); box.classList.remove('is-over'); take(e.dataTransfer && e.dataTransfer.files); }); |
| 113 | // Paste: only while this is the page's one drop zone, and never into a field someone is typing in. |
| 114 | document.addEventListener('paste', (e) => { |
| 115 | if (document.querySelectorAll('[data-ovk-drop]').length !== 1) return; |
| 116 | const t = e.target; if (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName))) return; |
| 117 | const files = e.clipboardData && e.clipboardData.files; if (files && files.length) { e.preventDefault(); take(files); } |
| 118 | }); |
| 119 | const cam = box.querySelector('.ovk-drop-cam'), camInput = box.querySelector('[data-ovk-drop-cam]'); |
| 120 | if (cam && camInput && window.matchMedia && matchMedia('(pointer: coarse)').matches) { |
| 121 | cam.hidden = false; |
| 122 | camInput.addEventListener('change', () => { take(camInput.files); camInput.value = ''; }); |
| 123 | } |
| 124 | }); |
| 125 | // The tool frame: a clear button shows while its box has text; a "Try" example fills the box and runs the tool |
| 126 | // (its form's submit, else the run button it names). Without this, the example is a link to ?name=value. |
| 127 | all('[data-ovk-clear]').forEach((b) => { |
| 128 | const f = document.querySelector(b.getAttribute('data-ovk-clear')); |
| 129 | if (!f || !once(b, 'ovkClear')) return; |
| 130 | const sync = () => { b.hidden = !f.value; }; |
| 131 | f.addEventListener('input', sync); sync(); |
| 132 | b.addEventListener('click', () => { f.value = ''; sync(); f.dispatchEvent(new Event('input', { bubbles: true })); f.focus(); }); |
| 133 | }); |
| 134 | all('a[data-ovk-try]').forEach((a) => { |
| 135 | if (!once(a, 'ovkTry')) return; |
| 136 | a.addEventListener('click', (e) => { |
| 137 | const sel = a.getAttribute('data-ovk-for'); |
| 138 | const form = a.closest('form'); |
| 139 | const f = (sel && document.querySelector(sel)) || (form && form.elements.namedItem(a.getAttribute('data-ovk-name') || 'q')); |
| 140 | if (!f || !('value' in f)) return; |
| 141 | e.preventDefault(); |
| 142 | f.value = a.getAttribute('data-ovk-try'); |
| 143 | f.dispatchEvent(new Event('input', { bubbles: true })); |
| 144 | const run = a.getAttribute('data-ovk-run'); |
| 145 | const btn = run && document.querySelector(run); |
| 146 | if (btn) btn.click(); |
| 147 | else if (f.form) { if (f.form.requestSubmit) f.form.requestSubmit(); else f.form.submit(); } |
| 148 | }); |
| 149 | }); |
| 150 | } |
| 151 | |
| 152 | const api = { enhance }; |
| 153 | root.OpenVibeKitData = api; |
| 154 | if (typeof module === 'object' && module.exports) { module.exports = api; return; } |
| 155 | if (typeof document !== 'undefined') { |
| 156 | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => enhance(document)); |
| 157 | else enhance(document); |
| 158 | document.addEventListener('ov:boost:load', () => enhance(document)); |
| 159 | } |
| 160 | })(typeof globalThis !== 'undefined' ? globalThis : this); |
| 161 |