/** @jsxImportSource react */ import { Widget, VDOM, WidgetConfig } from "../ui/Widget"; import { isArray } from "../util/isArray"; import { parseStyle } from "../util/parseStyle"; import { Instance } from "../ui/Instance"; import { RenderingContext } from "../ui/RenderingContext"; import { NumberProp, BooleanProp, StringProp, RecordsProp, StyleProp } from "../ui/Prop"; import type { ChartRenderingContext } from "./Chart"; import { ClassProp } from "../ui/Prop"; interface LinePoint { x: number; y: number; y0: number; } export interface LineGraphConfig extends WidgetConfig { /** Data for the graph. Each entry should be an object with at least two properties * whose names should match the `xField` and `yField` values. */ data?: RecordsProp; /** Index of a color from the standard palette of colors. 0-15. */ colorIndex?: NumberProp; /** Used to automatically assign a color based on the `name` and the contextual `ColorMap` widget. */ colorMap?: StringProp; /** Name used to resolve the color. If not provided, `name` is used instead. */ colorName?: StringProp; /** Name of the item as it will appear in the legend. */ name?: StringProp; /** Used to indicate if an item is active or not. Inactive items are shown only in the legend. */ active?: BooleanProp; /** Name of the stack. If multiple stacks are used, each should have a unique name. Default value is `stack`. */ stack?: StringProp; /** Indicate that values should be stacked on top of the other values. Default value is `false`. */ stacked?: BooleanProp; /** Set to `true` to enable smooth (curved) line rendering. */ smooth?: BooleanProp; /** Controls the curvature of smooth lines. Value should be between 0 and 0.4. Default is 0.05. */ smoothingRatio?: NumberProp; /** Name of the horizontal axis. Default value is `x`. */ xAxis?: string; /** Name of the vertical axis. Default value is `y`. */ yAxis?: string; /** Name of the property which holds the x value. Default value is `x`. */ xField?: string; /** Name of the property which holds the y value. Default value is `y`. */ yField?: string; /** Name of the property which holds the base value. Default value is `false`, meaning y0 is used instead. */ y0Field?: string | false; /** Base value. Default value is `0`. */ y0?: NumberProp; /** Hide the base value. */ hiddenBase?: boolean; /** Set to `true` to enable area rendering. */ area?: BooleanProp; /** Set to `false` to disable line rendering. Default is `true`. */ line?: BooleanProp; /** Style for the line element. */ lineStyle?: StyleProp; /** Style for the area element. */ areaStyle?: StyleProp; /** Name of the legend to be used. Default is `legend`. Set to `false` to hide the legend entry. */ legend?: string | false; /** Action to perform on legend item click. Default is `auto`. */ legendAction?: string; /** Shape to use in legend. */ legendShape?: string; /** * Additional CSS classes to be applied to the field. * If an object is provided, all keys with a "truthy" value will be added to the CSS class list. */ class?: ClassProp; /** * Additional CSS classes to be applied to the field. * If an object is provided, all keys with a "truthy" value will be added to the CSS class list. */ className?: ClassProp; } export interface LineGraphInstance extends Instance { xAxis: any; yAxis: any; axes: Record; colorMap: any; lineSpans: LinePoint[][] | null; } export class LineGraph extends Widget { declare baseClass: string; declare xAxis: string; declare yAxis: string; declare xField: string; declare yField: string; declare y0Field: string | false; declare y0: number; declare hiddenBase: boolean; declare area: boolean; declare line: boolean; declare active: boolean; declare legend: string | false; declare legendAction: string; declare legendShape: string; declare stack: string; declare smooth: boolean; declare smoothingRatio: number; constructor(config: LineGraphConfig) { super(config); } declareData(...args: any[]): void { super.declareData(...args, { data: undefined, colorIndex: undefined, colorMap: undefined, class: { structured: true, }, className: { structured: true, }, lineStyle: { structured: true, }, areaStyle: { structured: true, }, area: undefined, line: undefined, y0: undefined, name: undefined, active: true, stack: undefined, stacked: undefined, smooth: undefined, smoothingRatio: undefined, }); } prepareData(context: RenderingContext, instance: LineGraphInstance): void { let { data } = instance; if (data.name && !data.colorName) data.colorName = data.name; if (data.smooth && data.smoothingRatio != null) { if (data.smoothingRatio < 0) data.smoothingRatio = 0; if (data.smoothingRatio > 0.4) data.smoothingRatio = 0.4; } super.prepareData(context, instance); } explore(context: ChartRenderingContext, instance: LineGraphInstance): void { let { data } = instance; instance.colorMap = data.colorMap && context.getColorMap && context.getColorMap(data.colorMap); if (instance.colorMap && data.colorName) instance.colorMap.acknowledge(data.colorName); if (data.active) { instance.axes = context.axes!; instance.xAxis = instance.axes[this.xAxis]; instance.yAxis = instance.axes[this.yAxis]; super.explore(context, instance); if (isArray(data.data)) { data.data.forEach((p: any) => { let x = p[this.xField]; instance.xAxis.acknowledge(x); if (data.stacked) { instance.yAxis.stacknowledge(data.stack, x, this.y0Field ? p[this.y0Field] : data.y0); instance.yAxis.stacknowledge(data.stack, x, p[this.yField]); } else { instance.yAxis.acknowledge(p[this.yField]); if (data.area) { if (!this.hiddenBase) instance.yAxis.acknowledge(this.y0Field ? p[this.y0Field] : data.y0); } } }); } } } prepare(context: ChartRenderingContext, instance: LineGraphInstance): void { let { data, colorMap } = instance; if (colorMap && data.colorName) { data.colorIndex = colorMap.map(data.colorName); if (instance.cache("colorIndex", data.colorIndex)) instance.markShouldUpdate(context); } if (data.active) { if (instance.axes[this.xAxis].shouldUpdate || instance.axes[this.yAxis].shouldUpdate) instance.markShouldUpdate(context); } if (data.name && context.addLegendEntry) { context.addLegendEntry(this.legend, { name: data.name, active: data.active, colorIndex: data.colorIndex, disabled: data.disabled, style: { ...parseStyle(data.style), ...parseStyle(data.areaStyle), ...parseStyle(data.lineStyle), }, shape: this.legendShape, onClick: (e: MouseEvent) => { this.onLegendClick(e, instance); }, }); } if (data.active) { if (context.pointReducer && isArray(data.data)) { data.data.forEach((p: any, index: number) => { if (data.area && this.y0Field) context.pointReducer(p[this.xField], p[this.y0Field], data.name, p, data.data, index); context.pointReducer(p[this.xField], p[this.yField], data.name, p, data.data, index); }); } } instance.lineSpans = this.calculateLineSpans(context, instance); } onLegendClick(e: MouseEvent, instance: LineGraphInstance): void { let allActions = this.legendAction == "auto"; let { data } = instance; if (allActions || this.legendAction == "toggle") instance.set("active", !data.active); } calculateLineSpans(context: RenderingContext, instance: LineGraphInstance): LinePoint[][] | null { let { data, xAxis, yAxis } = instance; let spans: LinePoint[][] = []; let span: LinePoint[] = []; if (!data.active) return null; isArray(data.data) && data.data.forEach((p: any) => { let ax = p[this.xField], ay = p[this.yField], ay0 = this.y0Field ? p[this.y0Field] : data.y0, x: number | undefined, y: number | undefined, y0: number | undefined; if (ax != null && ay != null && ay0 != null) { x = xAxis.map(ax); y0 = data.stacked ? yAxis.stack(data.stack, ax, ay0) : yAxis.map(ay0); y = data.stacked ? yAxis.stack(data.stack, ax, ay) : yAxis.map(ay); } if (x != null && y != null && y0 != null) span.push({ x, y, y0 }); else if (span.length > 0) { spans.push(span); span = []; } }); if (span.length > 0) spans.push(span); return spans; } render(context: RenderingContext, instance: LineGraphInstance, key: string): React.ReactNode { let { data, lineSpans } = instance; if (!lineSpans) return null; let stateMods: Record = { ["color-" + data.colorIndex]: data.colorIndex != null, }; let line: React.ReactNode, area: React.ReactNode; const r = data.smoothingRatio; let linePath = ""; if (data.line) { lineSpans.forEach((span) => { span.forEach((p, i) => { linePath += i == 0 ? `M ${p.x} ${p.y}` : !data.smooth || span.length < 2 ? `L ${p.x} ${p.y}` : this.getCurvedPathSegment(p, span, i - 1, i - 2, i - 1, i + 1, r); }); }); line = ( ); } if (data.area) { let areaPath = ""; lineSpans.forEach((span) => { let closePath = ""; span.forEach((p, i) => { let segment = ""; if (i == 0) { segment = `M ${p.x} ${p.y}`; // closing point closePath = !data.smooth || span.length < 2 ? `L ${p.x} ${p.y0}` : this.getCurvedPathSegment(p, span, i + 1, i + 2, i + 1, i - 1, r, "y0"); } else { if (!data.smooth) { segment = `L ${p.x} ${p.y}`; closePath = `L ${p.x} ${p.y0}` + closePath; } else { segment = this.getCurvedPathSegment(p, span, i - 1, i - 2, i - 1, i + 1, r, "y"); // closing point if (i < span.length - 1) closePath = this.getCurvedPathSegment(p, span, i + 1, i + 2, i + 1, i - 1, r, "y0") + closePath; } } areaPath += segment; }); areaPath += `L ${span[span.length - 1].x} ${span[span.length - 1].y0}`; areaPath += closePath; areaPath += "Z"; }); area = ( ); } return ( {line} {area} ); } getCurvedPathSegment( p: LinePoint, points: LinePoint[], i1: number, i2: number, j1: number, j2: number, r: number, yField: "y" | "y0" = "y", ): string { const [sx, sy] = this.getControlPoint({ cp: points[i1], pp: points[i2], r, np: p, yField }); const [ex, ey] = this.getControlPoint({ cp: p, pp: points[j1], np: points[j2], r, reverse: true, yField }); return `C ${sx} ${sy}, ${ex} ${ey}, ${p.x} ${p[yField]}`; } getControlPoint({ cp, pp, np, r, reverse, yField = "y", }: { cp: LinePoint; pp: LinePoint | undefined; np: LinePoint | undefined; r: number; reverse?: boolean; yField?: "y" | "y0"; }): [number, number] { // When 'current' is the first or last point of the array 'previous' or 'next' don't exist. Replace with 'current'. const p = pp || cp; const n = np || cp; // Properties of the opposed-line let { angle, length } = this.getLineInfo(p.x, p[yField], n.x, n[yField]); // If it is end-control-point, add PI to the angle to go backward angle = angle + (reverse ? Math.PI : 0); length = length * r; // The control point position is relative to the current point const x = cp.x + Math.cos(angle) * length; const y = cp[yField] + Math.sin(angle) * length; return [x, y]; } getLineInfo(p1x: number, p1y: number, p2x: number, p2y: number): { length: number; angle: number } { const lengthX = p2x - p1x; const lengthY = p2y - p1y; return { length: Math.sqrt(Math.pow(lengthX, 2) + Math.pow(lengthY, 2)), angle: Math.atan2(lengthY, lengthX), }; } } LineGraph.prototype.xAxis = "x"; LineGraph.prototype.yAxis = "y"; LineGraph.prototype.area = false; LineGraph.prototype.line = true; LineGraph.prototype.xField = "x"; LineGraph.prototype.yField = "y"; LineGraph.prototype.baseClass = "linegraph"; LineGraph.prototype.y0 = 0; LineGraph.prototype.y0Field = false; LineGraph.prototype.active = true; LineGraph.prototype.legend = "legend"; LineGraph.prototype.legendAction = "auto"; LineGraph.prototype.legendShape = "rect"; LineGraph.prototype.stack = "stack"; LineGraph.prototype.hiddenBase = false; LineGraph.prototype.smooth = false; LineGraph.prototype.smoothingRatio = 0.05; LineGraph.prototype.styled = true; Widget.alias("line-graph", LineGraph);