opensheetmusicdisplay/opensheetmusicdisplay

View on GitHub
src/MusicalScore/Graphical/SkyBottomLineCalculator.ts

Summary

Maintainability
F
1 wk
Test Coverage
import { EngravingRules } from "./EngravingRules";
import { StaffLine } from "./StaffLine";
import { PointF2D } from "../../Common/DataObjects/PointF2D";
import { VexFlowMeasure } from "./VexFlow/VexFlowMeasure";
import { unitInPixels } from "./VexFlow/VexFlowMusicSheetDrawer";
import log from "loglevel";
import { BoundingBox } from "./BoundingBox";
import { SkyBottomLineCalculationResult } from "./SkyBottomLineCalculationResult";
import { CanvasVexFlowBackend } from "./VexFlow/CanvasVexFlowBackend";
/**
 * This class calculates and holds the skyline and bottom line information.
 * It also has functions to update areas of the two lines if new elements are
 * added to the staffline (e.g. measure number, annotations, ...)
 */
export class SkyBottomLineCalculator {
    /** Parent Staffline where the skyline and bottom line is attached */
    private mStaffLineParent: StaffLine;
    /** Internal array for the skyline */
    private mSkyLine: number[];
    /** Internal array for the bottomline */
    private mBottomLine: number[];
    /** Engraving rules for formatting */
    private mRules: EngravingRules;

    /**
     * Create a new object of the calculator
     * @param staffLineParent staffline where the calculator should be attached
     */
    constructor(staffLineParent: StaffLine) {
        this.mStaffLineParent = staffLineParent;
        this.mRules = staffLineParent.ParentMusicSystem.rules;
    }

    /**
     * This method updates the skylines and bottomlines for mStaffLineParent.
     * @param calculationResults the skylines and bottomlines of mStaffLineParent's measures calculated by SkyBottomLineBatchCalculator
     */
    public updateLines(calculationResults: SkyBottomLineCalculationResult[]): void {
        const measures: VexFlowMeasure[] = this.StaffLineParent.Measures as VexFlowMeasure[];

        if (calculationResults.length !== measures.length) {
            log.warn("SkyBottomLineCalculator: lengths of calculation result array and measure array do not match");

            if (calculationResults.length < measures.length) {
                while (calculationResults.length < measures.length) {
                    calculationResults.push(new SkyBottomLineCalculationResult([], []));
                }
            } else {
                calculationResults = calculationResults.slice(0, measures.length);
            }
        }

        const arrayLength: number = Math.max(Math.ceil(this.StaffLineParent.PositionAndShape.Size.width * this.SamplingUnit), 1);
        this.mSkyLine = [];
        this.mBottomLine = [];

        for (const { skyLine, bottomLine } of calculationResults) {
            this.mSkyLine.push(...skyLine);
            this.mBottomLine.push(...bottomLine);
        }

        // Subsampling:
        // The pixel width is bigger than the measure size in units. So we split the array into
        // chunks with the size of MeasurePixelWidth/measureUnitWidth and reduce the value to its
        // average
        const arrayChunkSize: number = this.mSkyLine.length / arrayLength;

        const subSampledSkyLine: number[] = [];
        const subSampledBottomLine: number[] = [];
        for (let chunkIndex: number = 0; chunkIndex < this.mSkyLine.length; chunkIndex += arrayChunkSize) {
            if (subSampledSkyLine.length === arrayLength) {
                break; // TODO find out why skyline.length becomes arrayLength + 1. see log.debug below
            }

            const endIndex: number = Math.min(this.mSkyLine.length, chunkIndex + arrayChunkSize);
            let chunk: number[] = this.mSkyLine.slice(chunkIndex, endIndex + 1); // slice does not include end index
            // TODO chunkIndex + arrayChunkSize is sometimes bigger than this.mSkyLine.length -> out of bounds
            // TODO chunkIndex + arrayChunkSize is often a non-rounded float as well. is that ok to use with slice?
            /*const diff: number = this.mSkyLine.length - (chunkIndex + arrayChunkSize);
            if (diff < 0) { // out of bounds
                console.log("length - slice end index: " + diff);
            }*/

            subSampledSkyLine.push(Math.min(...chunk));
            chunk = this.mBottomLine.slice(chunkIndex, endIndex + 1); // slice does not include end index
            subSampledBottomLine.push(Math.max(...chunk));
        }

        this.mSkyLine = subSampledSkyLine;
        this.mBottomLine = subSampledBottomLine;
        if (this.mSkyLine.length !== arrayLength) { // bottomline will always be same length as well
            log.debug(`SkyLine calculation was not correct (${this.mSkyLine.length} instead of ${arrayLength})`);
        }

        // Remap the values from 0 to +/- height in units
        const lowestSkyLine: number = Math.max(...this.mSkyLine);
        this.mSkyLine = this.mSkyLine.map(v => (v - lowestSkyLine) / unitInPixels + this.StaffLineParent.TopLineOffset);

        const highestBottomLine: number = Math.min(...this.mBottomLine);
        this.mBottomLine = this.mBottomLine.map(v => (v - highestBottomLine) / unitInPixels + this.StaffLineParent.BottomLineOffset);
    }

    /**
     * This method calculates the Sky- and BottomLines for a StaffLine.
     */
    public calculateLines(): void {
        const samplingUnit: number = this.mRules.SamplingUnit;
        const results: SkyBottomLineCalculationResult[] = [];

        // Create a temporary canvas outside the DOM to draw the measure in.
        const tmpCanvas: any = new CanvasVexFlowBackend(this.mRules);
        // search through all Measures
        for (const measure of this.StaffLineParent.Measures as VexFlowMeasure[]) {
            // must calculate first AbsolutePositions
            measure.PositionAndShape.calculateAbsolutePositionsRecursive(0, 0);

            // Pre initialize and get stuff for more performance
            const vsStaff: any = measure.getVFStave();
            let width: number = vsStaff.getWidth();
            if (!(width > 0) && !measure.IsExtraGraphicalMeasure) {
                log.warn("SkyBottomLineCalculator: width not > 0 in measure " + measure.MeasureNumber);
                width = 50;
            }
            // Headless because we are outside the DOM
            tmpCanvas.initializeHeadless(width);
            const ctx: any = tmpCanvas.getContext();
            const canvas: any = tmpCanvas.getCanvas();
            width = canvas.width;
            const height: number = canvas.height;

            // This magic number is an offset from the top image border so that
            // elements above the staffline can be drawn correctly.
            vsStaff.setY(vsStaff.y + 100);
            const oldMeasureWidth: number = vsStaff.getWidth();
            // We need to tell the VexFlow stave about the canvas width. This looks
            // redundant because it should know the canvas but somehow it doesn't.
            // Maybe I am overlooking something but for now this does the trick
            vsStaff.setWidth(width);
            measure.format();
            vsStaff.setWidth(oldMeasureWidth);
            try {
                measure.draw(ctx);
                // Vexflow errors can happen here, then our complete rendering loop would halt without catching errors.
            } catch (ex) {
                log.warn("SkyBottomLineCalculator.calculateLines.draw", ex);
            }

            // imageData.data is a Uint8ClampedArray representing a one-dimensional array containing the data in the RGBA order
            // RGBA is 32 bit word with 8 bits red, 8 bits green, 8 bits blue and 8 bit alpha. Alpha should be 0 for all background colors.
            // Since we are only interested in black or white we can take 32bit words at once
            const imageData: any = ctx.getImageData(0, 0, width, height);
            const rgbaLength: number = 4;
            const measureArrayLength: number = Math.max(Math.ceil(measure.PositionAndShape.Size.width * samplingUnit), 1);
            const tmpSkyLine: number[] = new Array(measureArrayLength);
            const tmpBottomLine: number[] = new Array(measureArrayLength);
            for (let x: number = 0; x < width; x++) {
                // SkyLine
                for (let y: number = 0; y < height; y++) {
                    const yOffset: number = y * width * rgbaLength;
                    const bufIndex: number = yOffset + x * rgbaLength;
                    const alpha: number = imageData.data[bufIndex + 3];
                    if (alpha > 0) {
                        tmpSkyLine[x] = y;
                        break;
                    }
                }
                // BottomLine
                for (let y: number = height; y > 0; y--) {
                    const yOffset: number = y * width * rgbaLength;
                    const bufIndex: number = yOffset + x * rgbaLength;
                    const alpha: number = imageData.data[bufIndex + 3];
                    if (alpha > 0) {
                        tmpBottomLine[x] = y;
                        break;
                    }
                }
            }

            for (let idx: number = 0; idx < tmpSkyLine.length; idx++) {
                if (tmpSkyLine[idx] === undefined) {
                    tmpSkyLine[idx] = Math.max(this.findPreviousValidNumber(idx, tmpSkyLine), this.findNextValidNumber(idx, tmpSkyLine));
                }
            }
            for (let idx: number = 0; idx < tmpBottomLine.length; idx++) {
                if (tmpBottomLine[idx] === undefined) {
                    tmpBottomLine[idx] = Math.max(this.findPreviousValidNumber(idx, tmpBottomLine), this.findNextValidNumber(idx, tmpBottomLine));
                }
            }

            results.push(new SkyBottomLineCalculationResult(tmpSkyLine, tmpBottomLine));

            // Set to true to only show the "mini canvases" and the corresponding skylines
            const debugTmpCanvas: boolean = false;
            if (debugTmpCanvas) {
                tmpSkyLine.forEach((y, x) => this.drawPixel(new PointF2D(x, y), tmpCanvas));
                tmpBottomLine.forEach((y, x) => this.drawPixel(new PointF2D(x, y), tmpCanvas, "blue"));
                const img: any = canvas.toDataURL("image/png");
                document.write('<img src="' + img + '"/>');
            }
            tmpCanvas.clear();
        }

        this.updateLines(results);
    }

    public updateSkyLineWithLine(start: PointF2D, end: PointF2D, value: number): void {
        const startIndex: number = Math.floor(start.x * this.SamplingUnit);
        const endIndex: number = Math.ceil(end.x * this.SamplingUnit);
        for (let i: number = startIndex + 1; i < Math.min(endIndex, this.SkyLine.length); i++) {
            this.SkyLine[i] = value;
        }
    }

    /**
     * This method updates the SkyLine for a given Wedge.
     * @param start Start point of the wedge (the point where both lines meet)
     * @param end End point of the wedge (the end of the most extreme line: upper line for skyline, lower line for bottomline)
     */
    public updateSkyLineWithWedge(start: PointF2D, end: PointF2D): void {
        // FIXME: Refactor if wedges will be added. Current status is that vexflow will be used for this
        let startIndex: number = Math.floor(start.x * this.SamplingUnit);
        let endIndex: number = Math.ceil(end.x * this.SamplingUnit);

        let slope: number = (end.y - start.y) / (end.x - start.x);

        if (endIndex - startIndex <= 1) {
            endIndex++;
            slope = 0;
        }

        if (startIndex < 0) {
            startIndex = 0;
        }
        if (startIndex >= this.BottomLine.length) {
            startIndex = this.BottomLine.length - 1;
        }
        if (endIndex < 0) {
            endIndex = 0;
        }
        if (endIndex >= this.BottomLine.length) {
            endIndex = this.BottomLine.length;
        }

        this.SkyLine[startIndex] = start.y;
        for (let i: number = startIndex + 1; i < Math.min(endIndex, this.SkyLine.length); i++) {
            this.SkyLine[i] = this.SkyLine[i - 1] + slope / this.SamplingUnit;
        }
    }

    /**
     * This method updates the BottomLine for a given Wedge.
     * @param start Start point of the wedge
     * @param end End point of the wedge
     */
    public updateBottomLineWithWedge(start: PointF2D, end: PointF2D): void {
        // FIXME: Refactor if wedges will be added. Current status is that vexflow will be used for this
        let startIndex: number = Math.floor(start.x * this.SamplingUnit);
        let endIndex: number = Math.ceil(end.x * this.SamplingUnit);

        let slope: number = (end.y - start.y) / (end.x - start.x);
        if (endIndex - startIndex <= 1) {
            endIndex++;
            slope = 0;
        }

        if (startIndex < 0) {
            startIndex = 0;
        }
        if (startIndex >= this.BottomLine.length) {
            startIndex = this.BottomLine.length - 1;
        }
        if (endIndex < 0) {
            endIndex = 0;
        }
        if (endIndex >= this.BottomLine.length) {
            endIndex = this.BottomLine.length;
        }

        this.BottomLine[startIndex] = start.y;
        for (let i: number = startIndex + 1; i < endIndex; i++) {
            this.BottomLine[i] = this.BottomLine[i - 1] + slope / this.SamplingUnit;
        }
    }

    /**
     * This method updates the SkyLine for a given range with a given value
     * //param  to update the SkyLine for
     * @param startIndex Start index of the range
     * @param endIndex End index of the range
     * @param value ??
     */
    public updateSkyLineInRange(startIndex: number, endIndex: number, value: number): void {
        this.updateInRange(this.mSkyLine, startIndex, endIndex, value);
    }

    /**
     * This method updates the BottomLine for a given range with a given value
     * @param startIndex Start index of the range
     * @param endIndex End index of the range (excluding)
     * @param value ??
     */
    public updateBottomLineInRange(startIndex: number, endIndex: number, value: number): void {
        this.updateInRange(this.BottomLine, startIndex, endIndex, value);
    }

    /**
     * Resets a SkyLine in a range to its original value
     * @param startIndex Start index of the range
     * @param endIndex End index of the range (excluding)
     */
    public resetSkyLineInRange(startIndex: number, endIndex: number): void {
        this.updateInRange(this.SkyLine, startIndex, endIndex);
    }

    /**
     * Resets a bottom line in a range to its original value
     * @param startIndex Start index of the range
     * @param endIndex End index of the range
     */
    public resetBottomLineInRange(startIndex: number, endIndex: number): void {
        this.setInRange(this.BottomLine, startIndex, endIndex);
    }

    /**
     * Update the whole skyline with a certain value
     * @param value value to be set
     */
    public setSkyLineWithValue(value: number): void {
        this.SkyLine.forEach(sl => sl = value);
    }

    /**
     * Update the whole bottomline with a certain value
     * @param value value to be set
     */
    public setBottomLineWithValue(value: number): void {
        this.BottomLine.forEach(bl => bl = value);
    }

    public getLeftIndexForPointX(x: number, length: number): number {
        const index: number = Math.floor(x * this.SamplingUnit);

        if (index < 0) {
            return 0;
        }

        if (index >= length) {
            return length - 1;
        }

        return index;
    }

    public getRightIndexForPointX(x: number, length: number): number {
        const index: number = Math.ceil(x * this.SamplingUnit);

        if (index < 0) {
            return 0;
        }

        if (index >= length) {
            return length - 1;
        }

        return index;
    }

    /**
     * This method updates the StaffLine Borders with the Sky- and BottomLines Min- and MaxValues.
     */
    public updateStaffLineBorders(): void {
        this.mStaffLineParent.PositionAndShape.BorderTop = this.getSkyLineMin();
        this.mStaffLineParent.PositionAndShape.BorderMarginTop = this.getSkyLineMin();
        this.mStaffLineParent.PositionAndShape.BorderBottom = this.getBottomLineMax();
        this.mStaffLineParent.PositionAndShape.BorderMarginBottom = this.getBottomLineMax();
    }

    /**
     * This method finds the minimum value of the SkyLine.
     */
    public getSkyLineMin(): number {
        return Math.min(...this.SkyLine.filter(s => !isNaN(s)));
    }

    public getSkyLineMinAtPoint(point: number): number {
        const index: number = Math.round(point * this.SamplingUnit);
        return this.mSkyLine[index];
    }

    /**
     * This method finds the SkyLine's minimum value within a given range.
     * @param startIndex Starting index
     * @param endIndex End index (including)
     */
    public getSkyLineMinInRange(startIndex: number, endIndex: number): number {
        return this.getMinInRange(this.SkyLine, startIndex, endIndex);
    }

    /**
     * This method finds the maximum value of the BottomLine.
     */
    public getBottomLineMax(): number {
        return Math.max(...this.BottomLine.filter(s => !isNaN(s)));
    }

    public getBottomLineMaxAtPoint(point: number): number {
        const index: number = Math.round(point * this.SamplingUnit);
        return this.mBottomLine[index];
    }

    /**
     * This method finds the BottomLine's maximum value within a given range.
     * @param startIndex Start index of the range
     * @param endIndex End index of the range (excluding)
     */
    public getBottomLineMaxInRange(startIndex: number, endIndex: number): number {
        return this.getMaxInRange(this.BottomLine, startIndex, endIndex);
    }

    /**
     * This method returns the maximum value of the bottom line around a specific
     * bounding box. Will return undefined if the bounding box is not valid or inside staffline
     * @param boundingBox Bounding box where the maximum should be retrieved from
     * @returns Maximum value inside bounding box boundaries or undefined if not possible
     */
    public getBottomLineMaxInBoundingBox(boundingBox: BoundingBox): number {
        //TODO: Actually it should be the margin. But that one is not implemented
        const startPoint: number = Math.floor(boundingBox.AbsolutePosition.x + boundingBox.BorderLeft);
        const endPoint: number = Math.ceil(boundingBox.AbsolutePosition.x + boundingBox.BorderRight);
        return this.getMaxInRange(this.mBottomLine, startPoint, endPoint);
    }

    /**
     * Updates sky- and bottom line with a boundingBox and its children
     * @param boundingBox Bounding box to be added
     */
    public updateWithBoundingBoxRecursively(boundingBox: BoundingBox): void {
        if (boundingBox.ChildElements && boundingBox.ChildElements.length > 0) {
            this.updateWithBoundingBoxRecursively(boundingBox);
        } else {
            const currentTopBorder: number = boundingBox.BorderTop + boundingBox.AbsolutePosition.y;
            const currentBottomBorder: number = boundingBox.BorderBottom + boundingBox.AbsolutePosition.y;

            if (currentTopBorder < 0) {
                const startPoint: number = Math.floor(boundingBox.AbsolutePosition.x + boundingBox.BorderLeft);
                const endPoint: number = Math.ceil(boundingBox.AbsolutePosition.x + boundingBox.BorderRight) ;

                this.updateInRange(this.mSkyLine, startPoint, endPoint, currentTopBorder);
            } else if (currentBottomBorder > this.StaffLineParent.StaffHeight) {
                const startPoint: number = Math.floor(boundingBox.AbsolutePosition.x + boundingBox.BorderLeft);
                const endPoint: number = Math.ceil(boundingBox.AbsolutePosition.x + boundingBox.BorderRight);

                this.updateInRange(this.mBottomLine, startPoint, endPoint, currentBottomBorder);
            }
        }
    }

    //#region Private methods

    /**
     * go backwards through the skyline array and find a number so that
     * we can properly calculate the average
     * @param start the starting index of the search
     * @param tSkyLine the skyline to search through
     */
     private findPreviousValidNumber(start: number, tSkyLine: number[]): number {
        for (let idx: number = start; idx >= 0; idx--) {
            if (!isNaN(tSkyLine[idx])) {
                return tSkyLine[idx];
            }
        }
        return 0;
    }

    /**
     * go forward through the skyline array and find a number so that
     * we can properly calculate the average
     * @param start the starting index of the search
     * @param tSkyLine the skyline to search through
     */
    private findNextValidNumber(start: number, tSkyLine: Array<number>): number {
        if (start >= tSkyLine.length) {
            return tSkyLine[start - 1];
        }
        for (let idx: number = start; idx < tSkyLine.length; idx++) {
            if (!isNaN(tSkyLine[idx])) {
                return tSkyLine[idx];
            }
        }
        return 0;
    }

    /**
     * Debugging drawing function that can draw single pixels
     * @param coord Point to draw to
     * @param backend the backend to be used
     * @param color the color to be used, default is red
     */
    private drawPixel(coord: PointF2D, backend: CanvasVexFlowBackend, color: string = "#FF0000FF"): void {
        const ctx: any = backend.getContext();
        const oldStyle: string = ctx.fillStyle;
        ctx.fillStyle = color;
        ctx.fillRect(coord.x, coord.y, 2, 2);
        ctx.fillStyle = oldStyle;
    }

    /**
     * Update an array with the value given inside a range. NOTE: will only be updated if value > oldValue
     * @param array Array to fill in the new value
     * @param startIndex start index to begin with (default: 0)
     * @param endIndex end index of array (excluding, default: array length)
     * @param value value to fill in (default: 0)
     */
    private updateInRange(array: number[], startIndex: number = 0, endIndex: number = array.length, value: number = 0): void {
        startIndex = Math.floor(startIndex * this.SamplingUnit);
        endIndex = Math.ceil(endIndex * this.SamplingUnit);

        if (endIndex < startIndex) {
            throw new Error("start index of line is greater than the end index");
        }

        if (startIndex < 0) {
            startIndex = 0;
        }

        if (endIndex > array.length) {
            endIndex = array.length;
        }

        for (let i: number = startIndex; i < endIndex; i++) {
            array[i] = Math.abs(value) > Math.abs(array[i]) ? value : array[i];
        }
    }

    /**
     * Sets the value given to the range inside the array. NOTE: will always update the value
     * @param array Array to fill in the new value
     * @param startIndex start index to begin with (default: 0)
     * @param endIndex end index of array (excluding, default: array length)
     * @param value value to fill in (default: 0)
     */
    private setInRange(array: number[], startIndex: number = 0, endIndex: number = array.length, value: number = 0): void {
        startIndex = Math.floor(startIndex * this.SamplingUnit);
        endIndex = Math.ceil(endIndex * this.SamplingUnit);

        if (endIndex < startIndex) {
            throw new Error("start index of line is greater then the end index");
        }

        if (startIndex < 0) {
            startIndex = 0;
        }

        if (endIndex > array.length) {
            endIndex = array.length;
        }

        for (let i: number = startIndex; i < endIndex; i++) {
            array[i] = value;
        }
    }
    /**
     * Get all values of the selected line inside the given range
     * @param skyBottomArray Skyline or bottom line
     * @param startIndex start index
     * @param endIndex end index (including)
     */
    private getMinInRange(skyBottomArray: number[], startIndex: number, endIndex: number): number {
        startIndex = Math.floor(startIndex * this.SamplingUnit);
        endIndex = Math.ceil(endIndex * this.SamplingUnit);

        if (!skyBottomArray) {
            // Highly questionable
            return Number.MAX_VALUE;
        }

        if (startIndex < 0) {
            startIndex = 0;
        }
        if (startIndex >= skyBottomArray.length) {
            startIndex = skyBottomArray.length - 1;
        }
        if (endIndex < 0) {
            endIndex = 0;
        }
        if (endIndex >= skyBottomArray.length) {
            endIndex = skyBottomArray.length;
        }

        if (startIndex >= 0 && endIndex <= skyBottomArray.length) {
            return Math.min(...skyBottomArray.slice(startIndex, endIndex + 1)); // slice does not include end (index)
        }
    }

    /**
     * Get the maximum value inside the given indices
     * @param skyBottomArray Skyline or bottom line
     * @param startIndex start index
     * @param endIndex end index (including)
     */
    private getMaxInRange(skyBottomArray: number[], startIndex: number, endIndex: number): number {
        startIndex = Math.floor(startIndex * this.SamplingUnit);
        endIndex = Math.ceil(endIndex * this.SamplingUnit);

        if (!skyBottomArray) {
            // Highly questionable
            return Number.MIN_VALUE;
        }

        if (startIndex < 0) {
            startIndex = 0;
        }
        if (startIndex >= skyBottomArray.length) {
            startIndex = skyBottomArray.length - 1;
        }
        if (endIndex < 0) {
            endIndex = 0;
        }
        if (endIndex >= skyBottomArray.length) {
            endIndex = skyBottomArray.length;
        }

        if (startIndex >= 0 && endIndex <= skyBottomArray.length) {
            return Math.max(...skyBottomArray.slice(startIndex, endIndex + 1)); // slice does not include end (index)
        }
    }
    // FIXME: What does this do here?
    // private isStaffLineUpper(): boolean {
    //     const instrument: Instrument = this.StaffLineParent.ParentStaff.ParentInstrument;

    //     if (this.StaffLineParent.ParentStaff === instrument.Staves[0]) {
    //         return true;
    //     } else {
    //         return false;
    //     }
    // }
    // #endregion

    //#region Getter Setter
    /** Sampling units that are used to quantize the sky and bottom line  */
    get SamplingUnit(): number {
        return this.mRules.SamplingUnit;
    }

    /** Parent staffline where the skybottomline calculator is attached to */
    get StaffLineParent(): StaffLine {
        return this.mStaffLineParent;
    }

    /** Get the plain skyline array */
    get SkyLine(): number[] {
        return this.mSkyLine;
    }

    /** Get the plain bottomline array */
    get BottomLine(): number[] {
        return this.mBottomLine;
    }
    //#endregion
}