@diagrammo/dgmo 0.8.18 → 0.8.20

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 (42) hide show
  1. package/dist/cli.cjs +89 -130
  2. package/dist/index.cjs +1202 -993
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +216 -114
  5. package/dist/index.d.ts +216 -114
  6. package/dist/index.js +1211 -985
  7. package/dist/index.js.map +1 -1
  8. package/docs/language-reference.md +73 -0
  9. package/package.json +22 -9
  10. package/src/boxes-and-lines/parser.ts +8 -3
  11. package/src/c4/parser.ts +8 -7
  12. package/src/class/parser.ts +6 -0
  13. package/src/cli.ts +1 -9
  14. package/src/d3.ts +16 -234
  15. package/src/dgmo-router.ts +97 -5
  16. package/src/diagnostics.ts +16 -6
  17. package/src/echarts.ts +43 -10
  18. package/src/er/parser.ts +22 -2
  19. package/src/gantt/renderer.ts +153 -91
  20. package/src/graph/flowchart-parser.ts +89 -52
  21. package/src/graph/state-parser.ts +60 -35
  22. package/src/index.ts +23 -18
  23. package/src/infra/parser.ts +9 -2
  24. package/src/kanban/renderer.ts +2 -2
  25. package/src/palettes/color-utils.ts +4 -12
  26. package/src/palettes/index.ts +0 -4
  27. package/src/render.ts +30 -16
  28. package/src/sequence/collapse.ts +169 -0
  29. package/src/sequence/parser.ts +21 -4
  30. package/src/sequence/renderer.ts +198 -52
  31. package/src/sharing.ts +86 -49
  32. package/src/sitemap/renderer.ts +1 -6
  33. package/src/utils/arrows.ts +180 -11
  34. package/src/utils/d3-types.ts +4 -0
  35. package/src/utils/legend-constants.ts +11 -4
  36. package/src/utils/legend-d3.ts +171 -0
  37. package/src/utils/legend-layout.ts +140 -13
  38. package/src/utils/legend-types.ts +45 -0
  39. package/src/utils/time-ticks.ts +213 -0
  40. package/src/branding.ts +0 -67
  41. package/src/dgmo-mermaid.ts +0 -262
  42. package/src/palettes/mermaid-bridge.ts +0 -220
@@ -14,6 +14,8 @@ import {
14
14
  LEGEND_ENTRY_TRAIL,
15
15
  LEGEND_GROUP_GAP,
16
16
  LEGEND_MAX_ENTRY_ROWS,
17
+ LEGEND_GEAR_PILL_W,
18
+ LEGEND_TOGGLE_DOT_R,
17
19
  measureLegendText,
18
20
  } from './legend-constants';
19
21
 
@@ -27,6 +29,8 @@ import type {
27
29
  LegendControlLayout,
28
30
  LegendEntryLayout,
29
31
  LegendControl,
32
+ ControlsGroupLayout,
33
+ ControlsGroupToggleLayout,
30
34
  } from './legend-types';
31
35
 
32
36
  // ── Constants ───────────────────────────────────────────────
@@ -140,6 +144,89 @@ function capsuleWidth(
140
144
  };
141
145
  }
142
146
 
147
+ // ── Controls group layout helpers ───────────────────────────
148
+
149
+ export function controlsGroupCapsuleWidth(
150
+ toggles: Array<{ label: string }>
151
+ ): number {
152
+ let w = LEGEND_CAPSULE_PAD * 2 + LEGEND_GEAR_PILL_W + 4;
153
+ for (const t of toggles) {
154
+ w +=
155
+ LEGEND_TOGGLE_DOT_R * 2 +
156
+ LEGEND_ENTRY_DOT_GAP +
157
+ measureLegendText(t.label, LEGEND_ENTRY_FONT_SIZE) +
158
+ LEGEND_ENTRY_TRAIL;
159
+ }
160
+ return w;
161
+ }
162
+
163
+ function buildControlsGroupLayout(
164
+ config: LegendConfig,
165
+ state: LegendState
166
+ ): ControlsGroupLayout | undefined {
167
+ const cg = config.controlsGroup;
168
+ if (!cg || cg.toggles.length === 0) return undefined;
169
+
170
+ const expanded = !!state.controlsExpanded;
171
+ const pillH = LEGEND_HEIGHT - LEGEND_CAPSULE_PAD * 2;
172
+
173
+ if (!expanded) {
174
+ // Collapsed: just a gear pill
175
+ return {
176
+ x: 0,
177
+ y: 0,
178
+ width: LEGEND_GEAR_PILL_W,
179
+ height: LEGEND_HEIGHT,
180
+ expanded: false,
181
+ pill: { x: 0, y: 0, width: LEGEND_GEAR_PILL_W, height: LEGEND_HEIGHT },
182
+ toggles: [],
183
+ };
184
+ }
185
+
186
+ // Expanded capsule
187
+ const capsuleW = controlsGroupCapsuleWidth(cg.toggles);
188
+ const toggleLayouts: ControlsGroupToggleLayout[] = [];
189
+ let tx = LEGEND_CAPSULE_PAD + LEGEND_GEAR_PILL_W + 4;
190
+
191
+ for (const toggle of cg.toggles) {
192
+ const dotCx = tx + LEGEND_TOGGLE_DOT_R;
193
+ const dotCy = LEGEND_HEIGHT / 2;
194
+ const textX = tx + LEGEND_TOGGLE_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP;
195
+ const textY = LEGEND_HEIGHT / 2;
196
+
197
+ toggleLayouts.push({
198
+ id: toggle.id,
199
+ label: toggle.label,
200
+ active: toggle.active,
201
+ dotCx,
202
+ dotCy,
203
+ textX,
204
+ textY,
205
+ });
206
+
207
+ tx +=
208
+ LEGEND_TOGGLE_DOT_R * 2 +
209
+ LEGEND_ENTRY_DOT_GAP +
210
+ measureLegendText(toggle.label, LEGEND_ENTRY_FONT_SIZE) +
211
+ LEGEND_ENTRY_TRAIL;
212
+ }
213
+
214
+ return {
215
+ x: 0,
216
+ y: 0,
217
+ width: capsuleW,
218
+ height: LEGEND_HEIGHT,
219
+ expanded: true,
220
+ pill: {
221
+ x: LEGEND_CAPSULE_PAD,
222
+ y: LEGEND_CAPSULE_PAD,
223
+ width: LEGEND_GEAR_PILL_W - LEGEND_CAPSULE_PAD * 2,
224
+ height: pillH,
225
+ },
226
+ toggles: toggleLayouts,
227
+ };
228
+ }
229
+
143
230
  // ── Main layout computation ─────────────────────────────────
144
231
 
145
232
  export function computeLegendLayout(
@@ -153,7 +240,7 @@ export function computeLegendLayout(
153
240
  // Filter groups for export: only active group shown
154
241
  const activeGroupName = state.activeGroup?.toLowerCase() ?? null;
155
242
 
156
- // In export mode with no active group, no legend
243
+ // In export mode with no active group and no groups, no legend
157
244
  if (isExport && !activeGroupName) {
158
245
  return {
159
246
  height: 0,
@@ -165,12 +252,18 @@ export function computeLegendLayout(
165
252
  };
166
253
  }
167
254
 
255
+ // Controls group (strip in export mode)
256
+ const controlsGroupLayout = isExport
257
+ ? undefined
258
+ : buildControlsGroupLayout(config, state);
259
+
168
260
  const visibleGroups = config.showEmptyGroups
169
261
  ? groups
170
262
  : groups.filter((g) => g.entries.length > 0);
171
263
  if (
172
264
  visibleGroups.length === 0 &&
173
- (!configControls || configControls.length === 0)
265
+ (!configControls || configControls.length === 0) &&
266
+ !controlsGroupLayout
174
267
  ) {
175
268
  return {
176
269
  height: 0,
@@ -238,10 +331,13 @@ export function computeLegendLayout(
238
331
  if (totalControlsW > 0) totalControlsW -= CONTROL_GAP;
239
332
  }
240
333
 
241
- // Available width for tag groups (controls anchor right)
334
+ // Available width for tag groups (controls anchor right, gear pill at end of pills)
242
335
  const controlsSpace =
243
336
  totalControlsW > 0 ? totalControlsW + LEGEND_GROUP_GAP * 2 : 0;
244
- const groupAvailW = containerWidth - controlsSpace;
337
+ const gearSpace = controlsGroupLayout
338
+ ? controlsGroupLayout.width + LEGEND_GROUP_GAP
339
+ : 0;
340
+ const groupAvailW = containerWidth - controlsSpace - gearSpace;
245
341
 
246
342
  // Build pill/capsule layouts
247
343
  const pills: LegendPillLayout[] = [];
@@ -256,7 +352,7 @@ export function computeLegendLayout(
256
352
  if (isActive) {
257
353
  activeCapsule = buildCapsuleLayout(
258
354
  g,
259
- containerWidth,
355
+ groupAvailW,
260
356
  config.capsulePillAddonWidth ?? 0
261
357
  );
262
358
  } else {
@@ -281,7 +377,8 @@ export function computeLegendLayout(
281
377
  groupAvailW,
282
378
  containerWidth,
283
379
  totalControlsW,
284
- alignLeft
380
+ alignLeft,
381
+ controlsGroupLayout
285
382
  );
286
383
 
287
384
  const height = rows.length * LEGEND_HEIGHT;
@@ -294,6 +391,7 @@ export function computeLegendLayout(
294
391
  activeCapsule,
295
392
  controls: controlLayouts,
296
393
  pills,
394
+ controlsGroup: controlsGroupLayout,
297
395
  };
298
396
  }
299
397
 
@@ -386,7 +484,8 @@ function layoutRows(
386
484
  groupAvailW: number,
387
485
  containerWidth: number,
388
486
  totalControlsW: number,
389
- alignLeft = false
487
+ alignLeft = false,
488
+ controlsGroup?: ControlsGroupLayout
390
489
  ): Array<{
391
490
  y: number;
392
491
  items: Array<LegendPillLayout | LegendCapsuleLayout | LegendControlLayout>;
@@ -401,6 +500,9 @@ function layoutRows(
401
500
  if (activeCapsule) groupItems.push(activeCapsule);
402
501
  groupItems.push(...pills);
403
502
 
503
+ // Controls group width for centering offset
504
+ const gearW = controlsGroup ? controlsGroup.width + LEGEND_GROUP_GAP : 0;
505
+
404
506
  // Compute total group items width
405
507
  let currentRowItems: Array<
406
508
  LegendPillLayout | LegendCapsuleLayout | LegendControlLayout
@@ -411,9 +513,16 @@ function layoutRows(
411
513
  for (const item of groupItems) {
412
514
  const itemW = item.width + LEGEND_GROUP_GAP;
413
515
  if (currentRowW + item.width > groupAvailW && currentRowItems.length > 0) {
414
- // Commit current row
415
- if (!alignLeft)
416
- centerRowItems(currentRowItems, containerWidth, totalControlsW);
516
+ // Commit current row (row 0 needs gear space deducted for centering)
517
+ if (!alignLeft) {
518
+ const rowGearW = rows.length === 0 ? gearW : 0;
519
+ centerRowItems(
520
+ currentRowItems,
521
+ containerWidth,
522
+ totalControlsW,
523
+ rowGearW
524
+ );
525
+ }
417
526
  rows.push({ y: rowY, items: currentRowItems });
418
527
  rowY += LEGEND_HEIGHT;
419
528
  currentRowItems = [];
@@ -447,10 +556,26 @@ function layoutRows(
447
556
 
448
557
  // Commit last row
449
558
  if (currentRowItems.length > 0) {
450
- centerRowItems(currentRowItems, containerWidth, totalControlsW);
559
+ centerRowItems(currentRowItems, containerWidth, totalControlsW, gearW);
451
560
  rows.push({ y: rowY, items: currentRowItems });
452
561
  }
453
562
 
563
+ // Position controls group AFTER centering so it follows the shifted items
564
+ if (controlsGroup) {
565
+ const row0Items = rows[0]?.items ?? [];
566
+ const groupItemsInRow0 = row0Items.filter(
567
+ (it) => 'groupName' in it
568
+ ) as Array<LegendPillLayout | LegendCapsuleLayout>;
569
+ if (groupItemsInRow0.length > 0) {
570
+ const last = groupItemsInRow0[groupItemsInRow0.length - 1];
571
+ controlsGroup.x = last.x + last.width + LEGEND_GROUP_GAP;
572
+ } else {
573
+ // No group items — controls group at start
574
+ controlsGroup.x = 0;
575
+ }
576
+ controlsGroup.y = 0;
577
+ }
578
+
454
579
  // Ensure at least one row height
455
580
  if (rows.length === 0) {
456
581
  rows.push({ y: 0, items: [] });
@@ -462,7 +587,8 @@ function layoutRows(
462
587
  function centerRowItems(
463
588
  items: Array<LegendPillLayout | LegendCapsuleLayout | LegendControlLayout>,
464
589
  containerWidth: number,
465
- totalControlsW: number
590
+ totalControlsW: number,
591
+ controlsGroupW = 0
466
592
  ): void {
467
593
  // Only center group items (pills and capsules), not controls
468
594
  const groupItems = items.filter((it) => 'groupName' in it) as Array<
@@ -477,7 +603,8 @@ function centerRowItems(
477
603
 
478
604
  const availW =
479
605
  containerWidth -
480
- (totalControlsW > 0 ? totalControlsW + LEGEND_GROUP_GAP * 2 : 0);
606
+ (totalControlsW > 0 ? totalControlsW + LEGEND_GROUP_GAP * 2 : 0) -
607
+ controlsGroupW;
481
608
  const offset = Math.max(0, (availW - totalGroupW) / 2);
482
609
 
483
610
  let x = offset;
@@ -9,6 +9,7 @@ import type { Selection } from 'd3-selection';
9
9
  export interface LegendState {
10
10
  activeGroup: string | null;
11
11
  hiddenAttributes?: Set<string>;
12
+ controlsExpanded?: boolean;
12
13
  }
13
14
 
14
15
  export interface LegendCallbacks {
@@ -23,6 +24,10 @@ export interface LegendCallbacks {
23
24
  groupEl: D3Sel,
24
25
  isActive: boolean
25
26
  ) => void;
27
+ /** Called when the controls group gear pill is clicked (expand/collapse) */
28
+ onControlsExpand?: () => void;
29
+ /** Called when a controls group toggle entry is clicked */
30
+ onControlsToggle?: (toggleId: string, active: boolean) => void;
26
31
  }
27
32
 
28
33
  // ── Position & Layout ───────────────────────────────────────
@@ -53,12 +58,28 @@ export interface LegendControlEntry {
53
58
  onClick?: () => void;
54
59
  }
55
60
 
61
+ // ── Controls Group ─────────────────────────────────────────
62
+
63
+ export interface ControlsGroupToggle {
64
+ id: string;
65
+ /** Only 'toggle' is implemented in v1. 'select' and 'action' future-proof for Infra playback etc. */
66
+ type: 'toggle' | 'select' | 'action';
67
+ label: string;
68
+ active: boolean;
69
+ onToggle: (active: boolean) => void;
70
+ }
71
+
72
+ export interface ControlsGroupConfig {
73
+ toggles: ControlsGroupToggle[];
74
+ }
75
+
56
76
  // ── Config ──────────────────────────────────────────────────
57
77
 
58
78
  export interface LegendConfig {
59
79
  groups: import('./legend-svg').LegendGroupData[];
60
80
  position: LegendPosition;
61
81
  controls?: LegendControl[];
82
+ controlsGroup?: ControlsGroupConfig;
62
83
  mode: LegendMode;
63
84
  /** Title width in pixels — used for inline-with-title computation */
64
85
  titleWidth?: number;
@@ -131,6 +152,28 @@ export interface LegendControlLayout {
131
152
  }>;
132
153
  }
133
154
 
155
+ export interface ControlsGroupToggleLayout {
156
+ id: string;
157
+ label: string;
158
+ active: boolean;
159
+ dotCx: number;
160
+ dotCy: number;
161
+ textX: number;
162
+ textY: number;
163
+ }
164
+
165
+ export interface ControlsGroupLayout {
166
+ x: number;
167
+ y: number;
168
+ width: number;
169
+ height: number;
170
+ expanded: boolean;
171
+ /** The gear pill layout (collapsed or inside capsule) */
172
+ pill: { x: number; y: number; width: number; height: number };
173
+ /** Toggle entries (only present when expanded) */
174
+ toggles: ControlsGroupToggleLayout[];
175
+ }
176
+
134
177
  export interface LegendRowLayout {
135
178
  y: number;
136
179
  items: Array<LegendPillLayout | LegendCapsuleLayout | LegendControlLayout>;
@@ -149,6 +192,8 @@ export interface LegendLayout {
149
192
  controls: LegendControlLayout[];
150
193
  /** All pill layouts (collapsed groups) */
151
194
  pills: LegendPillLayout[];
195
+ /** Controls group layout (gear pill / capsule) */
196
+ controlsGroup?: ControlsGroupLayout;
152
197
  }
153
198
 
154
199
  // ── Handle ──────────────────────────────────────────────────
@@ -0,0 +1,213 @@
1
+ // ============================================================
2
+ // Time axis tick computation — shared by d3.ts and gantt/renderer.ts
3
+ // ============================================================
4
+
5
+ import * as d3Scale from 'd3-scale';
6
+
7
+ export const MONTH_ABBR = [
8
+ 'Jan',
9
+ 'Feb',
10
+ 'Mar',
11
+ 'Apr',
12
+ 'May',
13
+ 'Jun',
14
+ 'Jul',
15
+ 'Aug',
16
+ 'Sep',
17
+ 'Oct',
18
+ 'Nov',
19
+ 'Dec',
20
+ ];
21
+
22
+ function fractionalYearToDate(frac: number): Date {
23
+ const year = Math.floor(frac);
24
+ const remainder = frac - year;
25
+ // Inverse of: (month-1)/12 + (day-1)/365 + hour/8760 + minute/525600
26
+ const monthFrac = remainder * 12;
27
+ const month = Math.floor(monthFrac); // 0-based
28
+ const monthRemainder = remainder - month / 12;
29
+ const dayFrac = monthRemainder * 365; // fractional day-of-year offset
30
+ const day = Math.floor(dayFrac) + 1;
31
+ const dayRemainder = dayFrac - Math.floor(dayFrac);
32
+ const hourFrac = dayRemainder * 24;
33
+ const hour = Math.floor(hourFrac);
34
+ const minute = Math.round((hourFrac - hour) * 60);
35
+ return new Date(year, month, day, hour, minute);
36
+ }
37
+
38
+ /** Convert a Date to a fractional year number. */
39
+ function dateToFractionalYear(d: Date): number {
40
+ return (
41
+ d.getFullYear() +
42
+ d.getMonth() / 12 +
43
+ (d.getDate() - 1) / 365 +
44
+ d.getHours() / 8760 +
45
+ d.getMinutes() / 525600
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Generates adaptive tick marks along a time axis.
51
+ * Picks the right granularity (years, months, weeks, days, hours, minutes)
52
+ * based on the domain span.
53
+ *
54
+ * Optional boundary parameters add ticks at exact data start/end:
55
+ * - boundaryStart/boundaryEnd: numeric date values
56
+ * - boundaryStartLabel/boundaryEndLabel: formatted labels for those dates
57
+ */
58
+ export function computeTimeTicks(
59
+ domainMin: number,
60
+ domainMax: number,
61
+ scale: d3Scale.ScaleLinear<number, number>,
62
+ boundaryStart?: number,
63
+ boundaryEnd?: number,
64
+ boundaryStartLabel?: string,
65
+ boundaryEndLabel?: string
66
+ ): { pos: number; label: string }[] {
67
+ const minYear = Math.floor(domainMin);
68
+ const maxYear = Math.floor(domainMax);
69
+ const span = domainMax - domainMin;
70
+
71
+ let ticks: { pos: number; label: string }[] = [];
72
+
73
+ // Year ticks for multi-year spans (need at least 2 boundaries)
74
+ const firstYear = Math.ceil(domainMin);
75
+ const lastYear = Math.floor(domainMax);
76
+ if (lastYear >= firstYear + 1) {
77
+ // Decimate ticks for long spans so labels don't overlap
78
+ const yearSpan = lastYear - firstYear;
79
+ let step = 1;
80
+ if (yearSpan > 80) step = 20;
81
+ else if (yearSpan > 40) step = 10;
82
+ else if (yearSpan > 20) step = 5;
83
+ else if (yearSpan > 10) step = 2;
84
+
85
+ // Align to step boundary so ticks land on round years (1700, 1710, …)
86
+ const alignedFirst = Math.ceil(firstYear / step) * step;
87
+ for (let y = alignedFirst; y <= lastYear; y += step) {
88
+ ticks.push({ pos: scale(y), label: String(y) });
89
+ }
90
+ } else if (span > 0.25) {
91
+ // Month ticks for spans > ~3 months
92
+ const crossesYear = maxYear > minYear;
93
+ for (let y = minYear; y <= maxYear + 1; y++) {
94
+ for (let m = 1; m <= 12; m++) {
95
+ const val = y + (m - 1) / 12;
96
+ if (val > domainMax) break;
97
+ if (val >= domainMin) {
98
+ ticks.push({
99
+ pos: scale(val),
100
+ label: crossesYear
101
+ ? `${MONTH_ABBR[m - 1]} '${String(y).slice(-2)}`
102
+ : MONTH_ABBR[m - 1],
103
+ });
104
+ }
105
+ }
106
+ }
107
+ } else if (span <= 0.000685) {
108
+ // Minute ticks for spans ≤ ~6 hours
109
+ // Adaptive step: >3h → 30min, >1h → 15min, >30min → 10min, else 5min
110
+ let stepMin = 5;
111
+ const spanHours = span * 8760;
112
+ if (spanHours > 3) stepMin = 30;
113
+ else if (spanHours > 1) stepMin = 15;
114
+ else if (spanHours > 0.5) stepMin = 10;
115
+
116
+ // Iterate from the start hour boundary
117
+ const startDate = fractionalYearToDate(domainMin);
118
+ // Round down to nearest step boundary
119
+ startDate.setMinutes(
120
+ Math.floor(startDate.getMinutes() / stepMin) * stepMin,
121
+ 0,
122
+ 0
123
+ );
124
+
125
+ while (true) {
126
+ const val = dateToFractionalYear(startDate);
127
+ if (val > domainMax) break;
128
+ if (val >= domainMin) {
129
+ const hh = String(startDate.getHours()).padStart(2, '0');
130
+ const mm = String(startDate.getMinutes()).padStart(2, '0');
131
+ ticks.push({ pos: scale(val), label: `${hh}:${mm}` });
132
+ }
133
+ startDate.setMinutes(startDate.getMinutes() + stepMin);
134
+ }
135
+ } else if (span <= 0.00822) {
136
+ // Hour ticks for spans ≤ ~3 days
137
+ // Adaptive step: >2d → 6h, >1d → 3h, >12h → 2h, else 1h
138
+ let stepHour = 1;
139
+ const spanHours = span * 8760;
140
+ if (spanHours > 48) stepHour = 6;
141
+ else if (spanHours > 24) stepHour = 3;
142
+ else if (spanHours > 12) stepHour = 2;
143
+
144
+ // For single-day spans, just show HH:MM without the date prefix
145
+ const singleDay = spanHours <= 24;
146
+
147
+ const startDate = fractionalYearToDate(domainMin);
148
+ // Round down to nearest step boundary
149
+ startDate.setHours(
150
+ Math.floor(startDate.getHours() / stepHour) * stepHour,
151
+ 0,
152
+ 0,
153
+ 0
154
+ );
155
+
156
+ while (true) {
157
+ const val = dateToFractionalYear(startDate);
158
+ if (val > domainMax) break;
159
+ if (val >= domainMin) {
160
+ const hh = String(startDate.getHours()).padStart(2, '0');
161
+ const mm = String(startDate.getMinutes()).padStart(2, '0');
162
+ if (singleDay) {
163
+ ticks.push({ pos: scale(val), label: `${hh}:${mm}` });
164
+ } else {
165
+ const mon = MONTH_ABBR[startDate.getMonth()];
166
+ const d = startDate.getDate();
167
+ ticks.push({ pos: scale(val), label: `${mon} ${d} ${hh}:${mm}` });
168
+ }
169
+ }
170
+ startDate.setHours(startDate.getHours() + stepHour);
171
+ }
172
+ } else {
173
+ // Week ticks for spans ≤ ~3 months (1st, 8th, 15th, 22nd of each month)
174
+ for (let y = minYear; y <= maxYear + 1; y++) {
175
+ for (let m = 1; m <= 12; m++) {
176
+ for (const d of [1, 8, 15, 22]) {
177
+ const val = y + (m - 1) / 12 + (d - 1) / 365;
178
+ if (val > domainMax) break;
179
+ if (val >= domainMin) {
180
+ ticks.push({
181
+ pos: scale(val),
182
+ label: `${MONTH_ABBR[m - 1]} ${d}`,
183
+ });
184
+ }
185
+ }
186
+ }
187
+ }
188
+ }
189
+
190
+ // Add boundary ticks at exact data start/end if provided
191
+ // When a boundary tick collides with a standard tick, replace the standard tick
192
+ const collisionThreshold = 40; // pixels
193
+
194
+ if (boundaryStart !== undefined && boundaryStartLabel) {
195
+ const boundaryPos = scale(boundaryStart);
196
+ // Remove any standard ticks that would collide with the start boundary
197
+ ticks = ticks.filter(
198
+ (t) => Math.abs(t.pos - boundaryPos) >= collisionThreshold
199
+ );
200
+ ticks.unshift({ pos: boundaryPos, label: boundaryStartLabel });
201
+ }
202
+
203
+ if (boundaryEnd !== undefined && boundaryEndLabel) {
204
+ const boundaryPos = scale(boundaryEnd);
205
+ // Remove any standard ticks that would collide with the end boundary
206
+ ticks = ticks.filter(
207
+ (t) => Math.abs(t.pos - boundaryPos) >= collisionThreshold
208
+ );
209
+ ticks.push({ pos: boundaryPos, label: boundaryEndLabel });
210
+ }
211
+
212
+ return ticks;
213
+ }
package/src/branding.ts DELETED
@@ -1,67 +0,0 @@
1
- import { FONT_FAMILY } from './fonts';
2
-
3
- const BRANDING_HEIGHT = 20;
4
-
5
- /**
6
- * Injects `diagrammo.app` branding text into an SVG string.
7
- * Extends the SVG height by 20px and places the text at the bottom-right.
8
- */
9
- export function injectBranding(svgHtml: string, mutedColor: string): string {
10
- if (!svgHtml) return svgHtml;
11
-
12
- const brandingText = `<text x="0" y="0" font-size="10" font-family="${FONT_FAMILY}" fill="${mutedColor}" opacity="0.5" text-anchor="end">diagrammo.app</text>`;
13
-
14
- // Parse viewBox
15
- const vbMatch = svgHtml.match(/viewBox="([^"]+)"/);
16
- const heightAttrMatch = svgHtml.match(/height="([^"]+)"/);
17
-
18
- if (vbMatch) {
19
- const parts = vbMatch[1].split(/\s+/).map(Number);
20
- if (parts.length === 4) {
21
- const [vx, vy, vw, vh] = parts;
22
- const newVh = vh + BRANDING_HEIGHT;
23
- const textX = vx + vw - 4;
24
- const textY = vy + vh + BRANDING_HEIGHT - 6;
25
- const positioned = brandingText.replace('x="0" y="0"', `x="${textX}" y="${textY}"`);
26
-
27
- let result = svgHtml.replace(
28
- /viewBox="[^"]+"/,
29
- `viewBox="${vx} ${vy} ${vw} ${newVh}"`
30
- );
31
-
32
- // Update height attribute if present
33
- if (heightAttrMatch) {
34
- const oldH = parseFloat(heightAttrMatch[1]);
35
- if (!isNaN(oldH)) {
36
- result = result.replace(
37
- `height="${heightAttrMatch[1]}"`,
38
- `height="${oldH + BRANDING_HEIGHT}"`
39
- );
40
- }
41
- }
42
-
43
- result = result.replace('</svg>', `${positioned}</svg>`);
44
- return result;
45
- }
46
- }
47
-
48
- // Fallback: no viewBox, try width/height attributes
49
- if (heightAttrMatch) {
50
- const widthMatch = svgHtml.match(/width="([^"]+)"/);
51
- const w = widthMatch ? parseFloat(widthMatch[1]) : 800;
52
- const h = parseFloat(heightAttrMatch[1]);
53
- if (!isNaN(h) && !isNaN(w)) {
54
- const textX = w - 4;
55
- const textY = h + BRANDING_HEIGHT - 6;
56
- const positioned = brandingText.replace('x="0" y="0"', `x="${textX}" y="${textY}"`);
57
- let result = svgHtml.replace(
58
- `height="${heightAttrMatch[1]}"`,
59
- `height="${h + BRANDING_HEIGHT}"`
60
- );
61
- result = result.replace('</svg>', `${positioned}</svg>`);
62
- return result;
63
- }
64
- }
65
-
66
- return svgHtml;
67
- }