UNPKG

uplot-vplugins

Version:
253 lines (252 loc) 11.6 kB
import uPlot from "uplot"; import { distr, SPACE_AROUND, SPACE_BETWEEN, SPACE_EVENLY } from "../Utils/@Ext/Distr.js"; import { pointWithin, Quadtree } from "../Utils/@Ext/QuadTree.js"; import { Assert } from "../Utils/FromJSVE.js"; export function GetOrientationValue(orientation) { if (orientation == "horizontal") return 0; if (orientation == "vertical") return 1; Assert(false, `Invalid orientation value: ${orientation}`); } export function GetDirectionValue(direction) { if (direction == "positive") return 1; if (direction == "negative") return -1; Assert(false, `Invalid direction value: ${direction}`); } export function GetDistributionValue(distribution) { if (distribution == "spaceBetween") return SPACE_BETWEEN; if (distribution == "spaceAround") return SPACE_AROUND; if (distribution == "spaceEvenly") return SPACE_EVENLY; Assert(false, `Invalid distribution value: ${distribution}`); } export class GroupedBarsPluginOptions { constructor(data) { this.ori = "horizontal"; this.dir = "positive"; this.stacked = false; this.ignore = []; this.radius = 0; // custom this.color = "black"; this.visualGroupDistribution = "spaceBetween"; this.barDistribution = "spaceBetween"; /** Given a certain space of "1" that covers the entire plotting area, how much of that space (.1 = 10%) is used for the gaps between visual-groups? */ this.gapBetweenVisualGroups = 0.1; /** Given a certain space of "1" that a visual-group has for rendering itself (excludes any gaps between visual-group itself and its neighbors), how much of that space (.1 = 10%) is used for the gaps between its bars? */ this.gapBetweenBars = 0; Object.assign(this, data); } // helpers for sending to uplot get Ori_Val() { return GetOrientationValue(this.ori); } get Dir_Val() { return GetDirectionValue(this.dir); } get VisualGroupDistribution_Val() { return GetDistributionValue(this.visualGroupDistribution); } get BarDistribution_Val() { return GetDistributionValue(this.barDistribution); } } export class GroupedBarsPlugin { constructor(options) { this.options = new GroupedBarsPluginOptions(options); const opt = this.options; let pxRatio; let font; function setPxRatio() { pxRatio = devicePixelRatio; font = `${Math.round(10 * pxRatio)}px Arial`; } setPxRatio(); window.addEventListener("dppxchange", setPxRatio); // calc based on opts above const groupWidth = 1 - opt.gapBetweenVisualGroups; const barWidth = 1 - opt.gapBetweenBars; function distrTwo(groupCount, barCount, barSpread = true, _groupWidth = groupWidth) { const out = Array.from({ length: barCount }, () => ({ offs: Array(groupCount).fill(0), size: Array(groupCount).fill(0), })); distr(groupCount, _groupWidth, opt.VisualGroupDistribution_Val, null, (groupIdx, groupOffPct, groupDimPct) => { distr(barCount, barWidth, opt.BarDistribution_Val, null, (barIdx, barOffPct, barDimPct) => { out[barIdx].offs[groupIdx] = groupOffPct + (barSpread ? (groupDimPct * barOffPct) : 0); out[barIdx].size[groupIdx] = groupDimPct * (barSpread ? barDimPct : 1); }); }); return out; } let barsPctLayout; let barsColors; const barsBuilder = uPlot.paths.bars({ radius: opt.radius, disp: Object.assign({ x0: { unit: 2, // discr: false, (unary, discrete, continuous) values: (u, seriesIdx, idx0, idx1) => barsPctLayout[seriesIdx].offs, }, size: { unit: 2, // discr: true, values: (u, seriesIdx, idx0, idx1) => barsPctLayout[seriesIdx].size, } }, opt.disp), each: (u, seriesIdx, dataIdx, lft, top, wid, hgt) => { // we get back raw canvas coords (included axes & padding). translate to the plotting area origin lft -= u.bbox.left; top -= u.bbox.top; qt.add({ x: lft, y: top, w: wid, h: hgt, sidx: seriesIdx, didx: dataIdx }); }, }); function drawPoints(u, sidx, i0, i1) { u.ctx.save(); u.ctx.font = font; u.ctx.fillStyle = opt.color; uPlot.orient(u, sidx, (series, dataX, dataY, scaleX, scaleY, valToPosX, valToPosY, xOff, yOff, xDim, yDim, moveTo, lineTo, rect) => { const _dir = opt.Dir_Val * (opt.ori == "horizontal" ? 1 : -1); const wid = Math.round(barsPctLayout[sidx].size[0] * xDim); barsPctLayout[sidx].offs.forEach((offs, ix) => { if (dataY[ix] != null) { const x0 = xDim * offs; const lft = Math.round(xOff + (_dir == 1 ? x0 : xDim - x0 - wid)); const barWid = Math.round(wid); const yPos = valToPosY(dataY[ix], scaleY, yDim, yOff); const x = opt.ori == "horizontal" ? Math.round(lft + barWid / 2) : Math.round(yPos); const y = opt.ori == "horizontal" ? Math.round(yPos) : Math.round(lft + barWid / 2); u.ctx.textAlign = opt.ori == "horizontal" ? "center" : dataY[ix] >= 0 ? "left" : "right"; u.ctx.textBaseline = opt.ori == "vertical" ? "middle" : dataY[ix] >= 0 ? "bottom" : "top"; u.ctx.fillText(dataY[ix], x, y); } }); }); u.ctx.restore(); } function range(u, dataMin, dataMax) { const [min, max] = uPlot.rangeNum(0, dataMax, 0.05, true); return [0, max]; } let qt; this.hooks = { drawClear: u => { var _a; qt = qt || new Quadtree(0, 0, u.bbox.width, u.bbox.height); qt.clear(); // force-clear the path cache to cause drawBars() to rebuild new quadtree u.series.forEach(s => { s._paths = null; }); barsPctLayout = [null].concat(distrTwo(u.data[0].length, u.series.length - 1 - opt.ignore.length, !opt.stacked, groupWidth)); // TODOL only do on setData, not every redraw if (((_a = opt.disp) === null || _a === void 0 ? void 0 : _a.fill) != null) { barsColors = [null]; for (let i = 1; i < u.data.length; i++) { barsColors.push({ fill: opt.disp.fill.values(u, i), stroke: opt.disp.stroke.values(u, i), }); } } }, }; this.opts = (u, uplotOpts) => { var _a; const yScaleOpts = { range, ori: opt.ori == "horizontal" ? 1 : 0, }; // hovered let hRect; uPlot.assign(uplotOpts, { select: { show: false }, cursor: { x: false, y: false, dataIdx: (u, seriesIdx) => { if (seriesIdx == 1) { hRect = null; const cx = u.cursor.left * pxRatio; const cy = u.cursor.top * pxRatio; qt.get(cx, cy, 1, 1, o => { if (pointWithin(cx, cy, o.x, o.y, o.x + o.w, o.y + o.h)) hRect = o; }); } return hRect && seriesIdx == hRect.sidx ? hRect.didx : null; }, points: { fill: "rgba(255,255,255, 0.3)", bbox: (u, seriesIdx) => { const isHovered = hRect && seriesIdx == hRect.sidx; return { left: isHovered ? hRect.x / pxRatio : -10, top: isHovered ? hRect.y / pxRatio : -10, width: isHovered ? hRect.w / pxRatio : 0, height: isHovered ? hRect.h / pxRatio : 0, }; }, }, }, scales: { x: { time: false, distr: 2, ori: opt.Ori_Val, dir: opt.Dir_Val, // auto: true, range: (u, min, max) => { min = 0; max = Math.max(1, u.data[0].length - 1); let pctOffset = 0; distr(u.data[0].length, groupWidth, opt.VisualGroupDistribution_Val, 0, (di, lftPct, widPct) => { pctOffset = lftPct + widPct / 2; }); const rn = max - min; if (pctOffset == 0.5) min -= rn; else { const upScale = 1 / (1 - pctOffset * 2); const offset = (upScale * rn - rn) / 2; min -= offset; max += offset; } return [min, max]; }, }, rend: yScaleOpts, size: yScaleOpts, mem: yScaleOpts, inter: yScaleOpts, toggle: yScaleOpts, }, }); if (opt.ori == "vertical") { uplotOpts.padding = [0, null, 0, null]; } Assert(((_a = uplotOpts.axes) === null || _a === void 0 ? void 0 : _a[0]) != null, "Expected opts.axes[0] to be set."); uPlot.assign(uplotOpts.axes[0], { splits: (u, axisIdx) => { const _dir = opt.Dir_Val * (opt.ori == "horizontal" ? 1 : -1); const splits = u._data[0].slice(); return _dir == 1 ? splits : splits.reverse(); }, values: u => u.data[0], gap: 15, size: opt.ori == "horizontal" ? 40 : 150, labelSize: 20, grid: { show: false }, ticks: { show: false }, side: opt.ori == "horizontal" ? 2 : 3, }); uplotOpts.series.forEach((s, i) => { if (i > 0 && !opt.ignore.includes(i)) { uPlot.assign(s, { // pxAlign: false, // stroke: "rgba(255,0,0,0.5)", paths: barsBuilder, points: { show: drawPoints, }, }); } }); }; } }