import { format as d3Format, HTMLWidget, Palette } from "@hpcc-js/common"; import { QuartileCandlestick } from "./QuartileCandlestick"; import { Scatter } from "./Scatter"; const rainbow = Palette.rainbow("Blues"); const palette = Palette.ordinal("Quartile", [rainbow(100, 0, 100), rainbow(50, 0, 100), rainbow(50, 0, 100), rainbow(75, 0, 100)]); palette("Std. Dev."); palette("MinMax"); palette("25%"); palette("50%"); type Mode = "min_max" | "25_75" | "normal"; type Tick = { label: string, value: number }; type Ticks = Tick[]; type AxisTick = { label: string, value: string }; type AxisTicks = AxisTick[]; function myFormatter(format: string): (num: number) => string { const formatter = d3Format(format); return function (num: number) { const strVal = (Math.round(num * 100) / 100).toString(); if (strVal.length <= 4) return strVal; return formatter(num); }; } export class StatChart extends HTMLWidget { private _mean: number; private _standardDeviation: number; private _quartiles: number[]; private _selectMode: any; private _tickFormatter: (_: number) => string; private _bellCurve: Scatter = new Scatter() .columns(["", "Std. Dev."]) .paletteID("Quartile") .interpolate_default("basis") .pointSize(0) .xAxisType("linear") .xAxisOverlapMode("none") .xAxisTickFormat(".2s") .yAxisHidden(true) .yAxisDomainLow(0) .yAxisDomainHigh(110) .yAxisGuideLines(false) as Scatter ; private _candle = new QuartileCandlestick() .columns(["Min", "25%", "50%", "75%", "Max"]) .edgePadding(0) .roundedCorners(1) .lineWidth(1) .upperTextRotation(-90) .lowerTextRotation(-90) .labelFontSize(0) .valueFontSize(0) .lineColor(rainbow(90, 0, 100)) .innerRectColor(rainbow(10, 0, 100)) ; private stdDev(degrees: number): number { return this.mean() + degrees * this.standardDeviation(); } private formatStdDev(degrees: number): string { return this._tickFormatter(this.stdDev(degrees)); } private quartile(q: 0 | 1 | 2 | 3 | 4): number { return this.data()[0][q]; } private formatQ(q: 0 | 1 | 2 | 3 | 4): string { return this._tickFormatter(this.quartile(q)); } private domain(mode: Mode): [number, number] { switch (mode) { case "25_75": return [this.quartile(1), this.quartile(3)]; case "normal": return [this.stdDev(-4), this.stdDev(4)]; case "min_max": default: return [this.quartile(0), this.quartile(4)]; } } mean(): number; mean(_: number): this; mean(_?: number): this | number { if (!arguments.length) return this._mean; this._mean = _; if (this.data()[0]) { this.data()[0][5] = _; } return this; } standardDeviation(): number; standardDeviation(_: number): this; standardDeviation(_?: number): this | number { if (!arguments.length) return this._standardDeviation; this._standardDeviation = _; if (this.data()[0]) { this.data()[0][6] = _; } return this; } quartiles(): number[]; quartiles(_: number[]): this; quartiles(_?: number[]): this | number[] { if (!arguments.length) return this._quartiles; this._quartiles = _; if (this.data()[0]) { this.data()[0] = _.concat(this.data()[0].slice(-2)); } return this; } enter(domNode, element) { super.enter(domNode, element); this._bellCurve.target(element.append("div").node()); this._candle.target(element.append("div").node()); this._selectMode = element.append("div") .style("position", "absolute") .style("top", "0px") .style("right", "0px").append("select") .on("change", () => { this.render(); }) ; this._selectMode.append("option").attr("value", "min_max").text("Min / Max"); this._selectMode.append("option").attr("value", "25_75").text("25% / 75%"); this._selectMode.append("option").attr("value", "normal").text("Normal"); } private bellTicks(mode: Mode): AxisTicks { let ticks: Ticks; switch (mode) { case "25_75": ticks = [ { label: this.formatQ(1), value: this.quartile(1) }, { label: this.formatQ(2), value: this.quartile(2) }, { label: this.formatQ(3), value: this.quartile(3) } ]; break; case "normal": ticks = [ { label: this.formatStdDev(-4), value: this.stdDev(-4) }, { label: "-3σ", value: this.stdDev(-3) }, { label: "-2σ", value: this.stdDev(-2) }, { label: "-1σ", value: this.stdDev(-1) }, { label: this.formatStdDev(0), value: this.stdDev(0) }, { label: "+1σ", value: this.stdDev(1) }, { label: "+2σ", value: this.stdDev(2) }, { label: "+3σ", value: this.stdDev(3) }, { label: this.formatStdDev(4), value: this.stdDev(4) } ]; break; case "min_max": default: ticks = [ { label: this.formatQ(0), value: this.quartile(0) }, { label: this.formatQ(1), value: this.quartile(1) }, { label: this.formatQ(2), value: this.quartile(2) }, { label: this.formatQ(3), value: this.quartile(3) }, { label: this.formatQ(4), value: this.quartile(4) } ]; } const [domainLow, domainHigh] = this.domain(this._selectMode.node().value); return ticks .filter(sd => sd.value >= domainLow && sd.value <= domainHigh) .map(sd => ({ label: sd.label, value: sd.value.toString() })) ; } updateScatter() { const mode = this._selectMode.node().value; const [domainLow, domainHigh] = this.domain(mode); const padding = (domainHigh - domainLow) * (this.domainPadding() / 100); this._bellCurve .xAxisDomainLow(domainLow - padding) .xAxisDomainHigh(domainHigh + padding) .xAxisTicks(this.bellTicks(mode)) .data([ [this.stdDev(-4), 0], [this.stdDev(-3), 0.3], [this.stdDev(-2), 5], [this.stdDev(-1), 68], [this.stdDev(0), 100], [this.stdDev(1), 68], [this.stdDev(2), 5], [this.stdDev(3), 0.3], [this.stdDev(4), 0] ]) .resize({ width: this.width(), height: this.height() - this.candleHeight() }) .render() ; } updateCandle() { const candleX = this._bellCurve.dataPos(this.quartile(0)); const candleW = this._bellCurve.dataPos(this.quartile(4)) - candleX; this._candle .resize({ width: this.width(), height: this.candleHeight() }) .pos({ x: (candleX + candleW / 2) + 2, y: this.candleHeight() / 2 }) .width(candleW) .candleWidth(this.candleHeight()) .data(this.quartiles()) .render() ; } update(domNode, element) { super.update(domNode, element); this._tickFormatter = myFormatter(this.tickFormat()); if (this.data()[0] && this.data()[0].length === 7) { this.quartiles(this.data()[0].slice(0, 5)); this.mean(this.data()[0][5]); this.standardDeviation(this.data()[0][6]); } this.updateScatter(); this.updateCandle(); } } export interface StatChart { tickFormat(): string; tickFormat(_: string): this; candleHeight(): number; candleHeight(_: number): this; domainPadding(): number; domainPadding(_: number): this; } StatChart.prototype.publish("tickFormat", ".2e", "string", "X-Axis Tick Format"); StatChart.prototype.publish("candleHeight", 20, "number", "Height of candle widget (pixels)"); StatChart.prototype.publish("domainPadding", 10, "number", "Domain value padding");