//
// DUMP demo for the Polar Method
// http://stackedboxes.org/2020/01/05/dump-of-unsorted-morsels-for-programmers/
// Code is licensed under the MIT license
//
// Copyright 2020 Leandro Motta Barros
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of
// this software and associated documentation files (the "Software"), to deal in
// the Software without restriction, including without limitation the rights to
// use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
// the Software, and to permit persons to whom the Software is furnished to do so,
// subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
// FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
// COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
// IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
// CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

// TODO: This code is mostly a duplicate of the Box-Muller demo. I could
// refactor to be DRY.
namespace PolarMethod {

const GoodPointColor = "#0066ff";
const BadPointColor = "#ff4444";
const HistogramColor = GoodPointColor;
const ProjectionColor = "#3399ff";

type SelBaseType = d3.Selection<d3.BaseType, unknown, HTMLElement, any>;
type SelSVGG = d3.Selection<SVGGElement, unknown, HTMLElement, any>;

export class TransformDiagram {
    /// The offset between the grid and the histogram
    private histogramOffset_ = 0.25
    private gridMin_ = -3
    private gridMax_ = +3
    private gridSize_ = this.gridMax_ - this.gridMin_;

    private numHistogramBuckets_ = 31;
    private histogramBucketSize_ = this.gridSize_ / this.numHistogramBuckets_;

    private btnOne_: SelBaseType;
    private btnTen_: SelBaseType;
    private btnHundred_: SelBaseType;
    private btnClear_: SelBaseType;

    private spanDiscarded_: SelBaseType;
    private spanTotal_: SelBaseType;
    private spanRatio_: SelBaseType;

    private svg_: SelSVGG;
    private grid_: SelSVGG;
    private points_: SelSVGG;
    private projections_: SelSVGG;
    private rightHistogram_: SelSVGG;
    private bottomHistogram_: SelSVGG;

    // Histogram data are pairs of (offset, numberOfElementsInBucket)
    private rightHistogramData_: [number, number][] = [];
    private bottomHistogramData_: [number, number][] = [];

    // Total number of points generated.
    private totalPoints_ = 0;

    // Number of points discarded (out of the unit circle).
    private discardedPoints_ = 0;

    public constructor(containerID: string) {
        this.svg_ = d3.select(`#${containerID} svg`);

        // Buttons
        this.btnOne_ = d3.select(`#${containerID}-btn-one`);
        this.btnOne_.on("click", async () => await this.onBtnOne());

        this.btnTen_ = d3.select(`#${containerID}-btn-ten`);
        this.btnTen_.on("click", () => this.onBtnTen());

        this.btnHundred_ = d3.select(`#${containerID}-btn-hundred`);
        this.btnHundred_.on("click", () => this.onBtnHundred());

        this.btnClear_ = d3.select(`#${containerID}-btn-clear`);
        this.btnClear_.on("click", () => this.onBtnClear());

        // Text
        this.spanDiscarded_ = d3.select(`#${containerID}-span-discarded`);
        this.spanTotal_ = d3.select(`#${containerID}-span-total`);
        this.spanRatio_ = d3.select(`#${containerID}-span-ratio`);
        this.updateTexts();

        // Grid
        this.grid_ = this.svg_.append("g")
            .attr("fill", "transparent")
            .attr("stroke-width", 0.01)
            .attr("stroke", "gray")
        this.makeGrid();

        // Points
        this.points_ = this.svg_.append("g")
            .attr("stroke-width", 0.01);

        // Projections
        this.projections_ = this.svg_.append("g")
            .style("stroke-width", 0)
            .style("fill", ProjectionColor);

            // Histograms
        this.rightHistogram_ = this.svg_.append("g")
            .style("stroke-width", 0)
            .style("fill", HistogramColor);
        this.initRightHistogram();

        this.bottomHistogram_ = this.svg_.append("g")
            .style("stroke-width", 0)
            .style("fill", HistogramColor);
        this.initBottomHistogram();
    }

    private async onBtnOne() {
        this.doOnePoint()
        this.updateTexts();
    }

    private onBtnTen() {
        for (let i = 0; i < 10; ++i) {
            this.doOnePoint();
        }
        this.updateTexts();
    }

    private onBtnHundred() {
        for (let i = 0; i < 100; ++i) {
            this.doOnePoint();
        }
        this.updateTexts();
    }

    private onBtnClear() {
        this.totalPoints_ = 0;
        this.discardedPoints_ = 0;
        this.updateTexts();
        this.initRightHistogram();
        this.initBottomHistogram();
    }

    // Draw one pair of numbers (as a point), using different colors for points
    // inside and outside the unit circle. If the point is in the unit circle,
    // make sure the next animation step (point scaling) runs at the right
    // moment.
    private doOnePoint() {
        let insideUnitCircle = false;
        let pointBefore: [number, number] = [0, 0];
        let pointAfter: [number, number] = [0, 0];
        let forceGoodPoint = false;

        while(true) {
            dump.data.set("ignoreSpare", true);
            polarMethodNormalRNG();

            insideUnitCircle = dump.data.get("in");

            if (forceGoodPoint && !insideUnitCircle) {
                continue;
            }

            pointBefore = dump.data.get("before");
            pointAfter = dump.data.get("after");

            if (insideUnitCircle && !this.isWithinGrid(pointAfter)) {
                // We got a point outside the grid, and therefore we want to run
                // the function again to generate a new point. However, the
                // initial point was inside the unit circle, so we want to
                // ensure that the next generated point is also a good one.
                // (This is for "statistical fairness" of the visualization: we
                // don't want to show more red points than we should).
                forceGoodPoint = true;
                continue;
            }

            break;
        }

        ++this.totalPoints_;

        if (insideUnitCircle) {
            this.points_.append("circle")
                .attr("cx", pointBefore[0]).attr("cy", pointBefore[1])
                .attr("r", 0.06)
                .style("stroke", GoodPointColor)
                .style("fill", GoodPointColor)
                .transition()
                    .delay(500)
                    .duration(750)
                    .attr("cx", pointAfter[0]).attr("cy", pointAfter[1])
                    .on("end", (() => this.drawProjection(pointAfter)))
                .transition()
                    .delay(250)
                    .duration(250)
                    .attr("r", 0.12)
                    .style("stroke", "transparent")
                    .style("fill", "transparent")
                    .remove();
        } else {
            ++this.discardedPoints_;
            this.points_.append("circle")
                .attr("cx", pointBefore[0]).attr("cy", pointBefore[1])
                .attr("r", 0.06)
                .style("stroke", BadPointColor)
                .style("fill", BadPointColor)
                .transition()
                    .delay(250)
                    .duration(250)
                    .attr("r", 0.12)
                    .style("stroke", "transparent")
                    .style("fill", "transparent")
                    .remove();
        }
    }

    private drawProjection(p: [number, number]) {
        this.projections_.append("circle")
            .attr("cx", p[0]).attr("cy", p[1])
            .attr("r", 0.06)
            .transition()
                .attr("cx", this.gridMax_ + this.histogramOffset_).attr("cy", p[1])
                .duration(750)
                .on("end", () => this.incrementRightHistogram(p[1]))
            .transition()
                .attr("r", 0.18)
                .style("fill", "transparent")
                .remove();

        this.projections_.append("circle")
            .attr("cx", p[0]).attr("cy", p[1])
            .attr("r", 0.06)
            .transition()
                .attr("cx", p[0]).attr("cy", this.gridMax_ + this.histogramOffset_)
                .duration(750)
                .on("end", () => this.incrementBottomHistogram(p[0]))
            .transition()
                .attr("r", 0.18)
                .style("fill", "transparent")
                .remove();
    }

    private updateTexts(): void {
        this.spanDiscarded_.text(this.discardedPoints_);
        this.spanTotal_.text(this.totalPoints_);
        this.spanRatio_.text(this.totalPoints_ > 0
            ? (this.discardedPoints_ / this.totalPoints_).toFixed(4)
            : "--");
    }

    private initRightHistogram() {
        this.rightHistogramData_ = [];
        for (let i = 0; i < this.numHistogramBuckets_; ++i) {
            this.rightHistogramData_.push([
                (this.gridMin_ + i * this.histogramBucketSize_),
                0]);
        }

        this.updateRightHistogram();
    }

    private updateRightHistogram() {
        let rects = this.rightHistogram_.selectAll("rect").data(this.rightHistogramData_);
        rects.exit().remove();

        const dx = this.gridMax_ + this.histogramOffset_;
        rects.enter().append("rect")
            .attr("transform", d => `translate(${dx}, ${d[0]})`)
            .attr("height", this.histogramBucketSize_);

        rects = this.rightHistogram_.selectAll("rect").data(this.rightHistogramData_);
        rects.attr("width", d => d[1] * 0.025);
    }

    private incrementRightHistogram(value: number) {
        ++this.rightHistogramData_[this.valueToBucket(value)][1];
        this.updateRightHistogram();
    }

    private initBottomHistogram() {
        this.bottomHistogramData_ = [];
        for (let i = 0; i < this.numHistogramBuckets_; ++i) {
            this.bottomHistogramData_.push([
                (this.gridMin_ + i * this.histogramBucketSize_),
                0]);
        }

        this.updateBottomHistogram();
    }

    private updateBottomHistogram() {
        let rects = this.bottomHistogram_.selectAll("rect").data(this.bottomHistogramData_);
        rects.exit().remove();

        const dy = this.gridMax_ + this.histogramOffset_;
        rects.enter().append("rect")
            .attr("transform", d => `translate(${d[0]}, ${dy})`)
            .attr("width", this.histogramBucketSize_);

        rects = this.bottomHistogram_.selectAll("rect").data(this.bottomHistogramData_);
        rects.attr("height", d => d[1] * 0.025);
    }

    private incrementBottomHistogram(value: number) {
        ++this.bottomHistogramData_[this.valueToBucket(value)][1];
        this.updateBottomHistogram();
    }

    private valueToBucket(value: number): number {
        const t1 = value - this.gridMin_;                     // [0, gridSize_]
        const t2 = t1 /this.gridSize_;                        // [0, 1]
        const t3 = Math.trunc(t2 * this.numHistogramBuckets_) // [0, numHistogramBuckets]
        return t3 < this.numHistogramBuckets_ ? t3 : this.numHistogramBuckets_;
    }

    private makeGrid(): void {
        const wholeInterval = 1.0
        const fractionalInterval = 0.2

        // Fractional lines
        for (let i = this.gridMin_ + fractionalInterval; i < this.gridMax_; i += fractionalInterval) {
            this.grid_.append("line")
                .attr("x1", `${this.gridMin_}`)
                .attr("y1", `${i}`)
                .attr("x2", `${this.gridMax_}`)
                .attr("y2", `${i}`)
                .attr("stroke-opacity", 0.15);
            this.grid_.append("line")
                .attr("x1", `${i}`)
                .attr("y1", `${this.gridMin_}`)
                .attr("x2", `${i}`)
                .attr("y2", `${this.gridMax_}`)
                .attr("stroke-opacity", 0.15);
        }

        // Whole lines
        for (let i = this.gridMin_ + wholeInterval; i < this.gridMax_; i += wholeInterval) {
            this.grid_.append("line")
                .attr("x1", `${this.gridMin_}`)
                .attr("y1", `${i}`)
                .attr("x2", `${this.gridMax_}`)
                .attr("y2", `${i}`)
                .attr("stroke-opacity", 0.5);
            this.grid_.append("line")
                .attr("x1", `${i}`)
                .attr("y1", `${this.gridMin_}`)
                .attr("x2", `${i}`)
                .attr("y2", `${this.gridMax_}`)
                .attr("stroke-opacity", 0.5);

        // Unit Circle
        this.grid_.append("circle")
            .attr("cx", 0.0)
            .attr("cy", 0.0)
            .attr("r", 1.0);

        // Axes lines
        this.grid_.append("line")
            .attr("x1", `${this.gridMin_}`)
            .attr("y1", 0)
            .attr("x2", `${this.gridMax_}`)
            .attr("y2", 0)
            .attr("stroke-opacity", 0.75);

        this.grid_.append("line")
            .attr("x1", 0)
            .attr("y1", `${this.gridMin_}`)
            .attr("x2", 0)
            .attr("y2", `${this.gridMax_}`)
            .attr("stroke-opacity", 0.75);

        // Boxes for the histograms
        this.grid_.append("line")
            .attr("x1", `${this.gridMin_}`)
            .attr("y1", this.gridMax_ + this.histogramOffset_)
            .attr("x2", `${this.gridMax_}`)
            .attr("y2", this.gridMax_ + this.histogramOffset_)
            .attr("stroke-width", 0.02)
            .attr("stroke", "black")
            .attr("stroke-opacity", 0.75);

        this.grid_.append("line")
            .attr("x1", `${this.gridMin_}`)
            .attr("y1", this.gridMax_ + this.histogramOffset_ + 1.73)
            .attr("x2", `${this.gridMax_}`)
            .attr("y2", this.gridMax_ + this.histogramOffset_ + 1.73)
            .attr("stroke-width", 0.02)
            .attr("stroke-opacity", 0.5);

        this.grid_.append("line")
            .attr("x1", this.gridMax_ + this.histogramOffset_)
            .attr("y1", `${this.gridMin_}`)
            .attr("x2", this.gridMax_ + this.histogramOffset_)
            .attr("y2", `${this.gridMax_}`)
            .attr("stroke-width", 0.02)
            .attr("stroke", "black")
            .attr("stroke-opacity", 0.75);

        this.grid_.append("line")
            .attr("x1", this.gridMax_ + this.histogramOffset_ + 1.73)
            .attr("y1", `${this.gridMin_}`)
            .attr("x2", this.gridMax_ + this.histogramOffset_ + 1.73)
            .attr("y2", `${this.gridMax_}`)
            .attr("stroke-width", 0.02)
            .attr("stroke-opacity", 0.5);
        }
    }

    private isWithinGrid(p: [number, number]): boolean {
        return p[0] >= this.gridMin_ &&
            p[0] <= this.gridMax_ &&
            p[1] >= this.gridMin_ &&
            p[1] <= this.gridMax_;
    }
} // class TransformDiagram

} // namespace PolarMethod

let polarMethodDiagram = new PolarMethod.TransformDiagram("demo-polar-method");
