8
z5@A>(?dtZM!}~^M;v-L(JfiakpGKc9`(;=nxYu+Fg)%VB{}KN-NlP0;9HK-J
z6Et{zfYPw+adB?w-L*|GGVCXT-W^LJFgt8_?GCqjQEf^(x_Clsn={ptj)(gj4^2nz
z{+~Zffsz8|DgbY5tgqjhsV`==x3_0P9?Z9r+ud>))vYBxQyq_;Akkc6!xJr4@TGe3
z;%*^iAj|}Xt36flPyPG*YxrJywgvtk8k%W#M9Eigse})jVE9HLZs->P;iWf!)@85J
z`?MadeKdO_f>O*UL=*y?5jc#reAuE%sD%!<&tuTxbW{9jyAX8;vVJ5-3j
zig@`h5sU>W4iGrdY-wpZRb>W{6}#-S^_&a@<2Litr^qK_Waik~42S;!enLXR
z=B5KbKRRnDO~@Y`&>7
zJ?N1jKYzU}0w^iVv&}OWv^}Rc?ee6zWM@)skxRXjYGPubDP12Yzkinr=Edk71EkmV#L8>&Tgc!i#j71{Ac~8ndf{OT#D7+f^78MB
zc!m9TJ?V)uK77cLthC^~gVlP;59akd5E&V(BWAA6nBg)yUmNya8|=OW
zkz+tO#>}Gq4|l51Oi-i(`t5n&!;5pYUq*T6b@puNxV(MMT(M9C#cK$G@Um4SI$wT(
zE~L?ekD{kITk*IZxY=q|QCS2wC&myCvA!QeaoKgv8`AhLtV@Mozzp1v{s*4p--BNu
zBz*|s>-r%5oth#=9d#-q>?^tgpuhIe&z}HJRvGnP%{;YejpIUaQ9SkUt+GFP@I%IE
z5)3E4R8x~A^iyg5&}RFdiHE6XX!;+2+Xl4CXWP5?Cr3xosb%BXvxOF%yCO%~O_*jl
zRa3?M6;O$hfbp;Wea>v+QbKNXyBj=D&s9G9_5DFUk(&7&%1;su#EaVPpZtUZerhdQ
z>x`htNt%?M3J%pTkR8}
zH(yIGH4zS}tE;O~6|X`Mn*wscXI-LW__X3$LaM|4T2dQfpbleJ58mgSdr%zHCi7R2
zdYDCx@zS1{-$cbcgqz*q<~2Yf+gyh#;ve&Vr7ym>=aycoNiKGtxT{LC^1XDEc(Rno
zY9mG&y59Mn$zsA0$=5sABxNWuJDM;;WwC?*uCilwWMF`!(B||iMy3A6j^Ga+Z7JWZ
z>Nrq`(|?S-yDT!%5;CRunrwsHw9J-(Vy4>vaaXojDPwO04OFkiWrj@mXH5mik5WA`
zlnPcDkT?q4Ox{M~pQLk`AhJxFUZp^g{m26P?4jXd7@Ziloxm%_E@AI=E`8cdkB89L
zd4&Em?V_2j~0lx5s4v@C~}{|tSKDKj1H24ZRkp0`hNtTPP>
zi6&$y&=27jQ?xG^+A3Wy?m3NN1VO2z
zKyxii`0AtRTHsfD7`w6&ZLT+5Bm&+`P;zd$`$_$5!gE9u$>oIXzlJuoBN$7yDE0)3Oj;1`|V{3VSC>n{yDRg#X*m##=Zh&4{R>?Z>El4!~b8uC@LmT&(Jnxgj^
literal 0
HcmV?d00001
From 5b13d68755137bdd7412176f70752415e42dd69a Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Wed, 9 Sep 2026 18:55:09 +0100
Subject: [PATCH 09/24] feat(timeline): let the marker column be points as well
as a share
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A timeline's marker column could only be a weight, so the markers sat a
different distance from the edge on every page width. Asking for points meant
computing a weight from a width the caller would have had to know.
axisWidth(18) says points; markerColumnWeight(0.10) still says a share. Both
survive to the layout as a two-case TimelineAxisSize, and neither is converted
into the other. A conversion needs a row width, and the one that exists is
right on exactly one page: the default 0.10 resolves to 24pt at 320pt wide and
38.5pt at 480pt. So the row is handed whichever it was given and resolves it
itself.
Declaring both throws, naming both calls, since they are two answers to one
question. A markerColumnWeight(...) call that was already ignored — the method
has always dropped a non-positive value — still does not count as declaring
anything, or code that used to work would start failing.
The two-page-width assertion is the one that matters and it is new. Sabotaging
the default weight into Fixed(24) leaves both layout snapshots green, because
24pt is exactly what 0.10 resolves to on the 320pt page they use; only the
pixel baseline on a 300pt page caught it. Any check at one page width is blind
to a converted weight by construction, so the axis test lays the same timeline
out at two widths and says a share has to give two answers.
Also measured and pinned: the section inside the axis column reports the
marker's width, not the column's — 6, 14 and 20 under a 20pt axis. A rail
derived from that number would drift with marker size, which is the defect
being reworked, so the phases that build the rail have to anchor on the
marker's resolved box instead.
---
CHANGELOG.md | 13 ++
.../document/dsl/TimelineAxisSize.java | 39 ++++
.../compose/document/dsl/TimelineBuilder.java | 82 ++++++++-
.../compose/document/dsl/TimelineSpec.java | 4 +-
.../document/dsl/TimelineBuilderTest.java | 68 +++++++
knowledge/api/authoring.json | 16 +-
knowledge/api/authoring.md | 3 +-
knowledge/api/excluded.json | 16 +-
.../api/TimelineAxisColumnLayoutTest.java | 169 ++++++++++++++++++
9 files changed, 397 insertions(+), 13 deletions(-)
create mode 100644 core/src/main/java/com/demcha/compose/document/dsl/TimelineAxisSize.java
create mode 100644 qa/src/test/java/com/demcha/compose/document/api/TimelineAxisColumnLayoutTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5779423e4..8b98866e7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,19 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A timeline's marker column can be given a width in points.**
+ `TimelineBuilder.axisWidth(double)` is the peer of `markerColumnWeight(double)`: points
+ rather than a share of the row. Reach for it when the markers should sit the same
+ distance from the edge on every page width — a weight is a share of what the row has
+ left, so it moves when the page or the columns beside it do.
+
+ Declare one or the other, not both; a timeline that asks for both throws, naming both
+ calls. There is no conversion between them that does not need a row width neither the
+ builder nor the caller has, and a conversion done anyway is right on exactly one page:
+ the default `markerColumnWeight(0.10)` resolves to 24pt on a 320pt page and to 38.5pt on
+ a 480pt one. Nothing converts; the row resolves whichever it was handed. Timelines that
+ set neither are unchanged.
+
- **A timeline can put a column before its markers — the `DATE` of `DATE | ● | CONTENT`.**
`TimelineBuilder.leadingColumn(DocumentRowColumn)` declares the width once for the whole
timeline and `TimelineEntryBuilder.leading(Consumer)` fills it per entry.
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineAxisSize.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineAxisSize.java
new file mode 100644
index 000000000..4a541d6ef
--- /dev/null
+++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineAxisSize.java
@@ -0,0 +1,39 @@
+package com.demcha.compose.document.dsl;
+
+/**
+ * How wide the axis column — the one the markers sit in — is.
+ *
+ * Two strategies, and deliberately not one number. A weight is a share of what the row
+ * has left; a fixed width is points. Converting one into the other would need the row's
+ * width, which the builder does not have, and doing it anyway is how a timeline that has
+ * rendered the same way for versions quietly moves: {@code markerColumnWeight(0.10)} means
+ * "a tenth of the remainder" on a 260pt page and on a 500pt one, and no single point value
+ * is both.
+ *
+ * So both survive to the layout, which asks the row to resolve whichever it was given.
+ * A closed set of two rather than a reused {@code DocumentRowColumn} because the third
+ * strategy that type offers — auto — cannot align a column across rows, which is the one
+ * thing this column has to do.
+ *
+ * @author Artem Demchyshyn
+ * @since 2.4.0
+ */
+sealed interface TimelineAxisSize {
+
+ /**
+ * An axis of exactly this many points, the same on any page width.
+ *
+ * @param points width in points
+ */
+ record Fixed(double points) implements TimelineAxisSize {
+ }
+
+ /**
+ * An axis taking this share of the row's remaining space, against a content weight
+ * of 1.0.
+ *
+ * @param weight relative share
+ */
+ record Weight(double weight) implements TimelineAxisSize {
+ }
+}
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineBuilder.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineBuilder.java
index bddffcd78..ca2b091f3 100644
--- a/core/src/main/java/com/demcha/compose/document/dsl/TimelineBuilder.java
+++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineBuilder.java
@@ -50,7 +50,8 @@ public final class TimelineBuilder {
private double connectorWidth = 1.5;
private double gutter = 8.0;
private double markerGap = 8.0;
- private double markerColumnWeight = 0.10;
+ private TimelineAxisSize axis = new TimelineAxisSize.Weight(0.10);
+ private String axisDeclaredBy;
private DocumentRowColumn leadingColumn;
private double entrySpacing = 14.0;
private DocumentTextStyle titleStyle;
@@ -134,16 +135,59 @@ public TimelineBuilder markerGap(double gap) {
* Sets the relative width of the marker column (its weight against a content
* weight of 1.0). Increase it for large numbered discs on narrow timelines.
*
+ * See {@link #axisWidth(double)} for the same column in points. Declare one or the
+ * other, not both.
+ *
* @param weight marker column weight; ignored when not positive
* @return this builder
+ * @throws IllegalStateException if the axis width is already declared
*/
public TimelineBuilder markerColumnWeight(double weight) {
if (weight > 0) {
- this.markerColumnWeight = weight;
+ declareAxisOnce("markerColumnWeight");
+ this.axis = new TimelineAxisSize.Weight(weight);
+ }
+ return this;
+ }
+
+ /**
+ * Sets the axis column — the one the markers sit in — to a fixed width in points.
+ *
+ * The peer of {@link #markerColumnWeight(double)}, and the one to reach for when the
+ * markers should sit the same distance from the edge whatever the page width: a weight
+ * is a share of what the row has left, so it moves when the page or the columns beside
+ * it do.
+ *
+ * Declare one or the other, not both. They are two answers to the same question, and
+ * there is no conversion between them that does not need a row width neither the
+ * builder nor the caller has.
+ *
+ * @param points axis width in points
+ * @return this builder
+ * @throws IllegalArgumentException if {@code points} is not positive and finite
+ * @throws IllegalStateException if the axis width is already declared
+ * @since 2.4.0
+ */
+ public TimelineBuilder axisWidth(double points) {
+ if (!(points > 0) || Double.isInfinite(points)) {
+ throw new IllegalArgumentException(
+ "A timeline's axis width must be a positive finite number of points, got: " + points);
}
+ declareAxisOnce("axisWidth");
+ this.axis = new TimelineAxisSize.Fixed(points);
return this;
}
+ private void declareAxisOnce(String call) {
+ if (axisDeclaredBy != null) {
+ throw new IllegalStateException(
+ "A timeline's axis column has one width, declared once: this one calls "
+ + axisDeclaredBy + "(...) and " + call + "(...). A weight is a share of the "
+ + "row and a fixed width is points, so neither can stand in for the other.");
+ }
+ axisDeclaredBy = call;
+ }
+
/**
* Gives every entry a column before its marker, for the {@code DATE} of a
* {@code DATE | ● | CONTENT} timeline.
@@ -322,7 +366,7 @@ private TimelineSpec normalize() {
specs.add(entry.normalize(resolvedTitle, resolvedMeta, resolvedBody));
}
return new TimelineSpec(new TimelineRailSpec(connectorColor, connectorWidth),
- leadingColumn, gutter, markerGap, markerColumnWeight, entrySpacing,
+ leadingColumn, gutter, markerGap, axis, entrySpacing,
keepTogether, keepEntriesTogether, List.copyOf(specs));
}
@@ -348,12 +392,18 @@ private static void layout(TimelineSpec spec, SectionBuilder timeline) {
.spacing(4);
section.addRow(header -> {
header.spacing(spec.markerGap());
- if (spec.leadingColumn() == null) {
- header.weights(spec.markerColumnWeight(), 1.0);
+ DocumentRowColumn axis = column(spec.axis());
+ if (spec.leadingColumn() == null && spec.axis() instanceof TimelineAxisSize.Weight weight) {
+ // The same two columns either way — columns(weight, weight) resolves
+ // exactly as weights(...) does, confirmed by the snapshots. But
+ // weights(...) is what a timeline has always put on its RowNode, and
+ // RowNode.weights() is public; spelling it the other way empties that
+ // list for every timeline that exists. Sugar where the sugar applies.
+ header.weights(weight.weight(), 1.0);
+ } else if (spec.leadingColumn() == null) {
+ header.columns(axis, DocumentRowColumn.weight(1.0));
} else {
- header.columns(spec.leadingColumn(),
- DocumentRowColumn.weight(spec.markerColumnWeight()),
- DocumentRowColumn.weight(1.0));
+ header.columns(spec.leadingColumn(), axis, DocumentRowColumn.weight(1.0));
// Present even when this entry put nothing in it: the column is the
// timeline's, not the entry's, and an entry that skipped it must
// still start its marker where every other entry starts one.
@@ -370,4 +420,20 @@ private static void layout(TimelineSpec spec, SectionBuilder timeline) {
}
}
+ /**
+ * Hands the axis size to the row in the row's own vocabulary.
+ *
+ * The only place the two strategies meet, and neither is converted into the other:
+ * the row resolves a weight against its own width, which is the number nobody upstream
+ * of it has.
+ *
+ * @param axis the axis size
+ * @return the column to give the row
+ */
+ private static DocumentRowColumn column(TimelineAxisSize axis) {
+ if (axis instanceof TimelineAxisSize.Fixed fixed) {
+ return DocumentRowColumn.fixed(fixed.points());
+ }
+ return DocumentRowColumn.weight(((TimelineAxisSize.Weight) axis).weight());
+ }
}
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineSpec.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineSpec.java
index fe5a280ef..0916938c0 100644
--- a/core/src/main/java/com/demcha/compose/document/dsl/TimelineSpec.java
+++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineSpec.java
@@ -19,7 +19,7 @@
* @param gutter space between the rail and the entry's content
* @param markerGap horizontal gap between the marker column and the content
* beside it
- * @param markerColumnWeight the marker column's weight against a content weight of 1.0
+ * @param axis how wide the column the markers sit in is
* @param entrySpacing vertical space between entries; the rail spans it
* @param keepTogether whether the timeline relocates whole rather than splitting
* @param keepEntriesTogether whether each entry relocates whole rather than splitting
@@ -31,7 +31,7 @@ record TimelineSpec(TimelineRailSpec rail,
DocumentRowColumn leadingColumn,
double gutter,
double markerGap,
- double markerColumnWeight,
+ TimelineAxisSize axis,
double entrySpacing,
boolean keepTogether,
boolean keepEntriesTogether,
diff --git a/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java b/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java
index 0744cc787..b8af73430 100644
--- a/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java
+++ b/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java
@@ -377,6 +377,74 @@ void aMarkerCannotBeDeclaredTwiceInTheAdvancedFormEither() {
.withMessageContaining("exactly one marker");
}
+ // --- the axis column -----------------------------------------------------
+
+ @Test
+ void axisWidthSizesTheMarkerColumnInPointsInsteadOfShares() {
+ SectionNode timeline = timelineOf(t -> t
+ .axisWidth(18)
+ .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x")));
+
+ assertThat(header(entry(timeline, 0)).columns())
+ .as("a fixed axis, then the content column taking the rest")
+ .containsExactly(DocumentRowColumn.fixed(18), DocumentRowColumn.weight(1.0));
+ }
+
+ @Test
+ void aWeightAxisStillReachesTheRowAsWeightsAsItAlwaysHas() {
+ // columns(weight, weight) resolves identically — the snapshots say so. But
+ // RowNode.weights() is public, and spelling it the other way would empty that list
+ // for every timeline already written.
+ SectionNode timeline = timelineOf(t -> t
+ .markerColumnWeight(0.4)
+ .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x")));
+
+ assertThat(header(entry(timeline, 0)).weights()).containsExactly(0.4, 1.0);
+ assertThat(header(entry(timeline, 0)).columns()).isEmpty();
+ }
+
+ @Test
+ void theAxisWidthIsDeclaredOnceInEitherOrder() {
+ // A weight is a share of the row and a fixed width is points. There is no
+ // conversion between them without a row width, so a timeline that asks for both
+ // has not said what it wants.
+ assertThatIllegalStateException()
+ .as("weight then points")
+ .isThrownBy(() -> timelineOf(t -> t.markerColumnWeight(0.4).axisWidth(18)))
+ .withMessageContaining("one width, declared once");
+ assertThatIllegalStateException()
+ .as("points then weight")
+ .isThrownBy(() -> timelineOf(t -> t.axisWidth(18).markerColumnWeight(0.4)))
+ .withMessageContaining("one width, declared once");
+ }
+
+ @Test
+ void anIgnoredMarkerColumnWeightDoesNotCountAsDeclaringTheAxis() {
+ // markerColumnWeight has always ignored a non-positive value. A call that changed
+ // nothing must not then block axisWidth — that would be a new failure in code that
+ // used to work.
+ SectionNode timeline = timelineOf(t -> t
+ .markerColumnWeight(0)
+ .axisWidth(18)
+ .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x")));
+
+ assertThat(header(entry(timeline, 0)).columns())
+ .containsExactly(DocumentRowColumn.fixed(18), DocumentRowColumn.weight(1.0));
+ }
+
+ @Test
+ void axisWidthTakesOnlyAPositiveFiniteNumberOfPoints() {
+ assertThatIllegalArgumentException().isThrownBy(() -> timelineOf(t -> t.axisWidth(0)))
+ .withMessageContaining("positive finite");
+ assertThatIllegalArgumentException().isThrownBy(() -> timelineOf(t -> t.axisWidth(-4)))
+ .withMessageContaining("positive finite");
+ assertThatIllegalArgumentException().isThrownBy(() -> timelineOf(t -> t.axisWidth(Double.NaN)))
+ .withMessageContaining("positive finite");
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> timelineOf(t -> t.axisWidth(Double.POSITIVE_INFINITY)))
+ .withMessageContaining("positive finite");
+ }
+
// --- the leading column --------------------------------------------------
@Test
diff --git a/knowledge/api/authoring.json b/knowledge/api/authoring.json
index c7ac3f44e..c6be4216a 100644
--- a/knowledge/api/authoring.json
+++ b/knowledge/api/authoring.json
@@ -23,7 +23,7 @@
],
"counts": {
"types": 233,
- "methods": 2082,
+ "methods": 2083,
"constants": 230,
"generated": 1111
},
@@ -15069,6 +15069,20 @@
}
]
},
+ {
+ "kind": "method",
+ "name": "axisWidth",
+ "static": false,
+ "origin": "source",
+ "typeParameters": null,
+ "returns": "TimelineBuilder",
+ "params": [
+ {
+ "type": "double",
+ "name": "points"
+ }
+ ]
+ },
{
"kind": "method",
"name": "leadingColumn",
diff --git a/knowledge/api/authoring.md b/knowledge/api/authoring.md
index 87e61a00a..284e867b3 100644
--- a/knowledge/api/authoring.md
+++ b/knowledge/api/authoring.md
@@ -28,7 +28,7 @@ note: "Generated from the pinned artifact's class files. Authoritative closed se
**GraphCompose version:** 2.4.0-SNAPSHOT
-Types: 233 · methods: 2082 · constants: 230 · compiler-generated members: 1111
+Types: 233 · methods: 2083 · constants: 230 · compiler-generated members: 1111
## com.demcha.compose
@@ -1125,6 +1125,7 @@ Types: 233 · methods: 2082 · constants: 230 · compiler-generated members: 111
- `TimelineBuilder gutter(double gutter)`
- `TimelineBuilder markerGap(double gap)`
- `TimelineBuilder markerColumnWeight(double weight)`
+- `TimelineBuilder axisWidth(double points)`
- `TimelineBuilder leadingColumn(DocumentRowColumn column)`
- `TimelineBuilder spacing(double spacing)`
- `TimelineBuilder titleStyle(DocumentTextStyle style)`
diff --git a/knowledge/api/excluded.json b/knowledge/api/excluded.json
index bf81274b3..a13431f4e 100644
--- a/knowledge/api/excluded.json
+++ b/knowledge/api/excluded.json
@@ -3,7 +3,7 @@
"verifiedAgainst": "2.4.0-SNAPSHOT",
"generator": "knowledge/tools/api-surface/extract-api.mjs",
"note": "Public types and members deliberately kept out of every surface. An exclusion nobody can see is indistinguishable from a bug, so each one records why.",
- "count": 173,
+ "count": 175,
"excluded": [
{
"binaryName": "com.demcha.compose.document.backend.fixed.pptx.handlers.PptxChromeRenderer",
@@ -96,6 +96,20 @@
"artifact": "graph-compose-core",
"reason": "package `document.dsl.internal` is implementation detail but carries no package-level @Internal; excluded by name until the annotation is added"
},
+ {
+ "binaryName": "com.demcha.compose.document.dsl.TimelineAxisSize$Fixed",
+ "package": "com.demcha.compose.document.dsl",
+ "kind": "record",
+ "artifact": "graph-compose-core",
+ "reason": "nested type no admitted signature mentions"
+ },
+ {
+ "binaryName": "com.demcha.compose.document.dsl.TimelineAxisSize$Weight",
+ "package": "com.demcha.compose.document.dsl",
+ "kind": "record",
+ "artifact": "graph-compose-core",
+ "reason": "nested type no admitted signature mentions"
+ },
{
"binaryName": "com.demcha.compose.document.emoji.EmojiLibrary",
"package": "com.demcha.compose.document.emoji",
diff --git a/qa/src/test/java/com/demcha/compose/document/api/TimelineAxisColumnLayoutTest.java b/qa/src/test/java/com/demcha/compose/document/api/TimelineAxisColumnLayoutTest.java
new file mode 100644
index 000000000..4fd114fa0
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/api/TimelineAxisColumnLayoutTest.java
@@ -0,0 +1,169 @@
+package com.demcha.compose.document.api;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.dsl.TimelineBuilder;
+import com.demcha.compose.document.dsl.TimelineMarker;
+import com.demcha.compose.document.layout.LayoutGraph;
+import com.demcha.compose.document.layout.PlacedNode;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * Where the axis column starts, and what a later phase may and may not read off it.
+ *
+ * Measured, and the answer has two halves. The column's x is steady under markers
+ * of every size, which is the property a rail needs. Its width is not the column's:
+ * the section placed in the column shrinks to the marker it holds, so a 6pt dot reports 6pt
+ * and a 14pt disc 14pt in a column declared at 20. The declared width acts as a cap, not as
+ * the section's size.
+ *
+ * That matters for the phases that derive the rail. {@code axisX + axisWidth * alignment}
+ * cannot take {@code axisWidth} from the graph — the column's own slot is not a node, and
+ * the number that is there belongs to the marker. Reading it would put the rail
+ * back where it is today, drifting with marker size. The marker's own resolved box is the
+ * thing to anchor on, which is what the resolved-layout seam reports.
+ *
+ * Both sizing strategies are asserted, because they resolve by different routes: a
+ * weight is a share of what the row has left, a fixed width is points. Neither is
+ * converted into the other — there is no row width at the point where that conversion
+ * would have to happen — so each has to be shown to hold on its own.
+ */
+class TimelineAxisColumnLayoutTest {
+
+ private static final DocumentColor RAIL = DocumentColor.rgb(150, 158, 172);
+ private static final DocumentColor INK = DocumentColor.rgb(20, 40, 70);
+
+ @Test
+ void aFixedAxisStartsAtTheSameXUnderMarkersOfEverySize() throws Exception {
+ List axis = axisColumns(t -> t.axisWidth(20));
+
+ assertThat(axis).hasSize(3);
+ assertThat(axis.stream().map(PlacedNode::placementX))
+ .as("same x under a 6pt dot, a 14pt disc and a 24pt one")
+ .containsOnly(axis.get(0).placementX());
+ }
+
+ @Test
+ void aWeightedAxisStartsAtTheSameXToo() throws Exception {
+ List axis = axisColumns(t -> t.markerColumnWeight(0.12));
+
+ assertThat(axis.stream().map(PlacedNode::placementX))
+ .containsOnly(axis.get(0).placementX());
+ }
+
+ @Test
+ void theSectionInTheAxisIsTheMarkersWidthAndNotTheColumnsSoNoRailMayBeDerivedFromIt() throws Exception {
+ // The measurement that decides how the rail is built later. Under a 20pt axis the
+ // three markers report 6, 14 and 20 — their own widths, with the declared width
+ // acting as a cap on the last. A rail computed as axisX + width/2 from these
+ // numbers would sit at three different x down one timeline, which is the defect
+ // being reworked, not a new one.
+ List widths = axisColumns(t -> t.axisWidth(20)).stream()
+ .map(PlacedNode::placementWidth).toList();
+
+ assertThat(widths)
+ .as("the marker's width, capped by the column, not the column's width")
+ .containsExactly(6.0, 14.0, 20.0);
+ }
+
+ @Test
+ void theDefaultAxisIsAShareOfTheRowAndSoMovesWithThePageWidth() throws Exception {
+ // The assertion no single-page snapshot can make, and the one that catches the
+ // conversion this phase forbids. Converting the default weight into points is
+ // right on exactly one page width: 0.10 resolves to 24pt on the 320pt page the
+ // layout snapshots use, so replacing the default with Fixed(24) leaves both of
+ // them green — and moves every timeline on any other width. Here the same
+ // timeline is laid out twice, and a share has to give two answers.
+ double narrow = contentColumnX(320);
+ double wide = contentColumnX(480);
+
+ assertThat(narrow)
+ .as("a weighted axis is wider on a wider page, so the content starts further in")
+ .isLessThan(wide);
+ }
+
+ @Test
+ void aFixedAxisDoesNotMoveWithThePageWidth() throws Exception {
+ // The other half of the same fact, and the reason both strategies exist: points
+ // are points. A caller who wants the markers the same distance from the edge on
+ // every page asks for this one.
+ assertThat(contentColumnX(320, t -> t.axisWidth(20)))
+ .as("same margin, same gutter, same 20pt axis — so the content starts in the same place")
+ .isEqualTo(contentColumnX(480, t -> t.axisWidth(20)), within(1e-9));
+ }
+
+ @Test
+ void aWeightAndAPointWidthAreNotTheSameNumberAndAreNotTreatedAsOne() throws Exception {
+ // The reason the two survive to the layout separately: on this page the default
+ // weight and a 20pt request land in different places. A builder that "helpfully"
+ // converted a weight into points would have to pick one page width to be right on,
+ // and would move every timeline already written on any other width.
+ double weighted = axisColumns(t -> t.markerColumnWeight(0.10)).get(0).placementX();
+ double fixed = axisColumns(t -> t.axisWidth(20)).get(0).placementX();
+
+ assertThat(weighted).isEqualTo(fixed, within(1e-9));
+
+ // Same x — both columns start at the entry's left edge — but the content beside
+ // them does not, because the axis they reserve is a different width.
+ double weightedContent = contentColumns(t -> t.markerColumnWeight(0.10)).get(0).placementX();
+ double fixedContent = contentColumns(t -> t.axisWidth(20)).get(0).placementX();
+ assertThat(weightedContent).isNotEqualTo(fixedContent);
+ }
+
+ /** The axis column of each entry, in entry order. */
+ private static List axisColumns(Consumer sizing) throws Exception {
+ return headerColumns(sizing, 0);
+ }
+
+ /** The content column beside the marker, in entry order. */
+ private static List contentColumns(Consumer sizing) throws Exception {
+ return headerColumns(sizing, 1);
+ }
+
+ private static List headerColumns(Consumer sizing, int index) throws Exception {
+ return columnsOf(timeline(sizing), index);
+ }
+
+ private static List columnsOf(LayoutGraph graph, int index) {
+ return graph.nodes().stream()
+ .filter(node -> node.parentPath() != null && node.parentPath().matches(".*RowNode\\[\\d+]$"))
+ .filter(node -> node.childIndex() == index)
+ .toList();
+ }
+
+ /** Where the content beside the marker starts, on a page of the given width. */
+ private static double contentColumnX(double pageWidth) throws Exception {
+ return contentColumnX(pageWidth, t -> { });
+ }
+
+ private static double contentColumnX(double pageWidth, Consumer sizing) throws Exception {
+ return columnsOf(timeline(sizing, pageWidth), 1).get(0).placementX();
+ }
+
+ private static LayoutGraph timeline(Consumer sizing) throws Exception {
+ return timeline(sizing, 320);
+ }
+
+ private static LayoutGraph timeline(Consumer sizing, double pageWidth) throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(pageWidth, 300)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ session.pageFlow().addTimeline(t -> {
+ t.connector(RAIL, 1.5);
+ sizing.accept(t);
+ t.entry(TimelineMarker.dot(6, INK), e -> e.title("Small"))
+ .entry(TimelineMarker.numbered(2, 14, INK, DocumentColor.WHITE), e -> e.title("Medium"))
+ .entry(TimelineMarker.numbered(3, 24, INK, DocumentColor.WHITE), e -> e.title("Large"));
+ }).build();
+ return session.layoutGraph();
+ }
+ }
+}
From cbbd0f052debd359b1025fcdd78cfd920bcf8eff Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Wed, 9 Sep 2026 19:12:05 +0100
Subject: [PATCH 10/24] feat(timeline): let a caller bring their own marker
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The four built-in shapes were the only markers a timeline could have. Anything
else — a ring around a disc, a date pill, an icon — meant a new factory inside
TimelineMarker, which is to say a change to the library for a change to one
document.
custom(width, height, recipe) takes a declared box and a recipe that fills it,
and the four built-ins now go through the same door. The box is declared, not
measured, and need not be square: a marker of three stacked shapes has one box
exactly as a marker of one ellipse does, so the geometry around it cannot tell
how it was built. That is asserted, not assumed — a three-shape marker and a
one-shape marker of the same declared box put the content beside them in the
same place — and a baseline shows both actually painted, because a recipe that
drew nothing would satisfy every layout assertion here.
The recipe stays an internal Consumer. Nothing outside this package calls it, so
it can grow later — a marker that needs to know it is the first or the last —
without any of that reaching the public factories.
TimelineMarker.size() is gone. Its documentation said the size laid out the rail
column; it never did, the column comes from the axis, and nothing read the field
at all.
---
CHANGELOG.md | 12 ++++
.../compose/document/dsl/TimelineMarker.java | 63 ++++++++++++++----
.../document/dsl/TimelineMarkerBounds.java | 43 ++++++++++++
.../document/dsl/TimelineBuilderTest.java | 32 +++++++++
knowledge/api/authoring.json | 24 ++++++-
knowledge/api/authoring.md | 3 +-
.../api/TimelineAxisColumnLayoutTest.java | 36 ++++++++++
.../visual/TimelineRailVisualTest.java | 42 ++++++++++++
.../timeline-dsl/custom-marker-page-0.png | Bin 0 -> 4590 bytes
9 files changed, 241 insertions(+), 14 deletions(-)
create mode 100644 core/src/main/java/com/demcha/compose/document/dsl/TimelineMarkerBounds.java
create mode 100644 qa/src/test/resources/visual-baselines/timeline-dsl/custom-marker-page-0.png
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8b98866e7..094f4c743 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,18 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A timeline marker can be anything you can draw.**
+ `TimelineMarker.custom(width, height, recipe)` takes a declared box and a recipe that
+ fills it, so a marker made of three stacked shapes, a bordered pill or an icon needs no
+ change to `TimelineBuilder` — the four built-in factories now go through the same door.
+ The box is declared rather than measured, and it does not have to be square; a marker
+ drawn as several fragments has one box exactly as a marker drawn as one does, which is
+ what keeps the geometry around it independent of how the marker was built.
+
+ Fixed along the way: `TimelineMarker`'s documentation said the marker's size laid out the
+ rail column. It never did — the column's width comes from `markerColumnWeight(...)` or
+ `axisWidth(...)`, and the field the sentence pointed at was read by nothing.
+
- **A timeline's marker column can be given a width in points.**
`TimelineBuilder.axisWidth(double)` is the peer of `markerColumnWeight(double)`: points
rather than a share of the row. Reach for it when the markers should sit the same
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineMarker.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineMarker.java
index 17bcb9be3..149817041 100644
--- a/core/src/main/java/com/demcha/compose/document/dsl/TimelineMarker.java
+++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineMarker.java
@@ -4,6 +4,7 @@
import com.demcha.compose.document.style.*;
import com.demcha.compose.font.FontName;
+import java.util.Objects;
import java.util.function.Consumer;
/**
@@ -12,20 +13,29 @@
* factories and passed to
* {@link TimelineBuilder#entry(TimelineMarker, java.util.function.Consumer)}.
*
- * A marker carries its own {@link #size()} (used to lay out the rail column)
- * and a recipe that draws it into that column, so new marker shapes are one
- * factory method without touching the timeline layout.
+ * A marker is a declared box and a recipe that draws into it, so a new marker
+ * shape is one factory method and the timeline layout never learns what shape it
+ * is. {@link #custom(double, double, Consumer)} holds that door open for callers:
+ * anything that can be drawn into a column can be a marker, and nothing in
+ * {@link TimelineBuilder} needs to know about it.
+ *
+ * The box is declared, not measured. A marker drawn as three stacked
+ * shapes has one box, exactly as one drawn as a single ellipse does — whatever
+ * anchors on a marker must not be able to tell how the marker was built.
*
* @author Artem Demchyshyn
* @since 1.7.0
*/
public final class TimelineMarker {
- private final double size;
+ private final TimelineMarkerBounds bounds;
+ // Internal, and a Consumer: the timeline calls it and nothing else does, so its
+ // shape can grow later — a marker that wants to know it is the first or the last,
+ // say — without any of that reaching the public factories.
private final Consumer recipe;
- private TimelineMarker(double size, Consumer recipe) {
- this.size = size;
+ private TimelineMarker(TimelineMarkerBounds bounds, Consumer recipe) {
+ this.bounds = bounds;
this.recipe = recipe;
}
@@ -37,7 +47,7 @@ private TimelineMarker(double size, Consumer recipe) {
* @return the marker
*/
public static TimelineMarker dot(double size, DocumentColor color) {
- return new TimelineMarker(size, column -> column.addCircle(size, color));
+ return new TimelineMarker(TimelineMarkerBounds.square(size), column -> column.addCircle(size, color));
}
/**
@@ -49,7 +59,7 @@ public static TimelineMarker dot(double size, DocumentColor color) {
* @return the marker
*/
public static TimelineMarker circle(double size, DocumentColor fill, DocumentStroke stroke) {
- return new TimelineMarker(size, column -> column.addCircle(size, ellipse -> {
+ return new TimelineMarker(TimelineMarkerBounds.square(size), column -> column.addCircle(size, ellipse -> {
if (fill != null) {
ellipse.fillColor(fill);
}
@@ -77,7 +87,7 @@ public static TimelineMarker numbered(int number, double size,
.color(textColor == null ? DocumentColor.WHITE : textColor)
.build();
String text = Integer.toString(number);
- return new TimelineMarker(size, column -> column.addCircle(size, fill, disc -> disc
+ return new TimelineMarker(TimelineMarkerBounds.square(size), column -> column.addCircle(size, fill, disc -> disc
.center(new ParagraphBuilder()
.text(text)
.textStyle(label)
@@ -94,15 +104,44 @@ public static TimelineMarker numbered(int number, double size,
* @return the marker
*/
public static TimelineMarker square(double size, DocumentColor fill) {
- return new TimelineMarker(size, column -> column.addShape(shape -> shape
+ return new TimelineMarker(TimelineMarkerBounds.square(size), column -> column.addShape(shape -> shape
.name("TimelineMarkerSquare")
.size(size, size)
.fillColor(fill)
.margin(DocumentInsets.zero())));
}
- double size() {
- return size;
+ /**
+ * A marker of your own: any content, drawn into the marker column.
+ *
+ * The box is yours to declare and the timeline takes it at its word — it never
+ * measures what you drew. That is what makes a marker of several shapes behave like a
+ * marker of one:
+ * {@code
+ * TimelineMarker.custom(16, 16, column -> column.addLayerStack(stack -> stack
+ * .back(ring).center(disc).center(pip)));
+ * }
+ *
+ * @param width the marker's declared width in points
+ * @param height the marker's declared height in points
+ * @param recipe draws the marker into its column
+ * @return the marker
+ * @throws NullPointerException if {@code recipe} is null
+ * @throws IllegalArgumentException if a dimension is not positive and finite
+ * @since 2.4.0
+ */
+ public static TimelineMarker custom(double width, double height, Consumer recipe) {
+ Objects.requireNonNull(recipe, "recipe");
+ return new TimelineMarker(new TimelineMarkerBounds(width, height), recipe);
+ }
+
+ /**
+ * The box this marker declares for itself.
+ *
+ * @return the declared bounds
+ */
+ TimelineMarkerBounds bounds() {
+ return bounds;
}
void renderInto(SectionBuilder column) {
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineMarkerBounds.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineMarkerBounds.java
new file mode 100644
index 000000000..0d551f383
--- /dev/null
+++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineMarkerBounds.java
@@ -0,0 +1,43 @@
+package com.demcha.compose.document.dsl;
+
+/**
+ * The box a marker declares for itself, whatever it draws inside it.
+ *
+ * One box per marker, and it is a declaration rather than a measurement. A marker drawn
+ * as three stacked shapes has one box; a marker drawn as one ellipse has one box; the rail
+ * that later anchors on it cannot tell the two apart, which is the point. Reading the box
+ * back off whatever the marker happened to draw would make the rail's position depend on
+ * the marker's construction.
+ *
+ * Width and height separately, not one {@code size}: the four built-in markers are all
+ * square, but a custom one — a chevron, a date pill, an icon — need not be.
+ *
+ * @param width declared width in points
+ * @param height declared height in points
+ * @author Artem Demchyshyn
+ * @since 2.4.0
+ */
+record TimelineMarkerBounds(double width, double height) {
+
+ /**
+ * Validates the box.
+ *
+ * @throws IllegalArgumentException if either dimension is not positive and finite
+ */
+ TimelineMarkerBounds {
+ require(width, "width");
+ require(height, "height");
+ }
+
+ /** A square box, which is what every built-in marker declares. */
+ static TimelineMarkerBounds square(double size) {
+ return new TimelineMarkerBounds(size, size);
+ }
+
+ private static void require(double value, String name) {
+ if (!(value > 0) || Double.isInfinite(value)) {
+ throw new IllegalArgumentException(
+ "A timeline marker's " + name + " must be a positive finite number of points, got: " + value);
+ }
+ }
+}
diff --git a/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java b/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java
index b8af73430..f34bba308 100644
--- a/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java
+++ b/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java
@@ -558,6 +558,38 @@ void aColumnSlotIsDeclaredOnceNotAssigned() {
// --- markers -------------------------------------------------------------
+ @Test
+ void aCustomMarkerRendersWithoutTheBuilderKnowingWhatItIs() {
+ // The point of the factory: three shapes and a caller's own arrangement reach the
+ // marker column, and nothing in TimelineBuilder was told about any of it.
+ SectionNode timeline = timelineOf(t -> t
+ .entry(TimelineMarker.custom(16, 16, column -> column.addLayerStack(stack -> stack
+ .back(new EllipseNode("ring", 16, 16, NAVY, null, null, null, null, null))
+ .center(new EllipseNode("disc", 10, 10, DocumentColor.WHITE,
+ null, null, null, null, null))
+ .center(new EllipseNode("pip", 4, 4, NAVY, null, null, null, null, null)))),
+ e -> e.title("Custom")));
+
+ DocumentNode marker = ((SectionNode) header(entry(timeline, 0)).children().get(0))
+ .children().get(0);
+ assertThat(marker).isInstanceOf(LayerStackNode.class);
+ assertThat(((LayerStackNode) marker).layers()).hasSize(3);
+ }
+
+ @Test
+ void aCustomMarkerDeclaresABoxTheTimelineTakesAtItsWord() {
+ // Declared rather than measured, and not required to be square: the box is what a
+ // rail anchors on, and it must not depend on what the recipe happened to draw.
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> TimelineMarker.custom(0, 16, column -> { }))
+ .withMessageContaining("width must be a positive finite");
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> TimelineMarker.custom(16, Double.NaN, column -> { }))
+ .withMessageContaining("height must be a positive finite");
+ assertThatNullPointerException()
+ .isThrownBy(() -> TimelineMarker.custom(16, 16, null));
+ }
+
@Test
void everyMarkerFactoryPutsItsOwnShapeInTheMarkerColumn() {
assertThat(markerNode(TimelineMarker.dot(8, NAVY))).isInstanceOf(EllipseNode.class);
diff --git a/knowledge/api/authoring.json b/knowledge/api/authoring.json
index c6be4216a..ab2cfa363 100644
--- a/knowledge/api/authoring.json
+++ b/knowledge/api/authoring.json
@@ -23,7 +23,7 @@
],
"counts": {
"types": 233,
- "methods": 2083,
+ "methods": 2084,
"constants": 230,
"generated": 1111
},
@@ -15502,6 +15502,28 @@
"name": "fill"
}
]
+ },
+ {
+ "kind": "method",
+ "name": "custom",
+ "static": true,
+ "origin": "source",
+ "typeParameters": null,
+ "returns": "TimelineMarker",
+ "params": [
+ {
+ "type": "double",
+ "name": "width"
+ },
+ {
+ "type": "double",
+ "name": "height"
+ },
+ {
+ "type": "Consumer",
+ "name": "recipe"
+ }
+ ]
}
]
},
diff --git a/knowledge/api/authoring.md b/knowledge/api/authoring.md
index 284e867b3..48615161e 100644
--- a/knowledge/api/authoring.md
+++ b/knowledge/api/authoring.md
@@ -28,7 +28,7 @@ note: "Generated from the pinned artifact's class files. Authoritative closed se
**GraphCompose version:** 2.4.0-SNAPSHOT
-Types: 233 · methods: 2083 · constants: 230 · compiler-generated members: 1111
+Types: 233 · methods: 2084 · constants: 230 · compiler-generated members: 1111
## com.demcha.compose
@@ -1156,6 +1156,7 @@ Types: 233 · methods: 2083 · constants: 230 · compiler-generated members: 111
- `TimelineMarker circle(double size, DocumentColor fill, DocumentStroke stroke)`
- `TimelineMarker numbered(int number, double size, DocumentColor fill, DocumentColor textColor)`
- `TimelineMarker square(double size, DocumentColor fill)`
+- `TimelineMarker custom(double width, double height, Consumer recipe)`
### TocBuilder (class)
- `new TocBuilder()`
diff --git a/qa/src/test/java/com/demcha/compose/document/api/TimelineAxisColumnLayoutTest.java b/qa/src/test/java/com/demcha/compose/document/api/TimelineAxisColumnLayoutTest.java
index 4fd114fa0..d5ef72df3 100644
--- a/qa/src/test/java/com/demcha/compose/document/api/TimelineAxisColumnLayoutTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/api/TimelineAxisColumnLayoutTest.java
@@ -5,6 +5,7 @@
import com.demcha.compose.document.dsl.TimelineMarker;
import com.demcha.compose.document.layout.LayoutGraph;
import com.demcha.compose.document.layout.PlacedNode;
+import com.demcha.compose.document.node.EllipseNode;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
import org.junit.jupiter.api.Test;
@@ -99,6 +100,23 @@ void aFixedAxisDoesNotMoveWithThePageWidth() throws Exception {
.isEqualTo(contentColumnX(480, t -> t.axisWidth(20)), within(1e-9));
}
+ @Test
+ void aMarkerOfThreeShapesSitsExactlyWhereAMarkerOfOneDoes() throws Exception {
+ // The contract a rail will depend on: how a marker is built must not be visible in
+ // the geometry around it. Both of these declare 16×16; one draws a single ellipse
+ // and the other draws three stacked ones.
+ double single = axisAndContent(TimelineMarker.dot(16, INK));
+ double composed = axisAndContent(TimelineMarker.custom(16, 16, column ->
+ column.addLayerStack(stack -> stack
+ .back(circle(16, INK))
+ .center(circle(10, DocumentColor.WHITE))
+ .center(circle(4, INK)))));
+
+ assertThat(composed)
+ .as("three fragments or one, the content beside the marker starts in the same place")
+ .isEqualTo(single, within(1e-9));
+ }
+
@Test
void aWeightAndAPointWidthAreNotTheSameNumberAndAreNotTreatedAsOne() throws Exception {
// The reason the two survive to the layout separately: on this page the default
@@ -138,6 +156,24 @@ private static List columnsOf(LayoutGraph graph, int index) {
.toList();
}
+ private static EllipseNode circle(double size, DocumentColor fill) {
+ return new EllipseNode("marker", size, size, fill, null, null, null, null, null);
+ }
+
+ /** Where the content beside one marker starts, with a fixed axis wide enough to hold it. */
+ private static double axisAndContent(TimelineMarker marker) throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(320, 300)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ session.pageFlow().addTimeline(t -> t
+ .connector(RAIL, 1.5)
+ .axisWidth(20)
+ .entry(marker, e -> e.title("Beside"))).build();
+ return columnsOf(session.layoutGraph(), 1).get(0).placementX();
+ }
+ }
+
/** Where the content beside the marker starts, on a page of the given width. */
private static double contentColumnX(double pageWidth) throws Exception {
return contentColumnX(pageWidth, t -> { });
diff --git a/qa/src/test/java/com/demcha/testing/visual/TimelineRailVisualTest.java b/qa/src/test/java/com/demcha/testing/visual/TimelineRailVisualTest.java
index 65c10696f..f76713e8f 100644
--- a/qa/src/test/java/com/demcha/testing/visual/TimelineRailVisualTest.java
+++ b/qa/src/test/java/com/demcha/testing/visual/TimelineRailVisualTest.java
@@ -3,9 +3,11 @@
import com.demcha.compose.GraphCompose;
import com.demcha.compose.document.api.DocumentSession;
import com.demcha.compose.document.dsl.TimelineMarker;
+import com.demcha.compose.document.node.EllipseNode;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentRowColumn;
+import com.demcha.compose.document.style.DocumentStroke;
import com.demcha.compose.testing.visual.PdfVisualRegression;
import org.junit.jupiter.api.Test;
@@ -87,4 +89,44 @@ void aDateColumnRendersBesideTheRailAndTheMarkersStayInLine() throws Exception {
VISUAL.assertMatchesBaseline("timeline-dsl/leading-column", session);
}
}
+
+ @Test
+ void aCustomMarkerIsDrawnWhateverItIsMadeOf() throws Exception {
+ // A marker the timeline has never heard of: a ring, a disc and a pip stacked, and
+ // a bordered pill. Both declare their own box and neither needed a line in
+ // TimelineBuilder. The baseline is here because "it lays out" and "it is painted"
+ // are different claims — a marker recipe that drew nothing would pass every layout
+ // assertion in this repository.
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(300, 170)
+ .margin(DocumentInsets.of(18))
+ .create()) {
+ session.pageFlow()
+ .addTimeline(t -> t
+ .connector(RAIL, 1.5)
+ .spacing(12)
+ .axisWidth(22)
+ .entry(TimelineMarker.custom(18, 18, column -> column
+ .addLayerStack(stack -> stack
+ .back(circle(18, INK))
+ .center(circle(11, DocumentColor.WHITE))
+ .center(circle(5, INK)))),
+ e -> e.title("Composed of three").meta("one declared box"))
+ .entry(TimelineMarker.custom(22, 12, column -> column
+ .addShape(shape -> shape
+ .size(22, 12)
+ .cornerRadius(6)
+ .fillColor(DocumentColor.WHITE)
+ .stroke(DocumentStroke.of(INK, 1.0))
+ .margin(DocumentInsets.zero()))),
+ e -> e.title("Not square either")))
+ .build();
+
+ VISUAL.assertMatchesBaseline("timeline-dsl/custom-marker", session);
+ }
+ }
+
+ private static EllipseNode circle(double size, DocumentColor fill) {
+ return new EllipseNode("marker", size, size, fill, null, null, null, null, null);
+ }
}
diff --git a/qa/src/test/resources/visual-baselines/timeline-dsl/custom-marker-page-0.png b/qa/src/test/resources/visual-baselines/timeline-dsl/custom-marker-page-0.png
new file mode 100644
index 0000000000000000000000000000000000000000..81d1d24bc000da3fd8d35ca1c647e3bdef94dd9f
GIT binary patch
literal 4590
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
i{10~$|A&FPk|oXtOK%pj8r
Date: Wed, 9 Sep 2026 19:33:02 +0100
Subject: [PATCH 11/24] feat(timeline): make every marker report where it
landed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The rail is a left border on each entry section, which is why it sits at the
section's edge rather than through the markers, and why it cannot know where a
marker of a different size ended up. Computing it from the markers needs the
markers' resolved positions, and a node definition is handed its own box and
nothing else.
Each marker is now wrapped in an anchor, so the finished layout carries one per
marker: its own box rather than its column's, on the page the marker is actually
on, and the same box whether the marker drew one shape or three. The wrapper
goes inside the row's column rather than being it — a row hosts a fixed set of
child types and this is not one of them, and widening a public builder's
contract for an internal wrapper is the wrong trade.
Identity is an owner object allocated once per timeline, so a pass asks for its
own markers and gets that timeline's and nobody else's; two timelines on a page
never merge, which a test holds by making one owner shared and watching it fail.
No path, no node name, no string convention.
Nothing is drawn and nothing moves. All three pixel baselines pass untouched,
every node box that was in the graph is still there at the same coordinates and
the page counts are unchanged; what the wrapper adds is two nodes per marker and
a level in the paths. The three layout snapshots are re-recorded on that
evidence, not on the strength of the tests going green.
---
CHANGELOG.md | 10 +
.../document/dsl/TimelineAnchorKind.java | 16 ++
.../compose/document/dsl/TimelineBuilder.java | 53 +++-
.../document/dsl/TimelineRailOwner.java | 36 +++
.../compose/document/dsl/TimelineSpec.java | 5 +-
.../document/dsl/TimelineBuilderTest.java | 70 ++++-
.../api/TimelineMarkerAnchorTest.java | 187 +++++++++++++
.../document/timeline_classic.json | 200 +++++++++++++-
.../document/timeline_leading_column.json | 258 +++++++++++++++++-
.../document/timeline_paginated.json | 128 ++++++++-
10 files changed, 927 insertions(+), 36 deletions(-)
create mode 100644 core/src/main/java/com/demcha/compose/document/dsl/TimelineAnchorKind.java
create mode 100644 core/src/main/java/com/demcha/compose/document/dsl/TimelineRailOwner.java
create mode 100644 qa/src/test/java/com/demcha/compose/document/api/TimelineMarkerAnchorTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 094f4c743..9f858ae1e 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -152,6 +152,16 @@ follow semantic versioning; release dates are ISO 8601.
middle — while horizontal margins come off every slice as before. **An anchor whose
content fits on one page is unchanged**, which is every anchor that exists today.
+- **A timeline's markers now report where they landed, and the marker column gains a
+ level in the node tree.** Each marker is wrapped so the finished layout carries one
+ resolved anchor per marker — one box however many shapes the marker drew, on the page
+ the marker is actually on — which is what the rail will be computed from instead of a
+ per-entry section border. Nothing is drawn for it and nothing moves: every existing
+ timeline renders pixel for pixel as it did, and every box that was in the layout graph is
+ still there at the same coordinates. What changes is the *paths*: the marker's column now
+ holds a wrapper holding the marker, so a **committed layout snapshot that includes a
+ timeline needs re-recording** — check that the diff is only added wrapper entries and
+ renamed paths before approving it, as the three snapshots in this repository were.
- **A built-in feature can now draw from geometry the layout has already resolved.**
Some things cannot be drawn while laying out because they depend on where other things
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineAnchorKind.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineAnchorKind.java
new file mode 100644
index 000000000..03b57ddc1
--- /dev/null
+++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineAnchorKind.java
@@ -0,0 +1,16 @@
+package com.demcha.compose.document.dsl;
+
+/**
+ * What a timeline anchor marks.
+ *
+ * An enum constant because the resolved-layout seam compares an anchor's kind by
+ * reference — a string would work or fail on interning, which is why it refuses one.
+ *
+ * @author Artem Demchyshyn
+ * @since 2.4.0
+ */
+enum TimelineAnchorKind {
+
+ /** The marker in an entry's axis column. */
+ MARKER
+}
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineBuilder.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineBuilder.java
index ca2b091f3..f1e6da48b 100644
--- a/core/src/main/java/com/demcha/compose/document/dsl/TimelineBuilder.java
+++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineBuilder.java
@@ -1,5 +1,8 @@
package com.demcha.compose.document.dsl;
+import com.demcha.compose.document.layout.LayoutAnchorId;
+import com.demcha.compose.document.layout.LayoutAnchorNode;
+import com.demcha.compose.document.node.DocumentNode;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentRowColumn;
@@ -365,8 +368,12 @@ private TimelineSpec normalize() {
}
specs.add(entry.normalize(resolvedTitle, resolvedMeta, resolvedBody));
}
- return new TimelineSpec(new TimelineRailSpec(connectorColor, connectorWidth),
- leadingColumn, gutter, markerGap, axis, entrySpacing,
+ // One owner per timeline, allocated here. Every marker below anchors on this
+ // instance, so the pass that draws the rail asks for it and gets these markers and
+ // nobody else's — two timelines on a page never merge.
+ TimelineRailSpec railSpec = new TimelineRailSpec(connectorColor, connectorWidth);
+ return new TimelineSpec(new TimelineRailOwner(railSpec),
+ railSpec, leadingColumn, gutter, markerGap, axis, entrySpacing,
keepTogether, keepEntriesTogether, List.copyOf(specs));
}
@@ -383,6 +390,7 @@ private static void layout(TimelineSpec spec, SectionBuilder timeline) {
List entries = spec.entries();
for (int i = 0; i < entries.size(); i++) {
TimelineEntrySpec entry = entries.get(i);
+ int index = i;
boolean last = i == entries.size() - 1;
double bottom = last ? 0.0 : spec.entrySpacing();
timeline.addSection(section -> {
@@ -409,10 +417,7 @@ private static void layout(TimelineSpec spec, SectionBuilder timeline) {
// still start its marker where every other entry starts one.
header.addSection(entry.leading() == null ? column -> { } : entry.leading());
}
- header.addSection(markerColumn -> {
- markerColumn.spacing(0);
- entry.marker().renderInto(markerColumn);
- });
+ header.addSection(anchoredMarker(spec, entry, index));
header.addSection(entry.beside());
});
entry.below().accept(section);
@@ -420,6 +425,42 @@ private static void layout(TimelineSpec spec, SectionBuilder timeline) {
}
}
+ /**
+ * The marker's column, wrapped so the finished layout reports where the marker landed.
+ *
+ * The wrapper is what makes a marker one box however many fragments it drew: the
+ * anchor reports the wrapped node's own box, so a ring-disc-pip marker and a plain dot
+ * of the same size resolve identically. It adds a level to the layout paths — anything
+ * keying on those, a snapshot for one, sees it — and no geometry: the wrapper measures
+ * to its child and adds no spacing.
+ *
+ * The anchor sits inside the row's column rather than being the column. A
+ * row hosts a fixed set of child types and an anchor is not one of them, and widening
+ * that list — a public builder's contract — for an internal wrapper would be the wrong
+ * trade. Wrapping the marker rather than its column is also the truer statement of what
+ * is being anchored, and it is what a recipe that draws several nodes needs: they
+ * become one box here.
+ *
+ * @param spec the timeline, for its owner
+ * @param entry the entry whose marker this is
+ * @param index the entry's position, which becomes the anchor's index
+ * @return the marker column, with the marker anchored inside it
+ */
+ private static Consumer anchoredMarker(TimelineSpec spec,
+ TimelineEntrySpec entry,
+ int index) {
+ SectionBuilder drawn = new SectionBuilder();
+ drawn.spacing(0);
+ entry.marker().renderInto(drawn);
+ DocumentNode anchored = new LayoutAnchorNode("",
+ new LayoutAnchorId(spec.owner(), TimelineAnchorKind.MARKER, index),
+ drawn.build());
+ return markerColumn -> {
+ markerColumn.spacing(0);
+ markerColumn.add(anchored);
+ };
+ }
+
/**
* Hands the axis size to the row in the row's own vocabulary.
*
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailOwner.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailOwner.java
new file mode 100644
index 000000000..d7f247b9d
--- /dev/null
+++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailOwner.java
@@ -0,0 +1,36 @@
+package com.demcha.compose.document.dsl;
+
+/**
+ * One timeline's identity, and the configuration its rail will be drawn from.
+ *
+ * Every marker in a timeline anchors on the same instance of this, so the pass that
+ * later draws the rail asks for its owner and gets back that timeline's markers
+ * and nobody else's. Two timelines on one page are two owners and never merge, and a pass
+ * asking for an owner the document does not contain gets an empty list — which is how it
+ * decides it has nothing to do, with no document inspection and no feature flag.
+ *
+ * Deliberately a plain final class and not a record. The seam compares these with
+ * {@code ==}, and a record invites the reader to think in value equality: two timelines
+ * configured identically are still two timelines, and a record would make them look like
+ * one.
+ *
+ * @author Artem Demchyshyn
+ * @since 2.4.0
+ */
+final class TimelineRailOwner {
+
+ private final TimelineRailSpec rail;
+
+ TimelineRailOwner(TimelineRailSpec rail) {
+ this.rail = rail;
+ }
+
+ /**
+ * The rail this timeline asked for.
+ *
+ * @return the rail spec
+ */
+ TimelineRailSpec rail() {
+ return rail;
+ }
+}
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineSpec.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineSpec.java
index 0916938c0..aca15764f 100644
--- a/core/src/main/java/com/demcha/compose/document/dsl/TimelineSpec.java
+++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineSpec.java
@@ -13,6 +13,8 @@
* the point — a second authoring API can produce the same spec, and the layout will not be
* able to tell which one it came from.
*
+ * @param owner this timeline's identity, which every one of its markers
+ * anchors on and no other timeline's markers do
* @param rail the connector rail
* @param leadingColumn how wide the column before the marker is, or null when the
* timeline has no leading column at all
@@ -27,7 +29,8 @@
* @author Artem Demchyshyn
* @since 2.4.0
*/
-record TimelineSpec(TimelineRailSpec rail,
+record TimelineSpec(TimelineRailOwner owner,
+ TimelineRailSpec rail,
DocumentRowColumn leadingColumn,
double gutter,
double markerGap,
diff --git a/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java b/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java
index f34bba308..eec0411f8 100644
--- a/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java
+++ b/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java
@@ -1,5 +1,7 @@
package com.demcha.compose.document.dsl;
+import com.demcha.compose.document.layout.LayoutAnchorId;
+import com.demcha.compose.document.layout.LayoutAnchorNode;
import com.demcha.compose.document.node.DocumentNode;
import com.demcha.compose.document.node.EllipseNode;
import com.demcha.compose.document.node.LayerStackNode;
@@ -461,7 +463,7 @@ void aLeadingColumnPutsAThirdColumnBeforeTheMarker() {
assertThat(paragraphTexts(header.children().get(0)))
.as("leading first, before the marker")
.containsExactly("2023");
- assertThat(((SectionNode) header.children().get(1)).children().get(0))
+ assertThat(markerContent(header.children().get(1)))
.as("then the marker")
.isInstanceOf(EllipseNode.class);
assertThat(paragraphTexts(header.children().get(2))).containsExactly("Senior Engineer");
@@ -484,7 +486,7 @@ void anEntryWithNoLeadingContentStillGetsTheColumn() {
assertThat(paragraphTexts(withoutLeading.children().get(0)))
.as("the first is simply empty")
.isEmpty();
- assertThat(((SectionNode) withoutLeading.children().get(1)).children().get(0))
+ assertThat(markerContent(withoutLeading.children().get(1)))
.as("so the marker is still the second column, as in the entry above")
.isInstanceOf(EllipseNode.class);
}
@@ -570,12 +572,48 @@ void aCustomMarkerRendersWithoutTheBuilderKnowingWhatItIs() {
.center(new EllipseNode("pip", 4, 4, NAVY, null, null, null, null, null)))),
e -> e.title("Custom")));
- DocumentNode marker = ((SectionNode) header(entry(timeline, 0)).children().get(0))
- .children().get(0);
+ DocumentNode marker = markerContent(header(entry(timeline, 0)).children().get(0));
assertThat(marker).isInstanceOf(LayerStackNode.class);
assertThat(((LayerStackNode) marker).layers()).hasSize(3);
}
+ @Test
+ void aMarkerIsWrappedInAnAnchorSoTheLayoutCanReportWhereItLanded() {
+ // The wrapper every other test in this file reads through. It is what turns "the
+ // marker drew three shapes" into one box the finished layout can report, and it
+ // sits inside the row's column rather than being it — a row hosts a fixed set of
+ // child types and this is not one of them.
+ SectionNode timeline = timelineOf(t -> t
+ .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x"))
+ .entry(TimelineMarker.dot(8, NAVY), e -> e.title("y")));
+
+ DocumentNode first = header(entry(timeline, 0)).children().get(0).children().get(0);
+ DocumentNode second = header(entry(timeline, 1)).children().get(0).children().get(0);
+ assertThat(first).isInstanceOf(LayoutAnchorNode.class);
+
+ LayoutAnchorId firstId = ((LayoutAnchorNode) first).id();
+ LayoutAnchorId secondId = ((LayoutAnchorNode) second).id();
+ assertThat(firstId.index()).as("the entry's position").isZero();
+ assertThat(secondId.index()).isEqualTo(1);
+ assertThat(firstId.kind()).isSameAs(secondId.kind());
+ assertThat(firstId.groupKey())
+ .as("one owner for the whole timeline, so its markers find each other")
+ .isSameAs(secondId.groupKey());
+ }
+
+ @Test
+ void twoTimelinesOnAPageAnchorOnDifferentOwners() {
+ // The property that lets a pass ask for its own markers and get nobody else's.
+ SectionNode page = new SectionBuilder()
+ .addTimeline(t -> t.entry(TimelineMarker.dot(8, NAVY), e -> e.title("first timeline")))
+ .addTimeline(t -> t.entry(TimelineMarker.dot(8, NAVY), e -> e.title("second timeline")))
+ .build();
+
+ Object first = ownerOf((SectionNode) page.children().get(0));
+ Object second = ownerOf((SectionNode) page.children().get(1));
+ assertThat(first).isNotSameAs(second);
+ }
+
@Test
void aCustomMarkerDeclaresABoxTheTimelineTakesAtItsWord() {
// Declared rather than measured, and not required to be square: the box is what a
@@ -647,8 +685,28 @@ private static RowNode header(SectionNode entry) {
private static DocumentNode markerNode(TimelineMarker marker) {
SectionNode timeline = timelineOf(t -> t.entry(marker, e -> e.title("x")));
- SectionNode markerColumn = (SectionNode) header(entry(timeline, 0)).children().get(0);
- return markerColumn.children().get(0);
+ return markerContent(header(entry(timeline, 0)).children().get(0));
+ }
+
+ /**
+ * What the marker's recipe drew, reached through the anchor that wraps it.
+ *
+ * The column holds a {@code LayoutAnchorNode} holding the drawn marker, so that the
+ * finished layout reports one box per marker however many shapes it took to draw. The
+ * unwrapping lives here rather than in each test, and
+ * {@link #aMarkerIsWrappedInAnAnchorSoTheLayoutCanReportWhereItLanded()} is where the
+ * wrapper itself is asserted.
+ */
+ private static DocumentNode markerContent(DocumentNode markerColumn) {
+ DocumentNode anchor = markerColumn.children().get(0);
+ assertThat(anchor).isInstanceOf(LayoutAnchorNode.class);
+ return anchor.children().get(0).children().get(0);
+ }
+
+ /** The owner every marker in one timeline anchors on. */
+ private static Object ownerOf(SectionNode timeline) {
+ DocumentNode anchor = header(entry(timeline, 0)).children().get(0).children().get(0);
+ return ((LayoutAnchorNode) anchor).id().groupKey();
}
private static List paragraphsOf(DocumentNode parent) {
diff --git a/qa/src/test/java/com/demcha/compose/document/api/TimelineMarkerAnchorTest.java b/qa/src/test/java/com/demcha/compose/document/api/TimelineMarkerAnchorTest.java
new file mode 100644
index 000000000..703f2a885
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/api/TimelineMarkerAnchorTest.java
@@ -0,0 +1,187 @@
+package com.demcha.compose.document.api;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.dsl.TimelineBuilder;
+import com.demcha.compose.document.dsl.TimelineMarker;
+import com.demcha.compose.document.layout.LayoutGraph;
+import com.demcha.compose.document.layout.PlacedFragment;
+import com.demcha.compose.document.layout.ResolvedLayoutAnchor;
+import com.demcha.compose.document.layout.ResolvedLayoutMetadata;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * Every timeline marker now reports where it landed.
+ *
+ * This is the first use of the resolved-layout seam: each marker is wrapped so the
+ * finished graph carries one anchor per marker, and a later phase computes the rail from
+ * those rather than from a section border. What is asserted here is what that later phase
+ * will depend on — one anchor per marker, on the page the marker is actually on, at the
+ * marker's own box and not its column's, and independent of how many shapes the marker
+ * took to draw.
+ *
+ * The anchors are read straight out of the compiled graph. Nothing registers a pass yet;
+ * a pass is what Phase 9 adds, and it will read exactly this.
+ */
+class TimelineMarkerAnchorTest {
+
+ private static final DocumentColor RAIL = DocumentColor.rgb(150, 158, 172);
+ private static final DocumentColor INK = DocumentColor.rgb(20, 40, 70);
+
+ @Test
+ void everyMarkerLeavesOneAnchorInTheFinishedGraph() throws Exception {
+ LayoutGraph graph = timeline(360, t -> t
+ .entry(TimelineMarker.dot(8, INK), e -> e.title("First"))
+ .entry(TimelineMarker.numbered(2, 14, INK, DocumentColor.WHITE), e -> e.title("Second"))
+ .entry(TimelineMarker.square(10, INK), e -> e.title("Third")));
+
+ List anchors = ResolvedLayoutMetadata.from(graph).anchors();
+ assertThat(anchors).hasSize(3);
+ assertThat(anchors.stream().map(a -> a.id().index()))
+ .as("indexed by entry, in document order")
+ .containsExactly(0, 1, 2);
+ assertThat(anchors.stream().map(a -> a.id().groupKey()).distinct())
+ .as("one owner for the timeline")
+ .hasSize(1);
+ }
+
+ @Test
+ void anAnchorIsTheMarkersOwnBoxAndNotItsColumns() throws Exception {
+ // The distinction the seam exists for. The marker column is 20pt wide here and the
+ // marker is 8pt; a rail taking the column would sit 6pt off the ink.
+ LayoutGraph graph = timeline(360, t -> t
+ .axisWidth(20)
+ .entry(TimelineMarker.dot(8, INK), e -> e.title("Only")));
+
+ ResolvedLayoutAnchor anchor = ResolvedLayoutMetadata.from(graph).anchors().get(0);
+ PlacedFragment ellipse = graph.fragments().stream()
+ .filter(f -> f.payload() != null
+ && f.payload().getClass().getSimpleName().contains("Ellipse"))
+ .findFirst().orElseThrow();
+
+ assertThat(anchor.width()).isEqualTo(8.0, within(1e-9));
+ assertThat(anchor.height()).isEqualTo(8.0, within(1e-9));
+ assertThat(anchor.x()).isEqualTo(ellipse.x(), within(1e-9));
+ assertThat(anchor.y()).isEqualTo(ellipse.y(), within(1e-9));
+ }
+
+ @Test
+ void aMarkerOfThreeShapesLeavesTheSameOneAnchorAsAMarkerOfOne() throws Exception {
+ // The binding contract: the number of fragments a marker draws must not be
+ // visible to whatever anchors on it.
+ ResolvedLayoutAnchor single = onlyAnchor(TimelineMarker.dot(16, INK));
+ ResolvedLayoutAnchor composed = onlyAnchor(TimelineMarker.custom(16, 16, column ->
+ column.addLayerStack(stack -> stack
+ .back(circle(16, INK))
+ .center(circle(10, DocumentColor.WHITE))
+ .center(circle(4, INK)))));
+
+ assertThat(composed.x()).isEqualTo(single.x(), within(1e-9));
+ assertThat(composed.y()).isEqualTo(single.y(), within(1e-9));
+ assertThat(composed.width()).isEqualTo(single.width(), within(1e-9));
+ assertThat(composed.height()).isEqualTo(single.height(), within(1e-9));
+ }
+
+ @Test
+ void anAnchorsLeftEdgeHoldsStillWhileItsCentreMovesWithTheMarker() throws Exception {
+ // Measured, and it is the whole reason the rail is anchored by a fraction plus an
+ // offset rather than by "the centre". Markers of different sizes share a left edge
+ // — they are left-packed in their column — so the centre of a 6pt marker and of a
+ // 24pt one are 9pt apart. A rail that wanted to pass through both centres could
+ // not; a rail placed at the left edge plus a constant can.
+ LayoutGraph graph = timeline(360, t -> t
+ .axisWidth(28)
+ .entry(TimelineMarker.dot(6, INK), e -> e.title("Small"))
+ .entry(TimelineMarker.dot(24, INK), e -> e.title("Large")));
+
+ List anchors = ResolvedLayoutMetadata.from(graph).anchors();
+ assertThat(anchors.get(0).x())
+ .as("same left edge whatever the marker's size")
+ .isEqualTo(anchors.get(1).x(), within(1e-9));
+ assertThat(anchors.get(1).pointX(0.5) - anchors.get(0).pointX(0.5))
+ .as("while the centres are half the size difference apart")
+ .isEqualTo(9.0, within(1e-9));
+ }
+
+ @Test
+ void anAnchorReportsThePageItsMarkerIsActuallyOn() throws Exception {
+ StringBuilder body = new StringBuilder();
+ for (int i = 0; i < 14; i++) {
+ body.append("Sentence ").append(i).append(" of a body long enough to run on. ");
+ }
+ LayoutGraph graph = timeline(320, 170, t -> t
+ .entry(TimelineMarker.dot(8, INK), e -> e.title("First").body(body.toString()))
+ .entry(TimelineMarker.dot(8, INK), e -> e.title("Second").body("Short.")));
+
+ assertThat(graph.totalPages()).isGreaterThan(1);
+ List anchors = ResolvedLayoutMetadata.from(graph).anchors();
+ assertThat(anchors).hasSize(2);
+ assertThat(anchors.get(0).pageIndex()).isZero();
+ assertThat(anchors.get(1).pageIndex())
+ .as("the second marker is pushed onto the next page and says so")
+ .isEqualTo(1);
+ }
+
+ @Test
+ void twoTimelinesOnOnePageKeepTheirMarkersApart() throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(360, 400).margin(DocumentInsets.of(20)).create()) {
+ session.pageFlow()
+ .addTimeline(t -> t.connector(RAIL, 1.5)
+ .entry(TimelineMarker.dot(8, INK), e -> e.title("A1"))
+ .entry(TimelineMarker.dot(8, INK), e -> e.title("A2")))
+ .addTimeline(t -> t.connector(RAIL, 1.5)
+ .entry(TimelineMarker.dot(8, INK), e -> e.title("B1")))
+ .build();
+
+ List anchors =
+ ResolvedLayoutMetadata.from(session.layoutGraph()).anchors();
+ assertThat(anchors).hasSize(3);
+
+ Object first = anchors.get(0).id().groupKey();
+ Object second = anchors.get(2).id().groupKey();
+ assertThat(first).isNotSameAs(second);
+ assertThat(ResolvedLayoutMetadata.from(session.layoutGraph())
+ .anchors(first, anchors.get(0).id().kind()))
+ .as("asking for one timeline's owner returns that timeline's markers only")
+ .hasSize(2);
+ }
+ }
+
+ private static com.demcha.compose.document.node.EllipseNode circle(double size, DocumentColor fill) {
+ return new com.demcha.compose.document.node.EllipseNode(
+ "marker", size, size, fill, null, null, null, null, null);
+ }
+
+ private static ResolvedLayoutAnchor onlyAnchor(TimelineMarker marker) throws Exception {
+ LayoutGraph graph = timeline(360, t -> t
+ .axisWidth(20)
+ .entry(marker, e -> e.title("Only")));
+ return ResolvedLayoutMetadata.from(graph).anchors().get(0);
+ }
+
+ private static LayoutGraph timeline(double pageWidth, Consumer spec) throws Exception {
+ return timeline(pageWidth, 400, spec);
+ }
+
+ private static LayoutGraph timeline(double pageWidth, double pageHeight,
+ Consumer spec) throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(pageWidth, pageHeight)
+ .margin(DocumentInsets.of(20))
+ .create()) {
+ session.pageFlow().addTimeline(t -> {
+ t.connector(RAIL, 1.5);
+ spec.accept(t);
+ }).build();
+ return session.layoutGraph();
+ }
+ }
+}
diff --git a/qa/src/test/resources/layout-snapshots/document/timeline_classic.json b/qa/src/test/resources/layout-snapshots/document/timeline_classic.json
index 8fc884ce1..80bbcc1a9 100644
--- a/qa/src/test/resources/layout-snapshots/document/timeline_classic.json
+++ b/qa/src/test/resources/layout-snapshots/document/timeline_classic.json
@@ -164,9 +164,9 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/EllipseNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
"entityName" : null,
- "entityKind" : "EllipseNode",
+ "entityKind" : "LayoutAnchorNode",
"parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]",
"childIndex" : 0,
"depth" : 6,
@@ -193,6 +193,66 @@
"bottom" : 0.0,
"left" : 0.0
}
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
+ "entityName" : null,
+ "entityKind" : "SectionNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
+ "childIndex" : 0,
+ "depth" : 7,
+ "layer" : 7,
+ "computedX" : 28.0,
+ "computedY" : 232.0,
+ "placementX" : 28.0,
+ "placementY" : 232.0,
+ "placementWidth" : 8.0,
+ "placementHeight" : 8.0,
+ "startPage" : 0,
+ "endPage" : 0,
+ "contentWidth" : 8.0,
+ "contentHeight" : 8.0,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ }
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/EllipseNode[0]",
+ "entityName" : null,
+ "entityKind" : "EllipseNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
+ "childIndex" : 0,
+ "depth" : 8,
+ "layer" : 8,
+ "computedX" : 28.0,
+ "computedY" : 232.0,
+ "placementX" : 28.0,
+ "placementY" : 232.0,
+ "placementWidth" : 8.0,
+ "placementHeight" : 8.0,
+ "startPage" : 0,
+ "endPage" : 0,
+ "contentWidth" : 8.0,
+ "contentHeight" : 8.0,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ }
}, {
"path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"entityName" : null,
@@ -404,9 +464,9 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/ShapeContainerNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
"entityName" : null,
- "entityKind" : "ShapeContainerNode",
+ "entityKind" : "LayoutAnchorNode",
"parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]",
"childIndex" : 0,
"depth" : 6,
@@ -434,14 +494,74 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/ShapeContainerNode[0]/ParagraphNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
"entityName" : null,
- "entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/ShapeContainerNode[0]",
+ "entityKind" : "SectionNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
"childIndex" : 0,
"depth" : 7,
"layer" : 7,
"computedX" : 28.0,
+ "computedY" : 179.175,
+ "placementX" : 28.0,
+ "placementY" : 179.175,
+ "placementWidth" : 14.0,
+ "placementHeight" : 14.0,
+ "startPage" : 0,
+ "endPage" : 0,
+ "contentWidth" : 14.0,
+ "contentHeight" : 14.0,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ }
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/ShapeContainerNode[0]",
+ "entityName" : null,
+ "entityKind" : "ShapeContainerNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
+ "childIndex" : 0,
+ "depth" : 8,
+ "layer" : 8,
+ "computedX" : 28.0,
+ "computedY" : 179.175,
+ "placementX" : 28.0,
+ "placementY" : 179.175,
+ "placementWidth" : 14.0,
+ "placementHeight" : 14.0,
+ "startPage" : 0,
+ "endPage" : 0,
+ "contentWidth" : 14.0,
+ "contentHeight" : 14.0,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ }
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/ShapeContainerNode[0]/ParagraphNode[0]",
+ "entityName" : null,
+ "entityKind" : "ParagraphNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/ShapeContainerNode[0]",
+ "childIndex" : 0,
+ "depth" : 9,
+ "layer" : 9,
+ "computedX" : 28.0,
"computedY" : 182.937,
"placementX" : 28.0,
"placementY" : 182.937,
@@ -674,9 +794,9 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[0]/TimelineMarkerSquare[0]",
- "entityName" : "TimelineMarkerSquare",
- "entityKind" : "ShapeNode",
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
+ "entityName" : null,
+ "entityKind" : "LayoutAnchorNode",
"parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[0]",
"childIndex" : 0,
"depth" : 6,
@@ -703,6 +823,66 @@
"bottom" : 0.0,
"left" : 0.0
}
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
+ "entityName" : null,
+ "entityKind" : "SectionNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
+ "childIndex" : 0,
+ "depth" : 7,
+ "layer" : 7,
+ "computedX" : 28.0,
+ "computedY" : 138.35,
+ "placementX" : 28.0,
+ "placementY" : 138.35,
+ "placementWidth" : 8.0,
+ "placementHeight" : 8.0,
+ "startPage" : 0,
+ "endPage" : 0,
+ "contentWidth" : 8.0,
+ "contentHeight" : 8.0,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ }
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/TimelineMarkerSquare[0]",
+ "entityName" : "TimelineMarkerSquare",
+ "entityKind" : "ShapeNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
+ "childIndex" : 0,
+ "depth" : 8,
+ "layer" : 8,
+ "computedX" : 28.0,
+ "computedY" : 138.35,
+ "placementX" : 28.0,
+ "placementY" : 138.35,
+ "placementWidth" : 8.0,
+ "placementHeight" : 8.0,
+ "startPage" : 0,
+ "endPage" : 0,
+ "contentWidth" : 8.0,
+ "contentHeight" : 8.0,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ }
}, {
"path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[1]",
"entityName" : null,
diff --git a/qa/src/test/resources/layout-snapshots/document/timeline_leading_column.json b/qa/src/test/resources/layout-snapshots/document/timeline_leading_column.json
index 55d6fb373..c1e581c90 100644
--- a/qa/src/test/resources/layout-snapshots/document/timeline_leading_column.json
+++ b/qa/src/test/resources/layout-snapshots/document/timeline_leading_column.json
@@ -224,9 +224,9 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/EllipseNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]",
"entityName" : null,
- "entityKind" : "EllipseNode",
+ "entityKind" : "LayoutAnchorNode",
"parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"childIndex" : 0,
"depth" : 6,
@@ -253,6 +253,66 @@
"bottom" : 0.0,
"left" : 0.0
}
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]",
+ "entityName" : null,
+ "entityKind" : "SectionNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]",
+ "childIndex" : 0,
+ "depth" : 7,
+ "layer" : 7,
+ "computedX" : 92.0,
+ "computedY" : 232.0,
+ "placementX" : 92.0,
+ "placementY" : 232.0,
+ "placementWidth" : 8.0,
+ "placementHeight" : 8.0,
+ "startPage" : 0,
+ "endPage" : 0,
+ "contentWidth" : 8.0,
+ "contentHeight" : 8.0,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ }
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]/EllipseNode[0]",
+ "entityName" : null,
+ "entityKind" : "EllipseNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]",
+ "childIndex" : 0,
+ "depth" : 8,
+ "layer" : 8,
+ "computedX" : 92.0,
+ "computedY" : 232.0,
+ "placementX" : 92.0,
+ "placementY" : 232.0,
+ "placementWidth" : 8.0,
+ "placementHeight" : 8.0,
+ "startPage" : 0,
+ "endPage" : 0,
+ "contentWidth" : 8.0,
+ "contentHeight" : 8.0,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ }
}, {
"path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[2]",
"entityName" : null,
@@ -494,9 +554,9 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]/EllipseNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]",
"entityName" : null,
- "entityKind" : "EllipseNode",
+ "entityKind" : "LayoutAnchorNode",
"parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]",
"childIndex" : 0,
"depth" : 6,
@@ -523,6 +583,66 @@
"bottom" : 0.0,
"left" : 0.0
}
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]",
+ "entityName" : null,
+ "entityKind" : "SectionNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]",
+ "childIndex" : 0,
+ "depth" : 7,
+ "layer" : 7,
+ "computedX" : 92.0,
+ "computedY" : 192.262,
+ "placementX" : 92.0,
+ "placementY" : 192.262,
+ "placementWidth" : 8.0,
+ "placementHeight" : 8.0,
+ "startPage" : 0,
+ "endPage" : 0,
+ "contentWidth" : 8.0,
+ "contentHeight" : 8.0,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ }
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]/EllipseNode[0]",
+ "entityName" : null,
+ "entityKind" : "EllipseNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]",
+ "childIndex" : 0,
+ "depth" : 8,
+ "layer" : 8,
+ "computedX" : 92.0,
+ "computedY" : 192.262,
+ "placementX" : 92.0,
+ "placementY" : 192.262,
+ "placementWidth" : 8.0,
+ "placementHeight" : 8.0,
+ "startPage" : 0,
+ "endPage" : 0,
+ "contentWidth" : 8.0,
+ "contentHeight" : 8.0,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ }
}, {
"path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[2]",
"entityName" : null,
@@ -764,9 +884,9 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[1]/TimelineMarkerSquare[0]",
- "entityName" : "TimelineMarkerSquare",
- "entityKind" : "ShapeNode",
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]",
+ "entityName" : null,
+ "entityKind" : "LayoutAnchorNode",
"parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[1]",
"childIndex" : 0,
"depth" : 6,
@@ -793,6 +913,66 @@
"bottom" : 0.0,
"left" : 0.0
}
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]",
+ "entityName" : null,
+ "entityKind" : "SectionNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]",
+ "childIndex" : 0,
+ "depth" : 7,
+ "layer" : 7,
+ "computedX" : 92.0,
+ "computedY" : 126.625,
+ "placementX" : 92.0,
+ "placementY" : 126.625,
+ "placementWidth" : 8.0,
+ "placementHeight" : 8.0,
+ "startPage" : 0,
+ "endPage" : 0,
+ "contentWidth" : 8.0,
+ "contentHeight" : 8.0,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ }
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]/TimelineMarkerSquare[0]",
+ "entityName" : "TimelineMarkerSquare",
+ "entityKind" : "ShapeNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]",
+ "childIndex" : 0,
+ "depth" : 8,
+ "layer" : 8,
+ "computedX" : 92.0,
+ "computedY" : 126.625,
+ "placementX" : 92.0,
+ "placementY" : 126.625,
+ "placementWidth" : 8.0,
+ "placementHeight" : 8.0,
+ "startPage" : 0,
+ "endPage" : 0,
+ "contentWidth" : 8.0,
+ "contentHeight" : 8.0,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ }
}, {
"path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[2]",
"entityName" : null,
@@ -974,9 +1154,9 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]/RowNode[0]/SectionNode[1]/EllipseNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]",
"entityName" : null,
- "entityKind" : "EllipseNode",
+ "entityKind" : "LayoutAnchorNode",
"parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]/RowNode[0]/SectionNode[1]",
"childIndex" : 0,
"depth" : 6,
@@ -1003,6 +1183,66 @@
"bottom" : 0.0,
"left" : 0.0
}
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]",
+ "entityName" : null,
+ "entityKind" : "SectionNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]",
+ "childIndex" : 0,
+ "depth" : 7,
+ "layer" : 7,
+ "computedX" : 92.0,
+ "computedY" : 99.675,
+ "placementX" : 92.0,
+ "placementY" : 99.675,
+ "placementWidth" : 8.0,
+ "placementHeight" : 8.0,
+ "startPage" : 0,
+ "endPage" : 0,
+ "contentWidth" : 8.0,
+ "contentHeight" : 8.0,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ }
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]/EllipseNode[0]",
+ "entityName" : null,
+ "entityKind" : "EllipseNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]",
+ "childIndex" : 0,
+ "depth" : 8,
+ "layer" : 8,
+ "computedX" : 92.0,
+ "computedY" : 99.675,
+ "placementX" : 92.0,
+ "placementY" : 99.675,
+ "placementWidth" : 8.0,
+ "placementHeight" : 8.0,
+ "startPage" : 0,
+ "endPage" : 0,
+ "contentWidth" : 8.0,
+ "contentHeight" : 8.0,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ }
}, {
"path" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]/RowNode[0]/SectionNode[2]",
"entityName" : null,
diff --git a/qa/src/test/resources/layout-snapshots/document/timeline_paginated.json b/qa/src/test/resources/layout-snapshots/document/timeline_paginated.json
index 53ff632a2..12d87445f 100644
--- a/qa/src/test/resources/layout-snapshots/document/timeline_paginated.json
+++ b/qa/src/test/resources/layout-snapshots/document/timeline_paginated.json
@@ -164,9 +164,9 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/EllipseNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
"entityName" : null,
- "entityKind" : "EllipseNode",
+ "entityKind" : "LayoutAnchorNode",
"parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]",
"childIndex" : 0,
"depth" : 6,
@@ -193,6 +193,66 @@
"bottom" : 0.0,
"left" : 0.0
}
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
+ "entityName" : null,
+ "entityKind" : "SectionNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
+ "childIndex" : 0,
+ "depth" : 7,
+ "layer" : 7,
+ "computedX" : 28.0,
+ "computedY" : 142.0,
+ "placementX" : 28.0,
+ "placementY" : 142.0,
+ "placementWidth" : 8.0,
+ "placementHeight" : 8.0,
+ "startPage" : 0,
+ "endPage" : 0,
+ "contentWidth" : 8.0,
+ "contentHeight" : 8.0,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ }
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/EllipseNode[0]",
+ "entityName" : null,
+ "entityKind" : "EllipseNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
+ "childIndex" : 0,
+ "depth" : 8,
+ "layer" : 8,
+ "computedX" : 28.0,
+ "computedY" : 142.0,
+ "placementX" : 28.0,
+ "placementY" : 142.0,
+ "placementWidth" : 8.0,
+ "placementHeight" : 8.0,
+ "startPage" : 0,
+ "endPage" : 0,
+ "contentWidth" : 8.0,
+ "contentHeight" : 8.0,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ }
}, {
"path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"entityName" : null,
@@ -404,9 +464,9 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/EllipseNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
"entityName" : null,
- "entityKind" : "EllipseNode",
+ "entityKind" : "LayoutAnchorNode",
"parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]",
"childIndex" : 0,
"depth" : 6,
@@ -433,6 +493,66 @@
"bottom" : 0.0,
"left" : 0.0
}
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
+ "entityName" : null,
+ "entityKind" : "SectionNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
+ "childIndex" : 0,
+ "depth" : 7,
+ "layer" : 7,
+ "computedX" : 28.0,
+ "computedY" : 142.0,
+ "placementX" : 28.0,
+ "placementY" : 142.0,
+ "placementWidth" : 8.0,
+ "placementHeight" : 8.0,
+ "startPage" : 1,
+ "endPage" : 1,
+ "contentWidth" : 8.0,
+ "contentHeight" : 8.0,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ }
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/EllipseNode[0]",
+ "entityName" : null,
+ "entityKind" : "EllipseNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
+ "childIndex" : 0,
+ "depth" : 8,
+ "layer" : 8,
+ "computedX" : 28.0,
+ "computedY" : 142.0,
+ "placementX" : 28.0,
+ "placementY" : 142.0,
+ "placementWidth" : 8.0,
+ "placementHeight" : 8.0,
+ "startPage" : 1,
+ "endPage" : 1,
+ "contentWidth" : 8.0,
+ "contentHeight" : 8.0,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ }
}, {
"path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]",
"entityName" : null,
From 5564c0c634b58d986b2c3742d954dc54eddb6245 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Wed, 9 Sep 2026 19:47:03 +0100
Subject: [PATCH 12/24] feat(timeline): make the rail one configuration instead
of two arguments
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
connector(colour, width) was the whole of a timeline's rail, which is fine while
a rail is a colour and a width and awkward the moment it is anything else. Every
property the rail grows would have to arrive as another argument, or as a second
way of configuring the same line.
rail(r -> r.stroke(...)) is that configuration, and connector(...) normalizes
into it: the same TimelineRailSpec, the same tree, asserted by comparing the two
rather than by describing them as equivalent. Saying it both ways throws and
names both calls.
Saying it twice the same way does not. The first version of that guard keyed on
"the rail was configured" and would have broken connector(colour, 0) followed by
connector(null, width) — a documented way to set the two halves separately, and
working code. Repeating one setter is accumulation; reaching for the other
spelling is the ambiguity worth refusing.
---
CHANGELOG.md | 9 +++
.../compose/document/dsl/TimelineBuilder.java | 69 ++++++++++++++--
.../document/dsl/TimelineRailBuilder.java | 46 +++++++++++
.../document/dsl/TimelineRailSpec.java | 17 ++--
.../document/dsl/TimelineBuilderTest.java | 80 +++++++++++++++++++
knowledge/api/authoring.json | 43 +++++++++-
knowledge/api/authoring.md | 6 +-
7 files changed, 252 insertions(+), 18 deletions(-)
create mode 100644 core/src/main/java/com/demcha/compose/document/dsl/TimelineRailBuilder.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9f858ae1e..a7e817d2b 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,15 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A timeline's rail is one configuration.**
+ `TimelineBuilder.rail(Consumer)` takes a `DocumentStroke`, and
+ `connector(colour, width)` is now the shorthand that normalizes into exactly the same
+ rail — one rendering system rather than an old spelling and a new one, which is what
+ lets the rail grow later without a second path growing beside it. Saying it both ways
+ throws, naming both calls; saying it twice the same way still works, because
+ `connector(colour, 0)` followed by `connector(null, width)` has always been a way to set
+ the two halves separately and code doing that must not start failing.
+
- **A timeline marker can be anything you can draw.**
`TimelineMarker.custom(width, height, recipe)` takes a declared box and a recipe that
fills it, so a marker made of three stacked shapes, a bordered pill or an icon needs no
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineBuilder.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineBuilder.java
index f1e6da48b..9bf874b42 100644
--- a/core/src/main/java/com/demcha/compose/document/dsl/TimelineBuilder.java
+++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineBuilder.java
@@ -6,6 +6,7 @@
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentInsets;
import com.demcha.compose.document.style.DocumentRowColumn;
+import com.demcha.compose.document.style.DocumentStroke;
import com.demcha.compose.document.style.DocumentTextDecoration;
import com.demcha.compose.document.style.DocumentTextStyle;
import com.demcha.compose.font.FontName;
@@ -49,8 +50,8 @@ public final class TimelineBuilder {
private static final DocumentColor DEFAULT_INK = DocumentColor.rgb(34, 38, 50);
private static final DocumentColor DEFAULT_MUTED = DocumentColor.rgb(120, 124, 136);
private final List entries = new ArrayList<>();
- private DocumentColor connectorColor = DEFAULT_RAIL;
- private double connectorWidth = 1.5;
+ private DocumentStroke railStroke = DocumentStroke.of(DEFAULT_RAIL, 1.5);
+ private String railDeclaredBy;
private double gutter = 8.0;
private double markerGap = 8.0;
private TimelineAxisSize axis = new TimelineAxisSize.Weight(0.10);
@@ -94,20 +95,72 @@ private static DocumentTextStyle defaultBodyStyle() {
/**
* Sets the connector rail colour and width.
*
+ * The shorthand for {@link #rail(Consumer)}: both describe the same rail, and a
+ * timeline that uses both throws rather than letting one of them win. A call that
+ * changes nothing — a null colour and a non-positive width — is not a use.
+ *
* @param color rail colour; ignored when {@code null}
* @param width rail width in points; ignored when not positive
* @return this builder
+ * @throws IllegalStateException if the rail is already configured
*/
public TimelineBuilder connector(DocumentColor color, double width) {
- if (color != null) {
- this.connectorColor = color;
+ if (color == null && !(width > 0)) {
+ return this;
}
- if (width > 0) {
- this.connectorWidth = width;
+ declareRailOnce("connector");
+ this.railStroke = DocumentStroke.of(
+ color == null ? railStroke.color() : color,
+ width > 0 ? width : railStroke.width());
+ return this;
+ }
+
+ /**
+ * Configures the connector rail.
+ *
+ * {@link #connector(DocumentColor, double)} is the shorthand for this and produces
+ * the same rail — one configuration, not an old one and a new one. Setting the rail
+ * both ways throws rather than letting one of them win.
+ *
+ * @param spec rail builder callback
+ * @return this builder
+ * @throws NullPointerException if {@code spec} is null
+ * @throws IllegalStateException if the rail is already configured
+ * @since 2.4.0
+ */
+ public TimelineBuilder rail(Consumer spec) {
+ Objects.requireNonNull(spec, "spec");
+ TimelineRailBuilder builder = new TimelineRailBuilder();
+ spec.accept(builder);
+ if (builder.stroke() == null) {
+ return this;
}
+ declareRailOnce("rail");
+ this.railStroke = builder.stroke();
return this;
}
+ /**
+ * Rejects the rail being configured through both spellings.
+ *
+ * Calling the same one twice is ordinary setter accumulation and stays
+ * legal — {@code connector(colour, 0)} then {@code connector(null, width)} has always
+ * been a way to set the two halves separately, and code doing that must not start
+ * throwing. What is rejected is a timeline that says it both ways.
+ *
+ * @param call the spelling being used
+ * @throws IllegalStateException if the other spelling already configured the rail
+ */
+ private void declareRailOnce(String call) {
+ if (railDeclaredBy != null && !railDeclaredBy.equals(call)) {
+ throw new IllegalStateException(
+ "A timeline has one rail, configured once: this one calls " + railDeclaredBy
+ + "(...) and " + call + "(...). connector(colour, width) is the shorthand for "
+ + "rail(r -> r.stroke(...)), so either says the whole thing.");
+ }
+ railDeclaredBy = call;
+ }
+
/**
* Sets the gutter between the rail and the marker / content.
*
@@ -371,7 +424,7 @@ private TimelineSpec normalize() {
// One owner per timeline, allocated here. Every marker below anchors on this
// instance, so the pass that draws the rail asks for it and gets these markers and
// nobody else's — two timelines on a page never merge.
- TimelineRailSpec railSpec = new TimelineRailSpec(connectorColor, connectorWidth);
+ TimelineRailSpec railSpec = new TimelineRailSpec(railStroke);
return new TimelineSpec(new TimelineRailOwner(railSpec),
railSpec, leadingColumn, gutter, markerGap, axis, entrySpacing,
keepTogether, keepEntriesTogether, List.copyOf(specs));
@@ -395,7 +448,7 @@ private static void layout(TimelineSpec spec, SectionBuilder timeline) {
double bottom = last ? 0.0 : spec.entrySpacing();
timeline.addSection(section -> {
section.keepTogether(spec.keepEntriesTogether())
- .accentLeft(spec.rail().color(), spec.rail().width())
+ .accentLeft(spec.rail().stroke().color(), spec.rail().stroke().width())
.padding(new DocumentInsets(0, 0, bottom, spec.gutter()))
.spacing(4);
section.addRow(header -> {
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailBuilder.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailBuilder.java
new file mode 100644
index 000000000..0e02cb422
--- /dev/null
+++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailBuilder.java
@@ -0,0 +1,46 @@
+package com.demcha.compose.document.dsl;
+
+import com.demcha.compose.document.style.DocumentStroke;
+
+import java.util.Objects;
+
+/**
+ * Configures a timeline's connector rail.
+ *
+ * Reached through {@link TimelineBuilder#rail(java.util.function.Consumer)}:
+ * {@code
+ * timeline.rail(rail -> rail.stroke(DocumentStroke.of(accent, 1.5)));
+ * }
+ *
+ * {@link TimelineBuilder#connector(com.demcha.compose.document.style.DocumentColor, double)}
+ * is the shorthand for exactly this and produces the same rail — there is one rail
+ * configuration, not an old one and a new one. Setting the rail both ways throws rather
+ * than letting one of them win.
+ *
+ * @author Artem Demchyshyn
+ * @since 2.4.0
+ */
+public final class TimelineRailBuilder {
+
+ private DocumentStroke stroke;
+
+ TimelineRailBuilder() {
+ }
+
+ /**
+ * Sets the rail's colour and width.
+ *
+ * @param stroke the rail stroke
+ * @return this builder
+ * @throws NullPointerException if {@code stroke} is null
+ */
+ public TimelineRailBuilder stroke(DocumentStroke stroke) {
+ this.stroke = Objects.requireNonNull(stroke, "stroke");
+ return this;
+ }
+
+ /** The stroke this rail was given, or null when the caller set none. */
+ DocumentStroke stroke() {
+ return stroke;
+ }
+}
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailSpec.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailSpec.java
index be63acd7b..a276ac849 100644
--- a/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailSpec.java
+++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailSpec.java
@@ -1,18 +1,21 @@
package com.demcha.compose.document.dsl;
-import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentStroke;
/**
* The rail, described rather than drawn.
*
- * Today a timeline's rail is a left border on every entry section, which is why it is
- * described by nothing more than a colour and a width. Naming it separately is what lets
- * that change later without every caller of the timeline layout learning about it.
+ * One stroke, whichever way the caller asked for it: {@code connector(colour, width)}
+ * and {@code rail(r -> r.stroke(...))} both normalize here, so there is one rail
+ * configuration for the layout to read rather than an old shape and a new one.
*
- * @param color rail colour
- * @param width rail width in points
+ * Today that rail is still a left border on every entry section, which is why a stroke
+ * is all it takes to describe. Naming it separately is what lets that change without every
+ * caller of the timeline layout learning about it.
+ *
+ * @param stroke the rail's colour and width
* @author Artem Demchyshyn
* @since 2.4.0
*/
-record TimelineRailSpec(DocumentColor color, double width) {
+record TimelineRailSpec(DocumentStroke stroke) {
}
diff --git a/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java b/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java
index eec0411f8..67b1b427f 100644
--- a/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java
+++ b/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java
@@ -12,6 +12,7 @@
import com.demcha.compose.document.node.ShapeNode;
import com.demcha.compose.document.style.DocumentColor;
import com.demcha.compose.document.style.DocumentRowColumn;
+import com.demcha.compose.document.style.DocumentStroke;
import com.demcha.compose.document.style.DocumentTextStyle;
import org.junit.jupiter.api.Test;
@@ -101,6 +102,85 @@ void connectorSetsTheRailColourAndWidth() {
assertThat(entry(timeline, 0).borders().left().width()).isEqualTo(3.25, within(1e-9));
}
+ @Test
+ void railAndConnectorAreTheSameFeature() {
+ // Not two rails with two code paths: connector(...) normalizes into the same
+ // stroke rail(...) sets, so the trees are identical rather than merely similar.
+ SectionNode shorthand = timelineOf(t -> t
+ .connector(NAVY, 3.25)
+ .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x")));
+ SectionNode longForm = timelineOf(t -> t
+ .rail(rail -> rail.stroke(DocumentStroke.of(NAVY, 3.25)))
+ .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x")));
+
+ assertThat(entry(longForm, 0).borders().left().color().color())
+ .isEqualTo(entry(shorthand, 0).borders().left().color().color());
+ assertThat(entry(longForm, 0).borders().left().width())
+ .isEqualTo(entry(shorthand, 0).borders().left().width(), within(1e-9));
+ assertThat(outline(longForm)).isEqualTo(outline(shorthand));
+ }
+
+ @Test
+ void theRailIsConfiguredOnceInEitherOrder() {
+ assertThatIllegalStateException()
+ .as("shorthand then long form")
+ .isThrownBy(() -> timelineOf(t -> t
+ .connector(NAVY, 2)
+ .rail(rail -> rail.stroke(DocumentStroke.of(NAVY, 3)))))
+ .withMessageContaining("one rail, configured once");
+ assertThatIllegalStateException()
+ .as("long form then shorthand")
+ .isThrownBy(() -> timelineOf(t -> t
+ .rail(rail -> rail.stroke(DocumentStroke.of(NAVY, 3)))
+ .connector(NAVY, 2)))
+ .withMessageContaining("one rail, configured once");
+ }
+
+ @Test
+ void aCallThatChangesNothingDoesNotCountAsConfiguringTheRail() {
+ // connector has always ignored a null colour and a non-positive width, and rail(...)
+ // with an empty lambda sets no stroke. Neither may block the other, or code that
+ // used to work would start throwing.
+ SectionNode fromEmptyRail = timelineOf(t -> t
+ .rail(rail -> { })
+ .connector(NAVY, 2)
+ .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x")));
+ SectionNode fromEmptyConnector = timelineOf(t -> t
+ .connector(null, 0)
+ .rail(rail -> rail.stroke(DocumentStroke.of(NAVY, 2)))
+ .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x")));
+
+ assertThat(entry(fromEmptyRail, 0).borders().left().width()).isEqualTo(2.0, within(1e-9));
+ assertThat(entry(fromEmptyConnector, 0).borders().left().width()).isEqualTo(2.0, within(1e-9));
+ }
+
+ @Test
+ void connectorStillTakesOneHalfOfTheStrokeAtATime() {
+ // Long-standing behaviour, and the reason two connector(...) calls stay legal: a
+ // null colour keeps the current one and a non-positive width keeps the current
+ // width, so setting the halves separately works. Normalizing into a single stroke
+ // is exactly where that could quietly become "both or nothing", and a guard against
+ // saying it two ways could just as quietly forbid saying it twice.
+ SectionNode widthOnly = timelineOf(t -> t
+ .connector(null, 4)
+ .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x")));
+ assertThat(entry(widthOnly, 0).borders().left().width()).isEqualTo(4.0, within(1e-9));
+ assertThat(entry(widthOnly, 0).borders().left().color().color())
+ .as("the default colour survives a width-only call")
+ .isEqualTo(DEFAULT_RAIL.color());
+
+ SectionNode inTwoCalls = timelineOf(t -> t
+ .connector(NAVY, 0)
+ .connector(null, 4)
+ .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x")));
+ assertThat(entry(inTwoCalls, 0).borders().left().color().color())
+ .as("the colour from the first call")
+ .isEqualTo(NAVY.color());
+ assertThat(entry(inTwoCalls, 0).borders().left().width())
+ .as("and the width from the second")
+ .isEqualTo(4.0, within(1e-9));
+ }
+
// --- spacing and the columns ---------------------------------------------
@Test
diff --git a/knowledge/api/authoring.json b/knowledge/api/authoring.json
index ab2cfa363..85da2dfe8 100644
--- a/knowledge/api/authoring.json
+++ b/knowledge/api/authoring.json
@@ -22,8 +22,8 @@
"graph-compose-testing:sources"
],
"counts": {
- "types": 233,
- "methods": 2084,
+ "types": 234,
+ "methods": 2086,
"constants": 230,
"generated": 1111
},
@@ -15027,6 +15027,20 @@
}
]
},
+ {
+ "kind": "method",
+ "name": "rail",
+ "static": false,
+ "origin": "source",
+ "typeParameters": null,
+ "returns": "TimelineBuilder",
+ "params": [
+ {
+ "type": "Consumer",
+ "name": "spec"
+ }
+ ]
+ },
{
"kind": "method",
"name": "gutter",
@@ -15527,6 +15541,31 @@
}
]
},
+ {
+ "name": "TimelineRailBuilder",
+ "binaryName": "com.demcha.compose.document.dsl.TimelineRailBuilder",
+ "kind": "class",
+ "modifiers": [
+ "final"
+ ],
+ "artifact": "graph-compose-core",
+ "members": [
+ {
+ "kind": "method",
+ "name": "stroke",
+ "static": false,
+ "origin": "source",
+ "typeParameters": null,
+ "returns": "TimelineRailBuilder",
+ "params": [
+ {
+ "type": "DocumentStroke",
+ "name": "stroke"
+ }
+ ]
+ }
+ ]
+ },
{
"name": "TocBuilder",
"binaryName": "com.demcha.compose.document.dsl.TocBuilder",
diff --git a/knowledge/api/authoring.md b/knowledge/api/authoring.md
index 48615161e..4349abf55 100644
--- a/knowledge/api/authoring.md
+++ b/knowledge/api/authoring.md
@@ -28,7 +28,7 @@ note: "Generated from the pinned artifact's class files. Authoritative closed se
**GraphCompose version:** 2.4.0-SNAPSHOT
-Types: 233 · methods: 2084 · constants: 230 · compiler-generated members: 1111
+Types: 234 · methods: 2086 · constants: 230 · compiler-generated members: 1111
## com.demcha.compose
@@ -1122,6 +1122,7 @@ Types: 233 · methods: 2084 · constants: 230 · compiler-generated members: 111
### TimelineBuilder (class)
- `TimelineBuilder connector(DocumentColor color, double width)`
+- `TimelineBuilder rail(Consumer spec)`
- `TimelineBuilder gutter(double gutter)`
- `TimelineBuilder markerGap(double gap)`
- `TimelineBuilder markerColumnWeight(double weight)`
@@ -1158,6 +1159,9 @@ Types: 233 · methods: 2084 · constants: 230 · compiler-generated members: 111
- `TimelineMarker square(double size, DocumentColor fill)`
- `TimelineMarker custom(double width, double height, Consumer recipe)`
+### TimelineRailBuilder (class)
+- `TimelineRailBuilder stroke(DocumentStroke stroke)`
+
### TocBuilder (class)
- `new TocBuilder()`
- `TocBuilder title(String title)`
From 1fb0c70c726c76a8e022ea22571ec5fa3bb9a5bf Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Wed, 9 Sep 2026 21:56:01 +0100
Subject: [PATCH 13/24] feat(timeline): draw the rail as one line from where
the markers landed
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The rail was a left border repeated on every entry section. That is why it sat
at the entry's edge whatever the markers did, why it could not stop short of
them, and why it drew a slightly darker row wherever two entries abutted: each
border antialiased its own end into the shared row.
It is now computed after layout from the anchors the entries and markers
resolved to, and contributed as one fragment per page — one logical rail however
many pages it crosses, bounded on each by that page alone, spliced ahead of the
body so a filled marker covers the line passing under it.
Two independent choices, kept independent. TimelineRailExtent says how far:
ENTRY_BOUNDS, the union of the entries' per-page slices and what every existing
timeline already draws, or MARKER_TO_MARKER, which starts and stops at the
anchor points it is named for. markerOnRail() says where: through the markers'
centres rather than a gutter to their left. One entry with MARKER_TO_MARKER
emits nothing rather than a line of no length. TIMELINE_BOUNDS is named and
rejected — one page makes it identical to its neighbour and no page makes it
measurable.
There is no legacy branch. Both anchors are one equation, marker box plus
fraction plus offset, and the default resolves to the offset the old rail
already had: a constant gutter, which is what it is at every marker size where
a centre would need a different number for each.
A leading column sits to the left of the axis and does not move the rail to the
entry boundary — LEADING | AXIS | CONTENT, with the rail belonging to the axis.
Its baseline is re-recorded on that decision; it has never shipped.
Nothing registers the pass. A timeline's owner is a ResolvedLayoutPass, so the
driver finds it among the anchors the document resolved and asks only whether an
owner is a pass, never what kind of thing it is. document.api names no feature.
Rendering: the geometry matches the border it replaces to 0.000000 in x and in
both ends on every page. Two pixels differ in the three-entry baseline and one in
the two-entry one, located rather than assumed — pdf y 177.50 and 145.50 against
measured entry junctions at 177.175 and 145.1375. They are the seams the old
per-entry borders drew twice. Matching them would mean emitting a fragment per
entry again, which is the architecture this removes.
Snapshots re-recorded: every node box that was there is still there at the same
coordinates, page counts unchanged, and the additions are exactly one anchor
wrapper per entry.
---
CHANGELOG.md | 25 +
.../document/api/ResolvedLayoutPasses.java | 49 +-
.../document/dsl/TimelineAnchorKind.java | 13 +-
.../compose/document/dsl/TimelineBuilder.java | 54 ++-
.../document/dsl/TimelineMarkerAnchor.java | 62 +++
.../document/dsl/TimelineRailBuilder.java | 23 +
.../document/dsl/TimelineRailExtent.java | 49 ++
.../document/dsl/TimelineRailOwner.java | 130 +++++-
.../document/dsl/TimelineBuilderTest.java | 87 +++-
knowledge/api/authoring.json | 63 ++-
knowledge/api/authoring.md | 7 +-
.../api/TimelineMarkerAnchorTest.java | 24 +-
.../api/TimelineRailGeometryTest.java | 296 ++++++++++++
.../document/timeline_classic.json | 318 ++++++++-----
.../document/timeline_leading_column.json | 432 +++++++++++-------
.../document/timeline_paginated.json | 212 ++++++---
.../timeline-dsl/classic-page-0.png | Bin 7561 -> 7540 bytes
.../timeline-dsl/custom-marker-page-0.png | Bin 4590 -> 4575 bytes
.../timeline-dsl/leading-column-page-0.png | Bin 7358 -> 7466 bytes
19 files changed, 1442 insertions(+), 402 deletions(-)
create mode 100644 core/src/main/java/com/demcha/compose/document/dsl/TimelineMarkerAnchor.java
create mode 100644 core/src/main/java/com/demcha/compose/document/dsl/TimelineRailExtent.java
create mode 100644 qa/src/test/java/com/demcha/compose/document/api/TimelineRailGeometryTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index a7e817d2b..5115b397a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,31 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **A timeline's rail is one line, drawn from where its markers landed.**
+ It was a left border repeated on every entry section, which is why it sat at the entry's
+ edge whatever the markers did, could not stop short of them, and had no way to be
+ anything but the full height of the entries. It is now computed after layout from the
+ markers' and entries' resolved positions, and contributed as one fragment per page —
+ one logical rail, however many pages it crosses, bounded on each by that page alone.
+
+ Two independent choices, and they stay independent. `TimelineRailExtent` says how far the
+ rail runs: `ENTRY_BOUNDS`, the default and what every existing timeline already draws, or
+ `MARKER_TO_MARKER`, which starts at the first marker and stops at the last.
+ `markerOnRail()` says where it runs: through the markers' centres instead of a gutter to
+ their left. A timeline with one entry and `MARKER_TO_MARKER` emits no rail at all rather
+ than a line of no length. `TIMELINE_BOUNDS` is named and rejected — on one page it is the
+ same line as `ENTRY_BOUNDS`, and across pages there is nothing to measure it against.
+
+ **A leading column sits to the left of the timeline axis; it does not move the rail to
+ the entry boundary.** The layout is `LEADING | AXIS | CONTENT`, and the rail belongs to
+ the axis.
+
+ Existing timelines render as they did, and that was measured rather than asserted: the
+ rail's geometry matches the border it replaces exactly, to 0.000000 in x and in both
+ ends, on every page. Two pixels differ in a three-entry timeline and one in a two-entry
+ one — the rows where two entry borders used to abut and each drew its own antialiased
+ end, so the seam came out slightly darker. One continuous line has no seams.
+
- **A timeline's rail is one configuration.**
`TimelineBuilder.rail(Consumer)` takes a `DocumentStroke`, and
`connector(colour, width)` is now the shorthand that normalizes into exactly the same
diff --git a/core/src/main/java/com/demcha/compose/document/api/ResolvedLayoutPasses.java b/core/src/main/java/com/demcha/compose/document/api/ResolvedLayoutPasses.java
index e5274e59d..e7d2dd564 100644
--- a/core/src/main/java/com/demcha/compose/document/api/ResolvedLayoutPasses.java
+++ b/core/src/main/java/com/demcha/compose/document/api/ResolvedLayoutPasses.java
@@ -48,18 +48,23 @@ private ResolvedLayoutPasses() {
*/
static LayoutGraph apply(LayoutGraph base, List passes) {
Objects.requireNonNull(base, "base");
- if (passes == null || passes.isEmpty()) {
- return base;
- }
// Collected once, from the compiled graph, and handed to every pass. Nothing a
// pass contributes can seed a new anchor for a later pass, so the result does not
// depend on how the passes happen to interleave.
ResolvedLayoutMetadata metadata = ResolvedLayoutMetadata.from(base);
+ List running = discover(metadata);
+ if (passes != null) {
+ running.addAll(passes);
+ }
+ if (running.isEmpty()) {
+ return base;
+ }
+
List under = new ArrayList<>();
List over = new ArrayList<>();
- for (ResolvedLayoutPass pass : passes) {
+ for (ResolvedLayoutPass pass : running) {
List additions = pass.contribute(base, metadata);
if (additions == null) {
throw new IllegalStateException(
@@ -100,6 +105,42 @@ static LayoutGraph apply(LayoutGraph base, List passes) {
return new LayoutGraph(base.canvas(), base.totalPages(), base.nodes(), combined);
}
+ /**
+ * The passes the document itself asks for, found in what it anchored.
+ *
+ * A built-in feature declares an owner on the semantic tree and keys its anchors on
+ * it; an owner that is also a pass is a feature saying it has something to
+ * draw once the layout is settled. Nothing is registered, no session is handed around,
+ * and this class stays ignorant of every feature that uses it — it asks whether the
+ * owner is a pass, not what kind of thing it is.
+ *
+ * Order is first appearance in the anchor list, which is the compiler's placement
+ * order, which is reading order down the document. Two instances of one feature on a
+ * page therefore draw in the order they were written.
+ *
+ * @param metadata the anchors the document resolved
+ * @return the passes to run, deduplicated by identity, in document order
+ */
+ private static List discover(ResolvedLayoutMetadata metadata) {
+ List found = new ArrayList<>();
+ for (var anchor : metadata.anchors()) {
+ if (anchor.id().groupKey() instanceof ResolvedLayoutPass pass && !containsSame(found, pass)) {
+ found.add(pass);
+ }
+ }
+ return found;
+ }
+
+ /** Identity, not equality: two owners configured alike are still two features. */
+ private static boolean containsSame(List passes, ResolvedLayoutPass pass) {
+ for (ResolvedLayoutPass known : passes) {
+ if (known == pass) {
+ return true;
+ }
+ }
+ return false;
+ }
+
private static void requireFinite(ResolvedLayoutPass pass, double value, String name) {
if (!Double.isFinite(value)) {
throw new IllegalStateException("Resolved-layout pass '" + pass.id()
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineAnchorKind.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineAnchorKind.java
index 03b57ddc1..f67724690 100644
--- a/core/src/main/java/com/demcha/compose/document/dsl/TimelineAnchorKind.java
+++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineAnchorKind.java
@@ -11,6 +11,15 @@
*/
enum TimelineAnchorKind {
- /** The marker in an entry's axis column. */
- MARKER
+ /** The marker in an entry's axis column — where the rail passes, horizontally. */
+ MARKER,
+
+ /**
+ * A whole entry — where the rail starts and stops, vertically.
+ *
+ * Separate from the marker because they answer different questions, and because an
+ * entry is the only one of the two that can span pages: its slices carry each page's
+ * content band, which is what a rail crossing a page break needs.
+ */
+ ENTRY
}
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineBuilder.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineBuilder.java
index 9bf874b42..1650f4ce4 100644
--- a/core/src/main/java/com/demcha/compose/document/dsl/TimelineBuilder.java
+++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineBuilder.java
@@ -52,6 +52,8 @@ public final class TimelineBuilder {
private final List entries = new ArrayList<>();
private DocumentStroke railStroke = DocumentStroke.of(DEFAULT_RAIL, 1.5);
private String railDeclaredBy;
+ private TimelineRailExtent railExtent = TimelineRailExtent.ENTRY_BOUNDS;
+ private TimelineMarkerAnchor markerAnchor;
private double gutter = 8.0;
private double markerGap = 8.0;
private TimelineAxisSize axis = new TimelineAxisSize.Weight(0.10);
@@ -132,6 +134,9 @@ public TimelineBuilder rail(Consumer spec) {
Objects.requireNonNull(spec, "spec");
TimelineRailBuilder builder = new TimelineRailBuilder();
spec.accept(builder);
+ if (builder.extent() != null) {
+ this.railExtent = builder.extent();
+ }
if (builder.stroke() == null) {
return this;
}
@@ -140,6 +145,25 @@ public TimelineBuilder rail(Consumer spec) {
return this;
}
+ /**
+ * Puts the markers on the rail, rather than beside it.
+ *
+ * A timeline draws its rail at the marker's left edge, pulled back by the gutter —
+ * where it has been since before there was a choice, and a distance that grows with the
+ * marker, so leaving it alone is what "renders unchanged" means. This opts a timeline
+ * into the other anchor: the rail passes through the marker's centre, at every marker
+ * size.
+ *
+ * It moves the rail, not the markers. Nothing else in the timeline shifts.
+ *
+ * @return this builder
+ * @since 2.4.0
+ */
+ public TimelineBuilder markerOnRail() {
+ this.markerAnchor = TimelineMarkerAnchor.onTheRail();
+ return this;
+ }
+
/**
* Rejects the rail being configured through both spellings.
*
@@ -424,8 +448,19 @@ private TimelineSpec normalize() {
// One owner per timeline, allocated here. Every marker below anchors on this
// instance, so the pass that draws the rail asks for it and gets these markers and
// nobody else's — two timelines on a page never merge.
+ if (railExtent == TimelineRailExtent.TIMELINE_BOUNDS) {
+ throw new IllegalArgumentException(
+ "TimelineRailExtent.TIMELINE_BOUNDS is not implemented. On one page it is the "
+ + "same line as ENTRY_BOUNDS, and across pages there is nothing to measure it "
+ + "against — a timeline's own box draws nothing. Use ENTRY_BOUNDS or "
+ + "MARKER_TO_MARKER.");
+ }
TimelineRailSpec railSpec = new TimelineRailSpec(railStroke);
- return new TimelineSpec(new TimelineRailOwner(railSpec),
+ // The gutter is only knowable here, so the default anchor is resolved here too —
+ // and it is the same model the opted-in one uses, not a branch beside it.
+ TimelineMarkerAnchor anchor =
+ markerAnchor == null ? TimelineMarkerAnchor.atLeftEdge(gutter) : markerAnchor;
+ return new TimelineSpec(new TimelineRailOwner(railSpec, railExtent, anchor),
railSpec, leadingColumn, gutter, markerGap, axis, entrySpacing,
keepTogether, keepEntriesTogether, List.copyOf(specs));
}
@@ -446,9 +481,14 @@ private static void layout(TimelineSpec spec, SectionBuilder timeline) {
int index = i;
boolean last = i == entries.size() - 1;
double bottom = last ? 0.0 : spec.entrySpacing();
- timeline.addSection(section -> {
+ SectionBuilder entrySection = new SectionBuilder();
+ {
+ SectionBuilder section = entrySection;
+ // No accentLeft. The rail is one logical line drawn from the resolved
+ // anchors below, not a border repeated per entry — which is why it can
+ // start and stop at the markers, and why it holds its x under markers of
+ // different sizes.
section.keepTogether(spec.keepEntriesTogether())
- .accentLeft(spec.rail().stroke().color(), spec.rail().stroke().width())
.padding(new DocumentInsets(0, 0, bottom, spec.gutter()))
.spacing(4);
section.addRow(header -> {
@@ -474,7 +514,13 @@ private static void layout(TimelineSpec spec, SectionBuilder timeline) {
header.addSection(entry.beside());
});
entry.below().accept(section);
- });
+ }
+ // The entry, anchored. Its slices are where the rail starts and stops on each
+ // page — and only its slices carry that, because an entry is the one thing
+ // here that can cross a page boundary.
+ timeline.add(new LayoutAnchorNode("",
+ new LayoutAnchorId(spec.owner(), TimelineAnchorKind.ENTRY, index),
+ entrySection.build()));
}
}
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineMarkerAnchor.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineMarkerAnchor.java
new file mode 100644
index 000000000..aaf4cabbd
--- /dev/null
+++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineMarkerAnchor.java
@@ -0,0 +1,62 @@
+package com.demcha.compose.document.dsl;
+
+import com.demcha.compose.document.layout.ResolvedLayoutAnchor;
+
+/**
+ * Which point of a marker the rail passes through.
+ *
+ * A fraction of the marker's own box plus an offset in points, and that shape is what
+ * lets one equation serve both the timeline written years ago and the one written to sit
+ * on its rail. Measured before it was chosen: today's rail sits at the entry's left edge
+ * while a marker's centre is {@code margin + gutter + size/2}, so the distance between
+ * them grows with the marker — 12pt at size 8, 18pt at size 20. Expressed as
+ * {@code relativeX = 0.0, offsetX = -gutter} it is one constant at every size; expressed
+ * as a centre it would need a different number for each.
+ *
+ * So there is no legacy branch anywhere in the layout. There are two anchors:
+ *
+ * - {@link #atLeftEdge(double)} — {@code (0.0, 0.5)} offset {@code (-gutter, 0)}, what
+ * a timeline that predates the choice already renders;
+ * - {@link #onTheRail()} — {@code (0.5, 0.5)} offset {@code (0, 0)}, the marker
+ * centred on its own rail.
+ *
+ *
+ * @param relativeX fraction of the marker's width, left to right
+ * @param relativeY fraction of the marker's height, bottom to top
+ * @param offsetX points added after the fraction, positive to the right
+ * @param offsetY points added after the fraction, positive upwards
+ * @author Artem Demchyshyn
+ * @since 2.4.0
+ */
+record TimelineMarkerAnchor(double relativeX, double relativeY, double offsetX, double offsetY) {
+
+ /** The marker's left edge, pulled back by the gutter — where the rail has always been. */
+ static TimelineMarkerAnchor atLeftEdge(double gutter) {
+ return new TimelineMarkerAnchor(0.0, 0.5, -gutter, 0.0);
+ }
+
+ /** The marker's centre, which is what "the marker sits on the rail" means. */
+ static TimelineMarkerAnchor onTheRail() {
+ return new TimelineMarkerAnchor(0.5, 0.5, 0.0, 0.0);
+ }
+
+ /**
+ * Where this anchor puts the rail, horizontally, for a resolved marker.
+ *
+ * @param marker the marker's resolved box
+ * @return the x the rail passes through
+ */
+ double x(ResolvedLayoutAnchor marker) {
+ return marker.pointX(relativeX) + offsetX;
+ }
+
+ /**
+ * Where this anchor puts the rail's end, vertically, for a resolved marker.
+ *
+ * @param marker the marker's resolved box
+ * @return the y the rail starts or stops at
+ */
+ double y(ResolvedLayoutAnchor marker) {
+ return marker.pointY(relativeY) + offsetY;
+ }
+}
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailBuilder.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailBuilder.java
index 0e02cb422..2dfbeaf9a 100644
--- a/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailBuilder.java
+++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailBuilder.java
@@ -23,6 +23,7 @@
public final class TimelineRailBuilder {
private DocumentStroke stroke;
+ private TimelineRailExtent extent;
TimelineRailBuilder() {
}
@@ -39,8 +40,30 @@ public TimelineRailBuilder stroke(DocumentStroke stroke) {
return this;
}
+ /**
+ * Sets how far the rail runs.
+ *
+ * Independent of where it runs: an extent is the line's two ends, and its x comes
+ * from the marker anchor. A timeline that sets neither keeps
+ * {@link TimelineRailExtent#ENTRY_BOUNDS}, which is what it already draws.
+ *
+ * @param extent the rail's extent
+ * @return this builder
+ * @throws NullPointerException if {@code extent} is null
+ * @since 2.4.0
+ */
+ public TimelineRailBuilder extent(TimelineRailExtent extent) {
+ this.extent = Objects.requireNonNull(extent, "extent");
+ return this;
+ }
+
/** The stroke this rail was given, or null when the caller set none. */
DocumentStroke stroke() {
return stroke;
}
+
+ /** The extent this rail was given, or null when the caller set none. */
+ TimelineRailExtent extent() {
+ return extent;
+ }
}
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailExtent.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailExtent.java
new file mode 100644
index 000000000..da2f6a5c5
--- /dev/null
+++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailExtent.java
@@ -0,0 +1,49 @@
+package com.demcha.compose.document.dsl;
+
+/**
+ * How far a timeline's rail runs.
+ *
+ * The two ends of the line, and nothing about where it sits horizontally — that comes
+ * from the marker anchor. The two are independent on purpose: a rail can start and stop at
+ * the markers while passing through their left edges, or span the entries while passing
+ * through their centres.
+ *
+ * @author Artem Demchyshyn
+ * @since 2.4.0
+ */
+public enum TimelineRailExtent {
+
+ /**
+ * From the first marker's anchor point to the last's.
+ *
+ * No rail above the first marker or below the last. With a single entry the extent
+ * is zero and no rail is drawn at all — a zero-length line is not a shorter line.
+ */
+ MARKER_TO_MARKER,
+
+ /**
+ * The union of the entries' resolved boxes, page by page.
+ *
+ * What a timeline written before there was a choice already draws, to the point:
+ * measured against the per-entry border it replaces, the two agree to 0.000000 in both
+ * ends on every page. The gaps between entries are inside it, because an entry's
+ * spacing is padding within its own box; there is no tail after the last entry,
+ * because the last entry has no such padding.
+ *
+ * Page by page is load-bearing. An entry that spans pages has a different
+ * extent on each of them, and its box as a whole is a coordinate belonging to no
+ * page.
+ */
+ ENTRY_BOUNDS,
+
+ /**
+ * The timeline's own box, rather than the union of the entries in it.
+ *
+ * Defined but not implemented: it is indistinguishable from
+ * {@link #ENTRY_BOUNDS} on a single page — a timeline has no padding of its own — and
+ * across pages there is nothing to measure it against, because the container draws
+ * nothing. Asking for it throws rather than quietly resolving to the neighbour it
+ * happens to equal in the easy case.
+ */
+ TIMELINE_BOUNDS
+}
diff --git a/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailOwner.java b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailOwner.java
index d7f247b9d..5a2af7fc4 100644
--- a/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailOwner.java
+++ b/core/src/main/java/com/demcha/compose/document/dsl/TimelineRailOwner.java
@@ -1,28 +1,54 @@
package com.demcha.compose.document.dsl;
+import com.demcha.compose.document.layout.LayoutDepth;
+import com.demcha.compose.document.layout.LayoutGraph;
+import com.demcha.compose.document.layout.NodeDefinitionSupport;
+import com.demcha.compose.document.layout.PlacedFragment;
+import com.demcha.compose.document.layout.ResolvedLayoutAddition;
+import com.demcha.compose.document.layout.ResolvedLayoutAnchor;
+import com.demcha.compose.document.layout.ResolvedLayoutMetadata;
+import com.demcha.compose.document.layout.ResolvedLayoutPass;
+import com.demcha.compose.document.layout.payloads.ShapeFragmentPayload;
+import com.demcha.compose.document.layout.payloads.SideBorders;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
/**
- * One timeline's identity, and the configuration its rail will be drawn from.
+ * One timeline's identity, its rail's configuration, and the pass that draws that rail.
+ *
+ * Every marker and every entry in a timeline anchors on the same instance of this, so
+ * the pass asks for its anchors and gets that timeline's and nobody else's. Two
+ * timelines on one page are two owners and never merge.
*
- * Every marker in a timeline anchors on the same instance of this, so the pass that
- * later draws the rail asks for its owner and gets back that timeline's markers
- * and nobody else's. Two timelines on one page are two owners and never merge, and a pass
- * asking for an owner the document does not contain gets an empty list — which is how it
- * decides it has nothing to do, with no document inspection and no feature flag.
+ * Being the pass as well as the identity is what removes the registration step. A
+ * feature declares an owner on the semantic tree; if that owner is also a
+ * {@link ResolvedLayoutPass}, the document has said it has something to draw once the
+ * layout is settled. Nothing calls a register method, no session is passed into the DSL,
+ * and the driver that runs it knows nothing about timelines — it asks whether an owner is
+ * a pass, not what kind of thing it is.
*
* Deliberately a plain final class and not a record. The seam compares these with
* {@code ==}, and a record invites the reader to think in value equality: two timelines
- * configured identically are still two timelines, and a record would make them look like
- * one.
+ * configured identically are still two timelines.
*
* @author Artem Demchyshyn
* @since 2.4.0
*/
-final class TimelineRailOwner {
+final class TimelineRailOwner implements ResolvedLayoutPass {
+
+ private static final double EPS = 1e-9;
private final TimelineRailSpec rail;
+ private final TimelineRailExtent extent;
+ private final TimelineMarkerAnchor markerAnchor;
- TimelineRailOwner(TimelineRailSpec rail) {
+ TimelineRailOwner(TimelineRailSpec rail, TimelineRailExtent extent, TimelineMarkerAnchor markerAnchor) {
this.rail = rail;
+ this.extent = extent;
+ this.markerAnchor = markerAnchor;
}
/**
@@ -33,4 +59,88 @@ final class TimelineRailOwner {
TimelineRailSpec rail() {
return rail;
}
+
+ @Override
+ public String id() {
+ return "timeline-rail";
+ }
+
+ @Override
+ public List contribute(LayoutGraph graph, ResolvedLayoutMetadata metadata) {
+ List markers = metadata.anchors(this, TimelineAnchorKind.MARKER);
+ if (markers.isEmpty()) {
+ // Not this timeline's document. No inspection, no feature flag: the anchors
+ // are simply not there.
+ return List.of();
+ }
+
+ // X from the marker, y from the extent — two independent questions. Every marker
+ // in a timeline resolves to the same x, so the first one answers for all of them;
+ // a marker of a different size moves its own centre but not its left edge, which
+ // is why the legacy anchor is expressed as an edge plus a constant.
+ double railX = markerAnchor.x(markers.get(0));
+ List segments = segments(metadata, markers);
+
+ List additions = new ArrayList<>(segments.size());
+ SideBorders leftOnly = new SideBorders(null, null, null,
+ NodeDefinitionSupport.toStroke(rail.stroke()));
+ for (Segment segment : segments) {
+ double height = segment.top - segment.bottom;
+ if (height <= EPS) {
+ // A rail of no length is not a shorter rail. One entry with
+ // MARKER_TO_MARKER lands here, and nothing reaches a backend.
+ continue;
+ }
+ additions.add(new ResolvedLayoutAddition(LayoutDepth.UNDER_BODY,
+ PlacedFragment.withZeroInsets("@timeline-rail", additions.size(),
+ segment.page, railX, segment.bottom,
+ rail.stroke().width(), height,
+ new ShapeFragmentPayload(null, null, null, null, null, leftOnly, null))));
+ }
+ return additions;
+ }
+
+ /**
+ * The rail's vertical extent, one segment per page it appears on.
+ *
+ * Both extents are built from the entries' resolved slices, because those already
+ * carry the one thing neither the markers nor the node boxes do: what a page's content
+ * band is, on that page, after per-page margins. {@code MARKER_TO_MARKER} then trims
+ * the first and last of them back to the markers rather than deriving a band of its
+ * own.
+ */
+ private List segments(ResolvedLayoutMetadata metadata, List markers) {
+ Map byPage = new LinkedHashMap<>();
+ for (ResolvedLayoutAnchor entry : metadata.anchors(this, TimelineAnchorKind.ENTRY)) {
+ byPage.merge(entry.pageIndex(),
+ new Segment(entry.pageIndex(), entry.pointY(1.0), entry.y()),
+ Segment::union);
+ }
+ if (extent == TimelineRailExtent.ENTRY_BOUNDS) {
+ return List.copyOf(byPage.values());
+ }
+
+ ResolvedLayoutAnchor first = markers.get(0);
+ ResolvedLayoutAnchor last = markers.get(markers.size() - 1);
+ double startY = markerAnchor.y(first);
+ double endY = markerAnchor.y(last);
+ List trimmed = new ArrayList<>();
+ for (Segment segment : byPage.values()) {
+ if (segment.page < first.pageIndex() || segment.page > last.pageIndex()) {
+ continue;
+ }
+ double top = segment.page == first.pageIndex() ? Math.min(segment.top, startY) : segment.top;
+ double bottom = segment.page == last.pageIndex() ? Math.max(segment.bottom, endY) : segment.bottom;
+ trimmed.add(new Segment(segment.page, top, bottom));
+ }
+ return trimmed;
+ }
+
+ /** One page's worth of rail, in page coordinates. */
+ private record Segment(int page, double top, double bottom) {
+
+ Segment union(Segment other) {
+ return new Segment(page, Math.max(top, other.top), Math.min(bottom, other.bottom));
+ }
+ }
}
diff --git a/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java b/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java
index 67b1b427f..5853bf4a2 100644
--- a/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java
+++ b/core/src/test/java/com/demcha/compose/document/dsl/TimelineBuilderTest.java
@@ -60,8 +60,8 @@ void addTimelineProducesOneSectionPerEntryWithRail() {
assertThat(timeline.children()).hasSize(2);
SectionNode entry = entry(timeline, 0);
assertThat(entry.borders().hasAny())
- .as("each entry carries the connector rail as a left border")
- .isTrue();
+ .as("the rail is no longer a border on the entry; it is one line drawn after layout")
+ .isFalse();
assertThat(entry.children()).anySatisfy(child -> assertThat(child).isInstanceOf(RowNode.class));
assertThat(lastParagraph(entry).text()).isEqualTo("body one");
}
@@ -83,23 +83,38 @@ void entryRejectsNullMarker() {
// --- the rail ------------------------------------------------------------
@Test
- void theDefaultRailIsAMutedGreyHairline() {
- SectionNode timeline = timelineOf(t -> t.entry(TimelineMarker.dot(8, NAVY), e -> e.title("x")));
+ void noEntryCarriesTheRailAsABorderAnyMore() {
+ // The rail left the node tree. It is one line computed from resolved anchors after
+ // layout, not a border repeated per entry, so there is nothing here to assert about
+ // it — its colour, width, extent and position are pinned in
+ // TimelineRailGeometryTest, where they can be measured.
+ SectionNode timeline = timelineOf(t -> t
+ .connector(NAVY, 3.25)
+ .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x")));
- assertThat(entry(timeline, 0).borders().left().color().color())
- .isEqualTo(DEFAULT_RAIL.color());
- assertThat(entry(timeline, 0).borders().left().width())
- .isEqualTo(DEFAULT_RAIL_WIDTH, within(1e-9));
+ assertThat(entry(timeline, 0).borders().hasAny())
+ .as("an entry decorates nothing now")
+ .isFalse();
}
@Test
- void connectorSetsTheRailColourAndWidth() {
+ void everyEntryIsAnchoredSoTheRailCanBeComputedFromWhereItLands() {
+ // The wrapper every other test in this file reads through, and the reason it
+ // exists: an entry is the only part of a timeline that can cross a page boundary,
+ // so only its anchor carries what the rail needs on each page.
SectionNode timeline = timelineOf(t -> t
- .connector(NAVY, 3.25)
- .entry(TimelineMarker.dot(8, NAVY), e -> e.title("x")));
+ .entry(TimelineMarker.dot(8, NAVY), e -> e.title("First"))
+ .entry(TimelineMarker.dot(8, NAVY), e -> e.title("Second")));
- assertThat(entry(timeline, 0).borders().left().color().color()).isEqualTo(NAVY.color());
- assertThat(entry(timeline, 0).borders().left().width()).isEqualTo(3.25, within(1e-9));
+ LayoutAnchorNode first = (LayoutAnchorNode) timeline.children().get(0);
+ LayoutAnchorNode second = (LayoutAnchorNode) timeline.children().get(1);
+ assertThat(first.id().index()).isZero();
+ assertThat(second.id().index()).isEqualTo(1);
+ assertThat(first.id().kind()).isSameAs(second.id().kind());
+ assertThat(first.id().kind().toString()).isEqualTo("ENTRY");
+ assertThat(first.id().groupKey())
+ .as("the same owner the markers anchor on, so one pass sees both")
+ .isSameAs(second.id().groupKey());
}
@Test
@@ -113,10 +128,10 @@ void railAndConnectorAreTheSameFeature() {
.rail(rail -> rail.stroke(DocumentStroke.of(NAVY, 3.25)))
.entry(TimelineMarker.dot(8, NAVY), e -> e.title("x")));
- assertThat(entry(longForm, 0).borders().left().color().color())
- .isEqualTo(entry(shorthand, 0).borders().left().color().color());
- assertThat(entry(longForm, 0).borders().left().width())
- .isEqualTo(entry(shorthand, 0).borders().left().width(), within(1e-9));
+ assertThat(railStroke(longForm).color().color())
+ .isEqualTo(railStroke(shorthand).color().color());
+ assertThat(railStroke(longForm).width())
+ .isEqualTo(railStroke(shorthand).width(), within(1e-9));
assertThat(outline(longForm)).isEqualTo(outline(shorthand));
}
@@ -150,8 +165,8 @@ void aCallThatChangesNothingDoesNotCountAsConfiguringTheRail() {
.rail(rail -> rail.stroke(DocumentStroke.of(NAVY, 2)))
.entry(TimelineMarker.dot(8, NAVY), e -> e.title("x")));
- assertThat(entry(fromEmptyRail, 0).borders().left().width()).isEqualTo(2.0, within(1e-9));
- assertThat(entry(fromEmptyConnector, 0).borders().left().width()).isEqualTo(2.0, within(1e-9));
+ assertThat(railStroke(fromEmptyRail).width()).isEqualTo(2.0, within(1e-9));
+ assertThat(railStroke(fromEmptyConnector).width()).isEqualTo(2.0, within(1e-9));
}
@Test
@@ -164,8 +179,8 @@ void connectorStillTakesOneHalfOfTheStrokeAtATime() {
SectionNode widthOnly = timelineOf(t -> t
.connector(null, 4)
.entry(TimelineMarker.dot(8, NAVY), e -> e.title("x")));
- assertThat(entry(widthOnly, 0).borders().left().width()).isEqualTo(4.0, within(1e-9));
- assertThat(entry(widthOnly, 0).borders().left().color().color())
+ assertThat(railStroke(widthOnly).width()).isEqualTo(4.0, within(1e-9));
+ assertThat(railStroke(widthOnly).color().color())
.as("the default colour survives a width-only call")
.isEqualTo(DEFAULT_RAIL.color());
@@ -173,10 +188,10 @@ void connectorStillTakesOneHalfOfTheStrokeAtATime() {
.connector(NAVY, 0)
.connector(null, 4)
.entry(TimelineMarker.dot(8, NAVY), e -> e.title("x")));
- assertThat(entry(inTwoCalls, 0).borders().left().color().color())
+ assertThat(railStroke(inTwoCalls).color().color())
.as("the colour from the first call")
.isEqualTo(NAVY.color());
- assertThat(entry(inTwoCalls, 0).borders().left().width())
+ assertThat(railStroke(inTwoCalls).width())
.as("and the width from the second")
.isEqualTo(4.0, within(1e-9));
}
@@ -730,7 +745,7 @@ void anUntouchedTimelineBuildsTheSameTreeItAlwaysHas() {
assertThat(timeline.children()).hasSize(2);
SectionNode first = entry(timeline, 0);
- assertThat(first.borders().hasAny()).isTrue();
+ assertThat(first.borders().hasAny()).as("no per-entry rail border").isFalse();
assertThat(first.padding().left()).as("the default gutter").isEqualTo(DEFAULT_GUTTER, within(1e-9));
assertThat(first.padding().bottom())
.as("the default entry spacing").isEqualTo(DEFAULT_ENTRY_SPACING, within(1e-9));
@@ -751,8 +766,19 @@ private static SectionNode timelineOf(Consumer spec) {
return (SectionNode) root.children().get(0);
}
+ /**
+ * One entry's section, reached through the anchor that wraps it.
+ *
+ * Entries are anchored so the rail can be computed from where they land — the pass
+ * needs each entry's extent on each page, and an anchor is the only thing that carries
+ * it. The unwrapping lives here rather than in every test, and
+ * {@link #everyEntryIsAnchoredSoTheRailCanBeComputedFromWhereItLands()} is where the
+ * wrapper itself is asserted.
+ */
private static SectionNode entry(SectionNode timeline, int index) {
- return (SectionNode) timeline.children().get(index);
+ DocumentNode anchored = timeline.children().get(index);
+ assertThat(anchored).isInstanceOf(LayoutAnchorNode.class);
+ return (SectionNode) anchored.children().get(0);
}
private static RowNode header(SectionNode entry) {
@@ -783,6 +809,17 @@ private static DocumentNode markerContent(DocumentNode markerColumn) {
return anchor.children().get(0).children().get(0);
}
+ /**
+ * The stroke this timeline's rail will be drawn with.
+ *
+ * Read off the owner rather than off a border, because the rail is no longer a
+ * border. This asserts the configuration itself instead of one of its side effects,
+ * which is the more direct claim anyway.
+ */
+ private static DocumentStroke railStroke(SectionNode timeline) {
+ return ((TimelineRailOwner) ownerOf(timeline)).rail().stroke();
+ }
+
/** The owner every marker in one timeline anchors on. */
private static Object ownerOf(SectionNode timeline) {
DocumentNode anchor = header(entry(timeline, 0)).children().get(0).children().get(0);
diff --git a/knowledge/api/authoring.json b/knowledge/api/authoring.json
index 85da2dfe8..21947648e 100644
--- a/knowledge/api/authoring.json
+++ b/knowledge/api/authoring.json
@@ -22,10 +22,10 @@
"graph-compose-testing:sources"
],
"counts": {
- "types": 234,
- "methods": 2086,
- "constants": 230,
- "generated": 1111
+ "types": 235,
+ "methods": 2088,
+ "constants": 233,
+ "generated": 1114
},
"packages": [
{
@@ -15041,6 +15041,15 @@
}
]
},
+ {
+ "kind": "method",
+ "name": "markerOnRail",
+ "static": false,
+ "origin": "source",
+ "typeParameters": null,
+ "returns": "TimelineBuilder",
+ "params": []
+ },
{
"kind": "method",
"name": "gutter",
@@ -15563,6 +15572,52 @@
"name": "stroke"
}
]
+ },
+ {
+ "kind": "method",
+ "name": "extent",
+ "static": false,
+ "origin": "source",
+ "typeParameters": null,
+ "returns": "TimelineRailBuilder",
+ "params": [
+ {
+ "type": "TimelineRailExtent",
+ "name": "extent"
+ }
+ ]
+ }
+ ]
+ },
+ {
+ "name": "TimelineRailExtent",
+ "binaryName": "com.demcha.compose.document.dsl.TimelineRailExtent",
+ "kind": "enum",
+ "modifiers": [
+ "final"
+ ],
+ "artifact": "graph-compose-core",
+ "members": [
+ {
+ "kind": "constant",
+ "name": "MARKER_TO_MARKER",
+ "static": true,
+ "origin": "generated",
+ "type": "TimelineRailExtent"
+ },
+ {
+ "kind": "constant",
+ "name": "ENTRY_BOUNDS",
+ "static": true,
+ "origin": "generated",
+ "type": "TimelineRailExtent"
+ },
+ {
+ "kind": "constant",
+ "name": "TIMELINE_BOUNDS",
+ "static": true,
+ "origin": "generated",
+ "type": "TimelineRailExtent"
}
]
},
diff --git a/knowledge/api/authoring.md b/knowledge/api/authoring.md
index 4349abf55..5a00f1060 100644
--- a/knowledge/api/authoring.md
+++ b/knowledge/api/authoring.md
@@ -28,7 +28,7 @@ note: "Generated from the pinned artifact's class files. Authoritative closed se
**GraphCompose version:** 2.4.0-SNAPSHOT
-Types: 234 · methods: 2086 · constants: 230 · compiler-generated members: 1111
+Types: 235 · methods: 2088 · constants: 233 · compiler-generated members: 1114
## com.demcha.compose
@@ -1123,6 +1123,7 @@ Types: 234 · methods: 2086 · constants: 230 · compiler-generated members: 111
### TimelineBuilder (class)
- `TimelineBuilder connector(DocumentColor color, double width)`
- `TimelineBuilder rail(Consumer spec)`
+- `TimelineBuilder markerOnRail()`
- `TimelineBuilder gutter(double gutter)`
- `TimelineBuilder markerGap(double gap)`
- `TimelineBuilder markerColumnWeight(double weight)`
@@ -1161,6 +1162,10 @@ Types: 234 · methods: 2086 · constants: 230 · compiler-generated members: 111
### TimelineRailBuilder (class)
- `TimelineRailBuilder stroke(DocumentStroke stroke)`
+- `TimelineRailBuilder extent(TimelineRailExtent extent)`
+
+### TimelineRailExtent (enum)
+- constants: `MARKER_TO_MARKER`, `ENTRY_BOUNDS`, `TIMELINE_BOUNDS`
### TocBuilder (class)
- `new TocBuilder()`
diff --git a/qa/src/test/java/com/demcha/compose/document/api/TimelineMarkerAnchorTest.java b/qa/src/test/java/com/demcha/compose/document/api/TimelineMarkerAnchorTest.java
index 703f2a885..11120c339 100644
--- a/qa/src/test/java/com/demcha/compose/document/api/TimelineMarkerAnchorTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/api/TimelineMarkerAnchorTest.java
@@ -42,7 +42,7 @@ void everyMarkerLeavesOneAnchorInTheFinishedGraph() throws Exception {
.entry(TimelineMarker.numbered(2, 14, INK, DocumentColor.WHITE), e -> e.title("Second"))
.entry(TimelineMarker.square(10, INK), e -> e.title("Third")));
- List anchors = ResolvedLayoutMetadata.from(graph).anchors();
+ List anchors = markerAnchors(graph);
assertThat(anchors).hasSize(3);
assertThat(anchors.stream().map(a -> a.id().index()))
.as("indexed by entry, in document order")
@@ -60,7 +60,7 @@ void anAnchorIsTheMarkersOwnBoxAndNotItsColumns() throws Exception {
.axisWidth(20)
.entry(TimelineMarker.dot(8, INK), e -> e.title("Only")));
- ResolvedLayoutAnchor anchor = ResolvedLayoutMetadata.from(graph).anchors().get(0);
+ ResolvedLayoutAnchor anchor = markerAnchors(graph).get(0);
PlacedFragment ellipse = graph.fragments().stream()
.filter(f -> f.payload() != null
&& f.payload().getClass().getSimpleName().contains("Ellipse"))
@@ -101,7 +101,7 @@ void anAnchorsLeftEdgeHoldsStillWhileItsCentreMovesWithTheMarker() throws Except
.entry(TimelineMarker.dot(6, INK), e -> e.title("Small"))
.entry(TimelineMarker.dot(24, INK), e -> e.title("Large")));
- List anchors = ResolvedLayoutMetadata.from(graph).anchors();
+ List anchors = markerAnchors(graph);
assertThat(anchors.get(0).x())
.as("same left edge whatever the marker's size")
.isEqualTo(anchors.get(1).x(), within(1e-9));
@@ -121,7 +121,7 @@ void anAnchorReportsThePageItsMarkerIsActuallyOn() throws Exception {
.entry(TimelineMarker.dot(8, INK), e -> e.title("Second").body("Short.")));
assertThat(graph.totalPages()).isGreaterThan(1);
- List anchors = ResolvedLayoutMetadata.from(graph).anchors();
+ List anchors = markerAnchors(graph);
assertThat(anchors).hasSize(2);
assertThat(anchors.get(0).pageIndex()).isZero();
assertThat(anchors.get(1).pageIndex())
@@ -142,7 +142,7 @@ void twoTimelinesOnOnePageKeepTheirMarkersApart() throws Exception {
.build();
List anchors =
- ResolvedLayoutMetadata.from(session.layoutGraph()).anchors();
+ markerAnchors(session.layoutGraph());
assertThat(anchors).hasSize(3);
Object first = anchors.get(0).id().groupKey();
@@ -160,11 +160,23 @@ private static com.demcha.compose.document.node.EllipseNode circle(double size,
"marker", size, size, fill, null, null, null, null, null);
}
+ /**
+ * The marker anchors only.
+ *
+ * A timeline anchors its entries as well as its markers — the rail needs each
+ * entry's extent on each page — so a test about markers has to say which it means.
+ */
+ private static List markerAnchors(LayoutGraph graph) {
+ return ResolvedLayoutMetadata.from(graph).anchors().stream()
+ .filter(a -> "MARKER".equals(a.id().kind().toString()))
+ .toList();
+ }
+
private static ResolvedLayoutAnchor onlyAnchor(TimelineMarker marker) throws Exception {
LayoutGraph graph = timeline(360, t -> t
.axisWidth(20)
.entry(marker, e -> e.title("Only")));
- return ResolvedLayoutMetadata.from(graph).anchors().get(0);
+ return markerAnchors(graph).get(0);
}
private static LayoutGraph timeline(double pageWidth, Consumer spec) throws Exception {
diff --git a/qa/src/test/java/com/demcha/compose/document/api/TimelineRailGeometryTest.java b/qa/src/test/java/com/demcha/compose/document/api/TimelineRailGeometryTest.java
new file mode 100644
index 000000000..fd5ed798f
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/api/TimelineRailGeometryTest.java
@@ -0,0 +1,296 @@
+package com.demcha.compose.document.api;
+
+import com.demcha.compose.GraphCompose;
+import com.demcha.compose.document.dsl.TimelineBuilder;
+import com.demcha.compose.document.dsl.TimelineMarker;
+import com.demcha.compose.document.dsl.TimelineRailExtent;
+import com.demcha.compose.document.layout.LayoutGraph;
+import com.demcha.compose.document.layout.PlacedFragment;
+import com.demcha.compose.document.layout.ResolvedLayoutAnchor;
+import com.demcha.compose.document.layout.ResolvedLayoutMetadata;
+import com.demcha.compose.document.style.DocumentColor;
+import com.demcha.compose.document.style.DocumentInsets;
+import com.demcha.compose.document.style.DocumentRowColumn;
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+import java.util.function.Consumer;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
+import static org.assertj.core.api.Assertions.within;
+
+/**
+ * The rail as one logical line, computed from resolved anchors.
+ *
+ * It used to be a left border repeated on every entry section, which is why it sat at
+ * the entry's edge, could not stop at the markers, and drew a slightly darker row wherever
+ * two entries abutted. It is now contributed by a pass reading the anchors the layout
+ * resolved: x from the marker anchor, the two ends from the extent, and one
+ * fragment per page it appears on.
+ *
+ * The two are independent, and the tests keep them so. A marker anchor moves the line
+ * sideways and never changes where it starts; an extent changes where it starts and never
+ * moves it sideways.
+ */
+class TimelineRailGeometryTest {
+
+ private static final DocumentColor RAIL = DocumentColor.rgb(150, 158, 172);
+ private static final DocumentColor INK = DocumentColor.rgb(20, 40, 70);
+
+ // --- extent ---------------------------------------------------------------
+
+ @Test
+ void entryBoundsIsExactlyTheUnionOfTheEntriesResolvedSlices() throws Exception {
+ // The compatibility contract, stated as an equation rather than as a picture. The
+ // slices are the same boxes the per-entry border used to be drawn on — measured at
+ // Δ = 0 against it — so a rail equal to their union is the rail that shipped.
+ LayoutGraph graph = timeline(320, 300, t -> t
+ .spacing(14)
+ .entry(TimelineMarker.dot(8, INK), e -> e.title("First").body("Body one."))
+ .entry(TimelineMarker.dot(8, INK), e -> e.title("Second").body("Body two."))
+ .entry(TimelineMarker.dot(8, INK), e -> e.title("Third").body("Body three.")));
+
+ List rails = rails(graph);
+ List entries = entryAnchors(graph);
+ assertThat(rails).as("one page, one rail").hasSize(1);
+ assertThat(entries).hasSize(3);
+
+ double top = entries.stream().mapToDouble(a -> a.pointY(1.0)).max().orElseThrow();
+ double bottom = entries.stream().mapToDouble(ResolvedLayoutAnchor::y).min().orElseThrow();
+ assertThat(rails.get(0).y() + rails.get(0).height()).isEqualTo(top, within(1e-9));
+ assertThat(rails.get(0).y()).isEqualTo(bottom, within(1e-9));
+
+ // And the union is contiguous, which is why one line can replace three: an entry's
+ // spacing is padding inside its own box, so the gaps are covered and there is no
+ // tail after the last entry.
+ assertThat(entries.get(0).y()).isEqualTo(entries.get(1).pointY(1.0), within(1e-9));
+ assertThat(entries.get(1).y()).isEqualTo(entries.get(2).pointY(1.0), within(1e-9));
+ }
+
+ @Test
+ void markerToMarkerStopsAtTheMarkersAndEntryBoundsDoesNot() throws Exception {
+ Consumer entries = t -> t
+ .spacing(14)
+ .entry(TimelineMarker.dot(8, INK), e -> e.title("First").body("Body one."))
+ .entry(TimelineMarker.dot(8, INK), e -> e.title("Second").body("Body two."));
+
+ PlacedFragment bounded = rails(timeline(320, 300, entries)).get(0);
+ PlacedFragment betweenMarkers = rails(timeline(320, 300, t -> {
+ t.rail(rail -> rail.extent(TimelineRailExtent.MARKER_TO_MARKER));
+ entries.accept(t);
+ })).get(0);
+
+ assertThat(betweenMarkers.y() + betweenMarkers.height())
+ .as("nothing above the first marker")
+ .isLessThan(bounded.y() + bounded.height());
+ assertThat(betweenMarkers.y())
+ .as("nothing below the last")
+ .isGreaterThan(bounded.y());
+ assertThat(betweenMarkers.x())
+ .as("and the extent moved neither end sideways")
+ .isEqualTo(bounded.x(), within(1e-9));
+ }
+
+ @Test
+ void markerToMarkerRunsBetweenTheAnchorPointsItIsNamedFor() throws Exception {
+ LayoutGraph graph = timeline(320, 300, t -> t
+ .rail(rail -> rail.extent(TimelineRailExtent.MARKER_TO_MARKER))
+ .entry(TimelineMarker.dot(8, INK), e -> e.title("First").body("Body one."))
+ .entry(TimelineMarker.dot(8, INK), e -> e.title("Second").body("Body two.")));
+
+ List markers = markerAnchors(graph);
+ PlacedFragment rail = rails(graph).get(0);
+ assertThat(rail.y() + rail.height())
+ .as("the first marker's anchor point, vertically its centre")
+ .isEqualTo(markers.get(0).pointY(0.5), within(1e-9));
+ assertThat(rail.y()).isEqualTo(markers.get(1).pointY(0.5), within(1e-9));
+ }
+
+ @Test
+ void oneEntryWithMarkerToMarkerEmitsNoRailAtAll() throws Exception {
+ // A rail of no length is not a shorter rail: nothing reaches a backend, rather
+ // than a zero- or negative-height fragment for one to cope with.
+ LayoutGraph graph = timeline(320, 300, t -> t
+ .rail(rail -> rail.extent(TimelineRailExtent.MARKER_TO_MARKER))
+ .entry(TimelineMarker.dot(8, INK), e -> e.title("Only").body("Body.")));
+
+ assertThat(rails(graph)).isEmpty();
+ assertThat(markerAnchors(graph)).as("the marker itself still renders").hasSize(1);
+ }
+
+ @Test
+ void oneEntryWithEntryBoundsStillDrawsItsRail() throws Exception {
+ // The other extent has no such degenerate case — an entry has height.
+ assertThat(rails(timeline(320, 300, t -> t
+ .entry(TimelineMarker.dot(8, INK), e -> e.title("Only").body("Body.")))))
+ .hasSize(1);
+ }
+
+ @Test
+ void timelineBoundsIsRejectedRatherThanResolvedToItsNeighbour() throws Exception {
+ assertThatIllegalArgumentException()
+ .isThrownBy(() -> timeline(320, 300, t -> t
+ .rail(rail -> rail.extent(TimelineRailExtent.TIMELINE_BOUNDS))
+ .entry(TimelineMarker.dot(8, INK), e -> e.title("x"))))
+ .withMessageContaining("not implemented");
+ }
+
+ // --- the marker anchor, which is a different question ----------------------
+
+ @Test
+ void theRailSitsOneGutterLeftOfTheMarkerWhateverTheMarkerSize() throws Exception {
+ // The measured reason the anchor is an edge plus a constant rather than a centre:
+ // a centre moves with the marker, an edge does not. Two timelines, markers of 6pt
+ // and 24pt, and one rail x.
+ double small = rails(timeline(320, 300, t -> t.gutter(8)
+ .entry(TimelineMarker.dot(6, INK), e -> e.title("x")))).get(0).x();
+ double large = rails(timeline(320, 300, t -> t.gutter(8)
+ .entry(TimelineMarker.dot(24, INK), e -> e.title("x")))).get(0).x();
+
+ assertThat(large).isEqualTo(small, within(1e-9));
+ }
+
+ @Test
+ void markerOnRailPutsTheLineThroughTheMarkerInstead() throws Exception {
+ LayoutGraph graph = timeline(320, 300, t -> t
+ .markerOnRail()
+ .entry(TimelineMarker.dot(12, INK), e -> e.title("First").body("Body."))
+ .entry(TimelineMarker.dot(12, INK), e -> e.title("Second").body("Body.")));
+
+ ResolvedLayoutAnchor marker = markerAnchors(graph).get(0);
+ assertThat(rails(graph).get(0).x())
+ .as("through the marker's centre")
+ .isEqualTo(marker.pointX(0.5), within(1e-9));
+ }
+
+ @Test
+ void aLeadingColumnAddsContentLeftOfTheAxisAndDoesNotMoveTheRail() throws Exception {
+ // LEADING | AXIS | CONTENT. The rail belongs to the axis, so a date column appears
+ // to the left of it rather than pushing it out to the entry's boundary.
+ LayoutGraph graph = timeline(360, 300, t -> t
+ .leadingColumn(DocumentRowColumn.fixed(56))
+ .entry(e -> e.marker(TimelineMarker.dot(8, INK))
+ .leading(d -> d.addParagraph("2023")).title("First").body("Body."))
+ .entry(e -> e.marker(TimelineMarker.dot(8, INK))
+ .leading(d -> d.addParagraph("2021")).title("Second").body("Body.")));
+
+ PlacedFragment rail = rails(graph).get(0);
+ ResolvedLayoutAnchor marker = markerAnchors(graph).get(0);
+ ResolvedLayoutAnchor entry = entryAnchors(graph).get(0);
+
+ assertThat(rail.x())
+ .as("one gutter left of the marker, as always")
+ .isEqualTo(marker.x() - 8.0, within(1e-9));
+ assertThat(rail.x())
+ .as("which is well right of the entry's own edge, because the date is there")
+ .isGreaterThan(entry.x() + 40.0);
+ }
+
+ // --- pages and draw order --------------------------------------------------
+
+ @Test
+ void aTimelineCrossingPagesGivesOneFragmentPerPageBoundedByThatPage() throws Exception {
+ StringBuilder body = new StringBuilder();
+ for (int i = 0; i < 26; i++) {
+ body.append("Sentence ").append(i).append(" of a body long enough to run on and on. ");
+ }
+ LayoutGraph graph = timeline(320, 150, t -> t
+ .spacing(14)
+ .entry(TimelineMarker.dot(8, INK), e -> e.title("First").body(body.toString()))
+ .entry(TimelineMarker.dot(8, INK), e -> e.title("Second").body("Short.")));
+
+ assertThat(graph.totalPages()).isGreaterThan(2);
+ List rails = rails(graph);
+ assertThat(rails).as("one logical rail, one fragment per page").hasSize(graph.totalPages());
+
+ for (PlacedFragment rail : rails) {
+ List onPage = entryAnchors(graph).stream()
+ .filter(a -> a.pageIndex() == rail.pageIndex()).toList();
+ double top = onPage.stream().mapToDouble(a -> a.pointY(1.0)).max().orElseThrow();
+ double bottom = onPage.stream().mapToDouble(ResolvedLayoutAnchor::y).min().orElseThrow();
+ assertThat(rail.y() + rail.height())
+ .as("page %d top", rail.pageIndex()).isEqualTo(top, within(1e-9));
+ assertThat(rail.y())
+ .as("page %d bottom", rail.pageIndex()).isEqualTo(bottom, within(1e-9));
+ }
+ // Not one physical line pretending to span pages: every fragment is on its own page
+ // and none of them reaches beyond it.
+ assertThat(rails.stream().map(PlacedFragment::pageIndex).distinct())
+ .hasSize(graph.totalPages());
+ }
+
+ @Test
+ void theRailIsDrawnBeneathTheMarkersItPassesUnder() throws Exception {
+ // There is no z in this engine — draw order is list order — so "under" is a
+ // statement about the fragment list and has to be asserted there.
+ LayoutGraph graph = timeline(320, 300, t -> t
+ .entry(TimelineMarker.dot(10, INK), e -> e.title("First"))
+ .entry(TimelineMarker.dot(10, INK), e -> e.title("Second")));
+
+ int lastRail = -1;
+ int firstMarker = Integer.MAX_VALUE;
+ for (int i = 0; i < graph.fragments().size(); i++) {
+ PlacedFragment fragment = graph.fragments().get(i);
+ if ("@timeline-rail".equals(fragment.path())) {
+ lastRail = Math.max(lastRail, i);
+ } else if (fragment.payload() != null
+ && fragment.payload().getClass().getSimpleName().contains("Ellipse")) {
+ firstMarker = Math.min(firstMarker, i);
+ }
+ }
+ assertThat(lastRail).isNotEqualTo(-1);
+ assertThat(lastRail)
+ .as("every rail fragment precedes every marker, so a filled marker covers it")
+ .isLessThan(firstMarker);
+ }
+
+ @Test
+ void twoTimelinesOnAPageDrawTwoRails() throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(320, 400).margin(DocumentInsets.of(20)).create()) {
+ session.pageFlow()
+ .addTimeline(t -> t.connector(RAIL, 1.5)
+ .entry(TimelineMarker.dot(8, INK), e -> e.title("A1").body("Body."))
+ .entry(TimelineMarker.dot(8, INK), e -> e.title("A2").body("Body.")))
+ .addTimeline(t -> t.connector(RAIL, 3.0)
+ .entry(TimelineMarker.dot(8, INK), e -> e.title("B1").body("Body."))
+ .entry(TimelineMarker.dot(8, INK), e -> e.title("B2").body("Body.")))
+ .build();
+
+ List rails = rails(session.layoutGraph());
+ assertThat(rails).hasSize(2);
+ assertThat(rails.get(0).width()).as("each with its own stroke").isEqualTo(1.5, within(1e-9));
+ assertThat(rails.get(1).width()).isEqualTo(3.0, within(1e-9));
+ assertThat(rails.get(0).y()).as("and its own extent").isGreaterThan(rails.get(1).y());
+ }
+ }
+
+ // --- helpers ---------------------------------------------------------------
+
+ private static List rails(LayoutGraph graph) {
+ return graph.fragments().stream().filter(f -> "@timeline-rail".equals(f.path())).toList();
+ }
+
+ private static List entryAnchors(LayoutGraph graph) {
+ return ResolvedLayoutMetadata.from(graph).anchors().stream()
+ .filter(a -> "ENTRY".equals(a.id().kind().toString())).toList();
+ }
+
+ private static List markerAnchors(LayoutGraph graph) {
+ return ResolvedLayoutMetadata.from(graph).anchors().stream()
+ .filter(a -> "MARKER".equals(a.id().kind().toString())).toList();
+ }
+
+ private static LayoutGraph timeline(double width, double height,
+ Consumer spec) throws Exception {
+ try (DocumentSession session = GraphCompose.document()
+ .pageSize(width, height).margin(DocumentInsets.of(20)).create()) {
+ session.pageFlow().addTimeline(t -> {
+ t.connector(RAIL, 1.5);
+ spec.accept(t);
+ }).build();
+ return session.layoutGraph();
+ }
+ }
+}
diff --git a/qa/src/test/resources/layout-snapshots/document/timeline_classic.json b/qa/src/test/resources/layout-snapshots/document/timeline_classic.json
index 80bbcc1a9..d8a5ccbd8 100644
--- a/qa/src/test/resources/layout-snapshots/document/timeline_classic.json
+++ b/qa/src/test/resources/layout-snapshots/document/timeline_classic.json
@@ -74,9 +74,9 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
"entityName" : null,
- "entityKind" : "SectionNode",
+ "entityKind" : "LayoutAnchorNode",
"parentPath" : "ContainerNode[0]/SectionNode[0]",
"childIndex" : 0,
"depth" : 3,
@@ -97,6 +97,36 @@
"bottom" : 0.0,
"left" : 0.0
},
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ }
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
+ "entityName" : null,
+ "entityKind" : "SectionNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
+ "childIndex" : 0,
+ "depth" : 4,
+ "layer" : 4,
+ "computedX" : 20.0,
+ "computedY" : 193.175,
+ "placementX" : 20.0,
+ "placementY" : 193.175,
+ "placementWidth" : 280.0,
+ "placementHeight" : 46.825,
+ "startPage" : 0,
+ "endPage" : 0,
+ "contentWidth" : 280.0,
+ "contentHeight" : 46.825,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
"padding" : {
"top" : 0.0,
"right" : 0.0,
@@ -104,13 +134,13 @@
"left" : 8.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]",
"entityName" : null,
"entityKind" : "RowNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
"childIndex" : 0,
- "depth" : 4,
- "layer" : 4,
+ "depth" : 5,
+ "layer" : 5,
"computedX" : 28.0,
"computedY" : 219.962,
"placementX" : 28.0,
@@ -134,13 +164,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]",
"childIndex" : 0,
- "depth" : 5,
- "layer" : 5,
+ "depth" : 6,
+ "layer" : 6,
"computedX" : 28.0,
"computedY" : 232.0,
"placementX" : 28.0,
@@ -164,13 +194,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
"entityName" : null,
"entityKind" : "LayoutAnchorNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]",
"childIndex" : 0,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 28.0,
"computedY" : 232.0,
"placementX" : 28.0,
@@ -194,13 +224,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
"childIndex" : 0,
- "depth" : 7,
- "layer" : 7,
+ "depth" : 8,
+ "layer" : 8,
"computedX" : 28.0,
"computedY" : 232.0,
"placementX" : 28.0,
@@ -224,13 +254,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/EllipseNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/EllipseNode[0]",
"entityName" : null,
"entityKind" : "EllipseNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
"childIndex" : 0,
- "depth" : 8,
- "layer" : 8,
+ "depth" : 9,
+ "layer" : 9,
"computedX" : 28.0,
"computedY" : 232.0,
"placementX" : 28.0,
@@ -254,13 +284,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]",
"childIndex" : 1,
- "depth" : 5,
- "layer" : 5,
+ "depth" : 6,
+ "layer" : 6,
"computedX" : 60.0,
"computedY" : 219.962,
"placementX" : 60.0,
@@ -284,13 +314,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/ParagraphNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/ParagraphNode[0]",
"entityName" : null,
"entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"childIndex" : 0,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 60.0,
"computedY" : 229.825,
"placementX" : 60.0,
@@ -314,13 +344,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/ParagraphNode[1]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/ParagraphNode[1]",
"entityName" : null,
"entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"childIndex" : 1,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 60.0,
"computedY" : 219.962,
"placementX" : 60.0,
@@ -344,13 +374,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/ParagraphNode[1]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/ParagraphNode[1]",
"entityName" : null,
"entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
"childIndex" : 1,
- "depth" : 4,
- "layer" : 4,
+ "depth" : 5,
+ "layer" : 5,
"computedX" : 28.0,
"computedY" : 207.175,
"placementX" : 28.0,
@@ -374,9 +404,9 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]",
"entityName" : null,
- "entityKind" : "SectionNode",
+ "entityKind" : "LayoutAnchorNode",
"parentPath" : "ContainerNode[0]/SectionNode[0]",
"childIndex" : 1,
"depth" : 3,
@@ -397,6 +427,36 @@
"bottom" : 0.0,
"left" : 0.0
},
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ }
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]",
+ "entityName" : null,
+ "entityKind" : "SectionNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]",
+ "childIndex" : 0,
+ "depth" : 4,
+ "layer" : 4,
+ "computedX" : 20.0,
+ "computedY" : 146.35,
+ "placementX" : 20.0,
+ "placementY" : 146.35,
+ "placementWidth" : 280.0,
+ "placementHeight" : 46.825,
+ "startPage" : 0,
+ "endPage" : 0,
+ "contentWidth" : 280.0,
+ "contentHeight" : 46.825,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
"padding" : {
"top" : 0.0,
"right" : 0.0,
@@ -404,13 +464,13 @@
"left" : 8.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]",
"entityName" : null,
"entityKind" : "RowNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]",
"childIndex" : 0,
- "depth" : 4,
- "layer" : 4,
+ "depth" : 5,
+ "layer" : 5,
"computedX" : 28.0,
"computedY" : 173.137,
"placementX" : 28.0,
@@ -434,13 +494,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]",
"childIndex" : 0,
- "depth" : 5,
- "layer" : 5,
+ "depth" : 6,
+ "layer" : 6,
"computedX" : 28.0,
"computedY" : 179.175,
"placementX" : 28.0,
@@ -464,13 +524,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
"entityName" : null,
"entityKind" : "LayoutAnchorNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]",
"childIndex" : 0,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 28.0,
"computedY" : 179.175,
"placementX" : 28.0,
@@ -494,13 +554,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
"childIndex" : 0,
- "depth" : 7,
- "layer" : 7,
+ "depth" : 8,
+ "layer" : 8,
"computedX" : 28.0,
"computedY" : 179.175,
"placementX" : 28.0,
@@ -524,13 +584,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/ShapeContainerNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/ShapeContainerNode[0]",
"entityName" : null,
"entityKind" : "ShapeContainerNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
"childIndex" : 0,
- "depth" : 8,
- "layer" : 8,
+ "depth" : 9,
+ "layer" : 9,
"computedX" : 28.0,
"computedY" : 179.175,
"placementX" : 28.0,
@@ -554,13 +614,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/ShapeContainerNode[0]/ParagraphNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/ShapeContainerNode[0]/ParagraphNode[0]",
"entityName" : null,
"entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/ShapeContainerNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/ShapeContainerNode[0]",
"childIndex" : 0,
- "depth" : 9,
- "layer" : 9,
+ "depth" : 10,
+ "layer" : 10,
"computedX" : 28.0,
"computedY" : 182.937,
"placementX" : 28.0,
@@ -584,13 +644,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]",
"childIndex" : 1,
- "depth" : 5,
- "layer" : 5,
+ "depth" : 6,
+ "layer" : 6,
"computedX" : 60.0,
"computedY" : 173.137,
"placementX" : 60.0,
@@ -614,13 +674,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]/ParagraphNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]/ParagraphNode[0]",
"entityName" : null,
"entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"childIndex" : 0,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 60.0,
"computedY" : 183.0,
"placementX" : 60.0,
@@ -644,13 +704,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]/ParagraphNode[1]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]/ParagraphNode[1]",
"entityName" : null,
"entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"childIndex" : 1,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 60.0,
"computedY" : 173.137,
"placementX" : 60.0,
@@ -674,13 +734,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/ParagraphNode[1]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/ParagraphNode[1]",
"entityName" : null,
"entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]",
"childIndex" : 1,
- "depth" : 4,
- "layer" : 4,
+ "depth" : 5,
+ "layer" : 5,
"computedX" : 28.0,
"computedY" : 160.35,
"placementX" : 28.0,
@@ -704,9 +764,9 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]",
"entityName" : null,
- "entityKind" : "SectionNode",
+ "entityKind" : "LayoutAnchorNode",
"parentPath" : "ContainerNode[0]/SectionNode[0]",
"childIndex" : 2,
"depth" : 3,
@@ -731,16 +791,46 @@
"top" : 0.0,
"right" : 0.0,
"bottom" : 0.0,
- "left" : 8.0
+ "left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]",
"entityName" : null,
- "entityKind" : "RowNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]",
+ "entityKind" : "SectionNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]",
"childIndex" : 0,
"depth" : 4,
"layer" : 4,
+ "computedX" : 20.0,
+ "computedY" : 126.312,
+ "placementX" : 20.0,
+ "placementY" : 126.312,
+ "placementWidth" : 280.0,
+ "placementHeight" : 20.038,
+ "startPage" : 0,
+ "endPage" : 0,
+ "contentWidth" : 280.0,
+ "contentHeight" : 20.038,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 8.0
+ }
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]",
+ "entityName" : null,
+ "entityKind" : "RowNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]",
+ "childIndex" : 0,
+ "depth" : 5,
+ "layer" : 5,
"computedX" : 28.0,
"computedY" : 126.312,
"placementX" : 28.0,
@@ -764,13 +854,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[0]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]",
"childIndex" : 0,
- "depth" : 5,
- "layer" : 5,
+ "depth" : 6,
+ "layer" : 6,
"computedX" : 28.0,
"computedY" : 138.35,
"placementX" : 28.0,
@@ -794,13 +884,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
"entityName" : null,
"entityKind" : "LayoutAnchorNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[0]",
"childIndex" : 0,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 28.0,
"computedY" : 138.35,
"placementX" : 28.0,
@@ -824,13 +914,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
"childIndex" : 0,
- "depth" : 7,
- "layer" : 7,
+ "depth" : 8,
+ "layer" : 8,
"computedX" : 28.0,
"computedY" : 138.35,
"placementX" : 28.0,
@@ -854,13 +944,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/TimelineMarkerSquare[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/TimelineMarkerSquare[0]",
"entityName" : "TimelineMarkerSquare",
"entityKind" : "ShapeNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
"childIndex" : 0,
- "depth" : 8,
- "layer" : 8,
+ "depth" : 9,
+ "layer" : 9,
"computedX" : 28.0,
"computedY" : 138.35,
"placementX" : 28.0,
@@ -884,13 +974,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[1]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]",
"childIndex" : 1,
- "depth" : 5,
- "layer" : 5,
+ "depth" : 6,
+ "layer" : 6,
"computedX" : 60.0,
"computedY" : 126.312,
"placementX" : 60.0,
@@ -914,13 +1004,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[1]/ParagraphNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]/ParagraphNode[0]",
"entityName" : null,
"entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[1]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"childIndex" : 0,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 60.0,
"computedY" : 136.175,
"placementX" : 60.0,
@@ -944,13 +1034,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[1]/ParagraphNode[1]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]/ParagraphNode[1]",
"entityName" : null,
"entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[1]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"childIndex" : 1,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 60.0,
"computedY" : 126.312,
"placementX" : 60.0,
diff --git a/qa/src/test/resources/layout-snapshots/document/timeline_leading_column.json b/qa/src/test/resources/layout-snapshots/document/timeline_leading_column.json
index c1e581c90..70caff6e8 100644
--- a/qa/src/test/resources/layout-snapshots/document/timeline_leading_column.json
+++ b/qa/src/test/resources/layout-snapshots/document/timeline_leading_column.json
@@ -74,9 +74,9 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
"entityName" : null,
- "entityKind" : "SectionNode",
+ "entityKind" : "LayoutAnchorNode",
"parentPath" : "ContainerNode[0]/SectionNode[0]",
"childIndex" : 0,
"depth" : 3,
@@ -97,6 +97,36 @@
"bottom" : 0.0,
"left" : 0.0
},
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ }
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
+ "entityName" : null,
+ "entityKind" : "SectionNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
+ "childIndex" : 0,
+ "depth" : 4,
+ "layer" : 4,
+ "computedX" : 20.0,
+ "computedY" : 200.262,
+ "placementX" : 20.0,
+ "placementY" : 200.262,
+ "placementWidth" : 320.0,
+ "placementHeight" : 39.738,
+ "startPage" : 0,
+ "endPage" : 0,
+ "contentWidth" : 320.0,
+ "contentHeight" : 39.738,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
"padding" : {
"top" : 0.0,
"right" : 0.0,
@@ -104,13 +134,13 @@
"left" : 8.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]",
"entityName" : null,
"entityKind" : "RowNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
"childIndex" : 0,
- "depth" : 4,
- "layer" : 4,
+ "depth" : 5,
+ "layer" : 5,
"computedX" : 28.0,
"computedY" : 227.05,
"placementX" : 28.0,
@@ -134,13 +164,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]",
"childIndex" : 0,
- "depth" : 5,
- "layer" : 5,
+ "depth" : 6,
+ "layer" : 6,
"computedX" : 28.0,
"computedY" : 227.05,
"placementX" : 28.0,
@@ -164,13 +194,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/ParagraphNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/ParagraphNode[0]",
"entityName" : null,
"entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]",
"childIndex" : 0,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 28.0,
"computedY" : 227.05,
"placementX" : 28.0,
@@ -194,13 +224,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]",
"childIndex" : 1,
- "depth" : 5,
- "layer" : 5,
+ "depth" : 6,
+ "layer" : 6,
"computedX" : 92.0,
"computedY" : 232.0,
"placementX" : 92.0,
@@ -224,13 +254,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]",
"entityName" : null,
"entityKind" : "LayoutAnchorNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"childIndex" : 0,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 92.0,
"computedY" : 232.0,
"placementX" : 92.0,
@@ -254,13 +284,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]",
"childIndex" : 0,
- "depth" : 7,
- "layer" : 7,
+ "depth" : 8,
+ "layer" : 8,
"computedX" : 92.0,
"computedY" : 232.0,
"placementX" : 92.0,
@@ -284,13 +314,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]/EllipseNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]/EllipseNode[0]",
"entityName" : null,
"entityKind" : "EllipseNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]",
"childIndex" : 0,
- "depth" : 8,
- "layer" : 8,
+ "depth" : 9,
+ "layer" : 9,
"computedX" : 92.0,
"computedY" : 232.0,
"placementX" : 92.0,
@@ -314,13 +344,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[2]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[2]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]",
"childIndex" : 2,
- "depth" : 5,
- "layer" : 5,
+ "depth" : 6,
+ "layer" : 6,
"computedX" : 121.818,
"computedY" : 229.825,
"placementX" : 121.818,
@@ -344,13 +374,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[2]/ParagraphNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[2]/ParagraphNode[0]",
"entityName" : null,
"entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[2]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[2]",
"childIndex" : 0,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 121.818,
"computedY" : 229.825,
"placementX" : 121.818,
@@ -374,13 +404,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/ParagraphNode[1]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/ParagraphNode[1]",
"entityName" : null,
"entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
"childIndex" : 1,
- "depth" : 4,
- "layer" : 4,
+ "depth" : 5,
+ "layer" : 5,
"computedX" : 28.0,
"computedY" : 214.262,
"placementX" : 28.0,
@@ -404,9 +434,9 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]",
"entityName" : null,
- "entityKind" : "SectionNode",
+ "entityKind" : "LayoutAnchorNode",
"parentPath" : "ContainerNode[0]/SectionNode[0]",
"childIndex" : 1,
"depth" : 3,
@@ -427,6 +457,36 @@
"bottom" : 0.0,
"left" : 0.0
},
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ }
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]",
+ "entityName" : null,
+ "entityKind" : "SectionNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]",
+ "childIndex" : 0,
+ "depth" : 4,
+ "layer" : 4,
+ "computedX" : 20.0,
+ "computedY" : 134.625,
+ "placementX" : 20.0,
+ "placementY" : 134.625,
+ "placementWidth" : 320.0,
+ "placementHeight" : 65.638,
+ "startPage" : 0,
+ "endPage" : 0,
+ "contentWidth" : 320.0,
+ "contentHeight" : 65.638,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
"padding" : {
"top" : 0.0,
"right" : 0.0,
@@ -434,13 +494,13 @@
"left" : 8.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]",
"entityName" : null,
"entityKind" : "RowNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]",
"childIndex" : 0,
- "depth" : 4,
- "layer" : 4,
+ "depth" : 5,
+ "layer" : 5,
"computedX" : 28.0,
"computedY" : 161.412,
"placementX" : 28.0,
@@ -464,13 +524,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]",
"childIndex" : 0,
- "depth" : 5,
- "layer" : 5,
+ "depth" : 6,
+ "layer" : 6,
"computedX" : 28.0,
"computedY" : 161.412,
"placementX" : 28.0,
@@ -494,13 +554,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/ParagraphNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/ParagraphNode[0]",
"entityName" : null,
"entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]",
"childIndex" : 0,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 28.0,
"computedY" : 161.412,
"placementX" : 28.0,
@@ -524,13 +584,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]",
"childIndex" : 1,
- "depth" : 5,
- "layer" : 5,
+ "depth" : 6,
+ "layer" : 6,
"computedX" : 92.0,
"computedY" : 192.262,
"placementX" : 92.0,
@@ -554,13 +614,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]",
"entityName" : null,
"entityKind" : "LayoutAnchorNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"childIndex" : 0,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 92.0,
"computedY" : 192.262,
"placementX" : 92.0,
@@ -584,13 +644,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]",
"childIndex" : 0,
- "depth" : 7,
- "layer" : 7,
+ "depth" : 8,
+ "layer" : 8,
"computedX" : 92.0,
"computedY" : 192.262,
"placementX" : 92.0,
@@ -614,13 +674,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]/EllipseNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]/EllipseNode[0]",
"entityName" : null,
"entityKind" : "EllipseNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]",
"childIndex" : 0,
- "depth" : 8,
- "layer" : 8,
+ "depth" : 9,
+ "layer" : 9,
"computedX" : 92.0,
"computedY" : 192.262,
"placementX" : 92.0,
@@ -644,13 +704,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[2]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[2]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]",
"childIndex" : 2,
- "depth" : 5,
- "layer" : 5,
+ "depth" : 6,
+ "layer" : 6,
"computedX" : 121.818,
"computedY" : 190.087,
"placementX" : 121.818,
@@ -674,13 +734,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[2]/ParagraphNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[2]/ParagraphNode[0]",
"entityName" : null,
"entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[2]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[2]",
"childIndex" : 0,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 121.818,
"computedY" : 190.087,
"placementX" : 121.818,
@@ -704,13 +764,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/ParagraphNode[1]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/ParagraphNode[1]",
"entityName" : null,
"entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]",
"childIndex" : 1,
- "depth" : 4,
- "layer" : 4,
+ "depth" : 5,
+ "layer" : 5,
"computedX" : 28.0,
"computedY" : 148.625,
"placementX" : 28.0,
@@ -734,9 +794,9 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]",
"entityName" : null,
- "entityKind" : "SectionNode",
+ "entityKind" : "LayoutAnchorNode",
"parentPath" : "ContainerNode[0]/SectionNode[0]",
"childIndex" : 2,
"depth" : 3,
@@ -757,6 +817,36 @@
"bottom" : 0.0,
"left" : 0.0
},
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ }
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]",
+ "entityName" : null,
+ "entityKind" : "SectionNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]",
+ "childIndex" : 0,
+ "depth" : 4,
+ "layer" : 4,
+ "computedX" : 20.0,
+ "computedY" : 107.675,
+ "placementX" : 20.0,
+ "placementY" : 107.675,
+ "placementWidth" : 320.0,
+ "placementHeight" : 26.95,
+ "startPage" : 0,
+ "endPage" : 0,
+ "contentWidth" : 320.0,
+ "contentHeight" : 26.95,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
"padding" : {
"top" : 0.0,
"right" : 0.0,
@@ -764,13 +854,13 @@
"left" : 8.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]",
"entityName" : null,
"entityKind" : "RowNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]",
"childIndex" : 0,
- "depth" : 4,
- "layer" : 4,
+ "depth" : 5,
+ "layer" : 5,
"computedX" : 28.0,
"computedY" : 121.675,
"placementX" : 28.0,
@@ -794,13 +884,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[0]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]",
"childIndex" : 0,
- "depth" : 5,
- "layer" : 5,
+ "depth" : 6,
+ "layer" : 6,
"computedX" : 28.0,
"computedY" : 121.675,
"placementX" : 28.0,
@@ -824,13 +914,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[0]/ParagraphNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[0]/ParagraphNode[0]",
"entityName" : null,
"entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[0]",
"childIndex" : 0,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 28.0,
"computedY" : 121.675,
"placementX" : 28.0,
@@ -854,13 +944,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[1]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]",
"childIndex" : 1,
- "depth" : 5,
- "layer" : 5,
+ "depth" : 6,
+ "layer" : 6,
"computedX" : 92.0,
"computedY" : 126.625,
"placementX" : 92.0,
@@ -884,13 +974,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]",
"entityName" : null,
"entityKind" : "LayoutAnchorNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[1]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"childIndex" : 0,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 92.0,
"computedY" : 126.625,
"placementX" : 92.0,
@@ -914,13 +1004,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]",
"childIndex" : 0,
- "depth" : 7,
- "layer" : 7,
+ "depth" : 8,
+ "layer" : 8,
"computedX" : 92.0,
"computedY" : 126.625,
"placementX" : 92.0,
@@ -944,13 +1034,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]/TimelineMarkerSquare[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]/TimelineMarkerSquare[0]",
"entityName" : "TimelineMarkerSquare",
"entityKind" : "ShapeNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]",
"childIndex" : 0,
- "depth" : 8,
- "layer" : 8,
+ "depth" : 9,
+ "layer" : 9,
"computedX" : 92.0,
"computedY" : 126.625,
"placementX" : 92.0,
@@ -974,13 +1064,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[2]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[2]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]",
"childIndex" : 2,
- "depth" : 5,
- "layer" : 5,
+ "depth" : 6,
+ "layer" : 6,
"computedX" : 121.818,
"computedY" : 124.45,
"placementX" : 121.818,
@@ -1004,13 +1094,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[2]/ParagraphNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[2]/ParagraphNode[0]",
"entityName" : null,
"entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[2]/RowNode[0]/SectionNode[2]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[2]/SectionNode[0]/RowNode[0]/SectionNode[2]",
"childIndex" : 0,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 121.818,
"computedY" : 124.45,
"placementX" : 121.818,
@@ -1034,9 +1124,9 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]",
"entityName" : null,
- "entityKind" : "SectionNode",
+ "entityKind" : "LayoutAnchorNode",
"parentPath" : "ContainerNode[0]/SectionNode[0]",
"childIndex" : 3,
"depth" : 3,
@@ -1061,16 +1151,46 @@
"top" : 0.0,
"right" : 0.0,
"bottom" : 0.0,
- "left" : 8.0
+ "left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]/RowNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]",
"entityName" : null,
- "entityKind" : "RowNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]",
+ "entityKind" : "SectionNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]",
"childIndex" : 0,
"depth" : 4,
"layer" : 4,
+ "computedX" : 20.0,
+ "computedY" : 97.5,
+ "placementX" : 20.0,
+ "placementY" : 97.5,
+ "placementWidth" : 320.0,
+ "placementHeight" : 10.175,
+ "startPage" : 0,
+ "endPage" : 0,
+ "contentWidth" : 320.0,
+ "contentHeight" : 10.175,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 8.0
+ }
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]",
+ "entityName" : null,
+ "entityKind" : "RowNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]",
+ "childIndex" : 0,
+ "depth" : 5,
+ "layer" : 5,
"computedX" : 28.0,
"computedY" : 97.5,
"placementX" : 28.0,
@@ -1094,13 +1214,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]/RowNode[0]/SectionNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[0]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]/RowNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]",
"childIndex" : 0,
- "depth" : 5,
- "layer" : 5,
+ "depth" : 6,
+ "layer" : 6,
"computedX" : 28.0,
"computedY" : 107.675,
"placementX" : 28.0,
@@ -1124,13 +1244,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]/RowNode[0]/SectionNode[1]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]/RowNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]",
"childIndex" : 1,
- "depth" : 5,
- "layer" : 5,
+ "depth" : 6,
+ "layer" : 6,
"computedX" : 92.0,
"computedY" : 99.675,
"placementX" : 92.0,
@@ -1154,13 +1274,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]",
"entityName" : null,
"entityKind" : "LayoutAnchorNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]/RowNode[0]/SectionNode[1]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"childIndex" : 0,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 92.0,
"computedY" : 99.675,
"placementX" : 92.0,
@@ -1184,13 +1304,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]",
"childIndex" : 0,
- "depth" : 7,
- "layer" : 7,
+ "depth" : 8,
+ "layer" : 8,
"computedX" : 92.0,
"computedY" : 99.675,
"placementX" : 92.0,
@@ -1214,13 +1334,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]/EllipseNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]/EllipseNode[0]",
"entityName" : null,
"entityKind" : "EllipseNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[1]/LayoutAnchorNode[0]/SectionNode[0]",
"childIndex" : 0,
- "depth" : 8,
- "layer" : 8,
+ "depth" : 9,
+ "layer" : 9,
"computedX" : 92.0,
"computedY" : 99.675,
"placementX" : 92.0,
@@ -1244,13 +1364,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]/RowNode[0]/SectionNode[2]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[2]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]/RowNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]",
"childIndex" : 2,
- "depth" : 5,
- "layer" : 5,
+ "depth" : 6,
+ "layer" : 6,
"computedX" : 121.818,
"computedY" : 97.5,
"placementX" : 121.818,
@@ -1274,13 +1394,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]/RowNode[0]/SectionNode[2]/ParagraphNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[2]/ParagraphNode[0]",
"entityName" : null,
"entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[3]/RowNode[0]/SectionNode[2]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[3]/SectionNode[0]/RowNode[0]/SectionNode[2]",
"childIndex" : 0,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 121.818,
"computedY" : 97.5,
"placementX" : 121.818,
diff --git a/qa/src/test/resources/layout-snapshots/document/timeline_paginated.json b/qa/src/test/resources/layout-snapshots/document/timeline_paginated.json
index 12d87445f..47b8cd1b6 100644
--- a/qa/src/test/resources/layout-snapshots/document/timeline_paginated.json
+++ b/qa/src/test/resources/layout-snapshots/document/timeline_paginated.json
@@ -74,9 +74,9 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
"entityName" : null,
- "entityKind" : "SectionNode",
+ "entityKind" : "LayoutAnchorNode",
"parentPath" : "ContainerNode[0]/SectionNode[0]",
"childIndex" : 0,
"depth" : 3,
@@ -97,6 +97,36 @@
"bottom" : 0.0,
"left" : 0.0
},
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ }
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
+ "entityName" : null,
+ "entityKind" : "SectionNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
+ "childIndex" : 0,
+ "depth" : 4,
+ "layer" : 4,
+ "computedX" : 20.0,
+ "computedY" : 22.475,
+ "placementX" : 20.0,
+ "placementY" : 22.475,
+ "placementWidth" : 280.0,
+ "placementHeight" : 127.525,
+ "startPage" : 0,
+ "endPage" : 0,
+ "contentWidth" : 280.0,
+ "contentHeight" : 127.525,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
"padding" : {
"top" : 0.0,
"right" : 0.0,
@@ -104,13 +134,13 @@
"left" : 8.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]",
"entityName" : null,
"entityKind" : "RowNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
"childIndex" : 0,
- "depth" : 4,
- "layer" : 4,
+ "depth" : 5,
+ "layer" : 5,
"computedX" : 28.0,
"computedY" : 129.962,
"placementX" : 28.0,
@@ -134,13 +164,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]",
"childIndex" : 0,
- "depth" : 5,
- "layer" : 5,
+ "depth" : 6,
+ "layer" : 6,
"computedX" : 28.0,
"computedY" : 142.0,
"placementX" : 28.0,
@@ -164,13 +194,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
"entityName" : null,
"entityKind" : "LayoutAnchorNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]",
"childIndex" : 0,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 28.0,
"computedY" : 142.0,
"placementX" : 28.0,
@@ -194,13 +224,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
"childIndex" : 0,
- "depth" : 7,
- "layer" : 7,
+ "depth" : 8,
+ "layer" : 8,
"computedX" : 28.0,
"computedY" : 142.0,
"placementX" : 28.0,
@@ -224,13 +254,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/EllipseNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/EllipseNode[0]",
"entityName" : null,
"entityKind" : "EllipseNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
"childIndex" : 0,
- "depth" : 8,
- "layer" : 8,
+ "depth" : 9,
+ "layer" : 9,
"computedX" : 28.0,
"computedY" : 142.0,
"placementX" : 28.0,
@@ -254,13 +284,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]",
"childIndex" : 1,
- "depth" : 5,
- "layer" : 5,
+ "depth" : 6,
+ "layer" : 6,
"computedX" : 60.0,
"computedY" : 129.962,
"placementX" : 60.0,
@@ -284,13 +314,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/ParagraphNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/ParagraphNode[0]",
"entityName" : null,
"entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"childIndex" : 0,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 60.0,
"computedY" : 139.825,
"placementX" : 60.0,
@@ -314,13 +344,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/ParagraphNode[1]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]/ParagraphNode[1]",
"entityName" : null,
"entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"childIndex" : 1,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 60.0,
"computedY" : 129.962,
"placementX" : 60.0,
@@ -344,13 +374,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]/ParagraphNode[1]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/ParagraphNode[1]",
"entityName" : null,
"entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
"childIndex" : 1,
- "depth" : 4,
- "layer" : 4,
+ "depth" : 5,
+ "layer" : 5,
"computedX" : 28.0,
"computedY" : 36.475,
"placementX" : 28.0,
@@ -374,9 +404,9 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]",
"entityName" : null,
- "entityKind" : "SectionNode",
+ "entityKind" : "LayoutAnchorNode",
"parentPath" : "ContainerNode[0]/SectionNode[0]",
"childIndex" : 1,
"depth" : 3,
@@ -401,16 +431,46 @@
"top" : 0.0,
"right" : 0.0,
"bottom" : 0.0,
- "left" : 8.0
+ "left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]",
"entityName" : null,
- "entityKind" : "RowNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]",
+ "entityKind" : "SectionNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]",
"childIndex" : 0,
"depth" : 4,
"layer" : 4,
+ "computedX" : 20.0,
+ "computedY" : -10.35,
+ "placementX" : 20.0,
+ "placementY" : -10.35,
+ "placementWidth" : 280.0,
+ "placementHeight" : 32.825,
+ "startPage" : 0,
+ "endPage" : 1,
+ "contentWidth" : 280.0,
+ "contentHeight" : 32.825,
+ "margin" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 0.0
+ },
+ "padding" : {
+ "top" : 0.0,
+ "right" : 0.0,
+ "bottom" : 0.0,
+ "left" : 8.0
+ }
+ }, {
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]",
+ "entityName" : null,
+ "entityKind" : "RowNode",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]",
+ "childIndex" : 0,
+ "depth" : 5,
+ "layer" : 5,
"computedX" : 28.0,
"computedY" : 129.962,
"placementX" : 28.0,
@@ -434,13 +494,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]",
"childIndex" : 0,
- "depth" : 5,
- "layer" : 5,
+ "depth" : 6,
+ "layer" : 6,
"computedX" : 28.0,
"computedY" : 142.0,
"placementX" : 28.0,
@@ -464,13 +524,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
"entityName" : null,
"entityKind" : "LayoutAnchorNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]",
"childIndex" : 0,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 28.0,
"computedY" : 142.0,
"placementX" : 28.0,
@@ -494,13 +554,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]",
"childIndex" : 0,
- "depth" : 7,
- "layer" : 7,
+ "depth" : 8,
+ "layer" : 8,
"computedX" : 28.0,
"computedY" : 142.0,
"placementX" : 28.0,
@@ -524,13 +584,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/EllipseNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]/EllipseNode[0]",
"entityName" : null,
"entityKind" : "EllipseNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[0]/LayoutAnchorNode[0]/SectionNode[0]",
"childIndex" : 0,
- "depth" : 8,
- "layer" : 8,
+ "depth" : 9,
+ "layer" : 9,
"computedX" : 28.0,
"computedY" : 142.0,
"placementX" : 28.0,
@@ -554,13 +614,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"entityName" : null,
"entityKind" : "SectionNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]",
"childIndex" : 1,
- "depth" : 5,
- "layer" : 5,
+ "depth" : 6,
+ "layer" : 6,
"computedX" : 60.0,
"computedY" : 129.962,
"placementX" : 60.0,
@@ -584,13 +644,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]/ParagraphNode[0]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]/ParagraphNode[0]",
"entityName" : null,
"entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"childIndex" : 0,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 60.0,
"computedY" : 139.825,
"placementX" : 60.0,
@@ -614,13 +674,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]/ParagraphNode[1]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]/ParagraphNode[1]",
"entityName" : null,
"entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/RowNode[0]/SectionNode[1]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/RowNode[0]/SectionNode[1]",
"childIndex" : 1,
- "depth" : 6,
- "layer" : 6,
+ "depth" : 7,
+ "layer" : 7,
"computedX" : 60.0,
"computedY" : 129.962,
"placementX" : 60.0,
@@ -644,13 +704,13 @@
"left" : 0.0
}
}, {
- "path" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]/ParagraphNode[1]",
+ "path" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]/ParagraphNode[1]",
"entityName" : null,
"entityKind" : "ParagraphNode",
- "parentPath" : "ContainerNode[0]/SectionNode[0]/SectionNode[1]",
+ "parentPath" : "ContainerNode[0]/SectionNode[0]/LayoutAnchorNode[1]/SectionNode[0]",
"childIndex" : 1,
- "depth" : 4,
- "layer" : 4,
+ "depth" : 5,
+ "layer" : 5,
"computedX" : 28.0,
"computedY" : 117.175,
"placementX" : 28.0,
diff --git a/qa/src/test/resources/visual-baselines/timeline-dsl/classic-page-0.png b/qa/src/test/resources/visual-baselines/timeline-dsl/classic-page-0.png
index 3caecc7ae518a67aca80e3e066911ca8caa5f2a3..3a4a08e5b00784562f41ae1038800d41433880a6 100644
GIT binary patch
delta 6407
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
QTZHGCs;){o^mXX}01;h^o&W#<
delta 6493
zcmchc^;;B;*TzKwS(+uK7naVIQt2fX5LQ5tbQfu9WXTF#cL=lT8-?_AgXI5TJFI_JLc^O?On(TWLh5~D-`JvhzE^hQwKWOr)Y=x4{xcehK9
zJ2q<#-<^9#LLwu_oATYZ{qAR|MB0?wH2paCKR$}J
zD~#QL6y)}y_VfHu)8syTNvS~Y4IVzU4K8so0DCs*ZuG`F((SU(tDyd|>DUawZ{Flb
zCApdwTC0KFq~n&)Ud5hUN|`?JE{e?0x}V?g?yra>^8YC9L5YhLE{|$^2#Jaw6kXDQ
z<#tL};;nHhF5<9<)e^bihq_Nl>a{`?afb{XF}O*5>A
zte#@+v)goe{+bd)y9z`)=-T~_PLo8?{J^_-8c
zDCeMy8KQuzQC)BwO)XvTH_Xmjdb*W~px{US)SK5AD<1c*5OXjCbAZyH3^^>s#i7IE
zP2#&3?0@ybKJq)k1H2=ww(aH820=p?xeit~S}CBXOY6WQQB7oVOiz#*hE>K5cB%e}f8_;X$0}43td5ysxOXC%lq)YsE8dw6FML3apHSXgjH9E0s2QZKfW>McM(a1Nk;Ja
zEs}MZ?mVor41v@ykd|1qR__l{zKMkFPsiUYyuQ8in66;MyMlbGd+R*^g$M0^DoZ~KHa+4)E6o2XJ8r;h^&rIh>xs==z1Tw!7fIL#
zYtHF&*zCiykvPxzmwz6Xm>F{X&L&^X`}^ii<8%Da#{i%z@0_1vPLwBe<{+kmqq9Wf
zMsM@!a~6#1g@Z0_`DEP-CvrzTex|8^0g6$Jx=Q{mw02`}Ntu*ivMu3dOc%ZEY&3NjmgCUBk+tBb$pfky8fDrlIbFkKnerT{FQwtG~f1<16ws3Wt8&-;kranJAyNWM2
zq~=)yF6_%)hr+8QC;}AU^vLpd8|Vc=S+mToCy1=k6am-YB+&l?$4aE|Mi)@_)tr)Y
zFA~Sb5dZ0Ne=098ufG0{;pVD4_eHG+_hUx(TMfsUUkX)E6XQD8V$GC#$$;>%{Vc~|
zxV=4dw|ADSE96+FLz|c<*fzS5bAJdQyxAD8_b8yv-tjzn0EujwWqc5}Ic}(bucat0
z|8n;X$Slrp6^wlqbrZJISR@O
zI|JSdskmJy8#XbGCl;HU}7CRbMr@xl)9?BBbKFC^V@^mMFEIhVif
zxx=!DNm1)kbx63@rSYqkZ(z-;%h~SUpWW#(G
zL`t@$xs~FB&fi^Hl%ioNn1ro2N!R*%cVCueA9826k1jk22?+_%5*P^NWaa^mZ*}#J
zk}yDZTBnUJbE?*Ila%GPG}|pQnyx@X&i8F!QUOfNjLA&Q8P5$?rYW2S7T_@PPoJ?Y
zEXu!rA}NqC31!AvwYeCx^rq}kr+4W)noLtksoH#ATEQAm7>nDN3%<2)P+q)k6xJfC
z$K1Vlzr5T-&4t}hYUW6!Aw3;X($uSjL_WtWgzse)sm4U9KhDL^wMcXP=M*?a|Itee
zY0bbbY06nd!PUD70Fr|OGFbu9`xSfBW0Hd+n!^4kyjn4^Wel!)-_>=KlZ(K{en1f_
zDJRj=HX(KX%eYk|H`VFdWDmpb3T@YN~Z`=qwHkTN*U`dQL%+ydlkEjsX3kE0)6HAkHa00KeNC3%XoIpuo>ABa+_z@lsgY5~6
zN}|R-l=}<6kEz7BwMk{pFUZLeG$D-8lkn?rz)z)#<=nmq*P&u5M-iMMLRXJ>39li1
zfaZTPE&hrxXQ9hhQicEfD$*9GQLWrt1D)ZsZ1Vdf#euV~d(v6hzi4FV)8*CGIPZ!Z
z8Lhn(HYF~l9NC0Kl#bPLL223Vpx5i&2zecq$^Z(X1b67|tB$&zR
z|FGfLl9zF-QsB=Jmx@dD;IDpMdjc
zs+Lxp-@3{bmlqRMs-8=8Z*31}m^;&(hPQKM_@+IszdQ#6Mts_LERwiIJiPE@vXtZT
zhVFiJ6Nrn7%RJw`NjRb8z6N@ETj4sW6KD(Rig%ORkt7%isIj7%EBGL2Q*
z<5rmt_I5%QhbQrtD;HkL3@Fz8n2DV47vVw`V&ha>MTQom`YE?$5D2njdMGsT_(;s7
zuXk^cdYqOi{8yzHWGPlL)qjS=`Fd3;Ughf$p^J(Yuxk#ri))B&FTy*W^{2Nf`f;hf
zSF_i4_#38GjOg=eoWfHDZhx}X36UX|f
z*q?SLW-yHxM(o`&PQlIP?ZI@Z|V6
z4cimUw}8DmLNkqtVo##+_ganW>bxro3=A(k(>xE4&K+a$!SMBa$R}P=-fpr1E&U}=
zsXOR<{5F-xPeev2j*Ul?@c0ydf-UV0!X^_9V1z`%cVW;4*0)DiRNrmaBOGMqSG>Kc
zD%dVFr@{lab4<>b9$L4~idx*o;nba--_^XO9|?}amqz1JUWKX>6Wf!vua5P8WSbj(
z6BVsq{}-9$Djyp#J*ah7lARPh>CsvrKUQ6h|Cwh1SMojSYsRt*?#b8~MH`I%EhUHk
z7d|(~2rIM#fZjHhwOvm;JT!wobXh>jd9}|ui)reEw0}|QcVWn+&Jv#o0|o9jdvpfmxK1XD`S8kH^Oz~
zzW8NtK%GT2xl`Wy2&L?@(S)z*(!ye7VQ!9=?dJV85~$xMpnPQ7&FpVi_vr53{YL`3P(Q5UsX~rRB3fYWHm0odY1V$`o~rGuO24h)XTyK%xI3$q
zElJ>b^3;1Uv`Z?^95yrMyTlO
z>u+y2MX+P(Y*FFcxtIjcA7$5?dD7^OA7dbcwHkx=6J3Pf?ZV5UXX##mK&o?4%~jq1
zGWd$jWkqYdK$YM#v|ZK2G99mQcjlk5Aw
z=xEu*^|cYKCPKH)r(6Al3|}iLn6_z^#pCSLDxaLj_IaPJBhKg;p#QzgchH-7leWX8
z_j#4BzDRfc5;F}P=gf>a($LTdNw_8W@V!Sb)dh@kn@HMznU0HZ!uZcEgWD2Qi%{f}
z7`Lb2d3&iy|6)A5qW$G}#uKTTemNq%eIINj=Le4`s@_qx&arSc>@jUH>;T=-(E>`dOU~kBV=S`$2eKkw
z+tEL7C%X784Y(b2|7n#8h8!<~;_~-v$!XxXxP`wrq@0Xg{g)(Qnuss>m}x54%hS%`
zg^g&kG?kq{uJ|e$eJPl(Zr75si;d4U)c*!<;a~VqF-a9x$)Mc4Wvuu9s6^#x&sI@M
z<5e<1feyfSU)kQW&3*q@{^Zx-Kkef*Nps)&yz$21Q!4jGF00qC_Zf9b$s%tz9N)3=
z?(X^(vcHn_xwt$wEh{Y_;81>)awgU<2o;T(H1xlY7q_Sd2KMAYU0C@MJP&1Ujy#lO
zdXJBvNFow3Qdz6!@o_P+QE~D9oK%(-E2{uc%I@0jxG>fH5>8n~1Uc7dY6AL*jMRQ{
zPW@-^G4aE6_
ze8Jabl$n+vzP&Hky9EW=bcAP;lZJu?g2^*Q@qp9E>(FO+tNDg+ZDHZB=^V?gjMJao#YL|4)Dop!
zZ4KgNWaWly6U)M{&8zir>I|eB&WMqGf1n)ST2|3cOM__lr
z{azK33TP{hH6*pR3clCdS~prJN2c~>3Ya)bX~NFXPtYi@T?3%qiQAe40@zd_GXTLm
zg8%$oca_u7jxt1@tTKL`dgLT>$#o_ng81uuT4=krfp0be=@Rfa&+0
zB#w6d6nQsb;i~Vlf+zKZhbADUIsfEpTV-48)sSMY9gkmLzLCY2XQ&wtnv$X)k$wHo
zZ!uizXSV(Sa*vW3&q=9y6GW(IIW{x$WjeC6>6EVR(X@wC%7yxQZd0F%z}jfY71;`9
zfWgdjs#LKE&vw`EqDXzWI})8(+yuQsde>sk2V&I(nA5V?N3B4`Yx8#4=KYB`k@)-Zvg9{`^Y
z5?fP!WyQpF5W2&lRrX#q9$pyN^2J_I2kit+eXo)%<6IheU6SWdX5Ae&$4U`2)+
z&Dd+0KD?Hd9J0SohbaxH#kO8(Azwm|}6Bs}Pu7@Xjc
z^8}x-6<2-7huc2)hnY5dls{oOJfa(zbC^@EP;@Zd;lsPw2lE9fMDwhg3=AK-d;fFz&;t
z!uswbgdmrWqYBNPpP4m>-)XeBo7`7$DIs`>HU9j$dKUI-3+^V`3
z24)O-sJxs=!aYRrwf^>vdni3VEeXTp9-qvc6yBz5IP$FgANh@90r%zdE{V3!E??9(v{$8~t>BSKrhR(QtR6Jh#
zxG_AI&E~3QkRt=21Qy5!)CZBXb#EH{cL#YW-`Mz9l4$R7)n;SfE4rsGLiS6^RPRBRgUP&79;
zmz9;9M#*25_DEP=Qx_ATg1@F6Z3vBwTm8+!zM~iYU$2R{p@F*ynvQ!j;NGGoE8pp2
z)#=?{gNKa^4ZCeRLKGk0036uUr#Yq_|4nAr(~tX)z{sBtpD+Zova+3=-Oz|nNJLl2
zt%#Qs$lFeTdnC*&C_Z+^8-GnZjL68!oj7mZ|8%kb)f>7nz;w=QX=!O0H*r?4KEvGH
zF9ct`X+362_nrfbic3t#$?=8~x_S6uSX}&r+S-@TV&iQ;!}E(q3ICEF6hdCHJrrAjZ2rU$;r(_T=FA!%TPF$?GR<7RoulIVNclFw}
z_b1GnHD}4qknj(!h9Hh9u(5O3kI%V!K`r#+oUX2}vFbN`(v-Ou{V&g%zYIAxH!uIY
z@Avi}GzzLXpLJinbQQAh^&6oWZ|`sxR--CLLe6-Sg}pjh5;CC-1{0F9M9q8!wF{+#|a9f
zo;_>O^eF{npe1A*FnA0isQM5IycCRoHXNI`aJiM_z(pmo8t!FqHtGWM-l5
z?Ba$K9gRUl$FASF9eEgCTej_d_x(PHem;rYM^;0TQDFkcsGR2L>}n$6=B?j%|8S7X
zv@Yj7bR*NBAg!TMTvBQnrnMU8C6+haI-+yPU6Fc=Y}
zTx4(KEt5Y#W|lBCGS1k*#>VHSBQy;`V-Mf(bsA&_oN73g)m{I9%TuP$fBZQ8KRfro
z*R8KQ(UHe62Iny(L|#H9;eHY#H*SWZjqwHvk!sX-P{l3$d?il$PWp8kBH1~)Xe05W
zef3%h@)$fs1i2IfbnVek34j}a)OGiKDIpT3iG(nuXYavS&7zIs8NE2*H<3`S2~jiS
zp=o*v-~XV$8rk+3l!O?pA~+V0uO-
zN|&)BmVYV&@p_M(uYzb8Jj4JM#FUrwUrTv;#aC-LBPYO|
z>9ZC+Ont24NRJQ_Cn7XcNeNEQp7(D}zubbBcOvhiy=;XYmQ|Ww!U?vsCr+LNf4*F`
zp`oEs)4%XDN^jAS;p0{2gic79Sn?tBKr^@vgxqvV0v~=-sRw1
zD)J;VySA<#?Yg>pG%6~q(8k}A(qCT8Y;JDJ$<5Dr^7P78-4w+s2iK~q>hg+8b+@Uh
z3CF2kbgincX=!Oiqq3^nFrE-&%rBvZKDL&bmE-8*6&`W-w4*!pb3W(o?GqFf9OAAQ
z44Q?6M!I`{`6ng6{9K!x$Hkks?%*$ap~M*{kBB=lmL?%sC{N8;%n7!!NHrluB7M%<2Z
zaPoljccSj$7?M*S2LxR=j3>kx^Gm2%<^$XisZ{@e-WohixS#Ck;;Hi9$2*7Xz+d@VgA_G<{+DG&>g(!cwG7
z*aFRv=pZ5D7_jfa3CzN6|4*kfv$6v(>ncwqOk)X;pVI9VisE2k-Mx6-<9eE%haI7T
z!+?-~DuENy7K=!zS>UToP^d~@(M3kb#lhpM9$4VvFY*kM7&4Xd
zcvyP>K}um^agkmpOoXU;`Gu+%LG(U*h6QG0S$RcEODpV3OV7lx%Bt#;k}^Z?!e;Rl
z00TAsYpwAK$?&th{Fj$uH2rHrC{S@1Q+KPXYap$17z<8yoQ4J=CUhhT33)4%aS$?p
zOGy|NoA~c_yLt|t-S*3bCO3@1$f)1?f^W*hN1fj7Ww&9Qx_i#m3wPBw4BPVj`Sar^
z9sitP;oJV*?p}R{Y5HHmIA!l#SFigujUmIws~;vNe?iPIVN-MShVOpvHDaE#$EEDt
zU%of=8ZZVUF&?v;Uj6lRCE>r`>m3z;9jpD8geOipqSx?qGJh&UZbh85chdA%S;IkA
zSJ!Bi@UM;eC4@`UKHpqZt9ycfb1)vWFxRx>1xmusU9GIgPDKtemhfgs_|zE-2M-&!
za`nc-!lLY)T%6PDm*>@$j1=A%{Kt%&j$uodt;QHcN=nL>E?oD>^JNK)(
zz85czwwX3^^yDMQPPewUVwl>0>2PaXPj5fty^s!#nI%M3iJbO(4OCzj=Dw;fRT4r5
z{6(p0kT5wpwR6{xm#wgaLZ1w^foA39l`B?mKrbSOinOA#3Iap6+jpYpFZ#m4QTMAb
zkfFy%gK$D`+Oh*VcHfUbV>~7vIcksIz)QiM^w&`ekBAyDcnt0$gCTN%D>On~ac#sX
z+dcaZ=j7)19WZk4g5}q)hYb02yxsb(#)pk(2Uv9N#;s{H7mk}WOM6j{*3{JEDFYk9
z18HbzgoNEc9ONHx`P&_PI_a+uh1)w_taj}==;7u2?)!awd;{>5fiCRp$Ad>xAEg=Z
zg>-1lEa6hST~6-0G}1nQFc(`ejp|VnqLy2=W>fc`19$H|q$I>?3cJ+G%FFvAf{95f
z>gu1yh@cj?^Z``~?)2|_MqMJw<5O_sNsOi33pNR~H6pDzS
ze~^+;T{alyG1^F97kmPc?mn~{V%TYUc?G)gjDW5!+jgRj1ZY@)6P`Nl(#fbCMNUQo
zNfp81t2g%_ID&?|r!VFrJz^tDLKtY+K6yJZvxGf{e3p~1`@M9-#*MN;s1VM)2RRbrS^fj*n(+Pk3mB1#Z_;W
zgovQ=!|nqyvGEgZXQOUxY-~Jb?~LH}8=+{c>1pXt#*UwVarV3?QX+EBprKawhE3l>8|+J6S`bS$C80VP6uQXFSPepQRK+`Y9e8=r
znuhh;jL#NhYjE$2mFrttS_~4Z3xFf(;mM+{gjncy(kENg+qP46*YQ>o;uws#Pu>E|
zETQExAuKX~T(}RXGS+pttEx&UhtnD-C^Sn>Nk!yAVlrGpa#JtBD3DKJ3x@R@XpME9
z+NOF5XXRI4qo!379zE`WiKr@3VPY4VSx@5=60Q0TM{teZmeDrT>>bWR48y@_x}U+n
zsxCq}Y~(~JZP+6~TK;t8By?dw(3NY59Qf&&S{&kkP_ZIKj>Rr!&i-7z9-|&+XxQ!6
z)>d_aUd)}haCt>VC5l^gsTYmN+k}}VwA`BTH+jR~GHW|bMIeKc&=C2(yk=JWx)i@T
zfOK@sETQFI9kn#Z5)$%9VrB_7i@i^1X>#-OE30&ue<9=##{3dm=o2%9ysflH!s?p3
znD`VD{59<`!t9?NCFE_TJrWW^%m|a=5ciYN5WW`w56=O7=|tik-!wvqD1S2z5JLV~lR*$Ze;rT4+Pb=e!XndXhoY&e
zskF4rG)n%ev_-e}eIWZau}v?3%TcI`KZ`;T4ne|;w7h6WxYXgcogfO|`pt$wGQ
zMVEK`3>`5cB=nBy2vNL!{BdB`b|8%qb)f>7nz;wM+wFnLa&ej{An
zE(Tq@WjStI&)$Ozi;7Lh$?=vFy1IK~SZv(GnwnRyViT=D!|RJi3ICF`i-Zv|3Geiq
z+S)zi8wOyQ>CD#?ekT%Vr@N<*dieNB8f;WWUEtO0e>jPe8<($Gmz|S~$kAiA-|YNh
zoy|4`Z8q(|tWjgE;c!xN3VOF}+qM7TQJnPX-SO?7#>Pejlaf+Co4*{9>9ZE!i`M08
zB;G|!Rw44~=qV>oomKlibN1XwOKaRto_0_XuZw=8$4(tLVW!Q-?X4yB^$WC`G#eZF
zV)e#?e}Y0B0Jer<*h^|^x*BuQ+rQUGL-6|Y{SQAX3BTR74;#d0@zgNj+y#%X)^A<;
z>wiqyS%Cz~H{I1MfxB@vgCpYiA
z@AnTJG6t$RopW2dd=0Yhjhi7DZ)blFR--CLLQa30iM>Ox(69)2ZX#i1R16Md!`C~I
zeM_z(pm#*6rW-{BVfM
zw9e<hrnM?Z0|Kw1jaQIvJqKv|=Lnf=Yjx#o>ti^6(jHSVe;5&@
zTx4(KEt5Y#W|lA{BG%ZzhK3iWBQy;`V-Mf*eHvs2oN73g)k8o3E7N8!eDWmiKf4dU
z*Q38W(UHe62Iny(L|#H9{y`!lH*bZajqwHvk!sX-P{l3&d^Jw`F8XyEB3aovXe05W
zeeHTM@)$fs1i2Ifbni7l34j~ae|7VCB_R@~iG(nuci*8{&7zIs8NE2*w~)vDa$sdujiXxtZfK|ox-dYE
z#FSU_Ur$R*i}m#RSUaJH#OdB|(5L}}Es-3;!gXs!ETWLCP|+b3Ku8k_lTQT@f5pY6
zC_zz(qJTtgiTsn9nFYO(JdwMU|46ei9}O(PqVC-vV>JV%U|MTW2RLf`_J8p-Jtw#RpG2n#>DHl%%=@fq@
zqYaY|fSD!yXWz+6t?heHtBA8lprP`_4G&cu15^Z4Ud{hQfP~9dtTl}iA_OHQL>Mzm
z*skTMxhN8zMp!uIiQ|JK*^Tg%SL$Mmf1+$%x1e^umZMpjL29on_E
zb!e1VRHBXh;*ww9%xr3E&d$k8fBNj|HQf}2t-VWSWmQ>ug?iZ7*ofm)Z@N}iRW~=c
zpixm-Wf)J0G3J-Boj$gfk(uq_>=_n*?~H>R^m97z<>eh17!>TL7Yv#OheWt}`Xwg4
z`dpi<`=wjA@8TZ4e^BDAqkH(>XiS755KldHJm;w-%r7W9>*Rs_cI>2MMMb60r67#F
zb2lb5EXvm}2+H9GW9=Q>5W!qy3HKg2fg|zo3W$z*P+eUEC&TYV+dI0$`MZ(#aSTbx
zPy7RK7{(J~jQJ(h+UEn@5UE7}zcqLm{~*c1*+b>M(`PQAe>gmT%K3h50;6!I*0|LNEDWyXbob)@k~}@>y06B!s0%o3I6%A<;oX#4+H&
zp;MTJ+rgh~GcvORuIMUHBurxoPul8s3Po`+uR4k5
z;jN~xeqkeCBOWB^KH!>{SBQ-u=zQTK4)Vo|Us)a#f1w}c8GHwc&M*aL#y?Dkdyo)`5fd@p<6=NUV#=eGGnXI^O-IaLY|=O
zA&4J6;edOCgxK~bSz
zCrpH>xp@Vu7eVwse~txaLupxgb8`#qN=?hau!_p6;^I<6?!spA5={d=u(@k#Kr
ztn8P!VKn`FLMTviQx7Yvsv)g%7z<8yoQ4J=2PSkR2?^%r7jt
zbR`7$rX5B^#w7fE!=B#5=eGVbp~($nFf#JDzTlhu=y8{K``B#Sp&p)h@x(**4a3&F
zc=6)oX@@`OSNL{*x3^Ef5t{y2Fl_CdYU^~rrZH^fMD@eOf5u~0J^R3l2x)=Dj2jejdb4@#5q9p9v-NJJGbmS0Y32z05
zO`o-R=!glc*KRH-D9p;v!8xsdd0t(~Na1zSZ`_2L7`AN1T8u%Yf4I1G`O5W(>_2$)
ztMyx5-MyQdn&!+~=ItAR-iXL(w6k+^5#06tff1u8W3%7v`~e9zH8mYwhmV{@Bs44{
zG4T=hh3#zIywl`$AwR!h`HFSuTDp9Vx=e+GlM5n<4<9uN2RU=jl8VYoY-iEZFIRrK
z0VDsj`=FZZbLsL}f2$dz$4)(V;!I0R3x=tkjHq~#Tr5E!!Exf```
z$rttxx?hEX481-Yf)jenwq3}v2Y&n+<1z8raXa(|Tn_4@f4`1WSa{^1q2urn84Quz
zA>rzZYoo?k?>lfbJ14LIpwaUet-O9Cc-W^CZ8mN~VC{rIEIQx!8hfRF9GnwcMI@TYB~$ym$W*B_U2z*rirh
zo?e#_Oh`;tSN}9FM==1WevjURkZ>HGT;TwM?|(3G^VVIOehrBe-9$qFfU8PEP50&c
z3}i5*P(*zF0+od7vcV{i(MI~Z=a)-VW46Aq;p_q340CuEIUv4dlPK~bFl@}
zs2+oaMa3n(`ws2iYk-mvx$(Uo{nc-oDQ#k7AGDSbWM$>x%$_oBJ~GU-S&I#=ItkU9P(977K{t`Kk(^O{s)>e7$P4jyKFloP|9)WAp#S})wt>0Wf@xHb
zK|<~2Rd1Dqh@kPq-b2wbag(g)qHb(xXt1?&e?sua%@DNJ^whMc<0sBKcfkWG5jkhb
z@bL)FTet$_;~yldv;zqdL|KSziS?VxdysI`mT#dA_N6W@h^3m6P#p{kU1Vmg1|d1B
z;@x`=y*g-3!}@L3XG^g)xc9~Cjm^!?1_{*#z>)OuV$oVcEOfi*lP&6P>*>1dcq<8U
ze+pDDCRV9?eX^j&Unk6NtAo4IF2`(YIsW)I0$S1G`
z!v+kt#JWyxQ@w?=`m3)|(<%v%pR~tBRF$YOv5Sn%XL0cf76V2ixXxzVSgRR!_U9mm
z;b1h~&){EE7a<%mdNPzY>=7U>e>!>!f4VRr@alC$4*hgOEe>(0Sdk*fVi&XLey-k+
zQBN}@^iE4li@HEB<}O&gvb?+k#Vxwjn?|HFVP*;2Z%z1{yyb71wH~G-kU>dki2PpO
zFsp4{ir*YSIyz>Su>Ia0ZEuVvB;=38%o1wt_J6|mCMP$qqEdJH7efAE%r9X(Abnzn
zkj_e5B&@2gjgCvky=jLLX8-IcA)S@BNJt1VBeTH}2mzCz5Um#cKmXf(J?NqwRR910
M07*qoM6N<$f(>%bRsaA1
diff --git a/qa/src/test/resources/visual-baselines/timeline-dsl/leading-column-page-0.png b/qa/src/test/resources/visual-baselines/timeline-dsl/leading-column-page-0.png
index 73a31c8bc12b306661819678f3640e0c051272f5..0653af35b92aa0898fdb99ab52ca14ec78f964ec 100644
GIT binary patch
literal 7466
zcmeHsRa6{Xm@YK#bRf99OK=a?gy8P(!68U!G)Uuwg9Z&DxVtwUG$ClvKyY_=n>w@Z
z`^>tt=56Ysc0E+ps=e#~Kie^y>PmRn6xc{eNO;O`6tt0$kY5AWKQKYSnZ&;40SSrf
zR9Qh**Wdgw7sK$S0Y%iJ!{mE2oA{UxW#5tZ3CAh2GzG(+8
z;;{UAYH>5)(Hff)?kdw|8cJ;cGS^e6sK-u5JDzcBg(iwg_SND))#j7UY?)X1iPRmU
zPR4@nm5=cbEGGcDA-mdl|eI=3fV0m?77HeCRn4^9t##~rRb1n?w@%jwEoPlumdj?7EfQVl
z8k?-s=6N1rPLwZ)pHN_{-OV#>gss}s^D;HdUtQSQ%jO|y4MBr>C+nb|L0&&FTWP|WN96;uFI&ySH?
zJz9j0K1K!K=ejQ7ODRM|IC(&44kDw&s^;Iv#I(h~t*L{rv#83`OG(*mWMVlkf4e2X
zA-P>||CDw)Uf}J%To$}6bSq8g(d4CoX}VbvFr^Ha
z3X{<@3J?jY^T@N@j>vERR^+HamEp0acpD^Q}X49U!
z@bG>H*r|K&Jtetu=l_)64~
z17p7_%y=?s-v?cO%whLQcs8N`liV|_(p^HJjw5|7=9K)R664K2ZuONf%{PG!rCOdE
zM9>|+?CMZj#k;WN#yD?tqE6(7fT?&20of@o^L9B0LjS&<)AH~X(v2caDhi5N!@a#d
zjsr9R{F)jXbSUVWs*Rh<*=*LPI{e&+KP6F#eHGLUp8`X-N3u&6PtW)nYO%uXh2YI{
zo!I6rp8e&PsO=CnlR~(+h(jOe4j!%LEBhn}Dx{!fGL&LD5Q89D^7;BaD~<#MJ50;}
zDs&z*{_ySZ=|Hq7`_@D{A;dwuX)t>uWQB;8Q44`fwN8igS~_VMWI%H439rjoPbOY5
z7$-;7{|D_%iLVPmPfxEHM-usUZzwc0bWHkQ^~3JQhON7Mjex4BNz*&M0=Y;4-0XNc
zz&=Sd(;?9eui*(!4QKV^Y*b;$FAOkaTBQ5-$LqEuPCHQu;P7D#Xu2vW(bytw#dMWM|=*fTt
zj}N>v@#_*mtx}^7H=?c88$~`A-C6Q#=}8CpN6xc+0wSE8)KuNC3Rr%A*Zwb)Xk{Rv
zhwJX=$CCVDIm%Y!dfT6e3oV5Q>+9!79mhR-MRjk|D23ilRT?!M9Jr*C@|r(vWxF%v
z2487L$`)X`G}EOA+^P;#-jC*X=vB;SzjAGEM*C2dX}AJ(9qG^BaEvFNVYzw55h?#1DR=#-`hekWi?qOU~JE8NMB0I#z=W5Gkc=p!XYh
z$IVRcCEEOW?Tio{u7?d|;G3R)GEbWk6}!hMZc1TSH8m9?3wjoXUKP?CVB#fX3Ngp%
z*rU1KWKc14ZV!mPJrrZGL2Ek#5fh`lhsH2&{+G7m3yCPl-ot6Mj*$_8x3{vCnIX>$`h{@O0d
zyqYM;0YH;en(eqOWq1QR0d<%c_b--M1SxO7u%oSr>wqby>GIHkb7f7+`3iZ%);?}a
zJT#F?!t)IGa?v3c;TZM3L^a#>z6%A_&3+Q!-ET_bni4zCgPTk^P>@3O{Sn@BRC~h5
z7jqKs-|G{yTSoFaS|eSdKj;SfBGILjvib13JQSwh@60u1oLj=&9`{8WgHGtEx1++p
zX1H=<0BpEV^OzVJ7l@aPHSoToqx7R0Ah^nO96NbW6Z@c3J}Vx%
z^yc5&Ih74enva;;{f__8e%BWy0rk!mS`C8Z2n8qw8^ha!y-Db|6>fS~qFvUceOAQY
z{RKck2vnG%sqS$$MUKgm7@3Iq-c{m4lp^>A`}<|&qq&8J4}?5|z<)lU28pydu|gdG
zRtkNszJb%De~(0hv#-yVEt@i-iRv-YIr0TF35aUAM0tG2%XBrrx_2F{f=S{1%-pOA6j8c9_s3_Mw
zqf7PuhXw{d21D~Ozhf*#L!HFP2ojJ*d`vhZJSsj~6dYrL`m<`Z_I13mrKeKIs3mDN
z^$)I8*)LtMq_1Lo0CY=5R|*!3N=~+*V_AO@&WRK3oV&^*UAL18SpjqhXOR3gtv)(y
z4L7fWWdw!7zlhbR95DkUpk=^k6lQ2~nQCk@UU5qdD#(Xe4&ROA$|Re#KZ-)Q1Pkxg
zEZ-}o&XCU}mb+Oolh{TgcG&FOcH-XpC)$#=CthW>I{UG$$yl?aLPQU@%Uk3^YojEo
zSfd)K`Q}g-g+Lm)(iq|e67_cfACLn?9v@u-)EH#+%Du(YHpGM0S<50lRWTh|Y
zN($&!)2&6Bj*h4Usx-6gxvQ^Arq*M_L&p^Ega-RS`J&`lWqn(73wjQ3M3bp}4_E5}
z?bI1BNge4`BaOoE-syW7^&8AjRIa3$Z?V^{?QjEq|vUN*ayY!KW6fi<#N_nhj^h5}uHcdDa?jNof1Sy8g>j
zg@EbQ$$nwjSS$P5OrY)4g5SXexpoi}H5->vvs~Z-j1ym>pNU)mQ`cHOjOew6DYIw6
zcbOIM;4O`(N`NN}aMpyE##u~R)gB$bAznIvqT9i(cwNrg645F5qzhtf=r68%L@7lZ
zs3a2;V5vAKz2j6t1E0OS)xmP{iVsWlQHFzv1YrNOVY>)3=kw<_*M(+)|MNqh
z#Ds+OIu?kTn3y7>qPBug{7g-$_Kp`PWr#KCSy@>nB?F(I9&hu8Z_l@Nw6z@Ns&xxM`j2M8W@4?p`DYJN!I84Xw(8oSQG&`>!b
z^M>THERZxb#)9rIMCi+Zj*J`v+72*PT-@9$scaLaT8yVBjR4_Y?@d>{-~HX>Jj-L=
zDOyeaH3%?e>g$slGF9eXcM`{)4exigFCDi>b2*LbZI=o$^ORPkFnX#a@OjMY%Ia5{
zIhqdRwb!$c`%wD&t`h8QT;o2*#3aaPjF?<#K$&VNi%I8tdO#o9ulN0-Pyu#zmZsZ72Wv!NIHk3H=%%b9oi8yYoyX
zR$YHpTZMjAzR73|)=-K$#od6N+zr{RA>XXcdiPnPfIkls|G9eRE4GQ&xs(p^?%CCu^D
zCy&kDov8lDzQTZ)FNji7g^-d9zO47c{_HTjX|dE>284mV0MA<8IsD^C=osevwN1d6
zN8ytHE>t222??=V9QfCbw!g=s5f>AyGHQV6j^;@L{&4x|Z(GAawcW4u1e(W(BnL48BR^I1ms!BSNSzR&=>Uinn)OHPke
zQldGBy?;8F%0&dnuh@|i5SpZEG68pivm5tY!iR*G6}lCi>07v_b;W^2HCmHRFq~jF
ztUm<`mX1ksK(8yJ)M0CQotb5)Uoq4skx7|57~+2iT&33fh9ucDPI5`1rrTmOPH=1y
zS~ORtxg`b$EaYLG77mAd=r!OFt#>~jx>r|MYuhMX$w-?2Z>8DD%5A8foredYojBwI;|L;O`%^!I{OAPXqi$|zg~8axE>Uk95v
z03&HWZeN;2Aa?u#3cP%q)%U>Pg}`jn)cRKZ!>7Y%-}iJaB`uv(w{&HFopI<#Ft|dI
zF9H$kVr6CJp(-gax=d`WwhRvfJ1NdTEJ2UaFfcefjGNv?a`sOai<<=oO7al(MoF3F
z>7Q7G2*{;$s9j1%P1?K`uU6EcEW_BEvxT3V9fFC8!<=W{-F*8#tFOt=u_vjzQ0v6`
zj>jx^#9(EU^D42SQwYcau6=we42zmeWs!Ta!^Gm!!IL$=!+h*;G5W^YH_?!6V9yc#CJ
z#trE8T>Z(bR2_)0QlLeTZ`vIUro
z4Q!a}&Y14@w^hra7ExAKhDRw}2?%}Dzmuph4Px88w>fyZxw!`uC|QRjd=C(cwl^oM
zdwN^M=Bkz(!xUkxw`eqy0dI5VGK9a)%;()Ahjmv^8X6H0Qoi`y3EIcqcQUB97n^d`
zmEzpr28;SHLN-<>?T0U^#7I8e{3-Q@;lr#*A?LY!upU4zMI!Fy2+A~K{{3WWpFLtO
zJ(!MWhwouBqf(D;boo=tZy;-nXelMY#(t3oSPwFKVg2riyN+Wx&UNb5$I8k|pg4pa
z$6uchnkXp^0=ZkPm%Tkp&kb;;IWYEBF^