uplot-vplugins
Version:
Plugins for uPlot, by Venryx.
316 lines (286 loc) • 11.2 kB
text/typescript
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});
}
}