/** * vegaToDevExtreme.js * * Converts a Vega-Lite chart specification into a DevExtreme dxChart * configuration object. * * Supported Vega-Lite features: * - Mark types: bar, line, area, point, circle, square, tick, rule, arc, rect, trail * - Encodings: x, y, color, size, tooltip (theta/radius for pie) * - Transforms: aggregate, fold, filter (predicate form), bin * - Inline shorthand aggregation on the y encoding * - Layered specs (each layer becomes an additional series) * - title, width, height, color scale palette */ (function (global) { 'use strict'; // ── Mark type → DevExtreme series type ──────────────────────────────────── var MARK_TYPE_MAP = { bar : 'bar', line : 'line', area : 'area', point : 'scatter', circle : 'scatter', square : 'scatter', tick : 'scatter', rule : 'line', arc : 'pie', rect : 'bar', trail : 'spline', boxplot : 'candlestick' }; // ── Vega-Lite encoding type → DevExtreme axis type ───────────────────────── var AXIS_TYPE_MAP = { quantitative : 'continuous', temporal : 'datetime', ordinal : 'discrete', nominal : 'discrete' }; // ── d3 format → DevExtreme format ───────────────────────────────────────── var FORMAT_MAP = { 'd' : { type: 'decimal' }, 'f' : { type: 'fixedPoint', precision: 2 }, '.0f' : { type: 'fixedPoint', precision: 0 }, '.1f' : { type: 'fixedPoint', precision: 1 }, '.2f' : { type: 'fixedPoint', precision: 2 }, '.3f' : { type: 'fixedPoint', precision: 3 }, '$' : { type: 'currency', currency: 'USD' }, '$.2f' : { type: 'currency', currency: 'USD' }, '%' : { type: 'percent' }, '.0%' : { type: 'percent', precision: 0 }, '.1%' : { type: 'percent', precision: 1 }, '.2%' : { type: 'percent', precision: 2 }, 's' : { type: 'largeNumber' } }; // ───────────────────────────────────────────────────────────────────────── // Internal helpers // ───────────────────────────────────────────────────────────────────────── function getMarkString(mark) { return typeof mark === 'string' ? mark : (mark && mark.type) || 'line'; } function mapMarkType(mark) { return MARK_TYPE_MAP[getMarkString(mark)] || 'line'; } function mapFormat(fmt) { return fmt ? (FORMAT_MAP[fmt] || undefined) : undefined; } /** * Build an argument or value axis config from a Vega-Lite encoding channel. */ function buildAxisConfig(enc) { if (!enc) return {}; var axis = {}; // Title: explicit title > axis.title > field name var titleText = (enc.title !== undefined) ? enc.title : (enc.axis && enc.axis.title !== undefined) ? enc.axis.title : enc.field || null; if (titleText) { axis.title = { text: titleText }; } // Type var axisType = enc.scale && enc.scale.type === 'log' ? 'logarithmic' : AXIS_TYPE_MAP[enc.type] || undefined; if (axisType) axis.type = axisType; // Label format var fmt = enc.axis && enc.axis.format ? mapFormat(enc.axis.format) : undefined; if (fmt) axis.label = { format: fmt }; return axis; } // ───────────────────────────────────────────────────────────────────────── // Data transform processing // ───────────────────────────────────────────────────────────────────────── /** * Apply an array of Vega-Lite transforms to a data array. * Returns a new array; the original is not mutated. */ function applyTransforms(data, transforms) { var result = data.slice(); transforms.forEach(function (t) { // ── fold ────────────────────────────────────────────────────────── if (t.fold) { var foldFields = t.fold; var keyName = (t.as && t.as[0]) || 'key'; var valueName = (t.as && t.as[1]) || 'value'; var folded = []; result.forEach(function (row) { foldFields.forEach(function (field) { var newRow = Object.assign({}, row); newRow[keyName] = field; newRow[valueName] = row[field]; folded.push(newRow); }); }); result = folded; // ── filter (predicate object form) ──────────────────────────────── } else if (t.filter && typeof t.filter === 'object' && !Array.isArray(t.filter)) { var f = t.filter; result = result.filter(function (row) { var v = row[f.field]; if (f.equal !== undefined) return v == f.equal; // intentional == if (f.gt !== undefined) return parseFloat(v) > parseFloat(f.gt); if (f.gte !== undefined) return parseFloat(v) >= parseFloat(f.gte); if (f.lt !== undefined) return parseFloat(v) < parseFloat(f.lt); if (f.lte !== undefined) return parseFloat(v) <= parseFloat(f.lte); if (f.range) return parseFloat(v) >= parseFloat(f.range[0]) && parseFloat(v) <= parseFloat(f.range[1]); if (f.oneOf) return f.oneOf.indexOf(v) !== -1; return true; }); // ── aggregate / groupby ─────────────────────────────────────────── } else if (t.aggregate) { result = aggregateData(result, t.aggregate, t.groupby || []); // ── bin ─────────────────────────────────────────────────────────── } else if (t.bin !== undefined) { var binField = t.field; var binAs = Array.isArray(t.as) ? t.as[0] : (t.as || binField + '_bin'); var step = (typeof t.bin === 'object') ? t.bin.step : undefined; if (step && binField) { result = result.map(function (row) { var newRow = Object.assign({}, row); var val = parseFloat(row[binField]); newRow[binAs] = Math.floor(val / step) * step; return newRow; }); } } // calculate and window transforms require eval / complex logic — skipped }); return result; } /** * Group data rows by groupby fields, then compute aggregations. */ function aggregateData(data, aggs, groupByFields) { var groups = {}; var groupOrder = []; data.forEach(function (row) { var key = groupByFields.map(function (f) { return row[f]; }).join('\x00'); if (!groups[key]) { groups[key] = { items: [], proto: {} }; groupByFields.forEach(function (f) { groups[key].proto[f] = row[f]; }); groupOrder.push(key); } groups[key].items.push(row); }); return groupOrder.map(function (key) { var group = groups[key]; var items = group.items; var outRow = Object.assign({}, group.proto); aggs.forEach(function (agg) { var field = agg.field; var op = agg.op; var as = agg.as || (op + '_' + field); switch (op) { case 'sum': outRow[as] = items.reduce(function (acc, r) { return acc + (parseFloat(r[field]) || 0); }, 0); break; case 'mean': case 'average': { var total = items.reduce(function (acc, r) { return acc + (parseFloat(r[field]) || 0); }, 0); outRow[as] = items.length ? total / items.length : 0; break; } case 'count': outRow[as] = items.length; break; case 'distinct': outRow[as] = new Set(items.map(function (r) { return r[field]; })).size; break; case 'min': outRow[as] = Math.min.apply(null, items.map(function (r) { return parseFloat(r[field]) || 0; })); break; case 'max': outRow[as] = Math.max.apply(null, items.map(function (r) { return parseFloat(r[field]) || 0; })); break; case 'median': { var sorted = items.map(function (r) { return parseFloat(r[field]) || 0; }) .sort(function (a, b) { return a - b; }); var mid = Math.floor(sorted.length / 2); outRow[as] = sorted.length % 2 === 0 ? (sorted[mid - 1] + sorted[mid]) / 2 : sorted[mid]; break; } case 'valid': outRow[as] = items.filter(function (r) { return r[field] != null && !isNaN(r[field]); }).length; break; default: outRow[as] = null; } }); return outRow; }); } // ───────────────────────────────────────────────────────────────────────── // Resolve the output field name for an encoding channel // ───────────────────────────────────────────────────────────────────────── function resolveOutputField(enc) { if (!enc) return null; if (enc.as) return enc.as; if (enc.aggregate) return enc.aggregate + '_' + (enc.field || 'value'); return enc.field || null; } // ───────────────────────────────────────────────────────────────────────── // Main converter // ───────────────────────────────────────────────────────────────────────── /** * Convert a Vega-Lite specification to a DevExtreme dxChart options object. * * @param {Object} vegaSpec Vega-Lite specification * @returns {Object} DevExtreme dxChart options * * @example * var opts = vegaToDevExtreme(spec); * $('
').dxChart(opts).appendTo($('#container')); */ function vegaToDevExtreme(vegaSpec) { // For layered specs the top-level properties (data, title, etc.) apply // to all layers; the first layer drives the primary series. var isLayered = Array.isArray(vegaSpec.layer); var baseSpec = isLayered ? vegaSpec.layer[0] : vegaSpec; var config = {}; // ── 1. Resolve inline data ───────────────────────────────────────────── var rawData = []; if (vegaSpec.data && vegaSpec.data.values) { rawData = vegaSpec.data.values.slice(); } else if (baseSpec.data && baseSpec.data.values) { rawData = baseSpec.data.values.slice(); } // url data is passed through as-is (caller is responsible for fetching) // ── 2. Apply transforms ──────────────────────────────────────────────── var topTransforms = vegaSpec.transform || []; var baseTransforms = baseSpec.transform || []; var allTransforms = topTransforms.concat(baseTransforms); var data = allTransforms.length > 0 && rawData.length > 0 ? applyTransforms(rawData, allTransforms) : rawData; config.dataSource = data; // ── 3. Encoding channels ─────────────────────────────────────────────── var enc = baseSpec.encoding || vegaSpec.encoding || {}; var xEnc = enc.x || enc.theta || null; var yEnc = enc.y || enc.radius || null; var colorEnc = enc.color || null; var sizeEnc = enc.size || null; var tooltipEnc = enc.tooltip || null; var xField = xEnc ? (xEnc.field || null) : null; var colorField = colorEnc ? (colorEnc.field || null) : null; // The output field for y may differ when aggregation renames the field var yOutputField = resolveOutputField(yEnc); // ── 4. Handle shorthand inline aggregation (no explicit transform) ───── // e.g. encoding.y = { field: "sales", aggregate: "sum" } if (yEnc && yEnc.aggregate && xField && allTransforms.length === 0 && data.length > 0) { var groupBy = colorField ? [xField, colorField] : [xField]; data = aggregateData(data, [{ op : yEnc.aggregate, field : yEnc.field, as : yOutputField }], groupBy); config.dataSource = data; } // ── 5. Mark / series type ────────────────────────────────────────────── var mark = baseSpec.mark || vegaSpec.mark; var markDef = (mark && typeof mark === 'object') ? mark : {}; var seriesType = mapMarkType(mark); var isPie = seriesType === 'pie'; // ── 6. Series ────────────────────────────────────────────────────────── if (isPie) { config.series = [{ type : 'pie', argumentField : xField || null, valueField : yOutputField, label : { visible: true } }]; } else if (colorField) { // color encoding → seriesTemplate automatically creates one series per // unique value of colorField config.commonSeriesSettings = { type : seriesType, argumentField : xField, valueField : yOutputField }; config.seriesTemplate = { nameField : colorField }; } else { var singleSeries = { type : seriesType, argumentField : xField, valueField : yOutputField, name : (yEnc && (yEnc.title || yEnc.field)) || yOutputField }; if (sizeEnc && sizeEnc.field && seriesType === 'bubble') { singleSeries.sizeField = sizeEnc.field; } if (markDef.color) singleSeries.color = markDef.color; if (markDef.opacity !== undefined) singleSeries.opacity = markDef.opacity; config.series = [singleSeries]; } // Extra layers become additional series if (isLayered && vegaSpec.layer.length > 1) { if (!config.series) config.series = []; vegaSpec.layer.slice(1).forEach(function (layer) { var lEnc = layer.encoding || enc; var lYEnc = lEnc.y || null; var lXEnc = lEnc.x || xEnc; config.series.push({ type : mapMarkType(layer.mark), argumentField : lXEnc ? (lXEnc.field || xField) : xField, valueField : resolveOutputField(lYEnc), name : (lYEnc && (lYEnc.title || lYEnc.field)) || resolveOutputField(lYEnc) }); }); } // ── 7. Axes ──────────────────────────────────────────────────────────── if (!isPie) { if (xEnc) config.argumentAxis = buildAxisConfig(xEnc); if (yEnc) config.valueAxis = buildAxisConfig(yEnc); } // ── 8. Tooltip ───────────────────────────────────────────────────────── config.tooltip = { enabled: true, shared: seriesType === 'bar' }; if (tooltipEnc) { var tooltipFields = Array.isArray(tooltipEnc) ? tooltipEnc : [tooltipEnc]; config.tooltip.customizeTooltip = function (pointInfo) { var lines = tooltipFields.map(function (t) { var label = t.title || t.field || ''; var value = pointInfo.point && pointInfo.point.data ? pointInfo.point.data[t.field] : undefined; return label + ': ' + (value !== undefined ? value : ''); }); return { text: lines.join('
') }; }; } // ── 9. Legend ────────────────────────────────────────────────────────── config.legend = { visible : !!colorField || (isLayered && vegaSpec.layer.length > 1), position : 'outside', horizontalAlignment : 'right', verticalAlignment : 'top' }; // ── 10. Title ────────────────────────────────────────────────────────── var titleDef = vegaSpec.title || baseSpec.title; if (titleDef) { config.title = { text: typeof titleDef === 'string' ? titleDef : (titleDef.text || '') }; } // ── 11. Size ─────────────────────────────────────────────────────────── if (vegaSpec.width || vegaSpec.height) { config.size = {}; if (vegaSpec.width) config.size.width = vegaSpec.width; if (vegaSpec.height) config.size.height = vegaSpec.height; } // ── 12. Color palette ────────────────────────────────────────────────── if (colorEnc && colorEnc.scale && Array.isArray(colorEnc.scale.range)) { config.palette = colorEnc.scale.range; } return config; } // ── Export ───────────────────────────────────────────────────────────────── if (typeof module !== 'undefined' && module.exports) { module.exports = vegaToDevExtreme; } else { global.vegaToDevExtreme = vegaToDevExtreme; } }(typeof window !== 'undefined' ? window : this));