UNPKG

uplot-vplugins

Version:
316 lines (286 loc) 11.2 kB
import uPlot from "uplot"; import {Assert, E, IsNaN, Lerp} from "../Utils/FromJSVE.js"; /** Mark some properties which only the former including as optional and set the value to never */ type Without<T, U> = { [P in Exclude<keyof T, keyof U>]?: never }; /** get the XOR type which could make 2 types exclude each other */ export type XOR<T, U> = T | U extends object ? (Without<T, U> & U) | (Without<U, T> & T) : T | U; export type FinalizeOp = "floor" | "ceiling" | "round" | ((val: number)=>number) | null; export type PositionIndicator = XOR<{ //type: "valueOnAxis"; //value: number | string; value: number; value_axis?: string; /** See uplot.valToPos for info. */ value_toCanvasPixels?: boolean; finalize?: FinalizeOp; }, { //type: "pixelOnCanvas"; pixel: number | string; pixel_relToFullCanvas?: boolean; finalize?: FinalizeOp; }>; export type SizeIndicator = PositionIndicator; // they're compatible export type DrawStage = "afterClear" | "afterAxes" | "afterDatasets" | "afterAll"; // based on uPlot's hook stages (draw[X] -> after[X]) /** See details here: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation */ export type DrawType = "source-over" | "source-in" | "source-out" | "source-atop" | "destination-over" | "destination-in" | "destination-out" | "destination-atop" | "lighter" | "copy" | "xor" | "multiply" | "screen" | "overlay" | "darken" | "lighten" | "color-dodge" | "color-burn" | "hard-light" | "soft-light" | "difference" | "exclusion" | "hue" | "saturation" | "color" | "luminosity"; //export type AnnotationType = "box" export type Annotation = { /** Default (if null) is "afterAll". Caution: "afterSeries" will get called multiple times per chart-draw, if there are multiple series. */ drawStage?: DrawStage; drawType?: DrawType; shouldRender?: (info: {chart: uPlot})=>boolean; extraData?: any; /** Called at very start of code for entry's for-loop iteration. (well, right after filtering based on entry.shouldRender) */ preSetup?: (info: {entry: Annotation, ctx: CanvasRenderingContext2D, chart: uPlot})=>void; /** Called just after setting the canvas context's clip, before doing actual drawing. */ preDraw?: (info: {entry: Annotation, ctx: CanvasRenderingContext2D, chart: uPlot})=>void; /** Called after doing the actual drawing for the entry, at end of entry's for-loop iteration. */ postDraw?: (info: {entry: Annotation, ctx: CanvasRenderingContext2D, chart: uPlot})=>void; } & ( XOR< XOR< XOR< { type: "freeform"; // uses the extraData and preDraw/postDraw fields exclusively }, { type: "box"; xMin?: PositionIndicator; xMax?: PositionIndicator; xSize?: SizeIndicator; yMin?: PositionIndicator; yMax?: PositionIndicator; ySize?: SizeIndicator; fillStyle: typeof CanvasRenderingContext2D.prototype.fillStyle; strokeStyle?: typeof CanvasRenderingContext2D.prototype.strokeStyle; lineWidth?: number; } >, { type: "line"; x?: PositionIndicator; y?: PositionIndicator; color: typeof CanvasRenderingContext2D.prototype.fillStyle; //width: number; lineWidth: number; // reuse prop-names where possible } >, { type: "text"; x: PositionIndicator; y: PositionIndicator; text: string; fillStyle?: typeof CanvasRenderingContext2D.prototype.fillStyle; strokeStyle?: typeof CanvasRenderingContext2D.prototype.strokeStyle; lineWidth?: number; // reuse prop-names where possible textAlign?: typeof CanvasRenderingContext2D.prototype.textAlign; font?: typeof CanvasRenderingContext2D.prototype.font; } > ); type Options_OptionalForInitOnly = any; export type AnnotationsOptions_ForInit = Omit<AnnotationsOptions, Options_OptionalForInitOnly> & Partial<Pick<AnnotationsOptions, Options_OptionalForInitOnly>>; export class AnnotationsOptions { constructor(opts?: Partial<AnnotationsOptions>) { Object.assign(this, opts); } annotations = [] as Annotation[]; } export function ConvertPosIndicatorToContextPoint(pos: PositionIndicator, chart: uPlot, context: CanvasRenderingContext2D, defaultScaleKey: "x" | "y", defaultFinalize: FinalizeOp) { /*if (typeof pos == "number") { pos = {type: "valueOnAxis", axisKey: defaultScaleKey, value: pos}; } else if (typeof pos == "string") { const {left, top, width, height} = chart.bbox; const percent = Number(pos.slice(0, -1)) / 100; if (defaultScaleKey == "x") { pos = {type: "pixelOnCanvas", value: Lerp(left, left + width, percent)}; } else { pos = {type: "pixelOnCanvas", value: Lerp(top + height, top, percent)}; } }*/ let result: number|undefined; if (pos.value != null) { result = chart.valToPos(pos.value, pos.value_axis ?? defaultScaleKey, pos.value_toCanvasPixels ?? true); } else if (pos.pixel != null) { const {left, top, width, height} = chart.bbox; if (typeof pos.pixel == "number") { if (defaultScaleKey == "x") { result = (pos.pixel_relToFullCanvas ? 0 : left) + pos.pixel; } else { result = (pos.pixel_relToFullCanvas ? 0 : top) + pos.pixel; } } else { const percent = Number(pos.pixel.slice(0, -1)) / 100; if (defaultScaleKey == "x") { result = Lerp(left, left + width, percent); } else { //result = Lerp(top + height, top, percent); result = Lerp(top, top + height, percent); } } } else { Assert(false, `Either "value" or "pixel" field must be supplied for pos/size indicator.`); } Assert(result != null, `Position/size element cannot be null.`); const finalize = pos.finalize !== undefined ? pos.finalize : defaultFinalize; if (finalize != null) { if (finalize == "floor") result = Math.floor(result); else if (finalize == "ceiling") result = Math.ceil(result); else if (finalize == "round") result = Math.round(result); else result = finalize(result); } Assert(result != null, `Position/size element cannot be null. (after finalization)`); return result; } export class AnnotationsPlugin implements uPlot.Plugin { constructor(options: ConstructorParameters<typeof AnnotationsOptions>[0]) { this.options = new AnnotationsOptions(options); } options: AnnotationsOptions; hooks: uPlot.Hooks.Defs = { drawClear: (u: uPlot)=>{ const annotationsForThisStage = this.options.annotations.filter(a=>a.drawStage == "afterClear"); if (annotationsForThisStage.length == 0) return; u.ctx.save(); for (const entry of annotationsForThisStage) { this.TryToDrawAnnotation(entry, u); } u.ctx.restore(); }, drawAxes: (u: uPlot)=>{ const annotationsForThisStage = this.options.annotations.filter(a=>a.drawStage == "afterAxes"); if (annotationsForThisStage.length == 0) return; u.ctx.save(); for (const entry of annotationsForThisStage) { this.TryToDrawAnnotation(entry, u); } u.ctx.restore(); }, drawSeries: (u: uPlot, seriesIdx: number)=>{ const annotationsForThisStage = this.options.annotations.filter(a=>a.drawStage == "afterDatasets"); if (annotationsForThisStage.length == 0) return; u.ctx.save(); for (const entry of annotationsForThisStage) { this.TryToDrawAnnotation(entry, u); } u.ctx.restore(); }, draw: (u: uPlot)=>{ const annotationsForThisStage = this.options.annotations.filter(a=>a.drawStage == "afterAll" || a.drawStage == null); if (annotationsForThisStage.length == 0) return; u.ctx.save(); for (const entry of annotationsForThisStage) { this.TryToDrawAnnotation(entry, u); } u.ctx.restore(); }, }; TryToDrawAnnotation(entry: Annotation, chart: uPlot) { const {ctx} = chart; const {left, top, width, height} = chart.bbox; const shouldRenderInfo = {chart}; if (entry.shouldRender && entry.shouldRender(shouldRenderInfo) == false) return; entry.preSetup?.({entry, ctx, chart}); ctx.globalCompositeOperation = entry.drawType ?? "source-over"; if (entry.type == "line") { // add "floor" op to position-indicator (if finalize-op unspecified), to ensure that line stays actually one pixel thick if (entry.x && entry.x.finalize === undefined) entry.x.finalize = a=>Math.floor(a); if (entry.y && entry.y.finalize === undefined) entry.y.finalize = a=>Math.floor(a); const newEntry: Annotation = E( { type: "box", fillStyle: entry.color, } as const, entry.x != null && { xMin: entry.x, xSize: {pixel: entry.lineWidth}, yMin: {pixel: "0%"}, yMax: {pixel: "100%"}, } as const, entry.y != null && { xMin: {pixel: "0%"}, xMax: {pixel: "100%"}, yMin: entry.y, ySize: {pixel: entry.lineWidth}, } as const, ); entry = newEntry; } ctx.beginPath(); ctx.rect(left, top, width, height); ctx.clip(); // make sure we don't draw outside of chart-bounds entry.preDraw?.({entry, ctx, chart}); if (entry.type == "box") { ctx.fillStyle = entry.fillStyle; function FillMinMaxAndSizeFrom2(vals: (number|null|undefined)[]) { return vals.map((val, i): number=>{ if (val != null) return val; if (i == 0) return vals[1]! - vals[2]!; if (i == 1) return vals[0]! + vals[2]!; /*if (i == 2)*/ Assert(i == 2); return vals[1]! - vals[0]!; }); } //const indexToType = ["min", "max", "size"]; const indexToDefaultFinalize = ["floor", "ceiling", "ceiling"] as const; const xVals = [entry.xMin, entry.xMax, entry.xSize]; Assert(xVals.filter(a=>a != null).length == 2, "Exactly two of these should be specified: xMin, xMax, xSize"); const xVals_ctx = xVals.map((val, i)=>(val != null ? ConvertPosIndicatorToContextPoint(val, chart, ctx, "x", indexToDefaultFinalize[i]) : null)); const xVals_ctx_final = FillMinMaxAndSizeFrom2(xVals_ctx); const yVals = [entry.yMin, entry.yMax, entry.ySize]; Assert(yVals.filter(a=>a != null).length == 2, "Exactly two of these should be specified: yMin, yMax, ySize"); const yVals_ctx = yVals.map((val, i)=>(val != null ? ConvertPosIndicatorToContextPoint(val, chart, ctx, "y", indexToDefaultFinalize[i]) : null)); const yVals_ctx_final = FillMinMaxAndSizeFrom2(yVals_ctx); ctx.fillRect(xVals_ctx_final[0], yVals_ctx_final[0], xVals_ctx_final[2], yVals_ctx_final[2]); if (entry.lineWidth! > 0) { ctx.strokeStyle = entry.strokeStyle!; ctx.lineWidth = entry.lineWidth!; ctx.rect(xVals_ctx_final[0], yVals_ctx_final[0], xVals_ctx_final[2], yVals_ctx_final[2]); } } /*else if (entry.type == "line") { ctx.strokeStyle = entry.strokeStyle; ctx.lineWidth = entry.lineWidth; ctx.beginPath(); ctx.moveTo(cx, top); ctx.lineTo(cx, height); ctx.closePath(); ctx.stroke(); }*/ else if (entry.type == "text") { const x = ConvertPosIndicatorToContextPoint(entry.x, chart, ctx, "x", "round"); const y = ConvertPosIndicatorToContextPoint(entry.y, chart, ctx, "y", "round"); if (entry.fillStyle) ctx.fillStyle = entry.fillStyle; if (entry.strokeStyle) ctx.strokeStyle = entry.strokeStyle; if (entry.lineWidth) ctx.lineWidth = entry.lineWidth; ctx.textAlign = entry.textAlign ?? "center"; if (entry.font) ctx.font = entry.font; ctx.fillText(entry.text, x, y); } entry.postDraw?.({entry, ctx, chart}); } }