@nowline/layout 0.2.5 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/inline-date-pin-geometry.d.ts +46 -0
- package/dist/inline-date-pin-geometry.d.ts.map +1 -0
- package/dist/inline-date-pin-geometry.js +149 -0
- package/dist/inline-date-pin-geometry.js.map +1 -0
- package/dist/item-bar-geometry.d.ts +30 -0
- package/dist/item-bar-geometry.d.ts.map +1 -1
- package/dist/item-bar-geometry.js +41 -0
- package/dist/item-bar-geometry.js.map +1 -1
- package/dist/layout.d.ts.map +1 -1
- package/dist/layout.js +93 -22
- package/dist/layout.js.map +1 -1
- package/dist/nodes/group-node.d.ts.map +1 -1
- package/dist/nodes/group-node.js +8 -0
- package/dist/nodes/group-node.js.map +1 -1
- package/dist/nodes/parallel-node.d.ts.map +1 -1
- package/dist/nodes/parallel-node.js +8 -0
- package/dist/nodes/parallel-node.js.map +1 -1
- package/dist/types.d.ts +33 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +12 -7
- package/src/inline-date-pin-geometry.ts +196 -0
- package/src/item-bar-geometry.ts +49 -0
- package/src/layout.ts +92 -22
- package/src/nodes/group-node.ts +8 -0
- package/src/nodes/parallel-node.ts +9 -0
- package/src/types.ts +34 -0
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Inline-date pin glyph placement.
|
|
2
|
+
//
|
|
3
|
+
// `after:DATE` paints a calendar glyph in the entity's top-LEFT decoration
|
|
4
|
+
// slot; `before:DATE` paints it in the top-RIGHT slot. The two helpers
|
|
5
|
+
// below produce `Point` coordinates for the glyph's top-left corner, plus
|
|
6
|
+
// a `spilled` flag indicating whether the bar was too narrow to host the
|
|
7
|
+
// glyph inside (item case only — containers always have room).
|
|
8
|
+
//
|
|
9
|
+
// Slot interleaving rules (per specs/rendering.md "Inline-date glyph"):
|
|
10
|
+
//
|
|
11
|
+
// Item top-LEFT: glyph sits at the bar's leftmost slot when no link icon
|
|
12
|
+
// is present, otherwise one decoration step right of the link icon's
|
|
13
|
+
// right edge.
|
|
14
|
+
//
|
|
15
|
+
// Item top-RIGHT: glyph sits at the bar's rightmost slot when no status
|
|
16
|
+
// dot or footnotes are present, one step LEFT of the status dot when no
|
|
17
|
+
// footnotes, and one step LEFT of the LEFTMOST footnote indicator when
|
|
18
|
+
// footnotes are present (so the inline-date glyph inserts at the LEFT
|
|
19
|
+
// end of the existing badge cluster rather than reordering it).
|
|
20
|
+
//
|
|
21
|
+
// Container (group, parallel): glyph sits at the bounding box's top
|
|
22
|
+
// corners with the standard inset. Containers don't carry status dots
|
|
23
|
+
// or footnote indicators in their own decoration row, so no
|
|
24
|
+
// interleaving math is needed.
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
INLINE_DATE_GLYPH_GAP_PX,
|
|
28
|
+
INLINE_DATE_GLYPH_INSET_LEFT_PX,
|
|
29
|
+
INLINE_DATE_GLYPH_INSET_RIGHT_PX,
|
|
30
|
+
INLINE_DATE_GLYPH_INSET_TOP_PX,
|
|
31
|
+
INLINE_DATE_GLYPH_TILE_SIZE_PX,
|
|
32
|
+
ITEM_CAPTION_SPILL_GAP_PX,
|
|
33
|
+
ITEM_FOOTNOTE_INDICATOR_INSET_RIGHT_PX,
|
|
34
|
+
ITEM_FOOTNOTE_INDICATOR_STEP_PX,
|
|
35
|
+
ITEM_LINK_ICON_INSET_PX,
|
|
36
|
+
ITEM_LINK_ICON_TILE_SIZE_PX,
|
|
37
|
+
ITEM_STATUS_DOT_INSET_RIGHT_PX,
|
|
38
|
+
ITEM_STATUS_DOT_RADIUS_PX,
|
|
39
|
+
MIN_BAR_WIDTH_FOR_INLINE_DATE_PX,
|
|
40
|
+
} from './item-bar-geometry.js';
|
|
41
|
+
import type { BoundingBox, InlineDatePin, Point } from './types.js';
|
|
42
|
+
|
|
43
|
+
export interface ItemInlineDatePinInputs {
|
|
44
|
+
box: BoundingBox;
|
|
45
|
+
/** ISO date string from `after:DATE`, or undefined when no inline `after`. */
|
|
46
|
+
afterDate: string | undefined;
|
|
47
|
+
/** ISO date string from `before:DATE`, or undefined when no inline `before`. */
|
|
48
|
+
beforeDate: string | undefined;
|
|
49
|
+
hasLinkIcon: boolean;
|
|
50
|
+
/** Number of footnote indicators rendered in the bar's top-RIGHT cluster. */
|
|
51
|
+
footnoteCount: number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Compute inline-date pin glyph placements for an item bar. Returns an
|
|
56
|
+
* empty array when neither `afterDate` nor `beforeDate` is set.
|
|
57
|
+
*
|
|
58
|
+
* Item bars participate in the narrow-bar spill family — when the bar is
|
|
59
|
+
* narrower than `MIN_BAR_WIDTH_FOR_INLINE_DATE_PX`, the `before:` glyph
|
|
60
|
+
* spills RIGHT of the bar (joining the status-dot / footnote spill
|
|
61
|
+
* column) and the `after:` glyph spills LEFT of the bar's leading edge
|
|
62
|
+
* so the side semantics stay readable.
|
|
63
|
+
*/
|
|
64
|
+
export function computeItemInlineDatePins(opts: ItemInlineDatePinInputs): InlineDatePin[] {
|
|
65
|
+
const { box, afterDate, beforeDate, hasLinkIcon, footnoteCount } = opts;
|
|
66
|
+
if (!afterDate && !beforeDate) return [];
|
|
67
|
+
|
|
68
|
+
const pins: InlineDatePin[] = [];
|
|
69
|
+
const tileSize = INLINE_DATE_GLYPH_TILE_SIZE_PX;
|
|
70
|
+
const topY = box.y + INLINE_DATE_GLYPH_INSET_TOP_PX;
|
|
71
|
+
const spilled = box.width < MIN_BAR_WIDTH_FOR_INLINE_DATE_PX;
|
|
72
|
+
|
|
73
|
+
if (afterDate) {
|
|
74
|
+
const insideLeftX = hasLinkIcon
|
|
75
|
+
? box.x +
|
|
76
|
+
ITEM_LINK_ICON_INSET_PX +
|
|
77
|
+
ITEM_LINK_ICON_TILE_SIZE_PX +
|
|
78
|
+
INLINE_DATE_GLYPH_GAP_PX
|
|
79
|
+
: box.x + INLINE_DATE_GLYPH_INSET_LEFT_PX;
|
|
80
|
+
const glyphLeft: Point = spilled
|
|
81
|
+
? {
|
|
82
|
+
x: box.x - ITEM_CAPTION_SPILL_GAP_PX - tileSize,
|
|
83
|
+
y: topY,
|
|
84
|
+
}
|
|
85
|
+
: { x: insideLeftX, y: topY };
|
|
86
|
+
pins.push({
|
|
87
|
+
side: 'after',
|
|
88
|
+
isoDate: afterDate,
|
|
89
|
+
glyphTopLeft: glyphLeft,
|
|
90
|
+
glyphSize: tileSize,
|
|
91
|
+
spilled,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (beforeDate) {
|
|
96
|
+
// Walk LEFT from the rightmost top-decoration slot:
|
|
97
|
+
// - rightmost footnote anchors at (box.right - INSET_RIGHT_PX)
|
|
98
|
+
// - leftmost footnote sits one step further left per extra digit
|
|
99
|
+
// - status dot left edge sits at (box.right - INSET_RIGHT - DOT_RADIUS)
|
|
100
|
+
// - inline-date glyph sits one INLINE_DATE_GLYPH_GAP_PX further left
|
|
101
|
+
const rightEdge = box.x + box.width;
|
|
102
|
+
let anchorRightX: number;
|
|
103
|
+
if (footnoteCount > 0) {
|
|
104
|
+
const leftmostFootnoteCenter =
|
|
105
|
+
rightEdge -
|
|
106
|
+
ITEM_FOOTNOTE_INDICATOR_INSET_RIGHT_PX -
|
|
107
|
+
(footnoteCount - 1) * ITEM_FOOTNOTE_INDICATOR_STEP_PX;
|
|
108
|
+
anchorRightX = leftmostFootnoteCenter - INLINE_DATE_GLYPH_GAP_PX;
|
|
109
|
+
} else {
|
|
110
|
+
const dotLeftEdge =
|
|
111
|
+
rightEdge - ITEM_STATUS_DOT_INSET_RIGHT_PX - ITEM_STATUS_DOT_RADIUS_PX;
|
|
112
|
+
anchorRightX = dotLeftEdge - INLINE_DATE_GLYPH_GAP_PX;
|
|
113
|
+
}
|
|
114
|
+
const insideRightX = anchorRightX - tileSize;
|
|
115
|
+
const glyphLeft: Point = spilled
|
|
116
|
+
? {
|
|
117
|
+
x: rightEdge + ITEM_CAPTION_SPILL_GAP_PX,
|
|
118
|
+
y: topY,
|
|
119
|
+
}
|
|
120
|
+
: { x: insideRightX, y: topY };
|
|
121
|
+
pins.push({
|
|
122
|
+
side: 'before',
|
|
123
|
+
isoDate: beforeDate,
|
|
124
|
+
glyphTopLeft: glyphLeft,
|
|
125
|
+
glyphSize: tileSize,
|
|
126
|
+
spilled,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return pins;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface ContainerInlineDatePinInputs {
|
|
134
|
+
/** Bounding box that anchors the glyph corners. For styled groups and
|
|
135
|
+
* bracketed parallels this is the visible box; for unstyled groups
|
|
136
|
+
* and bare parallels it is the logical bounding box (leftmost child
|
|
137
|
+
* start, rightmost child end, top of the highest child row). */
|
|
138
|
+
box: BoundingBox;
|
|
139
|
+
afterDate: string | undefined;
|
|
140
|
+
beforeDate: string | undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Compute inline-date pin glyph placements for a container (group or
|
|
145
|
+
* parallel). The glyphs sit flush to the box's top-LEFT (`after`) and
|
|
146
|
+
* top-RIGHT (`before`) corners with the standard inset; containers
|
|
147
|
+
* never spill (they always have room for a 12 px tile in their own
|
|
148
|
+
* top-decoration row).
|
|
149
|
+
*/
|
|
150
|
+
export function computeContainerInlineDatePins(
|
|
151
|
+
opts: ContainerInlineDatePinInputs,
|
|
152
|
+
): InlineDatePin[] {
|
|
153
|
+
const { box, afterDate, beforeDate } = opts;
|
|
154
|
+
if (!afterDate && !beforeDate) return [];
|
|
155
|
+
|
|
156
|
+
const pins: InlineDatePin[] = [];
|
|
157
|
+
const tileSize = INLINE_DATE_GLYPH_TILE_SIZE_PX;
|
|
158
|
+
const topY = box.y + INLINE_DATE_GLYPH_INSET_TOP_PX;
|
|
159
|
+
|
|
160
|
+
if (afterDate) {
|
|
161
|
+
pins.push({
|
|
162
|
+
side: 'after',
|
|
163
|
+
isoDate: afterDate,
|
|
164
|
+
glyphTopLeft: { x: box.x + INLINE_DATE_GLYPH_INSET_LEFT_PX, y: topY },
|
|
165
|
+
glyphSize: tileSize,
|
|
166
|
+
spilled: false,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (beforeDate) {
|
|
171
|
+
pins.push({
|
|
172
|
+
side: 'before',
|
|
173
|
+
isoDate: beforeDate,
|
|
174
|
+
glyphTopLeft: {
|
|
175
|
+
x: box.x + box.width - INLINE_DATE_GLYPH_INSET_RIGHT_PX - tileSize,
|
|
176
|
+
y: topY,
|
|
177
|
+
},
|
|
178
|
+
glyphSize: tileSize,
|
|
179
|
+
spilled: false,
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return pins;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Returns the first ISO date literal in `values`, or undefined when none
|
|
188
|
+
* is present. The validator enforces "at most one inline date per
|
|
189
|
+
* direction" so this lookup is unambiguous.
|
|
190
|
+
*/
|
|
191
|
+
export function pickInlineDate(values: readonly string[]): string | undefined {
|
|
192
|
+
for (const v of values) {
|
|
193
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(v)) return v;
|
|
194
|
+
}
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
package/src/item-bar-geometry.ts
CHANGED
|
@@ -116,6 +116,55 @@ export const MIN_BAR_WIDTH_FOR_LINK_AND_DOT_PX =
|
|
|
116
116
|
*/
|
|
117
117
|
export const MIN_BAR_WIDTH_FOR_FOOTNOTE_PX = ITEM_FOOTNOTE_INDICATOR_INSET_RIGHT_PX + 1;
|
|
118
118
|
|
|
119
|
+
// ---- Inline-date glyph (after:DATE / before:DATE corner badge) ---
|
|
120
|
+
//
|
|
121
|
+
// Renders the renderer's built-in `calendar` SVG at top-LEFT (`after`)
|
|
122
|
+
// or top-RIGHT (`before`) of the entity bar. Sized as a sibling of
|
|
123
|
+
// the status dot and footnote indicators (smaller than the link tile)
|
|
124
|
+
// so the top-decoration row stays visually unified. See
|
|
125
|
+
// specs/rendering.md "Inline-date glyph".
|
|
126
|
+
|
|
127
|
+
/** Side length (px) of the inline-date calendar glyph tile. */
|
|
128
|
+
export const INLINE_DATE_GLYPH_TILE_SIZE_PX = 12;
|
|
129
|
+
|
|
130
|
+
/** Distance (px) from the bar's left edge to the glyph's left edge
|
|
131
|
+
* when the glyph sits at the leftmost top-decoration slot (no link
|
|
132
|
+
* icon present). */
|
|
133
|
+
export const INLINE_DATE_GLYPH_INSET_LEFT_PX = 6;
|
|
134
|
+
|
|
135
|
+
/** Distance (px) from the bar's right edge to the glyph's right edge
|
|
136
|
+
* when the glyph sits at the rightmost top-decoration slot (no
|
|
137
|
+
* status dot or footnote present). */
|
|
138
|
+
export const INLINE_DATE_GLYPH_INSET_RIGHT_PX = 6;
|
|
139
|
+
|
|
140
|
+
/** Distance (px) from the bar's top edge to the glyph's top edge. */
|
|
141
|
+
export const INLINE_DATE_GLYPH_INSET_TOP_PX = 5;
|
|
142
|
+
|
|
143
|
+
/** Horizontal gap (px) between the inline-date glyph and an adjacent
|
|
144
|
+
* decoration (link icon on the left, status dot / footnote indicator
|
|
145
|
+
* on the right). Matches the existing decoration spill gap so the
|
|
146
|
+
* top-decoration row reads as one rhythm. */
|
|
147
|
+
export const INLINE_DATE_GLYPH_GAP_PX = ITEM_DECORATION_SPILL_GAP_PX;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Minimum bar width (px) needed to host the inline-date glyph inside
|
|
151
|
+
* the bar. Below this, the glyph spills:
|
|
152
|
+
* - `before:` glyph spills RIGHT into the column the status dot
|
|
153
|
+
* uses (same family of decorations, same step pitch).
|
|
154
|
+
* - `after:` glyph spills LEFT of the bar's leading edge so the
|
|
155
|
+
* side semantics (after vs before) stay readable in the spill.
|
|
156
|
+
*
|
|
157
|
+
* The threshold reserves room for the glyph itself plus a small
|
|
158
|
+
* clearance from the link-icon column (when present) so the two
|
|
159
|
+
* glyphs don't overlap inside narrow bars.
|
|
160
|
+
*/
|
|
161
|
+
export const MIN_BAR_WIDTH_FOR_INLINE_DATE_PX =
|
|
162
|
+
INLINE_DATE_GLYPH_INSET_LEFT_PX +
|
|
163
|
+
INLINE_DATE_GLYPH_TILE_SIZE_PX +
|
|
164
|
+
INLINE_DATE_GLYPH_GAP_PX +
|
|
165
|
+
INLINE_DATE_GLYPH_TILE_SIZE_PX +
|
|
166
|
+
INLINE_DATE_GLYPH_INSET_RIGHT_PX;
|
|
167
|
+
|
|
119
168
|
// ---- Label chips (along the bar's bottom) ------------------------
|
|
120
169
|
|
|
121
170
|
/** Height (px) of a label chip rectangle. */
|
package/src/layout.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
HEADER_TITLE_TO_AUTHOR_GAP_PX,
|
|
41
41
|
} from './header-card-geometry.js';
|
|
42
42
|
import { localeStrings } from './i18n.js';
|
|
43
|
+
import { computeItemInlineDatePins, pickInlineDate } from './inline-date-pin-geometry.js';
|
|
43
44
|
import {
|
|
44
45
|
ITEM_CAPTION_INSET_X_PX,
|
|
45
46
|
ITEM_CAPTION_META_BASELINE_OFFSET_PX,
|
|
@@ -226,11 +227,15 @@ function sequenceItem(
|
|
|
226
227
|
// "one engineer-week of work left".
|
|
227
228
|
const totalEffortDays = deriveTotalEffortDays(props, ctx.sizes, ctx.cal);
|
|
228
229
|
const afterRaw = propValues(props, 'after');
|
|
229
|
-
const beforeRaw =
|
|
230
|
+
const beforeRaw = propValues(props, 'before');
|
|
230
231
|
const dateRaw = propValue(props, 'date');
|
|
231
232
|
const remainingDays = resolveDuration(propValue(props, 'remaining'), ctx.sizes, ctx.cal);
|
|
232
233
|
|
|
233
|
-
// Resolve start x: explicit date > after-chain > cursor position
|
|
234
|
+
// Resolve start x: explicit date > after-chain > cursor position.
|
|
235
|
+
// `after:` accepts both entity ids (looked up in entityRightEdges) and
|
|
236
|
+
// inline ISO date literals (resolved through the time scale). The
|
|
237
|
+
// validator enforces "at most one inline date per direction" so at most
|
|
238
|
+
// one element will hit the date path per item.
|
|
234
239
|
let startX = cursor.x;
|
|
235
240
|
const explicitDate = parseDate(dateRaw);
|
|
236
241
|
if (explicitDate) {
|
|
@@ -239,6 +244,12 @@ function sequenceItem(
|
|
|
239
244
|
} else if (afterRaw.length > 0) {
|
|
240
245
|
let maxEnd = cursor.x;
|
|
241
246
|
for (const ref of afterRaw) {
|
|
247
|
+
const inlineDate = parseDate(ref);
|
|
248
|
+
if (inlineDate) {
|
|
249
|
+
const xd = ctx.scale.forwardWithinDomain(inlineDate);
|
|
250
|
+
if (xd !== null) maxEnd = Math.max(maxEnd, xd);
|
|
251
|
+
continue;
|
|
252
|
+
}
|
|
242
253
|
const endX = ctx.entityRightEdges.get(ref);
|
|
243
254
|
if (endX !== undefined) maxEnd = Math.max(maxEnd, endX);
|
|
244
255
|
}
|
|
@@ -286,24 +297,38 @@ function sequenceItem(
|
|
|
286
297
|
const chipInsideAvailWidth = Math.max(0, visualWidthPredict - 2 * ITEM_CAPTION_INSET_X_PX);
|
|
287
298
|
const chipsOutside = chipSamples.length > 0 && chipRowWidth > chipInsideAvailWidth;
|
|
288
299
|
|
|
289
|
-
// Handle `before:` — item must end by the
|
|
300
|
+
// Handle `before:` — item must end by the earliest cap. Each cap is
|
|
301
|
+
// either an entity id (looked up in entityLeftEdges) or an inline ISO
|
|
302
|
+
// date literal (resolved through the time scale). The validator enforces
|
|
303
|
+
// "at most one inline date per direction" so at most one element per
|
|
304
|
+
// before-list will hit the date path.
|
|
290
305
|
let hasOverflow = false;
|
|
291
306
|
let overflowBox: BoundingBox | undefined;
|
|
292
307
|
let overflowAnchorId: string | undefined;
|
|
293
|
-
if (beforeRaw) {
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
308
|
+
if (beforeRaw.length > 0) {
|
|
309
|
+
let earliestCapX: number | undefined;
|
|
310
|
+
let earliestCapRef: string | undefined;
|
|
311
|
+
for (const ref of beforeRaw) {
|
|
312
|
+
const inlineDate = parseDate(ref);
|
|
313
|
+
const capX = inlineDate
|
|
314
|
+
? (ctx.scale.forwardWithinDomain(inlineDate) ?? undefined)
|
|
315
|
+
: ctx.entityLeftEdges.get(ref);
|
|
316
|
+
if (capX === undefined) continue;
|
|
317
|
+
if (earliestCapX === undefined || capX < earliestCapX) {
|
|
318
|
+
earliestCapX = capX;
|
|
319
|
+
earliestCapRef = ref;
|
|
305
320
|
}
|
|
306
321
|
}
|
|
322
|
+
if (earliestCapX !== undefined && logicalRight > earliestCapX) {
|
|
323
|
+
hasOverflow = true;
|
|
324
|
+
overflowBox = {
|
|
325
|
+
x: earliestCapX,
|
|
326
|
+
y: cursor.y,
|
|
327
|
+
width: logicalRight - earliestCapX,
|
|
328
|
+
height: ctx.bandScale.bandwidth(),
|
|
329
|
+
};
|
|
330
|
+
overflowAnchorId = earliestCapRef;
|
|
331
|
+
}
|
|
307
332
|
}
|
|
308
333
|
|
|
309
334
|
// Progress fraction
|
|
@@ -695,6 +720,14 @@ function sequenceItem(
|
|
|
695
720
|
// `step − bandwidth` px below the (now-taller) bar bottom.
|
|
696
721
|
cursor.height = Math.max(cursor.height, ctx.bandScale.step() + chipBarExtra);
|
|
697
722
|
|
|
723
|
+
const inlineDatePins = computeItemInlineDatePins({
|
|
724
|
+
box: itemBox,
|
|
725
|
+
afterDate: pickInlineDate(afterRaw),
|
|
726
|
+
beforeDate: pickInlineDate(beforeRaw),
|
|
727
|
+
hasLinkIcon,
|
|
728
|
+
footnoteCount: footnoteIndicators.length,
|
|
729
|
+
});
|
|
730
|
+
|
|
698
731
|
const result: PositionedItem = {
|
|
699
732
|
kind: 'item',
|
|
700
733
|
id,
|
|
@@ -725,6 +758,7 @@ function sequenceItem(
|
|
|
725
758
|
capacity,
|
|
726
759
|
size: sizeResolved,
|
|
727
760
|
style,
|
|
761
|
+
inlineDatePins: inlineDatePins.length > 0 ? inlineDatePins : undefined,
|
|
728
762
|
};
|
|
729
763
|
return result;
|
|
730
764
|
}
|
|
@@ -895,6 +929,11 @@ function predictItemChipExtraHeight(item: ItemDeclaration, ctx: LayoutContext):
|
|
|
895
929
|
// pin) > `start:` (fixed pin) > `after:` (chain after refs) > sequential
|
|
896
930
|
// default (continue from `seqDefault`, which is the lane's rightmost time
|
|
897
931
|
// cursor across all rows).
|
|
932
|
+
//
|
|
933
|
+
// `after:` accepts both entity ids (looked up in `ctx.entityRightEdges`) and
|
|
934
|
+
// inline ISO date literals (looked up via `ctx.scale.forwardWithinDomain`).
|
|
935
|
+
// The validator already enforces "at most one inline date per direction" so
|
|
936
|
+
// at most one element in the list will hit the date path.
|
|
898
937
|
function resolveChildStart(
|
|
899
938
|
props: EntityProperty[],
|
|
900
939
|
seqDefault: number,
|
|
@@ -911,6 +950,12 @@ function resolveChildStart(
|
|
|
911
950
|
if (afterRefs.length > 0) {
|
|
912
951
|
let maxEnd = laneLeftX;
|
|
913
952
|
for (const ref of afterRefs) {
|
|
953
|
+
const inlineDate = parseDate(ref);
|
|
954
|
+
if (inlineDate) {
|
|
955
|
+
const xd = ctx.scale.forwardWithinDomain(inlineDate);
|
|
956
|
+
if (xd !== null) maxEnd = Math.max(maxEnd, xd);
|
|
957
|
+
continue;
|
|
958
|
+
}
|
|
914
959
|
const endX = ctx.entityRightEdges.get(ref);
|
|
915
960
|
if (endX !== undefined) maxEnd = Math.max(maxEnd, endX);
|
|
916
961
|
}
|
|
@@ -1121,7 +1166,14 @@ function computeContentEndDay(
|
|
|
1121
1166
|
const milestoneEnd = new Map<string, number>();
|
|
1122
1167
|
let maxDay = 0;
|
|
1123
1168
|
|
|
1124
|
-
|
|
1169
|
+
// Resolve a single `after:` element to a day-offset from `startDate`.
|
|
1170
|
+
// The element is either an entity id (looked up in itemEnd / anchorEnd /
|
|
1171
|
+
// milestoneEnd) or an inline ISO date literal (converted directly via
|
|
1172
|
+
// `daysBetween`). The validator already enforces "at most one inline date
|
|
1173
|
+
// per direction", so at most one element per list will hit the date path.
|
|
1174
|
+
const resolveAfterDay = (ref: string): number => {
|
|
1175
|
+
const inlineDate = parseDate(ref);
|
|
1176
|
+
if (inlineDate) return daysBetween(startDate, inlineDate);
|
|
1125
1177
|
if (itemEnd.has(ref)) return itemEnd.get(ref)!;
|
|
1126
1178
|
if (anchorEnd.has(ref)) return anchorEnd.get(ref)!;
|
|
1127
1179
|
if (milestoneEnd.has(ref)) return milestoneEnd.get(ref)!;
|
|
@@ -1164,7 +1216,7 @@ function computeContentEndDay(
|
|
|
1164
1216
|
} else if (startProp) {
|
|
1165
1217
|
start = daysBetween(startDate, startProp);
|
|
1166
1218
|
} else if (afterRefs.length > 0) {
|
|
1167
|
-
start = Math.max(prevEnd, ...afterRefs.map(
|
|
1219
|
+
start = Math.max(prevEnd, ...afterRefs.map(resolveAfterDay));
|
|
1168
1220
|
}
|
|
1169
1221
|
const end = start + dur;
|
|
1170
1222
|
if (node.name) itemEnd.set(node.name, end);
|
|
@@ -1172,17 +1224,30 @@ function computeContentEndDay(
|
|
|
1172
1224
|
}
|
|
1173
1225
|
if (isParallelBlock(node)) {
|
|
1174
1226
|
// All children share the parallel's start; the block's effective
|
|
1175
|
-
// end is the maximum child end.
|
|
1176
|
-
|
|
1227
|
+
// end is the maximum child end. The parallel's own `after:`
|
|
1228
|
+
// (including inline-date pins) widens that shared start.
|
|
1229
|
+
const afterRefs = propValues(node.properties, 'after');
|
|
1230
|
+
const containerStart =
|
|
1231
|
+
afterRefs.length > 0
|
|
1232
|
+
? Math.max(prevEnd, ...afterRefs.map(resolveAfterDay))
|
|
1233
|
+
: prevEnd;
|
|
1234
|
+
let parallelEnd = containerStart;
|
|
1177
1235
|
for (const child of node.content) {
|
|
1178
1236
|
if (child.$type === 'DescriptionDirective') continue;
|
|
1179
|
-
const childEnd = walkNode(child as ItemDeclaration | GroupBlock,
|
|
1237
|
+
const childEnd = walkNode(child as ItemDeclaration | GroupBlock, containerStart);
|
|
1180
1238
|
parallelEnd = Math.max(parallelEnd, childEnd);
|
|
1181
1239
|
}
|
|
1182
1240
|
return parallelEnd;
|
|
1183
1241
|
}
|
|
1184
1242
|
if (isGroupBlock(node)) {
|
|
1185
|
-
|
|
1243
|
+
// The group's own `after:` (including inline-date pins) widens
|
|
1244
|
+
// the baseline before walking the inner sequential lane.
|
|
1245
|
+
const afterRefs = propValues(node.properties, 'after');
|
|
1246
|
+
const containerStart =
|
|
1247
|
+
afterRefs.length > 0
|
|
1248
|
+
? Math.max(prevEnd, ...afterRefs.map(resolveAfterDay))
|
|
1249
|
+
: prevEnd;
|
|
1250
|
+
return walkLane(node.content as SwimlaneDeclaration['content'], containerStart);
|
|
1186
1251
|
}
|
|
1187
1252
|
return prevEnd;
|
|
1188
1253
|
};
|
|
@@ -1202,7 +1267,12 @@ function computeContentEndDay(
|
|
|
1202
1267
|
}
|
|
1203
1268
|
const after = propValues(ms.properties, 'after');
|
|
1204
1269
|
if (after.length > 0) {
|
|
1205
|
-
|
|
1270
|
+
// Milestones disallow inline date literals at the validator
|
|
1271
|
+
// level (rule 24a — milestones already have `date:`). The
|
|
1272
|
+
// shared `resolveAfterDay` helper still handles such input
|
|
1273
|
+
// gracefully if it slips past, treating the date as the
|
|
1274
|
+
// milestone's effective day.
|
|
1275
|
+
const day = Math.max(0, ...after.map(resolveAfterDay));
|
|
1206
1276
|
if (ms.name) milestoneEnd.set(ms.name, day);
|
|
1207
1277
|
maxDay = Math.max(maxDay, day);
|
|
1208
1278
|
}
|
package/src/nodes/group-node.ts
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
import type { EntityProperty, GroupBlock, ItemDeclaration, ParallelBlock } from '@nowline/core';
|
|
15
15
|
import { isItemDeclaration } from '@nowline/core';
|
|
16
16
|
import { deriveItemDurationDays } from '../calendar.js';
|
|
17
|
+
import { propValues } from '../dsl-utils.js';
|
|
18
|
+
import { computeContainerInlineDatePins, pickInlineDate } from '../inline-date-pin-geometry.js';
|
|
17
19
|
import type { LayoutContext, TrackCursor } from '../layout-context.js';
|
|
18
20
|
import { RowPacker } from '../row-packer.js';
|
|
19
21
|
import { resolveStyle } from '../style-resolution.js';
|
|
@@ -240,6 +242,11 @@ export class GroupNode {
|
|
|
240
242
|
ctx.entityRightEdges.set(id, box.x + box.width);
|
|
241
243
|
}
|
|
242
244
|
ctx.currentFlowKey = previousFlowKey;
|
|
245
|
+
const inlineDatePins = computeContainerInlineDatePins({
|
|
246
|
+
box,
|
|
247
|
+
afterDate: pickInlineDate(propValues(node.properties, 'after')),
|
|
248
|
+
beforeDate: pickInlineDate(propValues(node.properties, 'before')),
|
|
249
|
+
});
|
|
243
250
|
return {
|
|
244
251
|
kind: 'group',
|
|
245
252
|
id,
|
|
@@ -247,6 +254,7 @@ export class GroupNode {
|
|
|
247
254
|
box,
|
|
248
255
|
children,
|
|
249
256
|
style,
|
|
257
|
+
inlineDatePins: inlineDatePins.length > 0 ? inlineDatePins : undefined,
|
|
250
258
|
};
|
|
251
259
|
}
|
|
252
260
|
}
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
// helpers in `layout.ts`).
|
|
7
7
|
|
|
8
8
|
import type { GroupBlock, ItemDeclaration, ParallelBlock } from '@nowline/core';
|
|
9
|
+
import { propValues } from '../dsl-utils.js';
|
|
10
|
+
import { computeContainerInlineDatePins, pickInlineDate } from '../inline-date-pin-geometry.js';
|
|
9
11
|
import type { LayoutContext, TrackCursor } from '../layout-context.js';
|
|
10
12
|
import { resolveStyle } from '../style-resolution.js';
|
|
11
13
|
import { TRACK_BLOCK_TAIL_GUTTER_PX } from '../themes/shared.js';
|
|
@@ -89,6 +91,12 @@ export class ParallelNode {
|
|
|
89
91
|
ctx.entityRightEdges.set(id, box.x + box.width);
|
|
90
92
|
}
|
|
91
93
|
|
|
94
|
+
const inlineDatePins = computeContainerInlineDatePins({
|
|
95
|
+
box,
|
|
96
|
+
afterDate: pickInlineDate(propValues(node.properties, 'after')),
|
|
97
|
+
beforeDate: pickInlineDate(propValues(node.properties, 'before')),
|
|
98
|
+
});
|
|
99
|
+
|
|
92
100
|
return {
|
|
93
101
|
kind: 'parallel',
|
|
94
102
|
id,
|
|
@@ -96,6 +104,7 @@ export class ParallelNode {
|
|
|
96
104
|
box,
|
|
97
105
|
children,
|
|
98
106
|
style,
|
|
107
|
+
inlineDatePins: inlineDatePins.length > 0 ? inlineDatePins : undefined,
|
|
99
108
|
};
|
|
100
109
|
}
|
|
101
110
|
}
|
package/src/types.ts
CHANGED
|
@@ -223,6 +223,30 @@ export interface PositionedNowline {
|
|
|
223
223
|
style: ResolvedStyle;
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
/**
|
|
227
|
+
* One inline-date pin (`after:DATE` / `before:DATE`) attached to an entity.
|
|
228
|
+
* The renderer paints a small calendar glyph at `glyphTopLeft` with side
|
|
229
|
+
* length `glyphSize`, fills with the entity's resolved meta color, and emits
|
|
230
|
+
* a `<title>` child carrying `isoDate` for native browser tooltips. There is
|
|
231
|
+
* no inline date caption — see specs/rendering.md "Inline-date glyph".
|
|
232
|
+
*
|
|
233
|
+
* `side` decides which corner the glyph belongs in (top-LEFT for `after`,
|
|
234
|
+
* top-RIGHT for `before`). The validator enforces "at most one inline date
|
|
235
|
+
* per direction" so `inlineDatePins` carries 0..2 entries.
|
|
236
|
+
*
|
|
237
|
+
* `spilled === true` means the entity (item bar) was too narrow to host the
|
|
238
|
+
* glyph inside its decoration row, so `glyphTopLeft` points at the spill
|
|
239
|
+
* column outside the bar instead. Group and parallel containers never spill;
|
|
240
|
+
* they always have room for the glyph in their bounding box.
|
|
241
|
+
*/
|
|
242
|
+
export interface InlineDatePin {
|
|
243
|
+
side: 'after' | 'before';
|
|
244
|
+
isoDate: string;
|
|
245
|
+
glyphTopLeft: Point;
|
|
246
|
+
glyphSize: number;
|
|
247
|
+
spilled: boolean;
|
|
248
|
+
}
|
|
249
|
+
|
|
226
250
|
export interface PositionedItem {
|
|
227
251
|
kind: 'item';
|
|
228
252
|
id?: string;
|
|
@@ -232,6 +256,10 @@ export interface PositionedItem {
|
|
|
232
256
|
progressFraction: number; // 0..1; 1 == fully filled
|
|
233
257
|
footnoteIndicators: number[]; // 1-based superscript numbers, empty when no footnotes
|
|
234
258
|
labelChips: PositionedLabelChip[];
|
|
259
|
+
/** Inline-date pins attached to this item (`after:DATE` / `before:DATE`).
|
|
260
|
+
* Empty (or undefined) when the item has no inline-date pins. See
|
|
261
|
+
* `InlineDatePin` and specs/rendering.md "Inline-date glyph". */
|
|
262
|
+
inlineDatePins?: InlineDatePin[];
|
|
235
263
|
/** True when the chip row's natural total width exceeded the bar's
|
|
236
264
|
* effective inner width and the whole row spilled past the bar's
|
|
237
265
|
* right edge. The chips' `box.x` already reflects the spilled
|
|
@@ -359,6 +387,9 @@ export interface PositionedGroup {
|
|
|
359
387
|
box: BoundingBox;
|
|
360
388
|
// Bracket is drawn on the left edge when style.bracket != 'none'.
|
|
361
389
|
children: PositionedTrackChild[];
|
|
390
|
+
/** Inline-date pins attached to this group (`after:DATE` / `before:DATE`).
|
|
391
|
+
* See `InlineDatePin` and specs/rendering.md "Inline-date glyph". */
|
|
392
|
+
inlineDatePins?: InlineDatePin[];
|
|
362
393
|
style: ResolvedStyle;
|
|
363
394
|
}
|
|
364
395
|
|
|
@@ -368,6 +399,9 @@ export interface PositionedParallel {
|
|
|
368
399
|
title?: string;
|
|
369
400
|
box: BoundingBox;
|
|
370
401
|
children: PositionedTrackChild[]; // sub-tracks stacked vertically
|
|
402
|
+
/** Inline-date pins attached to this parallel (`after:DATE` / `before:DATE`).
|
|
403
|
+
* See `InlineDatePin` and specs/rendering.md "Inline-date glyph". */
|
|
404
|
+
inlineDatePins?: InlineDatePin[];
|
|
371
405
|
style: ResolvedStyle;
|
|
372
406
|
}
|
|
373
407
|
|