/*
* z-Tree screens as HTML, for participants' pages (participant/ztree.js and ztree.css show them):
* a screen's boxes placed as z-Leaf places them, each with its items and buttons, at the time of
* rendering (the server renders again when the data changes).
*
* Boxes take the screen's remaining area: their distances (left, right, top, bottom) and sizes
* (width, height) in % of it or in points (p); a box with a height or width given takes that
* strip off the remaining area (from the top, or the side it is placed at).
*/
const lang = require('./lang.js');
const escape = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]);
// --- Values and layouts -------------------------------------------------------------------
const parsed = new Map();
/** code parsed as an expression, once. */
function expression(code) {
if (!parsed.has(code)) parsed.set(code, lang.parseExpr(code));
return parsed.get(code);
}
/** The value of expression code in ctx; on an error, null (and the error noted in ctx.errors). */
function value(code, ctx) {
if (code == null || String(code).trim() === '') return null;
try {
return lang.evaluate(expression(code), ctx);
} catch (err) {
if (ctx.errors) ctx.errors.push(code + ': ' + err.message);
return null;
}
}
/**
* A layout: {kind: 'number', step} (a number's resolution: "1", "0.01", or a variable), or
* {kind: 'text'|'radio'|'radioline'|'checkbox'|'slider'|'scrollbar'|'button', options: [[value,
* label]], count}, from "!radio: 1 = "a"; 2 = "b"" and the like.
*/
function parseLayout(layout, ctx) {
let text = String(layout || '').trim();
let format = false;
if (text.startsWith('<>')) {
format = true;
text = text.slice(2).trim();
}
const m = /^!(\w+)\s*:(.*)$/s.exec(text);
if (m == null) {
const step = text === '' ? 1 : lang.num(value(text, ctx));
return { kind: 'number', step: step > 0 ? step : 1, format };
}
const options = [];
let count = null;
for (const part of layoutParts(m[2])) {
// value = "text": the value a number, or a variable's (State == AddPoint, \I-1, ...).
if (part.text != null) {
const key = /^-?[\d.]+$/.test(part.key) ? Number(part.key) : lang.num(value(part.key, ctx));
options.push([key, part.text]);
} else if (/^\d+$/.test(part.key)) count = Number(part.key);
}
return { kind: m[1].toLowerCase(), options, count, format };
}
/**
* A !kind: layout's parts, separated by ;: {key, text} for value = "text", {key} for the others
* (a count). A text may hold <var|layout> with quotes of their own, and RTF with ; in it (as
* GIMS's do): a quote inside <...> does not end it, nor a ; inside quotes.
*/
function layoutParts(text) {
const parts = [];
let i = 0;
const n = text.length;
while (i < n) {
while (i < n && /[\s;]/.test(text[i])) i++;
if (i >= n) break;
let key = '';
while (i < n && text[i] !== '=' && text[i] !== ';') key += text[i++];
if (text[i] !== '=') { parts.push({ key: key.trim() }); continue; }
i++;
while (i < n && /\s/.test(text[i])) i++;
if (text[i] !== '"') {
let rest = '';
while (i < n && text[i] !== ';') rest += text[i++];
parts.push({ key: key.trim(), text: rest.trim() });
continue;
}
// The text: to its closing ", its <expr|layout> taken whole (options may follow without ;).
const end = quotedEnd(text, i + 1);
parts.push({ key: key.trim(), text: text.slice(i + 1, end - 1) });
i = end;
}
return parts;
}
/** decimals for a resolution step (0.01 → 2). */
function decimals(step) {
if (!(step > 0)) return 0;
return Math.max(0, Math.min(10, -Math.floor(Math.log10(step) + 1e-9)));
}
/** v shown with a layout. */
function formatValue(v, layout, ctx) {
if (v == null) return '';
if (typeof v === 'string') return v;
if (lang.isArray(v)) return v.values.join(', ');
const l = typeof layout === 'object' ? layout : parseLayout(layout, ctx);
if (l.kind === 'number') {
return lang.roundTo(Number(v), l.step).toFixed(decimals(l.step));
}
// A text for the value: the one of the nearest value listed (of two as near, the lower), as
// z-Tree 5 does (fixtures/ztree5-probe/textmap.txt: 3, 50, 90, 200, 9.6, 9.5 with 9, 10, 99).
let best = null;
for (const [k, label] of l.options) {
const d = Math.abs(k - v);
if (best == null || d < best.d - 1e-9 || (Math.abs(d - best.d) <= 1e-9 && k < best.k)) best = { k, d, label };
}
let label = best == null ? String(v) : best.label;
// An option's text may be RTF ({\rtf \qc English}, as GIMS writes its options): its text.
// (In a <> format, after its values are put in: formatText.)
if (!l.format && label.startsWith('{\\rtf')) label = rtfToText(label);
// "<|1>" in the text: the value itself, with layout 1.
return label.replace(/<\|([^>]*)>/g, (all, lay) => formatValue(v, lay || '1', ctx));
}
/**
* A label or text with z-Tree's formatting: "<>" starts text with variables, <var|layout>;
* RTF ({\rtf ...}) is reduced to its text.
*/
function formatText(text, ctx) {
let s = String(text == null ? '' : text);
if (s.startsWith('{\\rtf')) s = rtfToText(s);
if (!s.startsWith('<>')) return escape(s).replace(/\n/g, '<br>');
s = s.slice(2);
// The values first, then the text as a whole: it may be RTF made with them (GIMS writes
// <>{\rtf \fs<\FontSize|1> \qc <\Language|!text: 1="..."; ...>}).
let raw = fillValues(s, ctx);
if (raw.trimStart().startsWith('{\\rtf')) raw = rtfToText(raw.trimStart());
return escape(raw).replace(/\n/g, '<br>');
}
/** A text with its <expr|layout> as their values; a value's text may have its own, filled in too. */
function fillValues(s, ctx, depth = 0) {
let raw = '';
for (const part of textParts(s)) {
if (part.text != null) { raw += part.text; continue; }
const v = part.expr.trim() === '' ? null : value(part.expr, ctx);
const piece = formatValue(v, part.layout, ctx);
raw += depth < 5 && textParts(piece).some((x) => x.expr != null) ? fillValues(piece, ctx, depth + 1) : piece;
}
return raw;
}
/**
* z-Tree's <expr|layout> in a text: the expression runs to the |, and may hold > (<if(\\N>1,1,0)|1>);
* the layout runs to its >, its quoted texts and the <expr|layout> in them taken whole, so it may
* hold its own (GIMS: <\\Language|!text: 1="<\\N|1> orders";>). substitutionAt(s, i): where the
* one starting at s[i] ends (after its >), with its parts; null if there is none there.
*/
function substitutionAt(s, i) {
if (s[i] !== '<') return null;
let bar = i + 1;
while (bar < s.length && !'|<">\n'.includes(s[bar])) bar++;
// (A > in the expression: one with no | before the next <, ", or line end is not one.)
if (s[bar] === '>') {
let k = bar;
while (k < s.length && !'|<"\n'.includes(s[k])) k++;
if (s[k] !== '|') return null;
bar = k;
}
if (s[bar] !== '|') return null;
const end = layoutEnd(s, bar + 1);
if (end < 0) return null;
return { end: end + 1, expr: s.slice(i + 1, bar), layout: s.slice(bar + 1, end) };
}
/** Where the layout starting at s[j] ends (its >), quoted texts and substitutions in it skipped; -1 if it does not. */
function layoutEnd(s, j) {
while (j < s.length) {
if (s[j] === '"') { j = quotedEnd(s, j + 1); continue; }
const sub = substitutionAt(s, j);
if (sub != null) { j = sub.end; continue; }
if (s[j] === '>') return j;
j++;
}
return -1;
}
/** Where a quoted text starting after its " at s[j] ends (after its closing "), its substitutions taken whole. */
function quotedEnd(s, j) {
while (j < s.length) {
if (s[j] === '"') return j + 1;
const sub = substitutionAt(s, j);
if (sub != null) { j = sub.end; continue; }
j++;
}
return j;
}
/** A "<>" text's parts: {text}, and {expr, layout} for each <expr|layout>. */
function textParts(s) {
const parts = [];
let text = '';
let i = 0;
while (i < s.length) {
const sub = substitutionAt(s, i);
if (sub != null) {
if (text) parts.push({ text });
text = '';
parts.push({ expr: sub.expr, layout: sub.layout });
i = sub.end;
continue;
}
text += s[i++];
}
if (text) parts.push({ text });
return parts;
}
/** RTF's text: its paragraphs as lines, its control words and groups taken out, its \'xx characters (Windows-1252). */
function rtfToText(rtf) {
const { decode } = require('./ztt.js');
// Escaped braces and backslashes kept aside (private-use characters) while groups go.
return rtf.replace(/\\'([0-9a-fA-F]{2})/g, (all, hex) => decode([parseInt(hex, 16)]))
.replace(/\\([{}\\])/g, (all, c) => ({ '{': '\ue000', '}': '\ue001', '\\': '\ue002' })[c])
.replace(/\\par[d]? ?/g, '\n').replace(/\{\\\*[^}]*\}/g, '')
.replace(/\\[a-z]+-?\d* ?/g, '').replace(/[{}]/g, '')
.replace(/[\ue000-\ue002]/g, (c) => ({ '\ue000': '{', '\ue001': '}', '\ue002': '\\' })[c]).trim();
}
// --- Placement ----------------------------------------------------------------------------
// A length: pct % of the screen plus px pixels.
const len = (pct = 0, px = 0) => ({ pct, px });
const add = (a, b) => len(a.pct + b.pct, a.px + b.px);
const sub = (a, b) => len(a.pct - b.pct, a.px - b.px);
const scale = (a, f) => len(a.pct * f, a.px * f);
const css = (a) => (Math.abs(a.px) < 1e-9 ? a.pct.toFixed(3) + '%' : 'calc(' + a.pct.toFixed(3) + '% + ' + a.px.toFixed(1) + 'px)');
/** A distance or size ("10%", "30p", "12"): a length, relative to whole; null if empty. */
function measure(text, whole) {
const t = String(text || '').trim();
if (t === '') return null;
const m = /^(-?[\d.]+)\s*(%|p)?$/.exec(t);
if (m == null) return null;
const n = Number(m[1]);
// A point is about 1.33 pixels.
return m[2] === '%' ? scale(whole, n / 100) : len(0, n * 4 / 3);
}
/** box's rectangle in the area {x0, y0, x1, y1}; and the area left for the boxes after it. */
function place(box, area) {
const W = sub(area.x1, area.x0);
const H = sub(area.y1, area.y0);
const axis = (lo, hi, size, start, end, whole) => {
const a = measure(lo, whole);
const b = measure(hi, whole);
const s = measure(size, whole);
if (s == null) return [add(start, a || len()), sub(end, b || len())];
if (a != null) return [add(start, a), add(add(start, a), s)];
if (b != null) return [sub(sub(end, b), s), sub(end, b)];
const mid = scale(add(start, end), 0.5);
return [sub(mid, scale(s, 0.5)), add(mid, scale(s, 0.5))];
};
const [x0, x1] = axis(box.left, box.right, box.width, area.x0, area.x1, W);
const [y0, y1] = axis(box.top, box.bottom, box.height, area.y0, area.y1, H);
const rect = { x0, y0, x1, y1 };
// The area left for the boxes after it: less the box's strip if it says so (its options:
// byte 1 left, 2 or 3 right, 4 or 5 top, 6 or 7 bottom; seen in z-Tree's examples).
const o = box.options || [];
let rest = area;
if (o[1]) rest = { ...area, x0: x1 };
else if (o[2] || o[3]) rest = { ...area, x1: x0 };
else if (o[4] || o[5]) rest = { ...area, y0: y1 };
else if (o[6] || o[7]) rest = { ...area, y1: y0 };
return { rect, rest };
}
const style = (r) => 'left:' + css(r.x0) + ';top:' + css(r.y0) + ';width:' + css(sub(r.x1, r.x0)) + ';height:' + css(sub(r.y1, r.y0));
// --- Boxes --------------------------------------------------------------------------------
/**
* The HTML of a screen (a stage's active or waiting boxes) for a subject.
* @param {Object[]} boxes
* @param {Object} view {ctx (the subject's record's), stage (index), screen ('active' or
* 'waiting'), header (the treatment's header box, if the stage shows it), period, numPeriods,
* selected ({boxPath: record id}), rt}
*/
function renderScreen(boxes, view) {
const all = (view.header ? [view.header] : []).concat(boxes);
let area = { x0: len(0), y0: len(0), x1: len(100), y1: len(100) };
let html = '';
all.forEach((box, i) => {
const path = box === view.header ? 'h' : String(i - (view.header ? 1 : 0));
if (box.condition && !lang.bool(value(box.condition, view.ctx))) return;
const { rect, rest } = place(box, area);
area = rest;
html += renderBox(box, rect, path, view);
});
return '<div class="ztree-screen-boxes" data-screen="' + view.screen + '">' + html + '</div>';
}
function renderBox(box, rect, path, view) {
const attrs = 'class="ztree-box ztree-box--' + box.type + (box.frame ? ' ztree-box--frame' : '') + '" data-box="' + path + '" style="' + style(rect) + '"';
if (box.type === 'container') {
let area = { x0: len(0), y0: len(0), x1: len(100), y1: len(100) };
let inner = '';
box.boxes.forEach((child, i) => {
if (child.condition && !lang.bool(value(child.condition, view.ctx))) return;
const { rect: r, rest } = place(child, area);
area = rest;
inner += renderBox(child, r, path + '.' + i, view);
});
return '<div ' + attrs + '>' + inner + '</div>';
}
let body;
switch (box.type) {
case 'header': body = headerBox(box, view); break;
case 'help': body = '<div class="ztree-help-title">' + escape(box.title) + '</div><div class="ztree-help-text">' + formatText(box.text, view.ctx) + '</div>'; break;
case 'contractList': body = contractList(box, path, view); break;
case 'contractCreation': body = '<div class="ztree-box-main">' + itemsTable(box.items, view.ctx, path, 'new') + '</div>' + buttons(box, path); break;
case 'history': body = history(box, view); break;
case 'grid': body = grid(box, view.ctx, path) + buttons(box, path); break;
case 'chat': body = chat(box, path, view); break;
case 'plot': body = plot(box, path, view) + buttons(box, path); break;
case 'standard':
case 'unknown':
default: body = '<div class="ztree-box-main">' + itemsTable(box.items, view.ctx, path, '') + '</div>' + buttons(box, path);
}
return '<div ' + attrs + '><div class="ztree-box-inner">' + body + '</div></div>';
}
function headerBox(box, view) {
const t = box.texts || {};
const period = view.period > 0
? escape(t.period || 'Period') + ' ' + view.period + (view.numPeriods ? ' ' + escape(t.of || 'of') + ' ' + view.numPeriods : '')
: escape(t.trial || 'Trial');
return '<div class="ztree-header"><span class="ztree-header-period">' + period + '</span>' +
'<span class="ztree-header-time" data-timeout-text="' + escape(t.timeout || 'Please reach a decision.') + '">' +
escape(t.time || 'Remaining time [sec]:') + ' <span class="ztree-time"></span></span></div>';
}
/** Items as rows: label, and the value or an input. prefix names inputs (new: a new record's). */
function itemsTable(items, ctx, path, prefix) {
let rows = '';
items.forEach((item, i) => {
if (item.kind !== 'item') return;
rows += '<div class="ztree-item">' + itemCell(item, ctx, path + ':' + i, prefix, true) + '</div>';
});
return '<div class="ztree-items">' + rows + '</div>';
}
/** An item: its label and its value, or its input. */
function itemCell(item, ctx, id, prefix, withLabel) {
const layout = parseLayout(item.layout, ctx);
const label = withLabel ? '<div class="ztree-label">' + formatText(item.label, ctx) + '</div>' : '';
if (!item.variable) {
return label === '' ? '' : '<div class="ztree-label ztree-label--wide">' + formatText(item.label, ctx) + '</div>';
}
if (!item.input) {
const v = value(item.variable, ctx);
return label + '<div class="ztree-value">' + (layout.format ? formatText('<>' + formatValue(v, layout, ctx), ctx) : escape(formatValue(v, layout, ctx))) + '</div>';
}
const name = escape(item.variable);
const data = ' data-item="' + escape(id) + '"';
const min = item.min ? lang.num(value(item.min, ctx)) : null;
const max = item.max ? lang.num(value(item.max, ctx)) : null;
let control;
if (layout.kind === 'radio' || layout.kind === 'text') {
control = layout.options.map(([v, l]) => '<label class="ztree-choice"><input type="radio" name="' + name + '" value="' + v + '"' + data + '> ' + escape(l) + '</label>').join('');
if (layout.kind === 'text') {
control = '<select name="' + name + '"' + data + '><option value=""></option>' +
layout.options.map(([v, l]) => '<option value="' + v + '">' + escape(l) + '</option>').join('') + '</select>';
}
} else if (layout.kind === 'radioline') {
const [lo, hi] = [layout.options[0], layout.options[layout.options.length - 1]];
const n = layout.count || 2;
let radios = '';
for (let k = 0; k < n; k++) {
const v = lo[0] + (hi[0] - lo[0]) * k / (n - 1);
radios += '<input type="radio" name="' + name + '" value="' + v + '"' + data + '>';
}
control = '<span class="ztree-radioline"><span>' + escape(lo[1]) + '</span>' + radios + '<span>' + escape(hi[1]) + '</span></span>';
} else if (layout.kind === 'checkbox') {
const [v, l] = layout.options[0] || [1, ''];
control = '<label class="ztree-choice"><input type="checkbox" name="' + name + '" value="' + v + '"' + data + '> ' + escape(l) + '</label>';
} else if (layout.kind === 'slider' || layout.kind === 'scrollbar') {
const [lo, hi] = [layout.options[0] || [min || 0, ''], layout.options[1] || [max || 100, '']];
const n = layout.count || 101;
const step = n > 1 ? (hi[0] - lo[0]) / (n - 1) : 1;
control = '<span class="ztree-slider"><span>' + escape(lo[1]) + '</span><input type="range" name="' + name + '" min="' + lo[0] + '" max="' + hi[0] + '" step="' + step + '"' + data +
' value="' + lo[0] + '"><output></output><span>' + escape(hi[1]) + '</span></span>';
} else if (layout.kind === 'button') {
control = layout.options.map(([v, l]) => '<button type="button" class="ztree-item-button" name="' + name + '" value="' + v + '"' + data + '>' + escape(l) + '</button>').join('');
} else {
control = '<input type="text" inputmode="decimal" autocomplete="off" name="' + name + '"' + data +
(min != null ? ' data-min="' + min + '"' : '') + (max != null ? ' data-max="' + max + '"' : '') + '>';
}
return label + '<div class="ztree-input" data-prefix="' + prefix + '">' + control + '</div>';
}
function buttons(box, path) {
if (box.buttons.length === 0) return '';
return '<div class="ztree-buttons">' + box.buttons.map((b, i) =>
'<button type="button" class="ztree-button" data-box="' + path + '" data-button="' + i + '"' + (b.color ? ' style="background:' + escape(b.color) + '"' : '') + '>' + escape(b.name) + '</button>').join('') + '</div>';
}
/** A contract list: the table's records that meet the box's condition, one a row, to choose from. */
function contractList(box, path, view) {
const rt = view.ctx.rt;
const records = view.ctx.rt.contractRows(box, view.ctx);
const items = box.items.filter((it) => it.kind === 'item');
const selected = (view.selected || {})[path];
let head = '<tr>' + (box.buttons.length ? '<th></th>' : '') + items.map((it) => '<th>' + formatText(it.label, view.ctx) + '</th>').join('') + '</tr>';
let rows = '';
for (const rec of records) {
const rctx = { rt, table: box.table, rec, outer: view.ctx, errors: view.ctx.errors };
const id = rec._id;
rows += '<tr class="ztree-row' + (String(selected) === String(id) ? ' ztree-row--selected' : '') + '" data-record="' + id + '">' +
(box.buttons.length ? '<td><input type="radio" name="ztree-select-' + escape(path) + '" value="' + id + '"' + (String(selected) === String(id) ? ' checked' : '') + '></td>' : '') +
items.map((it, i) => '<td>' + itemCell(it, rctx, path + ':' + i, 'record', false) + '</td>').join('') + '</tr>';
}
return '<table class="ztree-list" data-box="' + path + '"><thead>' + head + '</thead><tbody>' + rows + '</tbody></table>' + buttons(box, path);
}
/** The subject's records in the periods before this one, a row each. */
function history(box, view) {
const items = box.items.filter((it) => it.kind === 'item');
const rows = view.ctx.rt.history(view.ctx).map((rec) => {
const rctx = { rt: view.ctx.rt, table: 'subjects', rec, outer: null, errors: view.ctx.errors };
return '<tr>' + items.map((it) => '<td>' + escape(formatValue(value(it.variable, rctx), it.layout, rctx)) + '</td>').join('') + '</tr>';
}).join('');
return '<table class="ztree-list"><thead><tr>' + items.map((it) => '<th>' + formatText(it.label, view.ctx) + '</th>').join('') + '</tr></thead><tbody>' + rows + '</tbody></table>';
}
/** A grid box: its items in columns of equal length (the first column holds the rows' labels). */
function grid(box, ctx, path) {
const items = box.items.filter((it) => it.kind === 'item');
let rowsPerColumn = items.length;
for (let r = 2; r < items.length; r++) {
if (items.length % r === 0 && items.slice(0, r).every((it) => !it.variable) && !items[r].variable && items[r + 1] && items[r + 1].variable) {
rowsPerColumn = r;
break;
}
}
const columns = [];
for (let i = 0; i < items.length; i += rowsPerColumn) columns.push(items.slice(i, i + rowsPerColumn).map((it, k) => [it, i + k]));
let html = '<table class="ztree-grid">';
for (let r = 0; r < rowsPerColumn; r++) {
html += '<tr>' + columns.map((col) => {
const [it, k] = col[r] || [null, 0];
return '<td>' + (it ? itemCell(it, ctx, path + ':' + k, '', false) || formatText(it.label, ctx) : '') + '</td>';
}).join('') + '</tr>';
}
return html + '</table>';
}
/** A chat box: the table's entries that meet its condition, shown with its format; and an input. */
function chat(box, path, view) {
const rt = view.ctx.rt;
let out = '';
if (box.outputs && box.outputs.length) {
const records = rt.records(box.table, {}).filter((rec) => !box.condition2 || lang.bool(value(box.condition2, { rt, table: box.table, rec, outer: view.ctx })));
out += '<div class="ztree-chat-log">' + records.map((rec) => {
const rctx = { rt, table: box.table, rec, outer: view.ctx };
// <|-1>: the entry's text.
const format = box.outputs[0].replace(/<\|-1>/g, '<' + box.variable + '|-1>');
return '<div class="ztree-chat-line">' + formatText(format, rctx) + '</div>';
}).join('') + '</div>';
}
if (box.variable) {
out += '<div class="ztree-chat-input"><input type="text" class="ztree-chat-text" data-box="' + path + '" autocomplete="off"></div>';
}
return out;
}
// --- Plots ----------------------------------------------------------------------------------
/**
* A plot box: its items evaluated (graphs' for each of their table's records) to shapes in the
* plot's coordinates, for participant/ztree.js to draw (as SVG, at the box's size): {range,
* shapes, inputs}. A shape a subject can click or drag has the inputs it takes, and the records
* it was drawn for (its graphs', innermost first).
*/
function plot(box, path, view) {
const ctx = view.ctx;
const num = (code, c, fallback = 0) => {
if (code == null || String(code).trim() === '') return fallback;
const v = value(code, c);
return v == null ? fallback : lang.num(v);
};
const color = (code, c) => {
if (code == null || String(code).trim() === '') return null;
const v = value(code, c);
return typeof v === 'string' ? v : null;
};
const r = box.range || {};
const data = {
range: { x1: num(r.xLeft, ctx), x2: num(r.xRight, ctx, 100), y1: num(r.yBottom, ctx), y2: num(r.yTop, ctx, 100), keepAspect: box.keepAspect === true },
shapes: [],
inputs: [],
};
// Inputs: their path in the box, what they take, and whether they apply now.
const input = (inp, id, c, records) => {
if (inp.condition && !lang.bool(value(inp.condition, c))) return null;
data.inputs.push({ id, action: inp.action || 0, records });
return id;
};
// collected: the inputs at this level (of a graph's record: its items take them).
const visit = (items, prefix, c, records, collected) => {
items.forEach((item, i) => {
const id = prefix + i;
if (item.kind === 'graph') {
const rt = c.rt;
const recs = rt.records(item.table || 'globals', {}).filter((rec) => !item.graphCondition ||
lang.bool(value(item.graphCondition, { rt, table: item.table, rec, outer: c })));
const joined = [];
recs.forEach((rec) => {
const inner = { rt, table: item.table, rec, outer: c, errors: c.errors };
const before = data.shapes.length;
const level = [];
visit(item.items, id + '.', inner, [{ table: item.table, id: rec._id }, ...records], level);
data.shapes.slice(before).forEach((sh) => { if (sh.inputs) sh.inputs.push(...level); });
// The graph's line joins its points, record by record.
data.shapes.slice(before).filter((s) => s.kind === 'point').forEach((s) => joined.push([s.x, s.y]));
});
const lineColor = color(item.lineColor, c);
if (lineColor && joined.length > 1) {
data.shapes.unshift({ kind: 'polyline', points: joined, color: lineColor, width: num(item.lineWidth, c, 1) });
}
return;
}
if (item.kind === 'input') {
// In a graph: its record's items take it; in the box: a click anywhere.
const used = input(item, id + '@' + records.map((x) => x.table + ':' + x.id).join(','), c, records);
if (used) collected.push(used);
return;
}
if (item.condition && !lang.bool(value(item.condition, c))) return;
const shape = { kind: item.kind, inputs: [], records };
(item.inputs || []).forEach((inp, k) => {
const used = input(inp, id + ':' + k + '@' + records.map((x) => x.table + ':' + x.id).join(','), c, records);
if (used) shape.inputs.push(used);
});
switch (item.kind) {
case 'point':
Object.assign(shape, { x: num(item.x, c), y: num(item.y, c), size: num(item.size, c, 5), corners: num(item.corners, c, 4),
rotation: num(item.rotation, c), spokes: item.spokes, color: color(item.lineColor, c), width: num(item.lineWidth, c, 1),
fill: color(item.fillColor, c) });
break;
case 'line':
Object.assign(shape, { x1: num(item.x1, c), y1: num(item.y1, c), x2: num(item.x2, c), y2: num(item.y2, c),
// An arrow's start: none if not given (0, given: a bar).
arrowStart: String(item.arrowStart || '').trim() === '' ? null : num(item.arrowStart, c),
arrowWidth: num(item.arrowWidth, c), arrowEnd: String(item.arrowEnd || '').trim() === '' ? null : num(item.arrowEnd, c),
arrowFilled: item.arrowFilled, color: color(item.lineColor, c) || 'rgb(0,0,0)', width: num(item.lineWidth, c, 1) });
break;
case 'rect': {
const has = (code) => code != null && String(code).trim() !== '';
const axis = (lo, hi, center, size) => {
if (has(lo) && has(hi)) return [num(lo, c), num(hi, c)];
const w = num(size, c);
if (has(lo)) return [num(lo, c), num(lo, c) + w];
if (has(hi)) return [num(hi, c) - w, num(hi, c)];
return [num(center, c) - w / 2, num(center, c) + w / 2];
};
// Height grows up from the bottom (or down from the top) given.
const [x1, x2] = axis(item.xLeft, item.xRight, item.xCenter, item.width);
const [y1, y2] = axis(item.yBottom, item.yTop, item.yCenter, item.height);
Object.assign(shape, { x1, x2, y1, y2, color: color(item.lineColor, c), width: num(item.lineWidth, c, 1), fill: color(item.fillColor, c) });
break;
}
case 'pie':
Object.assign(shape, { x: num(item.x, c), y: num(item.y, c), rx: num(item.rx, c, 1), ry: num(item.ry, c, 1),
a1: num(item.angleStart, c), a2: num(item.angleEnd, c, 360), color: color(item.lineColor, c), width: num(item.lineWidth, c, 1),
fill: color(item.fillColor, c) });
break;
case 'text':
Object.assign(shape, { x: num(item.x, c), y: num(item.y, c), text: formatText(item.text, c).replace(/<br>/g, ' ').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"'),
hAlign: item.hAlign, vAlign: item.vAlign, color: color(item.color, c) || 'rgb(0,0,0)', size: num(item.fontSize, c, 12) });
break;
case 'axis':
Object.assign(shape, { horizontal: item.horizontal, position: num(item.position, c), from: num(item.from, c), to: num(item.to, c, 1),
tick: num(item.tickStep, c), label: num(item.labelStep, c), layout: item.labelLayout || '1', caption: item.caption || '',
color: color(item.lineColor, c) || 'rgb(0,0,0)', width: num(item.lineWidth, c, 1) });
if (shape.label > 0) {
shape.labels = [];
for (let v = Math.ceil(shape.from / shape.label - 1e-9) * shape.label; v <= shape.to + 1e-9 && shape.labels.length < 200; v += shape.label) {
shape.labels.push([v, formatValue(v, shape.layout, c)]);
}
}
break;
default:
return;
}
data.shapes.push(shape);
});
};
const boxInputs = [];
visit(box.plotItems || [], '', ctx, [], boxInputs);
data.boxInputs = boxInputs;
return '<div class="ztree-plot" data-box="' + path + '" data-plot="' + escape(JSON.stringify(data)) + '"></div>';
}
module.exports = { renderScreen, formatValue, formatText, parseLayout, layoutParts, textParts, rtfToText, place, measure, value, expression, escape };