// ***********************************************************************************
// ** Grid Gantt functions                                                       *****
// ***********************************************************************************

var LastEventGridObject = null;
var LastEventGanttScale = null;
var bGanttShowFloat = true;
var GanttNavHoldTimerID = null;
var GanttNavRepeatTimerID = null;
var bNavScrollActive = false;
var nNavScrollDeltaPixels = 0;
var bScaleZoomActive = false;


/**
 * Build all the Gantt elements in this grid
 *      - Bars
 *      - Summaries
 *      - Link lines
 *      - Scale
 *      - UI elements
 * @param {any} GridObject
 */
function BuildGanttElementsByGrid(GridObject) {

    try {

    if (!GridObject)
        return;
    if (!HasGanttColumn(GridObject))
        return;

    // Search for all divs that are marked "ganttcelldiv" with a special class
    var GanttCellDivs = GridObject.find('.ganttcelldiv');
    if (!GanttCellDivs || GanttCellDivs.length == 0)
        return;

    // Is there a Gantt column?
    var GanttHeaderTh = FindGanttHeaderTh(GridObject);
    if (!GanttHeaderTh || GanttHeaderTh.len == 0)
        return;

    // Help find the grid later
    GridObject.addClass("stgridtable");

    // Must be rebuilt because they cannot be scaled or repositioned.
    // Skip during zoom drag — link lines are rebuilt on the full render after drop.
    if (!bScaleZoomActive)
        DeleteAllGanttLinkLines(GridObject);

    // Build Gantt header
    // "|  7/1/2027  |  8/1/2027  |  9/1/2027  |  10/1/2027  |"
    BuildGanttHeader(GridObject);

    // Propagate the resolved scale (localStorage wins over server value) to all cell divs
    // so BuildGanttElementsByCell reads the same dPixelsPerDay the header used.
    var ResolvedGanttScale = FindGanttScaleVars(GridObject);
    if (ResolvedGanttScale) {
        var ResolvedGanttScaleJsonString = JSON.stringify(ResolvedGanttScale);
        GanttCellDivs.attr('data-gantt-scale', ResolvedGanttScaleJsonString);
    }

    // Compute the CSS grid column string once before the loop — it is identical for
    // every cell and rebuilding it per-cell (via string concatenation) is wasteful.
    // Column count must exceed grid-lines + bars so no row wraps to a second CSS row.
    var nColumns = Math.max(GanttScalePositionsArray.length + 50, 200);
    var TemplateCols = " 0px".repeat(nColumns);

    // Build all the elements in each gantt cell
    for (i = 0; i < GanttCellDivs.length; ++i) {
        var GanttCellDiv = $(GanttCellDivs[i]);

        // Full rebuild: clear and reconstruct everything.
        // Nav-scroll and scale-zoom keep all children — fast paths update positions in place.
        if (!bNavScrollActive && !bScaleZoomActive)
            GanttCellDiv.html("");

        // IG grid sets vertical-align:middle on all <td> cells; when a row is taller
        // than the ganttcelldiv (due to taller content in other columns), the div gets
        // centered, shifting bars downward. Force top alignment on the parent <td>.
        GanttCellDiv.closest('td').css('vertical-align', 'top');

        // Batch all CSS properties in one call to avoid repeated jQuery parse overhead.
        GanttCellDiv.css({
            "display":               "grid",
            "grid-template-columns": TemplateCols,
            "align-items":           "start",
            "overflow":              "hidden",
            "min-height":            "24px"
        });

        // Add new elements to the div — weekend shading first so it sits behind grid lines and bars
        BuildGanttWeekendShading(GanttCellDiv, ResolvedGanttScale);
        BuildGanttScaleGridLines(GanttCellDiv);
        BuildGanttElementsByCell(GanttCellDiv, i === 0/*bFirstRow*/);
    }

    // Lines between successors and predecessors.
    // During zoom drag: don't delete/rebuild — just scale the existing SVG on the x-axis.
    // All SVG coordinates were built at data-build-ppd; scaleX(newPPD/buildPPD) keeps arrows
    // accurate with zero DOM work. transform-origin: 0 0 scales from the Gantt column's left
    // edge, which is where the SVG is anchored. Arrowheads are slightly squished during drag
    // (acceptable); the SVG is fully rebuilt on drop via AjaxSetGanttPixelsPerDay.
    if (bScaleZoomActive) {
        if (ResolvedGanttScale) {
            var SvgEl = GridObject.find('svg[data-build-ppd]')[0];
            if (SvgEl) {
                var buildPPD = parseFloat(SvgEl.getAttribute('data-build-ppd'));
                if (buildPPD > 0) {
                    SvgEl.style.transformOrigin = '0 0';
                    SvgEl.style.transform = 'scaleX(' + (ResolvedGanttScale.dPixelsPerDay / buildPPD) + ')';
                }
            }
        }
    } else {
        BuildGanttLinkLinesOnTimer(GridObject, 500/*nMilliseconds*/);
    }

    } catch (e) {
        ToastError("BuildGanttElementsByGrid", e.name + ": " + e.message + "\n" + e.stack);
    }
}

function FindGanttHeaderTh(GridObject) {

    // Find all column headers
    var GridTableThs = GridObject.find('th');
    if (!GridTableThs || GridTableThs.length == 0) {
        GridTableThs = GridObject.parent().parent().find('th');
        if (!GridTableThs || GridTableThs.length == 0) {
            GridTableThs = GridObject.closest('th');

            // Still nothing?
            // Try going up to the nearest iggrid element
            if (!GridTableThs || GridTableThs.length == 0) {
                GridObject = GridObject.closest('.ui-iggrid');
                if (GridObject && GridObject.length > 0) {
                    GridTableThs = GridObject.find('th');
                    if (!GridTableThs || GridTableThs.length == 0) {
                        GridTableThs = GridObject.parent().parent().find('th');
                        if (!GridTableThs || GridTableThs.length == 0)
                            return;
                    }
                }
            }
        }
    }
    

    // Look for an id that contains "Gantt"
    for (i = 0; i < GridTableThs.length; ++i) {
        var GridTableTh = $(GridTableThs[i]);
        var GridTableThId = GridTableTh.attr('id');

        // Id has "Gantt" in it?
        if (GridTableThId && GridTableThId != null && GridTableThId.includes("Gantt"))
            return GridTableTh;
    }

    // No Gantt header in this grid
    return null;
}


function RebuildGanttLinkLinesFromSelectedContentPane() {

    var SelectedContentPartialViewDiv = GetSelectedContentPartialViewDivElement();
    if (!SelectedContentPartialViewDiv || SelectedContentPartialViewDiv.length == 0)
        return;

    if (!HasGanttColumn(SelectedContentPartialViewDiv))
        return;

    var GridTableObject = FindGridTableObject(SelectedContentPartialViewDiv);
    if (!GridTableObject || GridTableObject.length == 0)
        return;

    // Lines between successors and predecessors
    // Must be rebuilt because they cannot be scaled or repositioned
    DeleteAllGanttLinkLines(GridTableObject)

    setTimeout(function () { BuildGanttLinkLines(GridTableObject); }, 500);
}


function BuildGanttHeader(GridObject) {
    try {
        // Build Gantt header
        // "|  7/1/2027  |  8/1/2027  |  9/1/2027  |  10/1/2027  |"
        var GanttHeaderTh = FindGanttHeaderTh(GridObject);
        if (GanttHeaderTh) {
            BuildGanttScale(GridObject, GanttHeaderTh);
            AddWheelEventListener(GridObject, GanttHeaderTh);
        }
    } catch (e) {
        ToastError("BuildGanttHeader", e.name + ": " + e.message + "\n" + e.stack);
    }
}

// Derive ContentType from the pane that owns this grid element,
// falling back to the focused pane when the grid is not inside a dock pane.
function GetContentTypeFromGridObject(GridObject) {
    if (GridObject && GridObject.length > 0) {
        var PaneDiv = GridObject.closest('[ContentType]');
        if (PaneDiv && PaneDiv.length > 0) {
            var ContentType = PaneDiv.attr('ContentType');
            if (ContentType)
                return ContentType;
        }
    }
    return GetSelectedContentType();
}

function FindGanttScaleVars(GridObject) {

    if (!GridObject || GridObject.length == 0) {
        GridObject = FindGridObject(null);
        if (!GridObject || GridObject.length == 0)
            return null;
    }

    var ElementHasScale = GridObject.find('[data-gantt-scale]');
    if (!ElementHasScale)
        return null;
    if (ElementHasScale.length > 0)
        ElementHasScale = $(ElementHasScale[0]);
    var GanttScaleJsonString = ElementHasScale.attr('data-gantt-scale');
    if (!GanttScaleJsonString)
        return null;
    var GanttScale = JSON.parse(GanttScaleJsonString);
    var StoredScale = LoadGanttScaleFromLocalStorage(GetContentTypeFromGridObject(GridObject));
    if (StoredScale)
        return StoredScale;
    return GanttScale;
}

var SaveGanttScaleTimerID = 0;

function SaveGanttScaleToGridObject(GridObject, GanttScale) {

    var GanttScaleJsonString = JSON.stringify(GanttScale);
    var ElementWithScaleObject = GridObject.find('[data-gantt-scale]');
    if (ElementWithScaleObject == null || ElementWithScaleObject.length == 0)
        return;

    ElementWithScaleObject.attr('data-gantt-scale', GanttScaleJsonString);

    // Persist so the scale survives page refresh before the server round-trip completes
    var ContentType = GetContentTypeFromGridObject(GridObject);
    SaveGanttScaleToLocalStorage(ContentType, GanttScale);

    // Debounce the server call — pan/zoom fires this on every mousemove tick
    var dPixelsPerDay = GanttScale.dPixelsPerDay;
    clearTimeout(SaveGanttScaleTimerID);
    SaveGanttScaleTimerID = setTimeout(function () {
        AjaxSetGanttPixelsPerDay(dPixelsPerDay, ContentType, false/*bRefreshContent*/);
    }, 300);
}

function BuildGanttScale(GridObject, GanttHeaderTh) {
    try {
        if (!GridObject)
            return;
        if (!GanttHeaderTh)
            return;

        // Clear the old text so it doesn't conflict with the new scale div
        var GanttHeaderTextSpan = GanttHeaderTh.find('span');
        if (GanttHeaderTextSpan)
            GanttHeaderTextSpan.html("");

        // Find or create a new div for gantt scale
        var GanttScaleDiv = GanttHeaderTh.find(".ganttscale");
        if (GanttScaleDiv == null || GanttScaleDiv.length == 0) {
            GanttScaleDiv = $("<div>");
            GanttScaleDiv.addClass('ganttscale');
            GanttHeaderTh.append(GanttScaleDiv);
        }

        // Build scale labels for each interval
        // "|  7/1/2027  |  8/1/2027  |  9/1/2027  |  10/1/2027  |"
        BuildGanttScaleElements(GridObject, GanttScaleDiv);

        // Hover navigation buttons (scroll/page left/right)
        AddGanttNavButtons(GanttHeaderTh, GridObject);

        // Zoom level label — comment out this line to hide it
        AddGanttZoomLabel(GanttHeaderTh, GridObject);
    } catch (e) {
        ToastError("BuildGanttScale", e.name + ": " + e.message + "\n" + e.stack);
    }
}


function HasGanttColumn(AnyGridObject) {

    // Search for all divs that are marked "ganttcelldiv" with a special class
    var GanttCellDivs = AnyGridObject.find('.ganttcelldiv');
    if (GanttCellDivs && GanttCellDivs.length > 0)
        return true;

    // Is there a Gantt column?
    var GanttHeaderTh = FindGanttHeaderTh(AnyGridObject);
    if (GanttHeaderTh && GanttHeaderTh.length > 0)
        return true;

    // No Gantt column in this grid
    return false;
}

function FindGridTableObject(AnyGridObject) {
    if (!AnyGridObject || AnyGridObject.length == 0)
        AnyGridObject = $(document.body);
    var GridObject = AnyGridObject;

    var ContentDiv = AnyGridObject.closest('.contentdiv');
    if (ContentDiv && ContentDiv.length > 0) {
        GridObject = ContentDiv.find('.stgridtable');
        if (GridObject && GridObject.length > 0)
            return GridObject;
    }

    ContentDiv = AnyGridObject.find('.contentdiv');
    if (ContentDiv && ContentDiv.length > 0) {
        GridObject = ContentDiv.find('.stgridtable');
        if (GridObject && GridObject.length > 0)
            return GridObject;
    }

    return FindGridObject(AnyGridObject);
}

function FindGridObject(AnyGridObject) {

    //AnyGridObject = $(AnyGridObject);

    if (!AnyGridObject || AnyGridObject.length == 0)
        AnyGridObject = $(document.body);
    var GridObject = AnyGridObject;

    // Is this the grid object already?
    if (GridObject.hasClass('ui-iggrid'))
        return GridObject;

    // Find child with the grid class
    GridObject = GridObject.find('.ui-iggrid');
    if (GridObject && GridObject.length > 0)
        return GridObject;

    // Find ancestor with the grid class
    var GridObject = AnyGridObject.closest('.ui-iggrid');

    return GridObject;
}

function BuildGanttScaleFromAnyGridObject(AnyGridObject, GanttScale) {

    var GridObject = FindGridObject(AnyGridObject);
    if (!GridObject || GridObject.length == 0)
        return;

    var GanttScaleDiv = GridObject.find('.ganttscale');
    if (!GanttScaleDiv || GanttScaleDiv.length == 0)
        return;

    BuildGanttScaleElements(GridObject, GanttScaleDiv, GanttScale);
}


function FindGanttScaleDiv(AnyGridObject) {

    var GridObject = FindGridObject(AnyGridObject);
    if (!GridObject || GridObject.length == 0)
        return;

    var GanttScaleDiv = GridObject.find('.ganttscale');
    return GanttScaleDiv;
}

function BuildGanttScaleElements(GridObject, GanttScaleDiv, GanttScale) {

    try {

    // "|  7/1/2027  |  8/1/2027  |  9/1/2027  |  10/1/2027  |"
    if (!GanttScale)
        GanttScale = FindGanttScaleVars(GridObject);
    if (!GanttScale)
        return;

    // Global array of Gantt grid line positions
    GanttScalePositionsArray = [];

    // Scale settings
    // Use these to build label elements for each interval
    // Example: Label for each day
    var GanttScaleStartDate = new Date(GanttScale.Start);
    var dPixelsPerDay = GanttScale.dPixelsPerDay;
    var CellScale = GanttScale.CellScale; // NONE = -1, DAYS = 0, WEEKS = 1, MONTHS = 2, QTRS = 3, YEARS = 4

    var ToolTip = "Gantt Start: " + GanttScaleStartDate.toLocaleDateString();
    GanttScaleDiv.attr("title", ToolTip);

    // Find the last cell date based on the scale
    // Limit the gantt scale to a fixed date
    // Stop building gantt cells at this date (2 years is the limit)
    var LastGanttCellDate = new Date();
    LastGanttCellDate = addDays(GanttScaleStartDate, (2 * 365));


    var CellStartDate = new Date(GanttScaleStartDate.toDateString());
    var CellEndDate = new Date(CellStartDate.toDateString());
    var nGanttScaleCells = 0;
    var grid_template_columns = "";
    var Today = new Date(); Today.setHours(0, 0, 0, 0);

    // Create Gantt cell divs
    for (i = 0; i < 1000; ++i) {

        // End?
        if (CellEndDate.getTime() >= LastGanttCellDate.getTime())
            break;

        // Add days until cell scale has been reached
        var dCellDays = 0;
        for (day = 0; day < 366; ++day) {

            // Add one day
            CellEndDate.setDate(CellEndDate.getDate() + 1/*day*/);
            dCellDays++;


            // Keep adding days to the cell, or stop?
            if (CellScale == 0/*CELLSCALE.DAYS*/) {
                // Only add one day
                break;
            }
            else if (CellScale == 1/*CELLSCALE.WEEKS*/) {
                // Stop at beginning of week
                if (CellEndDate.getDay() == 1/*Monday*/)
                    break;
            }
            else if (CellScale == 2/*CELLSCALE.MONTHS*/) {
                // Stop on the first day of the month
                if (CellEndDate.getDate() == 1)
                    break;
            }
            else if (CellScale == 3/*CELLSCALE.QTRS*/) {
                // Stop on the thrid month, first day of the month
                var nMonth = CellEndDate.getMonth() + 1;
                if (CellEndDate.getDate() == 1 && (nMonth == 1 || nMonth == 4 || nMonth == 7 || nMonth == 10)) {
                    break;
                }
            }
            else if (CellScale == 4/*CELLSCALE.YEARS*/) {
                // Stop on January 1
                var nMonth = CellEndDate.getMonth() + 1;
                var nYear = CellEndDate.getFullYear();
                if (nMonth == 1 && CellEndDate.getDate() == 1)
                    break;
            }
        }

        var GanttScaleCellText = CellStartDate.toLocaleDateString();

        // Gantt scale cell positions
        var ScaleDiffTimeToStartDate = CellStartDate - GanttScaleStartDate;
        var ScaleDiffTimeToFinishDate = CellEndDate - GanttScaleStartDate;
        var ScaleDiffDaysToStartDate = Math.ceil(ScaleDiffTimeToStartDate / (1000 * 60 * 60 * 24));
        var ScaleDiffDaysToFinishDate = Math.ceil(ScaleDiffTimeToFinishDate / (1000 * 60 * 60 * 24));
        var ScalePixelsToStartDate = ScaleDiffDaysToStartDate * dPixelsPerDay;
        var ScalePixelsToFinishDate = ScaleDiffDaysToFinishDate * dPixelsPerDay;
        var ScalePixelsBarWidth = Math.abs(ScalePixelsToFinishDate - ScalePixelsToStartDate);

        // Find or create a new div for gantt scale
        var GanttScaleCellDiv = GanttScaleDiv.find("[gantt-scale-interval='" + nGanttScaleCells + "']");
        if (GanttScaleCellDiv == null || GanttScaleCellDiv.length == 0) {

            // Rebuild
            GanttScaleCellDiv = $("<div>");
            GanttScaleCellDiv.addClass('ganttscalecell');
            GanttScaleCellDiv.css("position", "relative");
            GanttScaleCellDiv.css("left", ScalePixelsToStartDate + "px");
            GanttScaleCellDiv.css("width", ScalePixelsBarWidth + "px");
            //GanttScaleCellDiv.css("background-color", "red");
            GanttScaleCellDiv.html(GanttScaleCellText);
            GanttScaleCellDiv.attr("gantt-scale-interval", nGanttScaleCells);
            GanttScaleDiv.append(GanttScaleCellDiv);

            // Add a grab handle for all scale cells after the first one
            // Cannot drag the first one
            if (nGanttScaleCells > 0) {
                var DragHandleDiv = GanttScaleCellDiv.find('.gantt-scale-draggablehandle');
                if (DragHandleDiv == null || DragHandleDiv.length == 0) {
                    var DragHandleDiv = $("<div style=\"height: 100%; width: 4px; top: 0px; bottom: 0px; right: 0px; position:absolute; cursor: ew-resize; /*background-color: green;*/\"><div>");
                    DragHandleDiv.draggable({
                        axis: "x",
                        delay: 250,
                        distance: 4,
                        stop: function (event, ui) { OnGanttScaleCellIntervalsDropped(event, ui); },
                        drag: function (event, ui) { OnGanttScaleCellIntervalsDragging(event, ui); },
                    });
                    DragHandleDiv.attr("gantt-scale-interval", nGanttScaleCells);
                    DragHandleDiv.addClass("gantt-scale-draggablehandle");
                    GanttScaleCellDiv.append(DragHandleDiv);
                }
            }
        }
        else {
            // GanttScaleCellDiv exists from a previous rebuild
            GanttScaleCellDiv.css("left", ScalePixelsToStartDate + "px");
            GanttScaleCellDiv.css("width", ScalePixelsBarWidth + "px");
            if (GanttScaleCellDiv[0].firstChild)
                 GanttScaleCellDiv[0].firstChild.nodeValue = GanttScaleCellText;    // This allows a change of text without removing children, like the drag handle
            else GanttScaleCellDiv.html(GanttScaleCellText);

            // Strangely... an "inset" is automatically added to this draggable div, and must be cleared and reset to the proper values below
            // Why?
            var DragHandleDiv = GanttScaleCellDiv.find('.gantt-scale-draggablehandle');
            if (DragHandleDiv && DragHandleDiv.length > 0) {
                DragHandleDiv.css("inset", "");
                DragHandleDiv.css("top", "0px");
                DragHandleDiv.css("right", "0px");
                DragHandleDiv.css("width", "4px");
            }
        }
        GanttScaleCellDiv.toggleClass('ganttscalecell-today', Today >= CellStartDate && Today < CellEndDate);
        // Highlight Saturday/Sunday header cells in Days mode so weekends are visible in the scale row.
        // Only meaningful at CellScale 0 (Days); weekly/monthly cells already span many days.
        if (CellScale === 0) {
            var dow = CellStartDate.getDay(); // 0 = Sunday, 6 = Saturday
            GanttScaleCellDiv.toggleClass('ganttscalecell-weekend', dow === 0 || dow === 6);
        }

        // Prep for next cell
        CellStartDate = new Date(CellEndDate.getTime());
        nGanttScaleCells++;
        grid_template_columns += " 0px";
        GanttScalePositionsArray.push(ScalePixelsToStartDate);
    }

    // Get an accurate count of all GanttCellDivs
    // This is necessary to keep all divs on the same line, using the "display:grid" trick below
    var GanttScaleCellDivs = GanttScaleDiv.find(".ganttscalecell");
    if (GanttScaleCellDivs && GanttScaleCellDivs.length > 0) {
        grid_template_columns = "";
        for (x = 0; x < GanttScaleCellDivs.length; ++x)
            grid_template_columns += " 0px";
    }

    // Trick to force divs onto one line, and use css left and width to position divs along the scale
    // 0px for each grid cell still allows "left" and "width" to position div relative to parent (GanttScaleDiv)
    GanttScaleDiv.css("display", "grid");
    GanttScaleDiv.css("grid-template-columns", grid_template_columns /*" 0px 0px 0px 0px 0px 0px"*/);

    } catch (e) {
        ToastError("BuildGanttScaleElements", e.name + ": " + e.message + "\n" + e.stack);
    }
}


// Global array of Gantt grid line positions
var GanttScalePositionsArray = [];


/**
 * Apply weekend shading to a Gantt cell row using a CSS repeating-linear-gradient.
 * Only active in Days scale (CellScale === 0).
 *
 * Uses a CSS background gradient instead of DOM elements — zero extra nodes are
 * created.  The gradient tiles every 7 days (Mon–Sun) and background-position-x
 * shifts the phase so Saturday always falls at Saturday's pixel offset regardless
 * of what day the scale starts on.
 *
 * @param {jQuery} GanttCellDiv  - The .ganttcelldiv element to shade.
 * @param {object} GanttScale    - Parsed gantt scale from data-gantt-scale attr.
 *                                 Must have .Start (date string), .dPixelsPerDay,
 *                                 and .CellScale (0=Days, 1=Weeks, 2=Months…).
 */
function BuildGanttWeekendShading(GanttCellDiv, GanttScale) {
    try {
        // Step 1: Clear any gradient left from a previous scale and exit for non-Days scales.
        // Weekly / monthly cells already span many days so per-day bands are meaningless.
        if (!GanttScale || GanttScale.CellScale !== 0) {
            GanttCellDiv.css('background-image', '');
            GanttCellDiv.css('background-position-x', '');
            return;
        }

        // Step 2: Compute the gradient geometry from the scale's zoom level.
        var StartDate = new Date(GanttScale.Start);
        StartDate.setHours(0, 0, 0, 0);          // normalise to midnight
        var dPixelsPerDay = GanttScale.dPixelsPerDay;
        var weekPx  = 7 * dPixelsPerDay;          // one full week in pixels
        var satPx   = 5 * dPixelsPerDay;          // pixel offset of Saturday within a Mon-anchored week
        //   Mon=0d, Tue=1d, Wed=2d, Thu=3d, Fri=4d, Sat=5d, Sun=6d → weekPx

        // Step 3: Calculate the phase shift so Monday of the gradient pattern aligns
        // with the actual Monday of the week containing the scale start date.
        // getDay() returns 0=Sun…6=Sat; convert to Mon-anchored (Sun → 6, Mon → 0, …).
        var nStartDow      = StartDate.getDay();
        var daysSinceMonday = (nStartDow === 0) ? 6 : (nStartDow - 1);
        var offsetPx       = -(daysSinceMonday * dPixelsPerDay);

        // Step 4: Build the repeating gradient string and apply it.
        // Pattern (Mon-anchored, repeating every weekPx):
        //   0px → satPx   : transparent  (Mon–Fri)
        //   satPx → weekPx : shaded       (Sat–Sun)
        var color    = 'var(--st-gantt-weekend-bg)';
        var gradient = 'repeating-linear-gradient(to right,' +
            ' transparent 0px,' +
            ' transparent ' + satPx + 'px,' +
            ' ' + color + ' ' + satPx + 'px,' +
            ' ' + color + ' ' + weekPx + 'px,' +
            ' transparent ' + weekPx + 'px)';

        GanttCellDiv.css('background-image',      gradient);
        GanttCellDiv.css('background-position-x', offsetPx + 'px');

    } catch (e) {
        ToastError("BuildGanttWeekendShading", e.name + ": " + e.message + "\n" + e.stack);
    }
}


function BuildGanttScaleGridLines(GanttCellDiv) {

    try {
        // Skip during cell pan drag or nav-button scroll — both fire rapid rebuilds;
        // creating/destroying grid-line divs per cell per frame causes severe sluggishness.
        // Grid lines are rebuilt on the next full render after the gesture ends.
        if (DraggingGanttScale != null)
            return;
        if (bNavScrollActive)
            return;
        if (bScaleZoomActive)
            return;

        if (!GanttScalePositionsArray)
            return;
        if (GanttScalePositionsArray.length == 0)
            return;

        for (var i = 0; i < GanttScalePositionsArray.length; i++) {
            var GanttGridLinePos = GanttScalePositionsArray[i];

            // Create a div for the line
            var GanttGridLineDiv = $("<div>");
            GanttGridLineDiv.addClass('ganttgridline');
            GanttGridLineDiv.css("position", "relative");
            GanttGridLineDiv.css("top", "0px");
            GanttGridLineDiv.css("height", "100%");
            GanttGridLineDiv.css("left", (GanttGridLinePos-2) + "px");
            GanttGridLineDiv.css("width", "1px");
            GanttCellDiv.append(GanttGridLineDiv);
        }
    } catch (e) {
        console.log("BuildGanttScaleGridLines: " + e.name + ": " + e.message);
    }
}

// Restore grid lines after a nav-button scroll. Called from stopRepeat.
//
// Fast path (Days scale nav scroll): BuildGanttElementsByGrid preserved .ganttgridline
// children when bNavScrollActive was true. For Days scale, positions are always
// [0, d, 2d, ...] relative to any start date, so the preserved lines are still correct.
// Detect this by checking count + first two positions against GanttScalePositionsArray
// and return immediately — zero DOM work.
//
// Slow path (scale changed, zoom changed, or non-Days scale): positions differ, so
// remove stale lines and rebuild using a single cloneNode(true) per row.
function RebuildGanttGridLinesOnly(GridObject) {
    try {
        if (!GanttScalePositionsArray || GanttScalePositionsArray.length === 0)
            return;

        var nativeGrid = GridObject[0] || GridObject;
        var GanttCellDivs = nativeGrid.querySelectorAll('.ganttcelldiv');
        if (!GanttCellDivs.length)
            return;

        // Sample the first cell to decide whether preserved lines are still valid.
        // Checking count + second position catches zoom changes (d changed) as well as
        // scale-type changes (Days→Weeks etc.) without iterating all 730 elements.
        var firstCell = GanttCellDivs[0];
        var existingLines = firstCell.querySelectorAll('.ganttgridline');
        if (existingLines.length === GanttScalePositionsArray.length && existingLines.length > 1) {
            var secondOk = Math.abs(parseFloat(existingLines[1].style.left) - (GanttScalePositionsArray[1] - 2)) < 0.5;
            if (secondOk)
                return; // Lines are valid — Days-scale nav scroll, nothing to do
        }

        // Lines are missing or stale — full rebuild using cloneNode(true) per row.
        var template = document.createDocumentFragment();
        for (var k = 0; k < GanttScalePositionsArray.length; k++) {
            var div = document.createElement('div');
            div.className = 'ganttgridline';
            div.style.cssText = 'position:relative;top:0px;height:100%;left:' + (GanttScalePositionsArray[k] - 2) + 'px;width:1px';
            template.appendChild(div);
        }

        for (var i = 0; i < GanttCellDivs.length; i++) {
            var nativeCell = GanttCellDivs[i];
            var old = nativeCell.querySelectorAll('.ganttgridline');
            for (var j = old.length - 1; j >= 0; j--)
                old[j].parentNode.removeChild(old[j]);
            var cloned = template.cloneNode(true);
            if (nativeCell.firstChild)
                nativeCell.insertBefore(cloned, nativeCell.firstChild);
            else
                nativeCell.appendChild(cloned);
        }
    } catch (e) {
        ToastError("RebuildGanttGridLinesOnly", e.name + ": " + e.message + "\n" + e.stack);
    }
}

/**
 * Build Gantt elements inside this cell only
 * @param {any} GanttCellDiv
 */
function BuildGanttElementsByCell(GanttCellDiv, bFirstRow) {

    try {
        if (!GanttCellDiv)
            return;

        // Scale-zoom fast path: recompute positions from stored day counts instead of rebuilding.
        // data-start-days / data-duration-days / data-px-offset are set during the full build.
        // When dPixelsPerDay changes all bar positions change proportionally, so we can update
        // existing DOM nodes without destroying and recreating them or re-attaching draggable().
        if (bScaleZoomActive) {
            var ScaleJson = GanttCellDiv.attr('data-gantt-scale');
            if (ScaleJson) {
                var Sc = JSON.parse(ScaleJson);
                var newPPD = Sc.dPixelsPerDay;
                var ScStart = new Date(Sc.Start);
                var TodayNow = new Date(); TodayNow.setHours(0, 0, 0, 0);
                var nTodayPx = (TodayNow - ScStart) / (1000 * 60 * 60 * 24) * newPPD;
                var todayEls = GanttCellDiv[0].querySelectorAll('.gantttoday');
                for (var ti = 0; ti < todayEls.length; ti++)
                    todayEls[ti].style.left = nTodayPx + 'px';
                var items = GanttCellDiv[0].querySelectorAll('.ganttitem');
                for (var gi = 0; gi < items.length; gi++) {
                    var el = items[gi];
                    var startDays = parseFloat(el.dataset.startDays || '0');
                    var pxOffset = parseFloat(el.dataset.pxOffset || '0');
                    el.style.left = (startDays * newPPD + pxOffset) + 'px';
                    if (el.dataset.durationDays) {
                        var newWidth = parseFloat(el.dataset.durationDays) * newPPD;
                        el.style.width = newWidth + 'px';
                        // Keep the handle pinned to the right edge as the bar width changes.
                        var resizeHandle = el.querySelector('.gantt-bar-resize-handle');
                        if (resizeHandle) resizeHandle.style.left = (newWidth - 6) + 'px';
                    }
                }
                BuildGanttWeekendShading(GanttCellDiv, Sc);
            }
            return;
        }

        // Nav-scroll fast path: shift existing element positions instead of rebuilding.
        // Bars, milestones and float bars (.ganttitem) keep their DOM nodes — only their
        // left CSS changes by nNavScrollDeltaPixels. Today line is recomputed from the
        // updated data-gantt-scale attr (already written by BuildGanttElementsByGrid).
        // This avoids all jQuery construction and draggable() setup per row per tick.
        if (bNavScrollActive) {
            var ScaleJson = GanttCellDiv.attr('data-gantt-scale');
            if (ScaleJson) {
                var Sc = JSON.parse(ScaleJson);
                var ScStart = new Date(Sc.Start);
                var TodayNow = new Date(); TodayNow.setHours(0, 0, 0, 0);
                var nTodayPx = (TodayNow - ScStart) / (1000 * 60 * 60 * 24) * Sc.dPixelsPerDay;
                var todayEls = GanttCellDiv[0].querySelectorAll('.gantttoday');
                for (var ti = 0; ti < todayEls.length; ti++)
                    todayEls[ti].style.left = nTodayPx + 'px';
            }
            if (nNavScrollDeltaPixels !== 0) {
                var items = GanttCellDiv[0].querySelectorAll('.ganttitem');
                for (var gi = 0; gi < items.length; gi++)
                    items[gi].style.left = (parseFloat(items[gi].style.left) || 0) + nNavScrollDeltaPixels + 'px';
                // No handle adjustment needed: the handle's left is relative to the bar (its
                // offset parent), so shifting the bar's left doesn't affect the handle's position within it.
            }
            return;
        }

        // Drag the cell that the bar is inside.
        // Cell div is preserved across renders (html("") clears children, not the element),
        // so the draggable widget persists — skip re-init if already set.
        if (!GanttCellDiv.data('ui-draggable')) {
            GanttCellDiv.draggable({
                axis: "x",
                delay: 250,
                distance: 4,
                cursor: "e-resize",
                stop: function (event, ui) { OnGanttCellDropped(event, ui); },
                drag: function (event, ui) { OnGanttCellDragging(event, ui); },
            });
        }

        // Div holds attributes to build gantt elements
        var GanttValuesJsonString = GanttCellDiv.attr('data-gantt-values');
        var GanttScaleJsonString = GanttCellDiv.attr('data-gantt-scale');
        if (!GanttValuesJsonString)
            return;
        if (!GanttScaleJsonString)
            return;

        //{ "Start": "12/29/2022 11:00 AM", "Finish": "1/1/2024 1:00 PM", "Summary": "true", "Name": "10200", "Percent": "26", "Milestones": [ { "Start": "1/19/2023", "Name": "Edging start", "End": "" }, { "Start": "2/15/2023", "Name": "Edging finish", "End": "" } ], "End": "" }
        // The attributes hold json strings with values. Parse these to objects
        var GanttValuesArray = JSON.parse(GanttValuesJsonString);
        var GanttScale = JSON.parse(GanttScaleJsonString);
        if (!GanttValuesArray)
            return;
        if (!GanttScale)
            return;

        // Gantt scale pixels
        var GanttScaleStartDate = new Date(GanttScale.Start);
        var dPixelsPerDay = GanttScale.dPixelsPerDay;

        // Not an array of GanttValues?
        // Create an array from the single object
        if (!Array.isArray(GanttValuesArray)) {
            var NewArray = [];
            NewArray[0] = GanttValuesArray;
            GanttValuesArray = NewArray;
        }
        if (GanttValuesArray.length == 0)
            return;

        var nMaxTopPosition = 0;

        // Today vertical marker — drawn before bars so bars render on top
        var TodayDate = new Date();
        TodayDate.setHours(0, 0, 0, 0);
        var nTodayPixels = (TodayDate - GanttScaleStartDate) / (1000 * 60 * 60 * 24) * dPixelsPerDay;
        var GanttTodayDiv = $('<div>');
        GanttTodayDiv.addClass('gantttoday');
        GanttTodayDiv.css('position', 'relative');
        GanttTodayDiv.css('left', nTodayPixels + 'px');
        GanttTodayDiv.css('height', '100%');
        GanttTodayDiv.css('width', '0');
        GanttTodayDiv.attr('title', 'Today: ' + TodayDate.toLocaleDateString());
        // Small "Today" tag pinned to the top of the line — first row only to avoid clutter
        if (bFirstRow) {
            var GanttTodayLabel = $('<span>');
            GanttTodayLabel.addClass('gantttoday-label');
            GanttTodayLabel.text('Today');
            GanttTodayDiv.append(GanttTodayLabel);
        }
        GanttCellDiv.append(GanttTodayDiv);

        // Array of gantt value objects to build elements from
        for (const GanttValues of GanttValuesArray) {

            var sRecordId = GanttValues.GUID;
            var sTaskName = GanttValues.Name;
            var sParentName = GanttValues.ParentName;
            var StartDate = new Date(GanttValues.Start);
            var FinishDate = new Date(GanttValues.Finish);
            var bSummary = (GanttValues.Summary == 'true');
            var bMilestone = (GanttValues.Milestone == 'true');
            var nLane = Number(GanttValues.Lane);
            var sHtmlColor = GanttValues.HtmlColor;
            var bCriticalPath = (GanttValues.IsCriticalPath === 'true');
            var dTotalFloat = parseFloat(GanttValues.TotalFloat) || 0;
            var sSuccessors = GanttValues.Successors;
            var sToolTip = GanttValues.ToolTip;
            var sRecordTableName = GanttValues.DbTable;
            var sDataType = GanttValues.DataType;
            var sFullName = sTaskName;
            if (sParentName)
                sFullName = sParentName + "\r\n" + sTaskName;
            if (sToolTip) {
                sToolTip = sToolTip.replaceAll("[CRLF]", "\r\n");
                sToolTip = sToolTip.replaceAll("[CR]", "\r");
                sToolTip = sToolTip.replaceAll("[LF]", "\n");
            }

            // Good values?
            if (GanttScaleStartDate.getFullYear() < 1970)
                return;
            if (!isValidDate(StartDate))
                return;
            if (!isValidDate(FinishDate))
                return;
            if (StartDate.getFullYear() < 1970)
                return;
            if (FinishDate.getFullYear() < 1970)
                return;

            // Gantt bar positions
            var ScaleDiffTimeToStartDate = StartDate - GanttScaleStartDate;
            var ScaleDiffTimeToFinishDate = FinishDate - GanttScaleStartDate;
            var ScaleDiffDaysToStartDate = Math.ceil(ScaleDiffTimeToStartDate / (1000 * 60 * 60 * 24));
            var ScaleDiffDaysToFinishDate = Math.ceil(ScaleDiffTimeToFinishDate / (1000 * 60 * 60 * 24));
            var ScalePixelsToStartDate = ScaleDiffDaysToStartDate * dPixelsPerDay;
            var ScalePixelsToFinishDate = ScaleDiffDaysToFinishDate * dPixelsPerDay;
            var ScalePixelsBarWidth = Math.abs(ScalePixelsToFinishDate - ScalePixelsToStartDate);
            ScalePixelsBarWidth = Math.max(ScalePixelsBarWidth, 6);
            var nBarWidth = ScalePixelsBarWidth;
            var nTopPosition = nLane * 24;
            var nLeftPosition = ScalePixelsToStartDate;
            var nRightPosition = nLeftPosition + nBarWidth;


            if (!bMilestone) {
                if (!sToolTip)
                    sToolTip = sFullName + "\r\n" + StartDate.toLocaleDateString() + " - " + FinishDate.toLocaleDateString();

                // Create a new div for gantt bar
                var GanttBarDiv = $("<div>");
                GanttBarDiv.addClass(bSummary ? 'ganttsummarybar' : 'ganttbar');
                if (bSummary && sDataType && sDataType.indexOf('SubprojectSet') >= 0)
                    GanttBarDiv.addClass('ganttsummarybar-sub');
                GanttBarDiv.addClass('ganttitem');
                GanttBarDiv.css("position", "relative");
                GanttBarDiv.css("top", nTopPosition + "px");
                GanttBarDiv.css("left", ScalePixelsToStartDate + "px");
                GanttBarDiv.css("width", ScalePixelsBarWidth + "px");
                GanttBarDiv.attr("data-start-days", ScaleDiffDaysToStartDate);
                GanttBarDiv.attr("data-duration-days", ScaleDiffDaysToFinishDate - ScaleDiffDaysToStartDate);
                GanttBarDiv.attr("title", sToolTip);
                GanttBarDiv.attr("data-guid", sRecordId);
                GanttBarDiv.attr("data-name", sTaskName);
                GanttBarDiv.attr("data-table", sRecordTableName);
                GanttBarDiv.attr("data-datatype", sDataType);
                GanttBarDiv.attr("oncontextmenu", "OnGanttBarContextMenu(event, this)");
                if (sSuccessors)
                    GanttBarDiv.attr("data-successors", sSuccessors);
                if (GanttValues.IsCriticalPath !== undefined) {
                    GanttBarDiv.addClass(bCriticalPath ? 'ganttbar-critical' : 'ganttbar-noncritical');
                } else if (sHtmlColor) {
                    GanttBarDiv.css("background-color", sHtmlColor);
                }
                GanttBarDiv.draggable({
                    axis: "x",
                    delay: 250,
                    distance: 4,
                    cancel: ".gantt-bar-resize-handle",  // prevent bar-move drag when user grabs the resize handle
                    stop: function (event, ui) { OnGanttBarDropped(event, ui, GanttValues, GanttScale); },
                    drag: function (event, ui) { OnGanttBarDragging(event, ui, GanttValues, GanttScale); },
                });

                // Right-edge resize handle — drag to change finish date.
                // left is set explicitly rather than relying on CSS right:0, because inline left
                // (set by jQuery UI after any drag) takes precedence over a class-level right rule.
                var GanttBarResizeHandle = $("<div>");
                GanttBarResizeHandle.addClass("gantt-bar-resize-handle");
                GanttBarResizeHandle.css('left', (ScalePixelsBarWidth - 6) + 'px');
                GanttBarResizeHandle.draggable({
                    axis: "x",
                    delay: 100,     // shorter than bar-move (250ms) — handle is only 6px wide
                    distance: 2,    // shorter than bar-move (4px) — same reason
                    drag: function (event, ui) { OnGanttBarResizeDragging(event, ui, GanttValues, GanttScale); },
                    stop: function (event, ui) { OnGanttBarResizeDropped(event, ui, GanttValues, GanttScale); },
                });
                GanttBarDiv.append(GanttBarResizeHandle);

                GanttCellDiv.append(GanttBarDiv);

                // Percent-complete fill: a thin strip anchored to the bottom of the bar.
                // Appended as a child of GanttBarDiv so width:% is relative to the bar width.
                // GanttBarDiv already has position:relative, making the absolute child work correctly.
                // Milestones are excluded (this block is inside the !bMilestone branch).
                var nPercent = parseInt(GanttValues.Percent) || 0;
                if (nPercent > 0) {
                    var GanttPercentBarDiv = $('<div>');
                    GanttPercentBarDiv.addClass('ganttpercentbar');
                    GanttPercentBarDiv.css('width', Math.min(nPercent, 100) + '%');
                    GanttBarDiv.append(GanttPercentBarDiv);
                }

                // Why is this necessary?
                // Why doesn't the GanttCellDiv automatically resize to fit content?
                // Is it because of css position:relative?
                // Does relative positioning prevent automatic height resize?
                var nMaxTopPosition = Math.max(nMaxTopPosition, nTopPosition);
                GanttCellDiv.css("height", (nMaxTopPosition + 24) + "px");

                // Group all related Gantt bar items by a single id
                // Used by draggable helper function to return a single element with all the grouped followers
                // Every element with this id should be dragged as a single unit
                var GanttBarGroupId = GetUniqueId("GanttBar-");
                GanttBarDiv.attr("data-group-id", GanttBarGroupId);

                if (!bSummary && !bMilestone && dTotalFloat > 0 && bGanttShowFloat) {
                    var nFloatWidthPx = dTotalFloat * dPixelsPerDay;
                    var nFloatLeft = ScalePixelsToStartDate + ScalePixelsBarWidth;
                    var sFloatLabel = dTotalFloat >= 7
                        ? (dTotalFloat / 7).toFixed(1) + ' wk float'
                        : dTotalFloat.toFixed(1) + ' d float';
                    var GanttFloatBarDiv = $('<div>');
                    GanttFloatBarDiv.addClass('ganttfloatbar ganttitem');
                    GanttFloatBarDiv.css('position', 'relative');
                    GanttFloatBarDiv.css('top', nTopPosition + 'px');
                    GanttFloatBarDiv.css('left', nFloatLeft + 'px');
                    GanttFloatBarDiv.css('width', nFloatWidthPx + 'px');
                    GanttFloatBarDiv.attr('data-start-days', ScaleDiffDaysToFinishDate);
                    GanttFloatBarDiv.attr('data-duration-days', dTotalFloat);
                    GanttFloatBarDiv.attr('title', sToolTip);
                    GanttFloatBarDiv.attr('data-group-id', GanttBarGroupId);
                    var GanttFloatLabelSpan = $('<span>');
                    GanttFloatLabelSpan.addClass('ganttfloatbar-label');
                    GanttFloatLabelSpan.text(sFloatLabel);
                    GanttFloatBarDiv.append(GanttFloatLabelSpan);
                    GanttCellDiv.append(GanttFloatBarDiv);
                }

                // Add triangles and milestones to summary bar
                if (bSummary) {
                    BuildGanttSummaryBarTriangleDiv(GanttCellDiv, nLeftPosition, sToolTip, GanttBarGroupId, ScaleDiffDaysToStartDate);
                    BuildGanttSummaryBarTriangleDiv(GanttCellDiv, nRightPosition, sToolTip, GanttBarGroupId, ScaleDiffDaysToFinishDate);
                    BuildSummaryMilestones(GanttCellDiv, GanttValues, GanttScale, nTopPosition, GanttBarDiv);
                }

                // Baseline ghost bar — rendered below the live bar only when dates have changed since snapshot
                if (GanttValues.BaselineStart && GanttValues.BaselineFinish) {
                    var BaselineStartDate  = new Date(GanttValues.BaselineStart);
                    var BaselineFinishDate = new Date(GanttValues.BaselineFinish);
                    var bBaselineDiffers = BaselineStartDate.toDateString() !== StartDate.toDateString()
                                       || BaselineFinishDate.toDateString() !== FinishDate.toDateString();
                    if (bBaselineDiffers) {
                        var nBaselineDaysToStart  = Math.ceil((BaselineStartDate  - GanttScaleStartDate) / 86400000);
                        var nBaselineDaysToFinish = Math.ceil((BaselineFinishDate - GanttScaleStartDate) / 86400000);
                        var nBaselineLeft  = nBaselineDaysToStart  * dPixelsPerDay;
                        var nBaselineWidth = Math.max((nBaselineDaysToFinish - nBaselineDaysToStart) * dPixelsPerDay, 6);
                        var nBaselineTop   = bSummary ? nTopPosition + 20 : nTopPosition + 16;
                        var GanttBaselineDiv = $("<div>");
                        GanttBaselineDiv.addClass("ganttbar-baseline ganttitem");
                        GanttBaselineDiv.css({ position: "relative", top: nBaselineTop + "px",
                                              left: nBaselineLeft + "px", width: nBaselineWidth + "px" });
                        GanttBaselineDiv.attr("data-start-days", nBaselineDaysToStart);
                        GanttBaselineDiv.attr("data-duration-days", nBaselineDaysToFinish - nBaselineDaysToStart);
                        GanttBaselineDiv.attr("title", "Baseline: " + BaselineStartDate.toLocaleDateString()
                                                     + " - " + BaselineFinishDate.toLocaleDateString());
                        GanttCellDiv.append(GanttBaselineDiv);
                    }
                }
            }
            else {
                // Milestone diamond
                if (!sToolTip)
                    sToolTip = "Milestone: " + sTaskName + ": " + StartDate.toLocaleDateString();
                BuildGanttMilestoneDiv(GanttCellDiv, GanttValues, GanttScale, nTopPosition, nLeftPosition, sToolTip);

                // Baseline ghost milestone diamond — outline only, no fill; shown only when date has moved
                if (GanttValues.BaselineStart) {
                    var BaselineMilestoneDate = new Date(GanttValues.BaselineStart);
                    if (BaselineMilestoneDate.toDateString() !== StartDate.toDateString()) {
                        var nBaselineMilestoneDays = Math.ceil((BaselineMilestoneDate - GanttScaleStartDate) / 86400000);
                        var nBaselineMilestoneLeft = nBaselineMilestoneDays * dPixelsPerDay - 7;
                        var GanttBaselineMilestoneDiv = $("<div>");
                        GanttBaselineMilestoneDiv.addClass("ganttbarmilestone-baseline ganttitem");
                        GanttBaselineMilestoneDiv.css({ position: "relative", top: nTopPosition + "px",
                                                        left: nBaselineMilestoneLeft + "px" });
                        GanttBaselineMilestoneDiv.attr("data-start-days", nBaselineMilestoneDays);
                        GanttBaselineMilestoneDiv.attr("data-px-offset", -7);
                        GanttBaselineMilestoneDiv.attr("title", "Baseline milestone: " + BaselineMilestoneDate.toLocaleDateString());
                        GanttCellDiv.append(GanttBaselineMilestoneDiv);
                    }
                }
            }
        }


        //console.log(ScalePixelsToStartDate + ", " + ScalePixelsToFinishDate);

    } catch (e) {
        ToastError("BuildGanttElementsByCell", e.name + ": " + e.message + "\n" + e.stack);
        console.log("BuildGanttElementsByCell: " + e.name + ": " + e.message + ", line: " + e.stack);
    }
}


function BuildGanttSummaryBarTriangleDiv(GanttCellDiv, nLeftPosition, ToolTip, GroupId, nStartDays) {
    try {
        var GanttDiv = $("<div>");
        GanttDiv.addClass('ganttsummarytriangle');
        GanttDiv.addClass('ganttitem');
        GanttDiv.css("position", "relative");
        GanttDiv.css("left", (nLeftPosition-7) + "px");
        GanttDiv.css("width", "0px");
        GanttDiv.attr("title", ToolTip);
        GanttDiv.attr("data-group-id", GroupId);
        if (nStartDays !== undefined) {
            GanttDiv.attr("data-start-days", nStartDays);
            GanttDiv.attr("data-px-offset", -7);
        }
        GanttCellDiv.append(GanttDiv);
    } catch (e) {
        ToastError("BuildGanttSummaryBarTriangleDiv", e.name + ": " + e.message + "\n" + e.stack);
    }
}


// Overlay small white-background, black-border diamond markers on a summary bar row —
// one per child milestone in GanttValues.MilestonesJson, positioned at each milestone's date.
// Diamonds are vertically centered on the summary bar regardless of bar height.
function BuildSummaryMilestones(GanttCellDiv, GanttValues, GanttScale, nTopPosition, GanttBarDiv) {
    try {
        // MilestonesJson is a JSON-encoded string escaped by Globals.ToJson — parse it to get the array.
        var sMilestonesJson = GanttValues.MilestonesJson;
        if (!sMilestonesJson)
            return;
        var Milestones = JSON.parse(sMilestonesJson);
        if (!Array.isArray(Milestones) || Milestones.length === 0)
            return;

        var GanttScaleStartDate = new Date(GanttScale.Start);
        var dPixelsPerDay = GanttScale.dPixelsPerDay;

        // ganttsummarybar uses translateY(50%), so its visual top is nTopPosition + barH/2
        // and its visual center is nTopPosition + barH/2 + barH/2 = nTopPosition + barH.
        var nBarHeight = GanttBarDiv.outerHeight() || 12;
        var nBarCenterY = nTopPosition + nBarHeight;

        // Diamond height is measured from the first appended element so the value
        // automatically tracks whatever CSS sets for .gantt-summary-milestone.
        var nDiamondHeight = -1;

        for (var i = 0; i < Milestones.length; i++) {
            var Milestone = Milestones[i];
            if (!Milestone || !Milestone.Start)
                continue;

            var MilestoneDate = new Date(Milestone.Start);
            if (!isValidDate(MilestoneDate))
                continue;

            var sMilestoneName = Milestone.Name || '';
            var sToolTip = sMilestoneName + ': ' + MilestoneDate.toLocaleDateString();

            var ScaleDiffTime = MilestoneDate - GanttScaleStartDate;
            var ScaleDiffDays = Math.ceil(ScaleDiffTime / (1000 * 60 * 60 * 24));
            var nLeftPx = ScaleDiffDays * dPixelsPerDay;

            var GanttDiv = $('<div>');
            GanttDiv.addClass('gantt-summary-milestone');
            GanttDiv.addClass('ganttitem');
            GanttDiv.css('position', 'relative');
            GanttCellDiv.append(GanttDiv);

            // Read diamond height once from the first appended element.
            if (nDiamondHeight < 0)
                nDiamondHeight = GanttDiv.outerHeight() || 8;

            GanttDiv.css('top', (nBarCenterY - nDiamondHeight / 2) + 'px');
            GanttDiv.css('left', (nLeftPx - nDiamondHeight / 2) + 'px');
            GanttDiv.attr('data-start-days', ScaleDiffDays);
            GanttDiv.attr('data-px-offset', -(nDiamondHeight / 2));
            GanttDiv.attr('title', sToolTip);
            GanttDiv.attr('data-name', sMilestoneName);
        }
    } catch (e) {
        ToastError('BuildSummaryMilestones', e.name + ': ' + e.message + '\n' + e.stack);
    }
}


function BuildGanttMilestoneDiv(GanttCellDiv, GanttValues, GanttScale, nTopPosition, nLeftPosition, ToolTip) {
    try {
        var sRecordId = GanttValues.GUID;
        var sTaskName = GanttValues.Name;
        var sParentName = GanttValues.ParentName;
        var sRecordTableName = GanttValues.DbTable;
        var sDataType = GanttValues.DataType;
        var sSuccessors = GanttValues.Successors;

        var GanttDiv = $("<div>");
        GanttDiv.addClass('ganttbarmilestone');
        GanttDiv.addClass('ganttitem');
        GanttDiv.css("position", "relative");
        GanttDiv.css("top", nTopPosition + "px");
        GanttDiv.css("left", (nLeftPosition - 7) + "px");
        GanttDiv.attr("data-start-days", GanttScale.dPixelsPerDay > 0 ? nLeftPosition / GanttScale.dPixelsPerDay : 0);
        GanttDiv.attr("data-px-offset", -7);
        GanttDiv.attr("title", ToolTip);
        GanttDiv.attr("data-guid", sRecordId);
        GanttDiv.attr("data-name", sTaskName);
        GanttDiv.attr("data-table", sRecordTableName);
        GanttDiv.attr("data-datatype", sDataType);
        GanttDiv.attr("oncontextmenu", "OnGanttBarContextMenu(event, this)");
        if (sSuccessors)
            GanttDiv.attr("data-successors", sSuccessors);

        // Milestone diamonds are draggable like regular bars, but the date label is handled differently in OnGanttBarDragging — see the ganttbarmilestone branch there.
        GanttDiv.draggable({
            axis: "x",
            delay: 250,
            distance: 4,
            stop: function (event, ui) { OnGanttBarDropped(event, ui, GanttValues, GanttScale); },
            drag: function (event, ui) { OnGanttBarDragging(event, ui, GanttValues, GanttScale); },
        });
        GanttCellDiv.append(GanttDiv);
    } catch (e) {
        ToastError("BuildGanttMilestoneDiv", e.name + ": " + e.message + "\n" + e.stack);
    }
}


/**
 * Lines between successors and predecessors
 * @param {any} GridObject
 */
function BuildGanttLinkLines(GridObject) {

    // Find elements with data-successors
    try {
        DeleteAllGanttLinkLines(GridObject)

        var Predecessors = GridObject.find('[data-successors]');
        if (Predecessors == null || Predecessors.length == 0)
            return;

        // TODO:
        //  1. Postion SVG over Gantt column using the first and last gantt cells
        //  2. Search for SVG and delete it if it exists
        //  4. Rebuild SVG when the window is resized

        var GridObjectId = GridObject.attr('id');
        var SvgObjectId = GridObjectId.replace('StGrid', 'GanttLinkLinesSvg');
        var SvgMarkerStartId = GridObjectId.replace('StGrid', 'GanttLinkLinesMarkerStart');
        var SvgMarkerEndId = GridObjectId.replace('StGrid', 'GanttLinkLinesMarkerEnd');
        var GridObjectOffset = GridObject.offset();

        var GanttCellDivs = GridObject.find('.ganttcelldiv');
        if (!GanttCellDivs || GanttCellDivs.length < 2)
            return;
        var FirstGanttCellDiv = $(GanttCellDivs[0]);
        var LastGanttCellDiv = $(GanttCellDivs[GanttCellDivs.length - 1]);
        if (!FirstGanttCellDiv || FirstGanttCellDiv.length == 0)
            return;
        if (!LastGanttCellDiv || LastGanttCellDiv.length == 0)
            return;
        //FirstGanttCellDiv.css("background-color", "red");
        var FirstGanttCellDivOffset = FirstGanttCellDiv.offset();
        var FirstGanttCellDivTop = FirstGanttCellDivOffset.top - GridObjectOffset.top;
        var FirstGanttCellDivLeft = FirstGanttCellDivOffset.left - GridObjectOffset.left;
        var nSvgLeft = FirstGanttCellDivOffset.left - GridObjectOffset.left;
        var SvgTop = 0;
        var SvgWidth = FirstGanttCellDiv.width();
        var SvgHeight = (LastGanttCellDiv.offset().top + LastGanttCellDiv.height()) - SvgTop;

        // This strange function is necessary when creating any SVG tags, including a path
        var SvgElement = makeSVG('svg', { id: SvgObjectId, cx: 100, cy: 50, r: 40, stroke: 'black', 'stroke-width': 2, fill: 'red' });
        var SvgObject = $(SvgElement);
        SvgObject.attr("id", SvgObjectId);
        SvgObject.css("position", "absolute");
        SvgObject.css("top", "0px");
        SvgObject.css("left", nSvgLeft);
        SvgObject.css("width", SvgWidth);
        SvgObject.css("height", SvgHeight);
        SvgObject.css("min-height", "100%");
        SvgObject.css("pointer-events", "none");
        // Record the PPD at build time so the zoom-drag fast path can compute scaleX(newPPD/buildPPD).
        var SvgBuildScale = FindGanttScaleVars(GridObject);
        if (SvgBuildScale) SvgObject.attr('data-build-ppd', SvgBuildScale.dPixelsPerDay);
        GridObject.append(SvgObject);

        var sCriticalArrow    = getComputedStyle(document.documentElement).getPropertyValue('--st-gantt-critical-arrow').trim()    || '#ef4444';
        var sNoncriticalArrow = getComputedStyle(document.documentElement).getPropertyValue('--st-gantt-noncritical-arrow').trim() || '#3b82f6';

        var SvgDefsElement = makeSVG('defs', {});

        var SvgMarkerStartNcElement = makeSVG('marker', { class: 'marker-start-nc', id: SvgMarkerStartId, markerWidth: 4, markerHeight: 4, refX: 2, refY: 2, orient: 'auto', 'stroke-width': '0' });
        var SvgMarkerStartNcPath    = makeSVG('circle', { cx: '2', cy: '2', r: '1', fill: sNoncriticalArrow, class: 'fade-in' });
        var SvgMarkerEndNcElement   = makeSVG('marker', { class: 'marker-end-nc', id: SvgMarkerEndId, markerWidth: 4, markerHeight: 4, refX: 1, refY: 1, orient: 'auto', 'stroke-width': '0' });
        var SvgMarkerEndNcPath      = makeSVG('path',   { d: 'M 0 0 L 2 1 L 0 2 z', fill: sNoncriticalArrow, class: 'fade-in' });

        var SvgMarkerCriticalStartId = GetUniqueId('marker-start-crit-');
        var SvgMarkerCriticalEndId   = GetUniqueId('marker-end-crit-');
        var SvgMarkerStartCrElement  = makeSVG('marker', { class: 'marker-start-cr', id: SvgMarkerCriticalStartId, markerWidth: 4, markerHeight: 4, refX: 2, refY: 2, orient: 'auto', 'stroke-width': '0' });
        var SvgMarkerStartCrPath     = makeSVG('circle', { cx: '2', cy: '2', r: '1', fill: sCriticalArrow, class: 'fade-in' });
        var SvgMarkerEndCrElement    = makeSVG('marker', { class: 'marker-end-cr', id: SvgMarkerCriticalEndId, markerWidth: 4, markerHeight: 4, refX: 1, refY: 1, orient: 'auto', 'stroke-width': '0' });
        var SvgMarkerEndCrPath       = makeSVG('path',   { d: 'M 0 0 L 2 1 L 0 2 z', fill: sCriticalArrow, class: 'fade-in' });

        SvgMarkerStartNcElement.appendChild(SvgMarkerStartNcPath);
        SvgMarkerEndNcElement.appendChild(SvgMarkerEndNcPath);
        SvgMarkerStartCrElement.appendChild(SvgMarkerStartCrPath);
        SvgMarkerEndCrElement.appendChild(SvgMarkerEndCrPath);
        SvgDefsElement.appendChild(SvgMarkerStartNcElement);
        SvgDefsElement.appendChild(SvgMarkerEndNcElement);
        SvgDefsElement.appendChild(SvgMarkerStartCrElement);
        SvgDefsElement.appendChild(SvgMarkerEndCrElement);
        SvgObject[0].appendChild(SvgDefsElement);


        // Build all the SVG path link lines
        Predecessors.each(function () {
            var PredecessorObject = $(this);
            var sSuccessors = PredecessorObject.attr("data-successors");
            if (sSuccessors && sSuccessors.length > 0) {
                var SuccessorArray = sSuccessors.split(',');
                SuccessorArray.forEach(function (sSuccessor) {
                    if (sSuccessor) {
                        sSuccessor = sSuccessor.trim();
                        var SuccessorPartsArray = sSuccessor.split('[');
                        var sSuccessorId = "";
                        var sSuccessorType = "";
                        if (SuccessorPartsArray && SuccessorPartsArray.length > 0)
                            sSuccessorId = SuccessorPartsArray[0];
                        if (SuccessorPartsArray && SuccessorPartsArray.length > 1)
                            sSuccessorType = SuccessorPartsArray[1];
                        sSuccessorType = sSuccessorType.replace("]", "");

                        // Find successor and draw link line to it
                        if (sSuccessorId) {
                            var SuccessorObject = GridObject.find('[data-guid="' + sSuccessorId + '"].ganttitem');
                            if (SuccessorObject && SuccessorObject.length > 0) {

                                // Build a SVG curved link line between the objects
                                BuildGanttLinkLine(GridObject, SvgObject, PredecessorObject, SuccessorObject, sSuccessorType,
                                    SvgMarkerStartId, SvgMarkerEndId, SvgMarkerCriticalStartId, SvgMarkerCriticalEndId);
                            }
                        }
                    }
                });
            }
        });

    } catch (e) {
        console.log("BuildGanttLinkLines: " + e.name + ": " + e.message + ", line: " + e.stack);
    }
}

/**
 * Build an SVG bezier curve from the predecessor bar to the successor bar
 * @param {any} PredecessorObject
 * @param {any} SuccessorObject
 */
function BuildGanttLinkLine(GridObject, SvgObject, PredecessorObject, SuccessorObject, sSuccessorType,
    SvgMarkerStartId, SvgMarkerEndId, SvgMarkerCriticalStartId, SvgMarkerCriticalEndId) {

    try {
        if (!PredecessorObject || PredecessorObject.length == 0)
            return;
        if (!SuccessorObject || SuccessorObject.length == 0)
            return;
        if (sSuccessorType == null)
            sSuccessorType = "";

        var bPredecessorIsMilestone = PredecessorObject.hasClass("ganttbarmilestone");
        var bSuccessorIsMilestone = SuccessorObject.hasClass("ganttbarmilestone");
        var nLeftPredecessorOffset = PredecessorObject.offset().left - PredecessorObject.parent().offset().left;
        var nLeftSuccessorOffset = SuccessorObject.offset().left - SuccessorObject.parent().offset().left;

        var SvgPosition = SvgObject.position();
        var SvgOffset = SvgObject.offset();
        var SvgLeft = SvgObject.css("left");
        var PredecessorPosition = PredecessorObject.position();
        var PredecessorOffset = PredecessorObject.offset();
        var SuccessorOffset = SuccessorObject.offset();
        var PredecessorOffsetTop = (PredecessorOffset.top - SvgOffset.top);
        var SuccessorOffsetTop = (SuccessorOffset.top - SvgOffset.top);
        var PredecessorOffsetLeft = (PredecessorOffset.left - SvgOffset.left);
        var SuccessorOffsetLeft = (SuccessorOffset.left - SvgOffset.left);

        var FromX = nLeftPredecessorOffset + PredecessorObject.width();
        var ToX = nLeftSuccessorOffset;

        var FromY = PredecessorOffsetTop + PredecessorObject.height() / 2;
        var ToY = SuccessorOffsetTop + SuccessorObject.height() / 2;

        // Special types of link relationships: Start-To-Start (SS), Start-To-Finish (SF), Finish-To-Finish (FF)
        if (sSuccessorType.startsWith("SS")) {
            FromX = nLeftPredecessorOffset;
        } else if (sSuccessorType.startsWith("SF")) {
            FromX = nLeftPredecessorOffset;
            ToX = nLeftSuccessorOffset + SuccessorObject.width();
        } else if (sSuccessorType.startsWith("FF")) {
            ToX = nLeftSuccessorOffset + SuccessorObject.width();
        }

        FromY = FromY.toFixed(0);
        ToY = ToY.toFixed(0);

        if (bPredecessorIsMilestone) {
            FromX = Number(FromX) + 5;
            FromY = Number(FromY) + 3;
        }
        if (bSuccessorIsMilestone) {
            ToX = Number(ToX) + 1;
            ToY = Number(ToY) + 3;
        }

        // Create svg path
        var sSvgPath = "M " + FromX.toString() + " " + FromY.toString() + " C " + (FromX + 50).toString() + " " + FromY.toString() + ", " + (ToX - 50).toString() + " " + ToY.toString() + ", " + ToX.toString() + " " + ToY.toString();

        // This strange function is necessary when creating any SVG tags, including a path
        var SvgPathElement = makeSVG('path', { });
        var SvgPathObject = $(SvgPathElement);
        SvgPathObject.attr("d", sSvgPath);
        SvgPathObject.addClass("ganttlinkline");

        var bCriticalLink = PredecessorObject.hasClass('ganttbar-critical')
                         && SuccessorObject.hasClass('ganttbar-critical');
        if (bCriticalLink && SvgMarkerCriticalStartId) {
            SvgPathObject.attr("marker-start", "url('#" + SvgMarkerCriticalStartId + "')");
            SvgPathObject.attr("marker-end",   "url('#" + SvgMarkerCriticalEndId   + "')");
            SvgPathObject.addClass('ganttlinkline-critical');
        } else {
            SvgPathObject.attr("marker-start", "url('#" + SvgMarkerStartId + "')");
            SvgPathObject.attr("marker-end",   "url('#" + SvgMarkerEndId   + "')");
            SvgPathObject.addClass('ganttlinkline-noncritical');
        }

        SvgPathObject.css("pointer-events", "none");
        SvgObject.append(SvgPathObject);

        // Unique ID shared by the visible path and hit path so bar-hover can find the arrow. // LINK-HOVER
        var sLinkId = GetUniqueId('gantt-link-');                                            // LINK-HOVER
        SvgPathObject.attr('data-link-id', sLinkId);                                        // LINK-HOVER

        // Transparent fat hit path carries the tooltip; pointer-events:stroke means only
        // the stroke area (not transparent fill) intercepts hover, so grid clicks pass through.
        var sPredName = GanttLinkLineGetBarName(PredecessorObject);
        var sSuccName = GanttLinkLineGetBarName(SuccessorObject);
        var sLinkType = sSuccessorType || "FS";
        var sTooltip  = sPredName + " → " + sSuccName + " (" + sLinkType + ")";
        var SvgHitElement = makeSVG('path', { d: sSvgPath, stroke: 'transparent', 'stroke-width': '12', fill: 'none', 'pointer-events': 'stroke' });
        var SvgTitleElement = makeSVG('title', {});
        SvgTitleElement.textContent = sTooltip;
        SvgHitElement.appendChild(SvgTitleElement);
        // Same ID as the visible path — attachBarHover queries hit paths by GUID then uses this
        // ID to locate the corresponding visible path element to add ganttlinkline-highlight.    // LINK-HOVER
        SvgHitElement.setAttribute('data-link-id',   sLinkId);                                         // LINK-HOVER
        // Store both endpoint GUIDs on the hit path so attachBarHover can find this arrow
        // when either the predecessor or the successor bar is hovered.                          // LINK-HOVER
        SvgHitElement.setAttribute('data-pred-guid', PredecessorObject.attr('data-guid') || '');       // LINK-HOVER
        SvgHitElement.setAttribute('data-succ-guid', SuccessorObject.attr('data-guid')   || '');       // LINK-HOVER
        SvgObject[0].appendChild(SvgHitElement);

        // Arrow hover → highlight predecessor bar, visible path, and successor bar.         // LINK-HOVER
        SvgHitElement.addEventListener('mouseenter', function () {                           // LINK-HOVER
            PredecessorObject.addClass('ganttbar-link-highlight');
            SvgPathObject.addClass('ganttlinkline-highlight');
            SuccessorObject.addClass('ganttbar-link-highlight');
        });                                                                                  // LINK-HOVER
        SvgHitElement.addEventListener('mouseleave', function () {                           // LINK-HOVER
            PredecessorObject.removeClass('ganttbar-link-highlight');
            SvgPathObject.removeClass('ganttlinkline-highlight');
            SuccessorObject.removeClass('ganttbar-link-highlight');
        });                                                                                  // LINK-HOVER

        // Bar hover → highlight all arrows connected to that bar and the bars at their other ends.
        // Namespaced events (.linkhover) let re-attachment on rebuild replace the previous handler
        // instead of stacking — each bar ends up with exactly one active handler regardless of
        // how many link lines pass through it. The handler queries the SVG live each time so it
        // finds every connected arrow, not just the one whose BuildGanttLinkLine call attached it.
        function attachBarHover(BarObject, sBarGuid) {                                       // LINK-HOVER
            // Replace any existing linkhover handlers rather than stacking a new pair on top.
            // A bar with N incoming/outgoing links gets attachBarHover called N times; .off()
            // first ensures exactly one mouseenter and one mouseleave are active at all times.
            BarObject.off('mouseenter.linkhover mouseleave.linkhover');

            BarObject.on('mouseenter.linkhover', function () {
                var Svg = GridObject.find('svg');
                // Query SVG hit paths whose pred or succ GUID matches this bar — finds every
                // arrow connected to it regardless of direction or link type.
                Svg.find('[data-pred-guid="' + sBarGuid + '"],[data-succ-guid="' + sBarGuid + '"]').each(function () {
                    // data-link-id ties each hit path to its visible sibling path element.
                    var sId = this.getAttribute('data-link-id');
                    Svg.find('[data-link-id="' + sId + '"].ganttlinkline').addClass('ganttlinkline-highlight');
                    // Highlight both endpoints of every connected arrow, including this bar itself.
                    GridObject.find('[data-guid="' + this.getAttribute('data-pred-guid') + '"].ganttitem').addClass('ganttbar-link-highlight');
                    GridObject.find('[data-guid="' + this.getAttribute('data-succ-guid') + '"].ganttitem').addClass('ganttbar-link-highlight');
                });
            });

            BarObject.on('mouseleave.linkhover', function () {
                // Clear all highlights at once — simpler than tracking per-link state.
                GridObject.find('.ganttbar-link-highlight').removeClass('ganttbar-link-highlight');
                GridObject.find('svg .ganttlinkline-highlight').removeClass('ganttlinkline-highlight');
            });
        }                                                                                    // LINK-HOVER
        var sPredGuid = PredecessorObject.attr('data-guid') || '';                          // LINK-HOVER
        var sSuccGuid = SuccessorObject.attr('data-guid') || '';                            // LINK-HOVER
        if (sPredGuid) attachBarHover(PredecessorObject, sPredGuid);                       // LINK-HOVER
        if (sSuccGuid) attachBarHover(SuccessorObject, sSuccGuid);                         // LINK-HOVER

        // Update the path length for animation
        // https://css-tricks.com/svg-line-animation-works/
        var nPathLength = SvgPathElement.getTotalLength();
        SvgPathObject.css("stroke-dasharray", nPathLength);
        SvgPathObject.css("stroke-dashoffset", nPathLength);


        var end = 0;
    } catch (e) {
        console.log("BuildGanttLinkLines: " + e.name + ": " + e.message + ", line: " + e.stack);
    }
}

// Returns the task name from a gantt bar element.
function GanttLinkLineGetBarName(BarObject) {
    return (BarObject.attr("data-name") || "").trim();
}


/**
 * This strange function is necessary when creating any SVG tags, including a path
 * Do not try to create them with jQuery. It will not work.
 * @param {any} tag
 * @param {any} attrs
 * @returns
 */
function makeSVG(tag, attrs) {
    var el = document.createElementNS('http://www.w3.org/2000/svg', tag);
    for (var k in attrs)
        el.setAttribute(k, attrs[k]);
    return el;
}


function DeleteAllGanttLinkLines(GridObject) {
    GridObject.find('svg').remove();
}




function OnGanttBarDropped(event, ui, GanttValues, GanttScale) {
    try {
        if (!ui)
            return;
        if (!ui.helper)
            return;
        if (!ui.position)
            return;
        if (!GanttValues)
            return;
        if (!GanttScale)
            return;
        if (!GanttScale.Start)
            return;

        // Bars may have been position-shifted by a nav scroll without being rebuilt,
        // leaving the closure's GanttScale.Start stale. Read the authoritative current
        // scale from the cell's data-gantt-scale attr (always updated before any render).
        var freshCellDiv = $(event.target).closest('.ganttcelldiv');
        if (freshCellDiv.length) {
            var freshScaleJson = freshCellDiv.attr('data-gantt-scale');
            if (freshScaleJson) GanttScale = JSON.parse(freshScaleJson);
        }

        // Clear the dragging date text. For milestones, this is a floating label outside
        // the DOM hierarchy; for regular bars it is inner HTML on the bar element itself.
        $('#GanttDragLabel').hide();
        ui.helper.html("");

        // Has the element been reverted to its original position?
        var revert = ui.helper.draggable("option", "revert");
        if (revert) {
            // Allow dragging the next time
            ui.helper.draggable("option", "revert", false);
            return;
        }

        // Gantt scale info
        var GanttScaleStartDate = new Date(GanttScale.Start);
        if (!GanttScaleStartDate)
            return;

        // Find the new date for the dragged object
        var NewStartDateString = "";
        var NewStartDate = GetGanttDateFromPosition(GanttScale, ui.position.left);
        if (NewStartDate)
            NewStartDateString = NewStartDate.toISOString();

        var ContentType = GetSelectedContentType();
        AjaxSetGanttRecordStartDate(ContentType, GanttValues.GUID, GanttValues.DbTable, NewStartDateString, null/*DragUIObject*/, null/*SuccessFunction*/);
    } catch (e) {
        ToastError("OnGanttBarDropped", e.name + ": " + e.message + "\n" + e.stack);
    }
}



function OnGanttBarResizeDragging(event, ui, GanttValues, GanttScale) {
    try {
        if (!ui || !ui.position || !GanttScale)
            return;

        // Read fresh scale from DOM in case nav scroll shifted bars since the closure was captured
        var freshCellDiv = $(event.target).closest('.ganttcelldiv');
        if (freshCellDiv.length) {
            var freshScaleJson = freshCellDiv.attr('data-gantt-scale');
            if (freshScaleJson) GanttScale = JSON.parse(freshScaleJson);
        }

        // ui.position.left is the handle's new left relative to the bar (its jQuery UI offset parent).
        // New bar width = handle left + handle width (6px); right edge in scale = bar left + new width.
        // Read bar left from live CSS rather than GanttValues — nav scroll or zoom may have shifted it.
        var nHandleWidth = 6;
        var BarDiv = $(event.target).parent();
        var newBarWidth = Math.max(ui.position.left + nHandleWidth, nHandleWidth);
        var newRightEdgePx = (parseFloat(BarDiv.css('left')) || 0) + newBarWidth;

        // Visually resize the bar while dragging
        BarDiv.css('width', newBarWidth + 'px');

        // Show floating finish-date label next to cursor
        var NewFinishDate = GetGanttDateFromPosition(GanttScale, newRightEdgePx);
        if (NewFinishDate) {
            var $label = $('#GanttDragLabel');
            if ($label.length === 0) {
                $label = $('<div id="GanttDragLabel">').css({
                    position: 'fixed', zIndex: 9999, pointerEvents: 'none',
                    fontSize: '8pt', fontWeight: '700', color: 'var(--st-text-body)', whiteSpace: 'nowrap'
                }).appendTo('body');
            }
            $label.text(NewFinishDate.toDateString()).css({
                left: (event.pageX + 12) + 'px',
                top: (BarDiv.offset().top - window.scrollY) + 'px'
            }).show();
        }
    } catch (e) {
        ToastError("OnGanttBarResizeDragging", e.name + ": " + e.message + "\n" + e.stack);
    }
}



function OnGanttBarResizeDropped(event, ui, GanttValues, GanttScale) {
    try {
        if (!ui || !ui.position || !GanttScale)
            return;

        // Bars may have been position-shifted by a nav scroll without being rebuilt,
        // leaving the closure's GanttScale.Start stale. Read the authoritative current
        // scale from the cell's data-gantt-scale attr (always updated before any render).
        var freshCellDiv = $(event.target).closest('.ganttcelldiv');
        if (freshCellDiv.length) {
            var freshScaleJson = freshCellDiv.attr('data-gantt-scale');
            if (freshScaleJson) GanttScale = JSON.parse(freshScaleJson);
        }

        $('#GanttDragLabel').hide();

        // Right edge in scale = bar left + handle's final left + handle width.
        // Read bar left from live CSS — nav scroll or zoom may have shifted it since the closure was built.
        var nHandleWidth = 6;
        var BarDiv = $(event.target).parent();
        var newRightEdgePx = (parseFloat(BarDiv.css('left')) || 0) + ui.position.left + nHandleWidth;

        var NewFinishDate = GetGanttDateFromPosition(GanttScale, newRightEdgePx);
        if (!NewFinishDate)
            return;

        var ContentType = GetSelectedContentType();
        AjaxSetGanttRecordFinishDate(ContentType, GanttValues.GUID, GanttValues.DbTable, NewFinishDate.toISOString(), null/*DragUIObject*/, null/*SuccessFunction*/);
    } catch (e) {
        ToastError("OnGanttBarResizeDropped", e.name + ": " + e.message + "\n" + e.stack);
    }
}





function OnGanttBarDragging(event, ui, GanttValues, GanttScale) {
    try {
        if (!ui)
            return;
        if (!ui.helper)
            return;
        if (!ui.position)
            return;
        if (!GanttValues)
            return;
        if (!GanttScale)
            return;
        if (!GanttScale.Start)
            return;

        // Bars may have been position-shifted by a nav scroll without being rebuilt,
        // leaving the closure's GanttScale.Start stale. Read the authoritative current
        // scale from the cell's data-gantt-scale attr (always updated before any render).
        var freshCellDiv2 = $(event.target).closest('.ganttcelldiv');
        if (freshCellDiv2.length) {
            var freshScaleJson2 = freshCellDiv2.attr('data-gantt-scale');
            if (freshScaleJson2) GanttScale = JSON.parse(freshScaleJson2);
        }

        // Has the element been reverted to its original position?
        var revert = ui.helper.draggable("option", "revert");
        if (revert) {
            // Allow dragging the next time
            ui.helper.draggable("option", "revert", false);
            return;
        }

        // Gantt scale info
        var GanttScaleStartDate = new Date(GanttScale.Start);
        if (!GanttScaleStartDate)
            return;

        // Find the new date for the dragged object
        var NewStartDateString = "";
        var NewStartDate = GetGanttDateFromPosition(GanttScale, ui.position.left);
        if (NewStartDate)
            NewStartDateString = NewStartDate.toDateString();

        // Display the date while dragging.
        // Milestones need special handling: the CSS class ganttbarmilestone applies transform: rotate(45deg) to render a diamond shape. Injecting date text via ui.helper.html() would render the text rotated and clipped inside the 12x12px diamond.
        // Instead, a fixed - position label is floated next to the cursor — it sits outside the ganttcelldiv's overflow:hidden boundary so it is never clipped, and it is pinned to the element's row (not the cursor's Y) so it doesn't bounce.
        if (ui.helper.hasClass('ganttbarmilestone')) {
            var $label = $('#GanttDragLabel');
            if ($label.length === 0) {
                $label = $('<div id="GanttDragLabel">').css({
                    position: 'fixed', zIndex: 9999, pointerEvents: 'none',
                    fontSize: '8pt', fontWeight: '700', color: 'var(--st-text-body)', whiteSpace: 'nowrap'
                }).appendTo('body');
            }
            $label.text(NewStartDateString).css({ left: (event.pageX + 12) + 'px', top: (ui.helper.offset().top - window.scrollY) + 'px' }).show();
        } else {
            ui.helper.html("<span style=\"width: 100%; height:100%; line-height: 0.9em; vertical-align: top; text-align: center; /*background-color: red;*/\">" + NewStartDateString + "</span>");
        }
    } catch (e) {
        ToastError("OnGanttBarDragging", e.name + ": " + e.message + "\n" + e.stack);
    }
}


function GanttBarDraggingSetStartDate(DragUIObject, ContentType, RecordId, DbTable, NewStartDateString) {

    if (!DragUIObject)
        return;
    if (!DragUIObject.helper)
        return;
    var DragObject = $(DragUIObject.helper);
    if (!DragObject || DragObject.length == 0)
        return;

    var GridTableObject = FindGridTableObject(DragObject);
    if (!GridTableObject || GridTableObject.length == 0)
        return;

// This doesn't work because rebuilding the entire grid deletes this gantt bar, and ends dragging
    //BuildGanttElementsOnTimer(GridTableObject, 5/*nMilliseconds*/);

    //ToastMessage("GanttBarDraggingSetStartDate", NewStartDateString);
}








var DraggingGanttScale = null;

// Call before any DOM swap that could destroy an in-progress Gantt drag.
// Reads the latest Start from localStorage (kept current by SaveGanttScaleToGridObject)
// and fires AjaxSetGanttStart so the server is not left with the pre-drag value.
function FlushGanttDragIfActive() {
    if (DraggingGanttScale == null)
        return;
    var ContentType = DraggingGanttScale._ContentType || GetSelectedContentType();
    var CurrentScale = LoadGanttScaleFromLocalStorage(ContentType);
    DraggingGanttScale = null;
    if (!CurrentScale || !CurrentScale.Start)
        return;
    AjaxSetGanttStart(CurrentScale.Start, ContentType, null, false/*bRefreshSelectedContent*/);
}

function OnGanttCellDragging(event, ui) {
    try {
        if (!ui)
            return;
        if (!ui.helper)
            return;
        if (!ui.position)
            return;

        var GridObject = FindGridObject(ui.helper);
        var GridTableObject = FindGridTableObject(ui.helper);
        var GanttScale = FindGanttScaleVars(GridObject);
        if (!GridTableObject || GridTableObject.length == 0)
            return;
        if (!GanttScale || !GanttScale.Start)
            return;
        // This popup gets in the way
        CloseGridEditPopupDialog();

        // Capture the scale when dragging first begins
        // All drag scale dates are based on this scale at the very beginning
        // Reset this to null when the element is dropped so it can begin again next time
        if (DraggingGanttScale == null) {
            // Read data-gantt-scale from the DOM directly for the baseline, bypassing localStorage.
            // localStorage may carry a stale scale from a previous session; the server-rendered
            // DOM value is always the authoritative starting point for a new drag.
            var DomScaleElement = GridObject.find('[data-gantt-scale]');
            var DomScale = (DomScaleElement && DomScaleElement.length > 0)
                ? JSON.parse($(DomScaleElement[0]).attr('data-gantt-scale'))
                : null;
            DraggingGanttScale = Object.assign({}, DomScale || GanttScale);
            DraggingGanttScale._ContentType = GetContentTypeFromGridObject(GridObject);
        }

        // Find the new date for the dragged object
        var NewStartDateString = "";
        var NewStartDate = GetGanttDateFromPosition(DraggingGanttScale, -ui.position.left);
        if (!NewStartDate)
            return;

        NewStartDateString = NewStartDate.toDateString();
        //ToastMessage("Date: " + NewStartDateString);
        GanttScale.Start = NewStartDate;
        SaveGanttScaleToGridObject(GridObject, GanttScale);
        //BuildGanttElementsByGrid(GridTableObject);
        BuildGanttElementsOnTimer(GridTableObject, 5/*nMilliseconds*/);

        // Reposition so this element remains locked to the others in Gantt column
        // Without this, the element is locked to the cursor, and not necessarily in sync with other cells in the same column
        ui.position.left = 0;
    } catch (e) {
        ToastError("OnGanttCellDragging", e.name + ": " + e.message + "\n" + e.stack);
    }
}






function OnGanttCellDropped(event, ui) {
    try {
        // Always reset so the next drag starts with a fresh reference scale
        DraggingGanttScale = null;

        if (!ui)
            return;
        if (!ui.helper)
            return;
        if (!ui.position)
            return;

        var GridObject = FindGridObject(ui.helper);
        var GridTableObject = FindGridTableObject(ui.helper);
        var GanttScale = FindGanttScaleVars(GridObject);
        if (!GridTableObject || GridTableObject.length == 0)
            return;
        if (!GanttScale || !GanttScale.Start)
            return;

        // Has the element been reverted to its original position?
        //var revert = ui.helper.draggable("option", "revert");
        //if (revert) {
        //    // Allow dragging the next time
        //    ui.helper.draggable("option", "revert", false);
        //    //RefreshSelectedContent();
        //    BuildGanttScaleFromAnyGridObject(ui.helper);
        //    return;
        //}

        // Persist final position to server and refresh date columns (Start, Finish, Due)
        var ContentType = GetContentTypeFromGridObject(GridObject);
        AjaxSetGanttStart(GanttScale.Start, ContentType, null, true/*bRefreshSelectedContent*/);
    } catch (e) {
        ToastError("OnGanttCellDropped", e.name + ": " + e.message + "\n" + e.stack);
    }
}






function OnGanttScaleCellIntervalsDropped(event, ui) {
    try {
        // Ensure fast-path flag is cleared so the subsequent rebuild is a full one.
        bScaleZoomActive = false;

        if (!ui)
            return;
        if (!ui.helper)
            return;
        if (!ui.position)
            return;

        // Has the element been reverted to its original position?
        ui.helper.draggable();
        var revert = ui.helper.draggable("option", "revert");
        if (revert) {
            // Allow dragging the next time
            ui.helper.draggable("option", "revert", false);
            //RefreshSelectedContent();
            BuildGanttScaleFromAnyGridObject(ui.helper);
            return;
        }

        var GridObject = FindGridObject(ui.helper);
        var GanttScale = FindGanttScaleVars(GridObject);
        if (!GanttScale)
            return;

        // Gantt scale info
        var GanttScaleStartDate = new Date(GanttScale.Start);
        if (!GanttScaleStartDate)
            return;

        // Display the date while dragging
        var GanttScaleDiv = FindGanttScaleDiv(ui.helper);
        if (GanttScaleDiv && GanttScaleDiv.length > 0) {
            var GridObject = FindGridObject(ui.helper);
            var GanttScale = FindGanttScaleVars(GridObject);
            if (GanttScale) {

                // Set the new pixels per day, then update the scale to see the results
                SetGanttScalePixelsPerDayFromUiPostion(GanttScaleDiv, GanttScale, ui);
                //BuildGanttScaleElements(GridObject, GanttScaleDiv, GanttScale);

                var ContentType = GetSelectedContentType();
                AjaxSetGanttPixelsPerDay(GanttScale.dPixelsPerDay, ContentType, true/*bRefreshContent*/);
                return;
            }
        }


        //ToastError("", "Dragging Gantt cells is not yet implemented!" + "\r\n" + ui.position.left + ", " + NewStartDateString);
    } catch (e) {
        ToastError("OnGanttScaleCellIntervalsDropped", e.name + ": " + e.message + "\n" + e.stack);
    }
}



function OnGanttScaleCellIntervalsDragging(event, ui) {
    try {
        if (!ui)
            return;
        if (!ui.helper)
            return;
        if (!ui.position)
            return;

        // Has the element been reverted to its original position?
        ui.helper.draggable();
        var revert = ui.helper.draggable("option", "revert");
        if (revert) {
            // Allow dragging the next time
            ui.helper.draggable("option", "revert", false);
            return;
        }

        var GridObject = FindGridObject(ui.helper);
        var GridTableObject = FindGridTableObject(ui.helper);
        var GanttScale = FindGanttScaleVars(GridObject);

        // Gantt scale info
        var GanttScaleStartDate = new Date(GanttScale.Start);
        if (!GanttScaleStartDate)
            return;

        // Display the date while dragging
        var GanttScaleDiv = FindGanttScaleDiv(ui.helper);
        if (GanttScaleDiv && GanttScaleDiv.length > 0) {
            var GanttScale = FindGanttScaleVars(GridObject);
            if (GanttScale) {

                // Compute the new scale and rebuild immediately on every event.
                // rAF throttling was removed: SetGanttScalePixelsPerDayFromUiPostion uses the
                // DOM cell width against the localStorage PPD — both must stay in sync, or the
                // formula compounds multiplicatively across events and PPD drifts in the wrong
                // direction. The bScaleZoomActive fast path makes each rebuild cheap enough
                // that per-event rebuilds are fine.
                SetGanttScalePixelsPerDayFromUiPostion(GanttScaleDiv, GanttScale, ui);
                SaveGanttScaleToGridObject(GridObject, GanttScale);
                bScaleZoomActive = true;
                try {
                    BuildGanttElementsByGrid(GridTableObject);
                } finally {
                    bScaleZoomActive = false;
                }
                return;
            }
        }
    } catch (e) {
        ToastError("OnGanttScaleCellIntervalsDragging", e.name + ": " + e.message + "\n" + e.stack);
    }
}




function GetGanttDateFromPosition(GanttScale, nLeftPosition) {

    if (!GanttScale)
        return null;
    if (!GanttScale.Start)
        return null;

    Date.prototype.addDays = function (days) {
        var date = new Date(this.valueOf());
        date.setDate(date.getDate() + days);
        return date;
    }

    var GanttScaleStartDate = new Date(GanttScale.Start);
    var dPixelsPerDay = GanttScale.dPixelsPerDay;

    var dDays = (nLeftPosition / dPixelsPerDay);
    var NewDate = GanttScaleStartDate.addDays(dDays)
    return NewDate;
}

function FindGanttScaleIntervalCellByNum(GanttScaleDiv, nIntervalNum) {

    if (!GanttScaleDiv || GanttScaleDiv.length == 0)
        GanttScaleDiv = FindGanttScaleDiv(AnyGridObject);
    if (!GanttScaleDiv || GanttScaleDiv.length == 0)
        return null;
    if (nIntervalNum < 0)
        return null;

    var GanttScaleIntervalCell = GanttScaleDiv.find("[gantt-scale-interval='" + nIntervalNum + "']");
    return GanttScaleIntervalCell;
}

function GetPixelsFromScaleCellInterval(GanttScaleDiv, nIntervalNum, nPostion) {

    var GanttScaleIntervalCell = FindGanttScaleIntervalCellByNum(GanttScaleDiv, nIntervalNum);
    if (!GanttScaleIntervalCell || GanttScaleIntervalCell.length == 0)
        return null;

    var nLastIntervalCellPos = GanttScaleIntervalCell.offset().left;
    var nPixelsFromLastIntervalCell = nPostion - nLastIntervalCellPos;
    return nPixelsFromLastIntervalCell;
}

function GetPixelsFromLastIntervalGridLine(ui) {

    if (!ui)
        return 0;
    if (!ui.helper)
        return 0;
}

function SetGanttScalePixelsPerDayFromUiPostion(GanttScaleDiv, GanttScale, ui) {

    // Step 1: Get offset at left side of this interval
    // Step 2: Get width of this interval
    // Step 3: Get offset at ui position
    // Step 4: Get percentage of position within this interval
    // Step 5: dNewPixelsPerDay = dOldPixelsPerDay * perc
    if (!ui)
        return;
    if (!ui.helper)
        return;

    // Every gantt scale cell has an interval number 1..n
    var nUiScaleCellIntervalNum = ui.helper.attr("gantt-scale-interval");
    if (!nUiScaleCellIntervalNum || nUiScaleCellIntervalNum == 0)
        return;

    // Step 1: Get offset at left side of this interval
    // Step 2: Get width of this interval
    var GanttScaleIntervalCell = FindGanttScaleIntervalCellByNum(GanttScaleDiv, nUiScaleCellIntervalNum);
    if (!GanttScaleIntervalCell || GanttScaleIntervalCell.length == 0)
        return;
    var nGanttScaleIntervalCellOffset = GanttScaleIntervalCell.offset().left;
    var nGanttScaleIntervalCellWidth = GanttScaleIntervalCell.width();
    if (nGanttScaleIntervalCellOffset <= 0 || nGanttScaleIntervalCellWidth <= 0)
        return;

    // Step 3: Get offset at ui position
    var nUiLeftOffset = ui.helper.offset().left;
    var nPixelsFromLastInterval = nUiLeftOffset - nGanttScaleIntervalCellOffset;
    if (nPixelsFromLastInterval == null || nPixelsFromLastInterval <= 4)
        nPixelsFromLastInterval = 4;
    //console.log("nPixelsFromLastInterval: " + nPixelsFromLastInterval);

    // Step 4: Get percentage of position within this interval
    var dPercentOfInterval = nPixelsFromLastInterval / nGanttScaleIntervalCellWidth;
    if (dPercentOfInterval < 0)
        return;
    //console.log("dPercentOfInterval: " + dPercentOfInterval);

    // Step 5: dNewPixelsPerDay = dOldPixelsPerDay * perc
    var dNewPixelsPerDay = GanttScale.dPixelsPerDay * dPercentOfInterval;
    if (dNewPixelsPerDay <= 0.05 || dNewPixelsPerDay > 1000)
        return;
    //console.log("dNewPixelsPerDay: " + dNewPixelsPerDay);

    // Final
    GanttScale.dPixelsPerDay = dNewPixelsPerDay;
}



function OnGanttBarDragHelper(event, GanttValues, GanttScale) {

    var ThisGanttBar = $(event.target);
    var NewGantBarDiv = $("<div></div>").draggable();
    NewGantBarDiv.prop("style", ThisGanttBar.attr("style"));
    NewGantBarDiv.addClass("ganttbardragging");
    return NewGantBarDiv;
}

//\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\//
//\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\//
//\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\////\\//



// Inject five hover-visible navigation buttons into the Gantt scale header <th>.
// Called at the end of BuildGanttScale so buttons are present whenever the scale is built.
//
// Left group (class gantt-nav-left, anchored left: 2px):
//   ◀  Scroll Left — move one interval unit to the left
//   ◀◀ Page Left   — move one visible-width page to the left
//   ●  Scroll to Selected Task — jump the scale to the earliest selected task
//
// Right group (class gantt-nav-right, anchored left: 100% so it pins to the <th>'s right edge):
//   ▶▶ Page Right   — move one visible-width page to the right
//   ▶  Scroll Right — move one interval unit to the right
//
// Hover visibility is handled entirely in CSS (th:has(.ganttscale):hover). The <th> must have
// position: relative so the absolutely-positioned groups measure correctly.
//
// NOTE: right: 2px cannot be used for the right group — when a dock slot is first rendered
// inside a hidden container, getBoundingClientRect().width returns 0 and right-anchored
// children compute to the wrong position. left: 100% + translateX(-100%) avoids that
// because it always resolves relative to the <th>'s natural width once visible.
function AddGanttNavButtons(GanttHeaderTh, GridObject) {
    try {
        // Remove groups left by the previous BuildGanttScale call before adding fresh ones.
        // BuildGanttScale is called on every zoom/scroll/drag rebuild, so this prevents
        // duplicate groups from accumulating in the DOM.
        GanttHeaderTh.find('.gantt-nav-left, .gantt-nav-right').remove();
        GanttHeaderTh.css('position', 'relative');

        // stopRepeat — cancel any in-progress auto-repeat scroll and rebuild grid lines.
        // Called on document mouseup (not button mouseleave) because BuildGanttElementsByGrid
        // destroys and recreates button elements on every tick; a button-level handler would
        // be removed along with the old button before the mouse could release.
        function stopRepeat() {
            clearTimeout(GanttNavHoldTimerID);
            clearInterval(GanttNavRepeatTimerID);
            GanttNavHoldTimerID = null;
            GanttNavRepeatTimerID = null;
            $(document).off('mouseup.ganttnavstop');
            // Restore grid lines now that scrolling has stopped. Uses the lightweight path
            // (grid lines only) so stopRepeat is fast regardless of row count.
            var GridTableObject = FindGridTableObject(GridObject);
            RebuildGanttGridLinesOnly(GridTableObject);
        }

        // makeGroup — build a <div> containing one button per entry in btns[].
        // Two interaction modes:
        //   action (truthy)  — plain click; used for the scroll-to-selected button which
        //                      does a one-shot server call and must not auto-repeat.
        //   dir + bPage      — mousedown with auto-repeat; fires immediately, then after
        //                      a 400 ms hold delay starts an 80 ms interval so the user
        //                      can hold the button down for continuous scrolling.
        function makeGroup(btns, className) {
            var GroupDiv = $('<div>').addClass(className);
            btns.forEach(function (b) {
                var Btn = $('<button type="button">').addClass('gantt-nav-btn').html(b.html).attr('title', b.title);
                if (b.extraClass)
                    Btn.addClass(b.extraClass);
                if (b.marginLeft)
                    Btn.css('margin-left', b.marginLeft);

                if (b.action) {
                    // One-shot action button — no auto-repeat.
                    Btn.on('click', function (e) {
                        e.stopPropagation();
                        b.action();
                    });
                } else {
                    // Scroll/page button — fire once immediately on mousedown, then repeat
                    // after a 400 ms hold. stopRepeat() on document mouseup cancels the interval.
                    Btn.on('mousedown', function (e) {
                        e.stopPropagation();
                        e.preventDefault();
                        stopRepeat();
                        OnGanttNavButton(GridObject, b.dir, b.bPage);
                        GanttNavHoldTimerID = setTimeout(function () {
                            GanttNavRepeatTimerID = setInterval(function () {
                                OnGanttNavButton(GridObject, b.dir, b.bPage);
                            }, 80);
                        }, 400);
                        $(document).on('mouseup.ganttnavstop', stopRepeat);
                    });
                }

                GroupDiv.append(Btn);
            });
            return GroupDiv;
        }

        // Left group: scroll-left, page-left, then (separated by a small margin) the
        // scroll-to-selected circle. Anchored at left: 2px inside the <th>.
        GanttHeaderTh.append(makeGroup([
            { html: '<span class="gantt-nav-glyph">&#9664;</span>',        title: 'Scroll Left',  dir: -1, bPage: false },
            { html: '<span class="gantt-nav-glyph">&#9664;&#9664;</span>', title: 'Page Left',    dir: -1, bPage: true  },
            {
                // CSS-drawn circle (not a Unicode glyph) so it centers perfectly in the button.
                // Reads HiddenSelected* inputs set by the grid selection handler and calls
                // AjaxSetGanttStart("EARLIEST", ...) — same path as OnGanttScrollEarliest.
                html: '<span class="gantt-nav-circle"></span>', title: 'Scroll to Selected Task', extraClass: 'gantt-nav-btn-wide',
                marginLeft: '4px',
                action: function () {
                    var ContentType = GetContentTypeFromGridObject(GridObject);
                    var SelectedRecordsetObject = {
                        SelectedDataType: $('input[id="HiddenSelectedDataTypeId"]').val(),
                        SelectedTableId:  $('input[id="HiddenSelectedTableId"]').val(),
                        SelectedRecordId: $('input[id="HiddenSelectedRecordId"]').val(),
                    };
                    AjaxSetGanttStart("EARLIEST", ContentType, SelectedRecordsetObject, true/*bRefreshSelectedContent*/);
                },
            },
        ], 'gantt-nav-left'));

        // Right group: page-right then scroll-right, anchored so the group's right edge
        // sits 2px inside the <th>'s right boundary. CSS uses left: 100% +
        // translateX(calc(-100% - 2px)) so the position resolves correctly even when the
        // pane width is 0 on first render (right: 2px would reference the wrong origin).
        GanttHeaderTh.append(makeGroup([
            { html: '<span class="gantt-nav-glyph">&#9654;&#9654;</span>', title: 'Page Right',   dir:  1, bPage: true  },
            { html: '<span class="gantt-nav-glyph">&#9654;</span>',        title: 'Scroll Right', dir:  1, bPage: false },
        ], 'gantt-nav-right'));
    } catch (e) {
        ToastError("AddGanttNavButtons", e.name + ": " + e.message + "\n" + e.stack);
    }
}





// Render a small read-only label ("Days", "Weeks", "Months", "Quarters", "Years") centered
// in the Gantt scale header <th> so the user always knows the current zoom interval.
//
// The label is non-interactive (pointer-events: none) and styled lighter than the nav
// buttons so it reads as informational. It is rebuilt on every BuildGanttScale call, which
// covers wheel zoom, nav-button scroll, and scale-drag — so it always reflects the live scale.
//
// To hide the label: comment out the AddGanttZoomLabel() call in BuildGanttScale.
function AddGanttZoomLabel(GanttHeaderTh, GridObject) {
    try {
        // Remove any label left by the previous rebuild before adding a fresh one.
        GanttHeaderTh.find('.gantt-zoom-label').remove();

        var GanttScale = FindGanttScaleVars(GridObject);
        if (!GanttScale)
            return;

        // CellScale values match the server-side enum: DAYS=0, WEEKS=1, MONTHS=2, QTRS=3, YEARS=4.
        var CellScaleNames = ['Days', 'Weeks', 'Months', 'Quarters', 'Years'];
        var sLabel = CellScaleNames[GanttScale.CellScale];
        if (!sLabel)
            return;

        GanttHeaderTh.append($('<div>').addClass('gantt-zoom-label').text(sLabel));
    } catch (e) {
        ToastError('AddGanttZoomLabel', e.name + ': ' + e.message + '\n' + e.stack);
    }
}




function OnGanttNavButton(GridObject, nDirection, bPage) {
    try {
        var GanttScale = FindGanttScaleVars(GridObject);
        if (!GanttScale || !GanttScale.Start)
            return;

        var nDays;
        if (bPage) {
            var GanttHeaderTh = FindGanttHeaderTh(GridObject);
            var nWidth = GanttHeaderTh ? GanttHeaderTh.width() : 300;
            nDays = Math.max(1, Math.round(nWidth / GanttScale.dPixelsPerDay));
        } else {
            var cs = GanttScale.CellScale;
            nDays = (cs == 0) ? 1 : (cs == 1) ? 7 : (cs == 2) ? 30 : (cs == 3) ? 91 : 365;
        }
        nDays *= nDirection;

        var StartDate = new Date(GanttScale.Start);
        StartDate.setDate(StartDate.getDate() + nDays);
        GanttScale.Start = StartDate;

        var ContentType = GetContentTypeFromGridObject(GridObject);

        // Update DOM attr and localStorage directly — avoids triggering the AjaxSetGanttPixelsPerDay
        // debounce that SaveGanttScaleToGridObject would fire (we are only changing Start, not zoom).
        var ScaleElement = GridObject.find('[data-gantt-scale]');
        if (ScaleElement && ScaleElement.length > 0)
            ScaleElement.attr('data-gantt-scale', JSON.stringify(GanttScale));
        SaveGanttScaleToLocalStorage(ContentType, GanttScale);

        var GridTableObject = FindGridTableObject(GridObject);
        nNavScrollDeltaPixels = -nDays * GanttScale.dPixelsPerDay;
        bNavScrollActive = true;
        try {
            BuildGanttElementsByGrid(GridTableObject);
        } finally {
            bNavScrollActive = false;
            nNavScrollDeltaPixels = 0;
        }

        // bRefreshSelectedContent=false: a full view refresh would overwrite localStorage with
        // response.GanttInterval, potentially changing the visible scale interval.
        AjaxSetGanttStart(StartDate, ContentType, null, false/*bRefreshSelectedContent*/);
    } catch (e) {
        ToastError("OnGanttNavButton", e.name + ": " + e.message + "\n" + e.stack);
    }
}





function OnGanttRevertToDefaults() {

    var ContentType = GetSelectedContentType();
    AjaxRevertGanttToDefaults(ContentType);
}

function OnGanttScaleDays() {

    var ContentType = GetSelectedContentType();
    AjaxSetGanttInterval("DAYS", ContentType);
}

function OnGanttScaleWeeks() {

    var ContentType = GetSelectedContentType();
    AjaxSetGanttInterval("WEEKS", ContentType);
}

function OnGanttScaleMonths() {

    var ContentType = GetSelectedContentType();
    AjaxSetGanttInterval("MONTHS", ContentType);
}

function OnGanttScaleQuarters() {

    var ContentType = GetSelectedContentType();
    AjaxSetGanttInterval("QTRS", ContentType);
}

function OnGanttScaleYears() {

    var ContentType = GetSelectedContentType();
    AjaxSetGanttInterval("YEARS", ContentType);
}





function OnGanttScrollEarliest(...arguments) {

    var ContentType = GetSelectedContentType();
    var SelectedRecordsetObject = GetSelectedRecordsetObjectFromArguments(...arguments);
    AjaxSetGanttStart("EARLIEST", ContentType, SelectedRecordsetObject, true/*bRefreshSelectedContent*/);
}

function OnGanttScrollToday(...arguments) {

    var ContentType = GetSelectedContentType();
    var SelectedRecordsetObject = GetSelectedRecordsetObjectFromArguments(...arguments);
    AjaxSetGanttStart("TODAY", ContentType, SelectedRecordsetObject, true/*bRefreshSelectedContent*/);
}

function OnGanttScrollLastWeek(...arguments) {

    var ContentType = GetSelectedContentType();
    var SelectedRecordsetObject = GetSelectedRecordsetObjectFromArguments(...arguments);
    AjaxSetGanttStart("LASTWEEK", ContentType, SelectedRecordsetObject, true/*bRefreshSelectedContent*/);
}

function OnGanttScrollThisWeek(...arguments) {

    var ContentType = GetSelectedContentType();
    var SelectedRecordsetObject = GetSelectedRecordsetObjectFromArguments(...arguments);
    AjaxSetGanttStart("THISWEEK", ContentType, SelectedRecordsetObject, true/*bRefreshSelectedContent*/);
}

function OnGanttScrollNextWeek(...arguments) {

    var ContentType = GetSelectedContentType();
    var SelectedRecordsetObject = GetSelectedRecordsetObjectFromArguments(...arguments);
    AjaxSetGanttStart("NEXTWEEK", ContentType, SelectedRecordsetObject, true/*bRefreshSelectedContent*/);
}

function OnGanttScrollLastMonth(...arguments) {

    var ContentType = GetSelectedContentType();
    var SelectedRecordsetObject = GetSelectedRecordsetObjectFromArguments(...arguments);
    AjaxSetGanttStart("LASTMONTH", ContentType, SelectedRecordsetObject, true/*bRefreshSelectedContent*/);
}

function OnGanttScrollThisMonth(...arguments) {

    var ContentType = GetSelectedContentType();
    var SelectedRecordsetObject = GetSelectedRecordsetObjectFromArguments(...arguments);
    AjaxSetGanttStart("THISMONTH", ContentType, SelectedRecordsetObject, true/*bRefreshSelectedContent*/);
}

function OnGanttScrollNextMonth(...arguments) {

    var ContentType = GetSelectedContentType();
    var SelectedRecordsetObject = GetSelectedRecordsetObjectFromArguments(...arguments);
    AjaxSetGanttStart("NEXTMONTH", ContentType, SelectedRecordsetObject, true/*bRefreshSelectedContent*/);
}








var BuildGanttElementsTimerID = 0;

/**
 * Rebuild Gantt link lines after a short delay
 * This serves two purposes
 *     1. Makes scrolling faster
 *     2. Prevents flashing when called repeatedly
 * @param {any} GridTableObject
 * @param {any} nMilliseconds
 */
function BuildGanttElementsOnTimer(GridTableObject, nMilliseconds) {

    // Cancel any pending rebuild and restart the clock (standard debounce)
    clearTimeout(BuildGanttElementsTimerID);
    BuildGanttElementsTimerID = setTimeout(function () {
        BuildGanttElementsTimerID = 0;
        BuildGanttElementsByGrid(GridTableObject);
    }, nMilliseconds);
}




var BuildGanttLinkLinesTimerID = 0;

/**
 * Rebuild Gantt link lines after a short delay
 * This serves two purposes
 *     1. Oddly, the grid must "settle" before positions and offsets are available (500ms)
 *     2. Prevents flashing when called repeatedly
 * @param {any} GridTableObject
 * @param {any} nMilliseconds
 */
function BuildGanttLinkLinesOnTimer(GridTableObject, nMilliseconds) {

    // Recently called?
    // Kill the timer and start over again
    // This prevents multiple rebuilds, which flash and look annoying
    if (BuildGanttLinkLinesTimerID) {
        clearTimeout(BuildGanttLinkLinesTimerID);
        BuildGanttLinkLinesTimerID = 0;
        //console.log("BuildGanttLinkLinesOnTimer setTimeout() already called. (" + nMilliseconds + "ms)");
    }

    BuildGanttLinkLinesTimerID = setTimeout(function () {
        BuildGanttLinkLinesTimerID = 0;
        BuildGanttLinkLines(GridTableObject);
    }, nMilliseconds);
}






var AjaxSetGanttPixelsPerDayTimerID = 0;
var sLastWheelMsg = "";
var LastGanttWheelTime = new Date();




function AddWheelEventListener(GridObject, GanttHeaderTh) {

    // Add event listener
    GanttHeaderTh[0].removeEventListener('wheel', OnGanttHeaderWheel);
    GanttHeaderTh[0].addEventListener('wheel', OnGanttHeaderWheel);
}

function OnGanttHeaderWheel(e) {
    try {
        // ALT key is not necessary. This is a small area
        //if (!event.altKey)
        //    return;

        var CurrentTarget = $(e.currentTarget);
        var GridObject = FindGridObject(CurrentTarget);
        var GridTableObject = FindGridTableObject(CurrentTarget);
        if (!GridTableObject || GridTableObject.length == 0)
            return;

        var GanttScale = FindGanttScaleVars(GridObject);
        if (!GanttScale)
            return;

        e.preventDefault();

        var Now = new Date();
        var millisecondsSinceWheel = Now.getTime() - LastGanttWheelTime.getTime();

        var dOldPixelsPerDay = GanttScale.dPixelsPerDay;
        GanttScale.dPixelsPerDay = GanttScale.dPixelsPerDay * (e.deltaY > 0 ? 0.85 : 1.18);
        GanttScale.dPixelsPerDay = Math.max(GanttScale.dPixelsPerDay, 2);
        GanttScale.dPixelsPerDay = Math.min(GanttScale.dPixelsPerDay, 100);
        GanttScale.dPixelsPerDay = Math.round(GanttScale.dPixelsPerDay * 10) / 10;
        if (GanttScale.dPixelsPerDay == dOldPixelsPerDay)
            return;

        // Save the new value
        SaveGanttScaleToGridObject(GridObject, GanttScale);
        BuildGanttElementsByGrid(GridTableObject);
    } catch (e) {
        ToastError("OnGanttHeaderWheel", e.name + ": " + e.message + "\n" + e.stack);
    }
}


/**
 * Right-click Gantt bar
 * Display right-click menu for project tasks and projects when clicking Gantt bars
 * @param {any} clickedelement
 * @returns
 */
function OnGanttBarContextMenu(e, clickedelement) {

    var ClickedObject = $(clickedelement);
    if (!ClickedObject || ClickedObject.length == 0)
        return;

    // Good values?
    var RecordId = ClickedObject.attr("data-guid");
    var TableName = ClickedObject.attr("data-table");
    var DataType = ClickedObject.attr("data-datatype");
    if (!RecordId || !TableName || !DataType)
        return;

    e.preventDefault();

    // This prevents the normal grid right-click menu from appearing
    bIgnoreNextContextMenuEvent = true;

    // Open rightclick context menu
    AjaxGetObjectContextMenu(e, clickedelement, DataType, TableName, RecordId);
}



function AjaxSetGanttInterval(GanttInterval, ContentType) {

    if (!GanttInterval)
        return;

    var params = {
        "GanttInterval": GanttInterval,
        "ContentType": ContentType
    };

    $.ajax({
        url: VirtualDirectory + "/Home/AjaxSetGanttInterval",
        type: "POST",
        data: JSON.stringify(params),
        contentType: "application/json",
        success: function (response) {
            if (response.Success) {
                SaveGanttScaleToLocalStorage(ContentType, { Start: response.GanttStart, CellScale: response.GanttInterval, dPixelsPerDay: response.GanttPixelsPerDay });
                RefreshSelectedContent();
            }
            else {
                ToastError("AjaxSetGanttInterval", response.ErrorResults);
            }
        },
        error: function (xhr, status, error) {
            var errormsg = "Error " + xhr.status + ", " + xhr.statusText + "\r\nText: " + BarcodeText;
            ToastError("AjaxSetGanttInterval", errormsg);
            console.log('AJAX: ' + errormsg);
        }
    });
}



function AjaxSetGanttStart(GanttStart, ContentType, SelectedRecordsetObject, bRefreshSelectedContent) {

    if (!GanttStart)
        return;

    var SelectedDataType = "";
    var SelectedTableId = "";
    var SelectedRecordId = "";
    if (SelectedRecordsetObject) {
        SelectedDataType = SelectedRecordsetObject.SelectedDataType;
        SelectedTableId = SelectedRecordsetObject.SelectedTableId;
        SelectedRecordId = SelectedRecordsetObject.SelectedRecordId;
    }

    // Include current CellScale and dPixelsPerDay so the server session stays in sync
    // with localStorage. A server session reset (app pool recycle, timeout) creates a
    // new GanttScale with the default CellScale=WEEKS; without these values the server
    // would echo WEEKS back and overwrite the user's Days choice in localStorage.
    var StoredScale = LoadGanttScaleFromLocalStorage(ContentType);

    var params = {
        "GanttStart":       GanttStart,
        "ContentType":      ContentType,
        "SelectedDataType": SelectedDataType,
        "SelectedTableId":  SelectedTableId,
        "SelectedRecordId": SelectedRecordId,
        "GanttCellScale":   StoredScale ? StoredScale.CellScale   : null,
        "GanttPixelsPerDay": StoredScale ? StoredScale.dPixelsPerDay : null,
    };

    $.ajax({
        url: VirtualDirectory + "/Home/AjaxSetGanttStart",
        type: "POST",
        data: JSON.stringify(params),
        contentType: "application/json",
        success: function (response) {
            if (response.Success) {
                // Only update Start — CellScale and dPixelsPerDay are owned by
                // AjaxSetGanttInterval / AjaxSetGanttPixelsPerDay. Overwriting them
                // from a stale server session would silently revert the user's scale.
                // Fall back to the server's values only when localStorage was empty.
                var PrevScale = LoadGanttScaleFromLocalStorage(ContentType);
                var NewScale = {
                    Start:         response.GanttStart,
                    CellScale:     (PrevScale && PrevScale.CellScale     !== undefined) ? PrevScale.CellScale     : response.GanttInterval,
                    dPixelsPerDay: (PrevScale && PrevScale.dPixelsPerDay !== undefined) ? PrevScale.dPixelsPerDay : response.GanttPixelsPerDay,
                };
                SaveGanttScaleToLocalStorage(ContentType, NewScale);
                if (bRefreshSelectedContent)
                    RefreshSelectedContent();
            }
            else {
                ToastError("AjaxSetGanttStart", response.ErrorResults);
            }
        },
        error: function (xhr, status, error) {
            var errormsg = "Error " + xhr.status + ", " + xhr.statusText + "\r\nText: " + BarcodeText;
            ToastError("AjaxSetGanttStart", errormsg);
            console.log('AJAX: ' + errormsg);
        }
    });
}


function AjaxSetGanttRecordStartDate(ContentType, RecordId, DbTable, NewStartDateString, DragUIObject, SuccessFunction) {

    if (!ContentType)
        return;
    if (!RecordId)
        return;
    if (!NewStartDateString)
        return;

    var params = {
        "ContentType": ContentType,
        "RecordId": RecordId,
        "DbTable": DbTable,
        "NewStartDate": NewStartDateString,
    };

    $.ajax({
        url: VirtualDirectory + "/Home/AjaxSetGanttRecordStartDate",
        type: "POST",
        data: JSON.stringify(params),
        contentType: "application/json",
        success: function (response) {
            if (response.Success) {
                if (SuccessFunction)
                    SuccessFunction(DragUIObject, ContentType, RecordId, DbTable, NewStartDateString)
                else RefreshSelectedContent();
            }
            else {
                ToastError("AjaxSetGanttRecordStartDate", response.ErrorResults);
            }
        },
        error: function (xhr, status, error) {
            var errormsg = "Error " + xhr.status + ", " + xhr.statusText + "\r\nText: " + BarcodeText;
            ToastError("AjaxSetGanttRecordStartDate", errormsg);
            console.log('AJAX: ' + errormsg);
        }
    });
}


function AjaxSetGanttRecordFinishDate(ContentType, RecordId, DbTable, NewFinishDateString, DragUIObject, SuccessFunction) {

    if (!ContentType)
        return;
    if (!RecordId)
        return;
    if (!NewFinishDateString)
        return;

    var params = {
        "ContentType": ContentType,
        "RecordId": RecordId,
        "DbTable": DbTable,
        "NewFinishDate": NewFinishDateString,
    };

    $.ajax({
        url: VirtualDirectory + "/Home/AjaxSetGanttRecordFinishDate",
        type: "POST",
        data: JSON.stringify(params),
        contentType: "application/json",
        success: function (response) {
            if (response.Success) {
                if (SuccessFunction)
                    SuccessFunction(DragUIObject, ContentType, RecordId, DbTable, NewFinishDateString)
                else RefreshSelectedContent();
            }
            else {
                ToastError("AjaxSetGanttRecordFinishDate", response.ErrorResults);
            }
        },
        error: function (xhr, status, error) {
            var errormsg = "Error " + xhr.status + ", " + xhr.statusText + "\r\nText: " + BarcodeText;
            ToastError("AjaxSetGanttRecordFinishDate", errormsg);
            console.log('AJAX: ' + errormsg);
        }
    });
}


function AjaxSetGanttPixelsPerDay(GanttPixelsPerDay, ContentType, bRefreshContent) {

    if (GanttPixelsPerDay < 0)
        return;

    var params = {
        "GanttPixelsPerDay": GanttPixelsPerDay,
        "ContentType": ContentType
    };

    $.ajax({
        url: VirtualDirectory + "/Home/AjaxSetGanttPixelsPerDay",
        type: "POST",
        data: JSON.stringify(params),
        contentType: "application/json",
        success: function (response) {
            if (response.Success) {
                if (bRefreshContent)
                    RefreshSelectedContent();
            }
            else {
                ToastError("AjaxSetGanttPixelsPerDay", response.ErrorResults);
            }
        },
        error: function (xhr, status, error) {
            var errormsg = "Error " + xhr.status + ", " + xhr.statusText + "\r\nText: " + BarcodeText;
            ToastError("AjaxSetGanttPixelsPerDay", errormsg);
            console.log('AJAX: ' + errormsg);
        }
    });
}




function AjaxRevertGanttToDefaults(ContentType) {

    var params = {
        "ContentType": ContentType
    };

    $.ajax({
        url: VirtualDirectory + "/Home/AjaxRevertGanttToDefaults",
        type: "POST",
        data: JSON.stringify(params),
        contentType: "application/json",
        success: function (response) {
            if (response.Success) {
                localStorage.removeItem("ST-GanttScale-" + ContentType);
                RefreshSelectedContent();
            }
            else {
                ToastError("AjaxRevertGanttToDefaults", response.ErrorResults);
            }
        },
        error: function (xhr, status, error) {
            var errormsg = "Error " + xhr.status + ", " + xhr.statusText + "\r\nText: " + BarcodeText;
            ToastError("AjaxRevertGanttToDefaults", errormsg);
            console.log('AJAX: ' + errormsg);
        }
    });
}

