@nowline/renderer 0.8.6 → 0.10.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.
Files changed (86) hide show
  1. package/dist/image-size.d.ts +9 -0
  2. package/dist/image-size.d.ts.map +1 -0
  3. package/dist/image-size.js +104 -0
  4. package/dist/image-size.js.map +1 -0
  5. package/dist/index.d.ts +4 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +4 -1
  8. package/dist/index.js.map +1 -1
  9. package/dist/logo.d.ts +24 -0
  10. package/dist/logo.d.ts.map +1 -0
  11. package/dist/logo.js +60 -0
  12. package/dist/logo.js.map +1 -0
  13. package/dist/stage.d.ts +78 -0
  14. package/dist/stage.d.ts.map +1 -0
  15. package/dist/stage.js +88 -0
  16. package/dist/stage.js.map +1 -0
  17. package/dist/svg/edges.d.ts +3 -0
  18. package/dist/svg/edges.d.ts.map +1 -0
  19. package/dist/svg/edges.js +144 -0
  20. package/dist/svg/edges.js.map +1 -0
  21. package/dist/svg/footnotes.d.ts +4 -0
  22. package/dist/svg/footnotes.d.ts.map +1 -0
  23. package/dist/svg/footnotes.js +81 -0
  24. package/dist/svg/footnotes.js.map +1 -0
  25. package/dist/svg/header.d.ts +6 -0
  26. package/dist/svg/header.d.ts.map +1 -0
  27. package/dist/svg/header.js +215 -0
  28. package/dist/svg/header.js.map +1 -0
  29. package/dist/svg/include-region.d.ts +4 -0
  30. package/dist/svg/include-region.d.ts.map +1 -0
  31. package/dist/svg/include-region.js +157 -0
  32. package/dist/svg/include-region.js.map +1 -0
  33. package/dist/svg/item.d.ts +36 -0
  34. package/dist/svg/item.d.ts.map +1 -0
  35. package/dist/svg/item.js +549 -0
  36. package/dist/svg/item.js.map +1 -0
  37. package/dist/svg/markers.d.ts +7 -0
  38. package/dist/svg/markers.d.ts.map +1 -0
  39. package/dist/svg/markers.js +151 -0
  40. package/dist/svg/markers.js.map +1 -0
  41. package/dist/svg/nowline.d.ts +4 -0
  42. package/dist/svg/nowline.d.ts.map +1 -0
  43. package/dist/svg/nowline.js +133 -0
  44. package/dist/svg/nowline.js.map +1 -0
  45. package/dist/svg/primitives.d.ts +7 -0
  46. package/dist/svg/primitives.d.ts.map +1 -0
  47. package/dist/svg/primitives.js +70 -0
  48. package/dist/svg/primitives.js.map +1 -0
  49. package/dist/svg/render.d.ts +13 -7
  50. package/dist/svg/render.d.ts.map +1 -1
  51. package/dist/svg/render.js +64 -1701
  52. package/dist/svg/render.js.map +1 -1
  53. package/dist/svg/sanitize.d.ts +10 -0
  54. package/dist/svg/sanitize.d.ts.map +1 -1
  55. package/dist/svg/sanitize.js +264 -16
  56. package/dist/svg/sanitize.js.map +1 -1
  57. package/dist/svg/swimlanes.d.ts +5 -0
  58. package/dist/svg/swimlanes.d.ts.map +1 -0
  59. package/dist/svg/swimlanes.js +228 -0
  60. package/dist/svg/swimlanes.js.map +1 -0
  61. package/dist/svg/timeline.d.ts +24 -0
  62. package/dist/svg/timeline.d.ts.map +1 -0
  63. package/dist/svg/timeline.js +284 -0
  64. package/dist/svg/timeline.js.map +1 -0
  65. package/dist/svg/waves.d.ts +62 -0
  66. package/dist/svg/waves.d.ts.map +1 -0
  67. package/dist/svg/waves.js +397 -0
  68. package/dist/svg/waves.js.map +1 -0
  69. package/package.json +6 -6
  70. package/src/image-size.ts +113 -0
  71. package/src/index.ts +10 -1
  72. package/src/logo.ts +82 -0
  73. package/src/stage.ts +191 -0
  74. package/src/svg/edges.ts +154 -0
  75. package/src/svg/footnotes.ts +115 -0
  76. package/src/svg/header.ts +285 -0
  77. package/src/svg/include-region.ts +201 -0
  78. package/src/svg/item.ts +672 -0
  79. package/src/svg/markers.ts +178 -0
  80. package/src/svg/nowline.ts +152 -0
  81. package/src/svg/primitives.ts +86 -0
  82. package/src/svg/render.ts +94 -2110
  83. package/src/svg/sanitize.ts +286 -16
  84. package/src/svg/swimlanes.ts +317 -0
  85. package/src/svg/timeline.ts +343 -0
  86. package/src/svg/waves.ts +502 -0
@@ -0,0 +1,178 @@
1
+ // The marker layer: anchors, milestones and the cut lines they drop through the
2
+ // chart body, with the diamonds and labels that sit in the marker row.
3
+
4
+ import type { PositionedAnchor, PositionedMilestone, Theme } from '@nowline/layout';
5
+ import { ACCENT_DASH_PATTERN } from '@nowline/layout';
6
+ import type { FontFamilies } from './render.js';
7
+ import { escText, num, tag, textTag } from './xml.js';
8
+
9
+ export function renderAnchor(a: PositionedAnchor, palette: Theme, fonts: FontFamilies): string {
10
+ const size = a.radius;
11
+ const cx = a.center.x;
12
+ const cy = a.center.y;
13
+ const fill = palette.anchorDiamond.fill;
14
+ const stroke = palette.anchorDiamond.stroke;
15
+ const diamond = tag('path', {
16
+ d: `M${num(cx)} ${num(cy - size)} L${num(cx + size)} ${num(cy)} L${num(cx)} ${num(cy + size)} L${num(cx - size)} ${num(cy)} Z`,
17
+ fill,
18
+ stroke,
19
+ 'stroke-width': 1.25,
20
+ });
21
+ const labelColor = palette.anchorDiamond.label;
22
+ // For left-flipped labels, anchor the text at its RIGHT edge using
23
+ // `text-anchor: end`. The layout's `labelBox.width` is intentionally
24
+ // pessimistic (0.58 em/char) so positioning by the box's left edge
25
+ // would leave a visible gap between the actual text right edge and
26
+ // the diamond. End-anchoring lets the browser size the glyph run
27
+ // exactly and put the rightmost glyph 6 px from the diamond — same
28
+ // rhythm the right-side labels already get from start-anchoring at
29
+ // `diamondRight + 6`.
30
+ const labelX = a.labelSide === 'left' ? a.labelBox.x + a.labelBox.width : a.labelBox.x;
31
+ const labelAttrs: Record<string, string | number | null | undefined> = {
32
+ x: num(labelX),
33
+ y: num(cy + 4),
34
+ 'font-family': fonts.sans,
35
+ 'font-size': 10,
36
+ fill: labelColor,
37
+ };
38
+ if (a.labelSide === 'left') labelAttrs['text-anchor'] = 'end';
39
+ const label = a.title ? textTag(labelAttrs, a.title) : '';
40
+ return tag(
41
+ 'g',
42
+ { 'data-layer': 'anchor', 'data-id': a.id ?? null },
43
+ hiddenDateTitle(a.hiddenDate) + diamond + label,
44
+ );
45
+ }
46
+
47
+ export function renderAnchorCutLine(a: PositionedAnchor, palette: Theme): string {
48
+ const stroke = palette.anchorDiamond.cutLine;
49
+ return tag('line', {
50
+ x1: num(a.center.x),
51
+ y1: num(a.center.y + a.radius + 1),
52
+ x2: num(a.center.x),
53
+ y2: num(a.cutBottomY),
54
+ stroke,
55
+ 'stroke-width': 1,
56
+ 'stroke-dasharray': '1 3',
57
+ });
58
+ }
59
+
60
+ export function renderMilestone(
61
+ m: PositionedMilestone,
62
+ palette: Theme,
63
+ fonts: FontFamilies,
64
+ ): string {
65
+ const cx = m.center.x;
66
+ const cy = m.center.y;
67
+ const r = m.radius;
68
+ const overrun = palette.milestoneDiamond.cutLineOverrun;
69
+ const diamond = (x: number, fill: string): string =>
70
+ tag('path', {
71
+ d: `M${num(x)} ${num(cy - r)} L${num(x + r)} ${num(cy)} L${num(x)} ${num(cy + r)} L${num(x - r)} ${num(cy)} Z`,
72
+ fill,
73
+ stroke: fill,
74
+ 'stroke-width': 1,
75
+ });
76
+ // An overrun dated milestone paints its diamond in the overrun colour
77
+ // (specs/rendering.md, Milestones). When it slipped, that diamond marks
78
+ // the missed date and a dotted span runs on to a second diamond, in the
79
+ // normal colour, where it lands; the label sits there. When it did not
80
+ // slip (the two diamonds would meet), the label is red too.
81
+ const slipX = m.slip?.x;
82
+ let flag: string;
83
+ if (slipX !== undefined) {
84
+ flag =
85
+ diamond(cx, overrun) +
86
+ tag('path', {
87
+ d: `M${num(cx + r)} ${num(cy)} H${num(slipX - r)}`,
88
+ fill: 'none',
89
+ stroke: overrun,
90
+ 'stroke-width': 1.1,
91
+ 'stroke-dasharray': '3 3',
92
+ 'stroke-linecap': 'round',
93
+ }) +
94
+ diamond(slipX, palette.milestoneDiamond.fill);
95
+ } else {
96
+ flag = diamond(cx, m.isOverrun ? overrun : palette.milestoneDiamond.fill);
97
+ }
98
+ const labelColor =
99
+ m.isOverrun && slipX === undefined ? overrun : palette.milestoneDiamond.label;
100
+ // See renderAnchor — left-flipped labels use `text-anchor: end` so
101
+ // the visual right edge sits at `diamondLeft - 6`, matching the
102
+ // 6 px rhythm of right-side labels.
103
+ const labelX = m.labelSide === 'left' ? m.labelBox.x + m.labelBox.width : m.labelBox.x;
104
+ const labelAttrs: Record<string, string | number | null | undefined> = {
105
+ x: num(labelX),
106
+ y: num(cy + 4),
107
+ 'font-family': fonts.sans,
108
+ 'font-size': 10,
109
+ 'font-weight': 600,
110
+ fill: labelColor,
111
+ };
112
+ if (m.labelSide === 'left') labelAttrs['text-anchor'] = 'end';
113
+ const label = m.title ? textTag(labelAttrs, m.title) : '';
114
+ return tag(
115
+ 'g',
116
+ { 'data-layer': 'milestone', 'data-id': m.id ?? null },
117
+ hiddenDateTitle(m.hiddenDate) + flag + label,
118
+ );
119
+ }
120
+
121
+ /**
122
+ * A marker dated on a day the axis hides sits at the next working day's
123
+ * x, so its own date rides along as a native tooltip, the way
124
+ * `renderInlineDatePin` titles its glyph (specs/working-calendar.md
125
+ * §7.4). Empty for a working-day date.
126
+ */
127
+ function hiddenDateTitle(hiddenDate: string | undefined): string {
128
+ return hiddenDate === undefined ? '' : `<title>${escText(hiddenDate)}</title>`;
129
+ }
130
+
131
+ export function renderMilestoneCutLine(
132
+ m: PositionedMilestone,
133
+ idPrefix: string,
134
+ palette: Theme,
135
+ ): string {
136
+ // A slipped milestone's cut line drops from where it lands, in the
137
+ // normal colour; one that overran without slipping keeps the red line.
138
+ const cutX = m.slip?.x ?? m.center.x;
139
+ const stroke =
140
+ m.isOverrun && m.slip === undefined
141
+ ? palette.milestoneDiamond.cutLineOverrun
142
+ : palette.milestoneDiamond.cutLineNormal;
143
+ const parts: string[] = [];
144
+ // A milestone on a wave boundary draws no cut line: the boundary
145
+ // already marks that instant (specs/waves.md §9.2).
146
+ if (!m.onWaveBoundary) {
147
+ parts.push(
148
+ tag('line', {
149
+ x1: num(cutX),
150
+ y1: num(m.center.y + m.radius + 1),
151
+ x2: num(cutX),
152
+ y2: num(m.cutBottomY),
153
+ stroke,
154
+ 'stroke-width': 2,
155
+ 'stroke-dasharray': ACCENT_DASH_PATTERN,
156
+ 'stroke-linecap': 'round',
157
+ }),
158
+ );
159
+ }
160
+ if (m.slackArrows && m.slackArrows.length > 0) {
161
+ // Only a floating milestone draws slack arrows, in ink; a dated one
162
+ // slips instead.
163
+ for (const arrow of m.slackArrows) {
164
+ parts.push(
165
+ tag('path', {
166
+ d: `M${num(arrow.x)} ${num(arrow.y)} H${num(m.center.x - 6)}`,
167
+ fill: 'none',
168
+ stroke: palette.milestoneDiamond.slack,
169
+ 'stroke-width': 1.1,
170
+ 'stroke-dasharray': '3 3',
171
+ 'stroke-linecap': 'round',
172
+ 'marker-end': `url(#${idPrefix}-arrow-dark)`,
173
+ }),
174
+ );
175
+ }
176
+ }
177
+ return parts.join('');
178
+ }
@@ -0,0 +1,152 @@
1
+ // The now-line layer: the vertical line marking the current date and the pill
2
+ // at its top that carries the date label.
3
+
4
+ import type { PositionedNowline, Theme } from '@nowline/layout';
5
+ import {
6
+ NOW_PILL_CORNER_RADIUS_PX,
7
+ NOW_PILL_HEIGHT_PX,
8
+ NOW_PILL_LABEL_BASELINE_OFFSET_PX,
9
+ NOW_PILL_LABEL_FONT_SIZE_PX,
10
+ NOW_PILL_LABEL_INSET_X_PX,
11
+ NOWLINE_STROKE_WIDTH_PX,
12
+ } from '@nowline/layout';
13
+ import type { FontFamilies } from './render.js';
14
+ import { num, tag, textTag } from './xml.js';
15
+
16
+ export function renderNowline(
17
+ n: PositionedNowline | null,
18
+ palette: Theme,
19
+ fonts: FontFamilies,
20
+ ): string {
21
+ if (!n) return '';
22
+ const color = palette.nowline.stroke;
23
+ const labelTextColor = palette.nowline.labelText;
24
+ // Line drops from `topY` (just below the pill / top of date headers)
25
+ // through the headers into the chart, ending at `bottomY`.
26
+ const line = tag('line', {
27
+ x1: num(n.x),
28
+ y1: num(n.topY),
29
+ x2: num(n.x),
30
+ y2: num(n.bottomY),
31
+ stroke: color,
32
+ 'stroke-width': NOWLINE_STROKE_WIDTH_PX,
33
+ });
34
+ // Pill — sits above the date headers at `pillTopY`. Three modes
35
+ // (decided by layout in `buildNowline`):
36
+ // - center → rounded rect centered on the line, label `middle`
37
+ // - flag-right → squared LEFT, rounded RIGHT, line at left edge,
38
+ // label `start` past the line
39
+ // - flag-left → rounded LEFT, squared RIGHT, line at right edge,
40
+ // label `end` before the line
41
+ // The squared edge IS the line; the rounded edge points into the
42
+ // chart, so the pill always hugs the line and never overflows.
43
+ const pillBg = renderNowPillBg(n, color);
44
+ const label = renderNowPillLabel(n, labelTextColor, fonts);
45
+ return tag('g', { 'data-layer': 'nowline' }, line + pillBg + label);
46
+ }
47
+
48
+ /**
49
+ * X coordinate of the pill's squared edge in flag modes. SVG strokes
50
+ * are centered on their geometry, so a 2.25 px line at `n.x` actually
51
+ * paints from `n.x - 1.125` to `n.x + 1.125`. To make the pill's
52
+ * squared edge line up with the OUTER edge of the line stroke (so
53
+ * the line and the pill share a single continuous edge instead of
54
+ * the line peeking past the pill by half-stroke), we offset by
55
+ * `NOWLINE_STROKE_WIDTH_PX / 2` on the side the line is on.
56
+ *
57
+ * flag-right: line on the LEFT → squared edge at n.x - half-stroke
58
+ * flag-left: line on the RIGHT → squared edge at n.x + half-stroke
59
+ *
60
+ * Center mode doesn't apply — the line passes through the pill's
61
+ * vertical center, so a half-stroke offset would make things worse.
62
+ */
63
+ function squaredEdgeX(n: PositionedNowline): number {
64
+ const halfStroke = NOWLINE_STROKE_WIDTH_PX / 2;
65
+ if (n.pillMode === 'flag-right') return n.x - halfStroke;
66
+ if (n.pillMode === 'flag-left') return n.x + halfStroke;
67
+ return n.x;
68
+ }
69
+
70
+ function renderNowPillBg(n: PositionedNowline, color: string): string {
71
+ const top = n.pillTopY;
72
+ const bottom = n.pillTopY + NOW_PILL_HEIGHT_PX;
73
+ const r = NOW_PILL_CORNER_RADIUS_PX;
74
+ // Pill width comes from the layout (`n.pillWidth`) — locale-aware,
75
+ // floored at `NOW_PILL_WIDTH_PX` so en-US output stays byte-stable
76
+ // and longer locale strings (e.g. fr `'maint.'`) grow the pill
77
+ // instead of clipping. See `buildNowline` in `@nowline/layout`.
78
+ const width = n.pillWidth;
79
+ if (n.pillMode === 'center') {
80
+ return tag('rect', {
81
+ x: num(n.x - width / 2),
82
+ y: num(top),
83
+ width: num(width),
84
+ height: num(NOW_PILL_HEIGHT_PX),
85
+ rx: r,
86
+ ry: r,
87
+ fill: color,
88
+ });
89
+ }
90
+ const edgeX = squaredEdgeX(n);
91
+ if (n.pillMode === 'flag-right') {
92
+ // Squared LEFT edge aligns with line's left outer stroke edge,
93
+ // rounded corners on the RIGHT.
94
+ const right = edgeX + width;
95
+ const d = [
96
+ `M ${num(edgeX)} ${num(top)}`,
97
+ `L ${num(right - r)} ${num(top)}`,
98
+ `A ${r} ${r} 0 0 1 ${num(right)} ${num(top + r)}`,
99
+ `L ${num(right)} ${num(bottom - r)}`,
100
+ `A ${r} ${r} 0 0 1 ${num(right - r)} ${num(bottom)}`,
101
+ `L ${num(edgeX)} ${num(bottom)}`,
102
+ 'Z',
103
+ ].join(' ');
104
+ return tag('path', { d, fill: color });
105
+ }
106
+ // flag-left: squared RIGHT edge aligns with line's right outer
107
+ // stroke edge, rounded corners on the LEFT.
108
+ const left = edgeX - width;
109
+ const d = [
110
+ `M ${num(edgeX)} ${num(top)}`,
111
+ `L ${num(left + r)} ${num(top)}`,
112
+ `A ${r} ${r} 0 0 0 ${num(left)} ${num(top + r)}`,
113
+ `L ${num(left)} ${num(bottom - r)}`,
114
+ `A ${r} ${r} 0 0 0 ${num(left + r)} ${num(bottom)}`,
115
+ `L ${num(edgeX)} ${num(bottom)}`,
116
+ 'Z',
117
+ ].join(' ');
118
+ return tag('path', { d, fill: color });
119
+ }
120
+
121
+ function renderNowPillLabel(
122
+ n: PositionedNowline,
123
+ labelTextColor: string,
124
+ fonts: FontFamilies,
125
+ ): string {
126
+ const baselineY = n.pillTopY + NOW_PILL_LABEL_BASELINE_OFFSET_PX;
127
+ const edgeX = squaredEdgeX(n);
128
+ let labelX: number;
129
+ let textAnchor: 'start' | 'middle' | 'end';
130
+ if (n.pillMode === 'center') {
131
+ labelX = n.x;
132
+ textAnchor = 'middle';
133
+ } else if (n.pillMode === 'flag-right') {
134
+ labelX = edgeX + NOW_PILL_LABEL_INSET_X_PX;
135
+ textAnchor = 'start';
136
+ } else {
137
+ labelX = edgeX - NOW_PILL_LABEL_INSET_X_PX;
138
+ textAnchor = 'end';
139
+ }
140
+ return textTag(
141
+ {
142
+ x: num(labelX),
143
+ y: num(baselineY),
144
+ 'font-family': fonts.sans,
145
+ 'font-size': NOW_PILL_LABEL_FONT_SIZE_PX,
146
+ 'font-weight': 700,
147
+ fill: labelTextColor,
148
+ 'text-anchor': textAnchor,
149
+ },
150
+ n.label,
151
+ );
152
+ }
@@ -0,0 +1,86 @@
1
+ // Shared drawing primitives: the typography and stroke helpers every layer of
2
+ // the SVG renderer draws with, plus the rectangle frame and the box
3
+ // intersection the item, wave and non-working-band layers share. Nothing here
4
+ // knows about a particular roadmap element.
5
+
6
+ import type { BoundingBox, ResolvedStyle } from '@nowline/layout';
7
+ import { CORNER_RADIUS_PX, TEXT_SIZE_PX } from '@nowline/layout';
8
+ import type { FontFamilies } from './render.js';
9
+ import { num, tag } from './xml.js';
10
+
11
+ // `TEXT_SIZE_PX`, `CORNER_RADIUS_PX`, `FONT_STACK` come from
12
+ // `@nowline/layout` (themes/shared) so a typography or radius change
13
+ // flows from one place to both layout and renderer.
14
+ //
15
+ // `WEIGHT_NUM` lives here only because no DSL `weight` table exists in
16
+ // shared yet. If/when it does, hoist this alongside `FONT_STACK`.
17
+ const WEIGHT_NUM: Record<string, number> = {
18
+ thin: 100,
19
+ light: 300,
20
+ normal: 400,
21
+ bold: 700,
22
+ };
23
+
24
+ // `style.textSize` is `SizeBucket` which includes `'full'`; the shared
25
+ // `TEXT_SIZE_PX` table only carries the size buckets (no `'full'` —
26
+ // that's a corner-radius-only value). Widen the lookup so a stray
27
+ // `'full'` falls through to the `?? 14` fallback instead of compiling.
28
+ function textSizePx(bucket: ResolvedStyle['textSize']): number {
29
+ return (TEXT_SIZE_PX as Record<string, number>)[bucket] ?? 14;
30
+ }
31
+
32
+ export function fontAttrs(
33
+ style: ResolvedStyle,
34
+ fonts: FontFamilies,
35
+ overrideSize?: number,
36
+ ): Record<string, string | number> {
37
+ return {
38
+ 'font-family': fonts[style.font],
39
+ 'font-size': overrideSize ?? textSizePx(style.textSize),
40
+ 'font-weight': WEIGHT_NUM[style.weight] ?? 400,
41
+ 'font-style': style.italic ? 'italic' : 'normal',
42
+ fill: style.text,
43
+ };
44
+ }
45
+
46
+ function strokeDash(style: ResolvedStyle): string | undefined {
47
+ if (style.border === 'dashed') return '4 3';
48
+ if (style.border === 'dotted') return '1 3';
49
+ return undefined;
50
+ }
51
+
52
+ export function rectFrame(
53
+ x: number,
54
+ y: number,
55
+ w: number,
56
+ h: number,
57
+ style: ResolvedStyle,
58
+ extra: Record<string, string | number | undefined | null | boolean> = {},
59
+ ): string {
60
+ // The radius is a maximum: a frame narrower than two radii, such as a
61
+ // short bar drawn at its true width, rounds into a pill, then a line.
62
+ const rx = Math.min(CORNER_RADIUS_PX[style.cornerRadius] ?? 4, h / 2, w / 2);
63
+ return tag('rect', {
64
+ x: num(x),
65
+ y: num(y),
66
+ width: num(w),
67
+ height: num(h),
68
+ rx: num(rx),
69
+ ry: num(rx),
70
+ fill: style.bg === 'none' ? 'transparent' : style.bg,
71
+ stroke: style.fg,
72
+ 'stroke-width': 1,
73
+ 'stroke-dasharray': strokeDash(style) ?? null,
74
+ ...extra,
75
+ });
76
+ }
77
+
78
+ /** `a ∩ b`, or undefined when they do not overlap. */
79
+ export function intersectBox(a: BoundingBox, b: BoundingBox): BoundingBox | undefined {
80
+ const x1 = Math.max(a.x, b.x);
81
+ const y1 = Math.max(a.y, b.y);
82
+ const x2 = Math.min(a.x + a.width, b.x + b.width);
83
+ const y2 = Math.min(a.y + a.height, b.y + b.height);
84
+ if (x2 <= x1 || y2 <= y1) return undefined;
85
+ return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
86
+ }