@@ -1962,36 +1962,119 @@ Ganttify <- function(
19621962
19631963 # Pass show_yaxis_labels to JavaScript to conditionally apply alignment
19641964 js_show_yaxis_labels <- tolower(as.character(show_yaxis_labels ))
1965+ # Pass the reserved left-gutter width (in px) so JS knows where the y-axis
1966+ # line sits and can guard against labels crossing it into the plot area.
1967+ js_gutter_width <- as.character(effective_label_width )
19651968
19661969 fig <- fig %> % onRender(paste0("
19671970 function(el) {
19681971 // Flag to control whether to apply y-axis label alignment
19691972 var shouldAlignLabels = " , js_show_yaxis_labels , " ;
1973+ // Width (px) of the reserved left gutter; the y-axis line sits at this x.
1974+ var gutterWidth = " , js_gutter_width , " ;
19701975
1971- // Function to align y-axis tick labels within the left gutter.
1976+ // Function to LEFT-ALIGN y-axis tick labels within the left gutter so the
1977+ // WBS hierarchy (encoded as leading indentation/whitespace in each label)
1978+ // stays visible, while GUARANTEEING no label ever crosses the axis into
1979+ // the activity bars.
19721980 //
1973- // PREVIOUS BUG: this used to force text-anchor='start' on every label
1974- // WITHOUT moving the label's x coordinate. For a left-side y-axis, plotly
1975- // places each tick label's x just left of the axis line (x is in plot
1976- // pixel coords; the axis sits at x = left margin = effective_label_width)
1977- // and renders with the native text-anchor='end', so the label's RIGHT
1978- // edge butts against the axis and the text grows LEFTWARD into the
1979- // reserved gutter -- no overlap with the bars. Flipping the anchor to
1980- // 'start' at that same x made the text grow RIGHTWARD from the axis,
1981- // crossing into the plot area and overlapping the activity bars (only
1982- // when show_yaxis_labels is ON, i.e. shouldAlignLabels === true) .
1981+ // HISTORY:
1982+ // - Originally this forced text-anchor='start' on every label WITHOUT
1983+ // moving the label's x. plotly places each left- axis tick label's x
1984+ // just left of the axis line and renders with text-anchor='end', so
1985+ // the RIGHT edge butts against the axis and the text grows LEFTWARD
1986+ // into the gutter. Flipping to 'start' at that SAME x made the text
1987+ // grow RIGHTWARD from the axis, overlapping the bars.
1988+ // - It was then made a no-op (keep native end-anchor). That fixed the
1989+ // overlap but right-aligned every label flush to the axis, which
1990+ // collapses the leading indentation and hides the WBS hierarchy .
19831991 //
1984- // FIX: leave plotly's native text-anchor='end' in place. The left margin
1985- // (effective_label_width) is sized to the label width, so right-aligned
1986- // labels sit flush against the axis, fully inside the gutter, and never
1987- // overlap the bars -- on initial render and after every pan/zoom relayout.
1988- // This intentionally preserves plotly's default anchoring rather than
1989- // re-anchoring, which would risk long labels clipping or colliding with
1990- // the axis.
1991- function alignYAxisLabels() {
1992+ // CURRENT BEHAVIOR (left-align + collision guard):
1993+ // For each tick label we set text-anchor='start' AND set x to a small
1994+ // constant left pad (LEFT_PAD) measured from the SVG/plot left edge, so
1995+ // every label begins at the same left edge of the gutter. Because the
1996+ // indentation is LEADING whitespace baked into the label text, starting
1997+ // all labels at the same x makes that indentation visible again and the
1998+ // parent/child hierarchy reads correctly.
1999+ //
2000+ // COLLISION GUARD: the y-axis line sits at x = gutterWidth (the reserved
2001+ // left margin). We measure each rendered label width with
2002+ // getComputedTextLength(). If LEFT_PAD + width would cross the axis
2003+ // (i.e. extend past gutterWidth - SAFETY_GAP into the plot/bars), that
2004+ // SINGLE label is degraded gracefully: it falls back to plotly's native
2005+ // right-alignment (text-anchor='end' anchored AT the axis line), which
2006+ // keeps even an over-long label fully inside the gutter and flush to the
2007+ // axis -- never over the bars. The gutter was sized to fit the longest
2008+ // label right-aligned, so in practice almost every label fits left-
2009+ // aligned; the fallback is a safety net for pathological edge cases.
2010+ // Labels that fit are left-aligned (hierarchy visible); labels that do
2011+ // not are right-aligned (hierarchy hidden for that one row, but no
2012+ // overlap) -- the trade-off is documented in NEWS.
2013+ //
2014+ // TIMING: getComputedTextLength() only returns a real value once the text
2015+ // node is laid out. This runs in onRender and on plotly_afterplot. If the
2016+ // measurement comes back 0 (not yet laid out), we defer one frame via
2017+ // requestAnimationFrame and re-run, retrying a bounded number of times.
2018+ var LEFT_PAD = 4; // px from the SVG/plot left edge for left-aligned labels
2019+ var SAFETY_GAP = 6; // px clearance kept between label end and the axis line
2020+
2021+ function alignYAxisLabels(retriesLeft) {
19922022 if (!shouldAlignLabels) return; // Skip when labels are hidden/partial
1993- // No-op: plotly's native text-anchor='end' already keeps left-axis
1994- // labels within the reserved left gutter. Do NOT override the anchor.
2023+ if (typeof retriesLeft === 'undefined') retriesLeft = 5;
2024+
2025+ // plotly renders left/below y-axis tick labels in g.yaxislayer-above;
2026+ // fall back to the generic .ytick text selector if the layer is absent.
2027+ var nodes = el.querySelectorAll('g.yaxislayer-above text');
2028+ if (!nodes || nodes.length === 0) {
2029+ nodes = el.querySelectorAll('.ytick text');
2030+ }
2031+ if (!nodes || nodes.length === 0) {
2032+ // Text not in the DOM yet -- try again next frame.
2033+ if (retriesLeft > 0 && typeof requestAnimationFrame === 'function') {
2034+ requestAnimationFrame(function() { alignYAxisLabels(retriesLeft - 1); });
2035+ }
2036+ return;
2037+ }
2038+
2039+ // The axis line sits at x = gutterWidth; never let a label cross it.
2040+ var axisX = gutterWidth;
2041+ var measuredAll = true;
2042+
2043+ for (var i = 0; i < nodes.length; i++) {
2044+ var node = nodes[i];
2045+ // Remember the native (plotly-assigned) x so we can restore it when a
2046+ // label must fall back to right-alignment.
2047+ if (node.getAttribute('data-native-x') === null) {
2048+ node.setAttribute('data-native-x', node.getAttribute('x'));
2049+ }
2050+ var nativeX = node.getAttribute('data-native-x');
2051+
2052+ var width = 0;
2053+ try { width = node.getComputedTextLength(); } catch (e) { width = 0; }
2054+ if (width === 0 && node.textContent && node.textContent.trim().length > 0) {
2055+ // Non-empty label measured as 0 -> not laid out yet; defer.
2056+ measuredAll = false;
2057+ continue;
2058+ }
2059+
2060+ if (LEFT_PAD + width <= axisX - SAFETY_GAP) {
2061+ // Fits: left-align so leading indentation (hierarchy) is visible.
2062+ node.setAttribute('text-anchor', 'start');
2063+ node.setAttribute('x', LEFT_PAD);
2064+ } else {
2065+ // Too wide for the gutter when left-aligned: fall back to plotly's
2066+ // native right-alignment so it stays flush to the axis, never over
2067+ // the bars.
2068+ node.setAttribute('text-anchor', 'end');
2069+ if (nativeX !== null) {
2070+ node.setAttribute('x', nativeX);
2071+ }
2072+ }
2073+ }
2074+
2075+ if (!measuredAll && retriesLeft > 0 && typeof requestAnimationFrame === 'function') {
2076+ requestAnimationFrame(function() { alignYAxisLabels(retriesLeft - 1); });
2077+ }
19952078 }
19962079
19972080 // Function to update x-axis date format based on visible range
@@ -2337,17 +2420,20 @@ Ganttify <- function(
23372420 };
23382421 })();
23392422
2340- // Apply alignment on initial render
2341- setTimeout(alignYAxisLabels, 100);
2423+ // Apply alignment on initial render. Wrap so alignYAxisLabels() is called
2424+ // with no args (retriesLeft defaults to its built-in retry budget).
2425+ setTimeout(function() { alignYAxisLabels(); }, 100);
23422426
23432427 // Apply initial date format
23442428 setTimeout(updateDateFormat, 150);
23452429
23462430 // Apply initial bar width adjustment (after date format is applied and relayout completes)
23472431 setTimeout(function() { updateBarWidths(el); }, 500);
23482432
2349- // Re-apply alignment after every plot update (pan, zoom, etc.)
2350- el.on('plotly_afterplot', alignYAxisLabels);
2433+ // Re-apply alignment after every plot update (pan, zoom, etc.). Wrap so
2434+ // plotly's event payload is NOT passed as retriesLeft -- the alignment
2435+ // must re-run with a fresh retry budget on each relayout.
2436+ el.on('plotly_afterplot', function() { alignYAxisLabels(); });
23512437
23522438 // Store the y-axis range to prevent zoom (but allow pan)
23532439 var currentYRange = null;
0 commit comments