openvibers/openvibe.shared kit-data-web.js

kit-data-web.js

main 11.0 KB · 161 lines · javascript

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 'use strict';
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