Skip to content

Commit 94eaf79

Browse files
committed
Left-align y-axis labels within gutter to reveal WBS hierarchy
Replace the no-op alignYAxisLabels() handler with a real left-align implementation: each tick label is anchored text-anchor='start' at the gutter's left edge so the leading indentation (which encodes the WBS parent/child hierarchy) is visible again. A per-label collision guard measures rendered width via getComputedTextLength() and falls back to plotly's native right-alignment for any label too wide for the gutter, guaranteeing no label ever crosses the axis into the activity bars. Re-applies on initial render and after every pan/zoom relayout, with a requestAnimationFrame retry budget for unlaid-out text.
1 parent 5ac8a45 commit 94eaf79

3 files changed

Lines changed: 138 additions & 36 deletions

File tree

DESCRIPTION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
Package: ganttify
22
Type: Package
33
Title: Create Interactive Gantt Charts with Work Breakdown Structure
4-
Version: 0.2.8
4+
Version: 0.2.9
55
Authors@R: person("Ahmed", "Aredah", email = "Ahmed.Aredah@gmail.com",
66
role = c("aut", "cre"))
77
Description: Create Primavera-style interactive Gantt charts with Work Breakdown

NEWS.md

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,32 @@
1-
# ganttify 0.2.8
1+
# ganttify 0.2.9
22

33
## Bug Fix
44

5-
* **Y-axis labels no longer overlap the bars**: When `show_yaxis_labels = TRUE`,
6-
the activity tick labels were rendering inside the plot area, to the right of
7-
the y-axis line, overlapping the activity bars. The `onRender` alignment
8-
handler was forcing SVG `text-anchor='start'` on each left-axis label without
9-
shifting its `x` coordinate, so the text grew rightward from the axis into the
10-
bars. The handler is now a no-op: plotly's native `text-anchor='end'` keeps
11-
left-axis labels flush against the axis and fully within the reserved left
12-
gutter, both on initial render and after every pan/zoom. No overlap, no
13-
clipping.
5+
* **Y-axis labels are now left-aligned to preserve the WBS hierarchy, without
6+
overlapping the bars**: When `show_yaxis_labels = TRUE`, the activity tick
7+
labels were rendering inside the plot area, to the right of the y-axis line,
8+
overlapping the activity bars. The `onRender` alignment handler was forcing
9+
SVG `text-anchor='start'` on each left-axis label without shifting its `x`
10+
coordinate, so the text grew rightward from the axis into the bars.
11+
12+
The handler now LEFT-ALIGNS each label flush to the left edge of the reserved
13+
gutter (`text-anchor='start'` with a small constant left pad). Because the WBS
14+
hierarchy is encoded as leading indentation/whitespace in each label, starting
15+
every label at the same left edge makes that indentation — and therefore the
16+
parent/child hierarchy — visible again. (Right-aligning the labels flush to the
17+
axis, as a previous interim fix did, removed the overlap but collapsed the
18+
indentation and hid the hierarchy.)
19+
20+
A per-label collision guard measures each rendered label with
21+
`getComputedTextLength()`. Any label that would cross the axis line
22+
(`x = effective_label_width`) into the plot area is degraded gracefully by
23+
falling back to plotly's native right-alignment (`text-anchor='end'`, anchored
24+
at the axis), so it stays flush to the axis and never overlaps the bars. Since
25+
the gutter is sized to fit the longest label, virtually all labels fit
26+
left-aligned; the fallback is a safety net for pathological edge cases.
27+
Alignment is applied on initial render and re-applied after every pan/zoom
28+
relayout; if text is not yet laid out (measurement returns 0) it is retried on
29+
the next animation frame.
1430

1531
# ganttify 0.2.7
1632

R/Ganttify.R

Lines changed: 111 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)