@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.
- package/dist/cli.cjs +89 -130
- package/dist/index.cjs +1202 -993
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +216 -114
- package/dist/index.d.ts +216 -114
- package/dist/index.js +1211 -985
- package/dist/index.js.map +1 -1
- package/docs/language-reference.md +73 -0
- package/package.json +22 -9
- package/src/boxes-and-lines/parser.ts +8 -3
- package/src/c4/parser.ts +8 -7
- package/src/class/parser.ts +6 -0
- package/src/cli.ts +1 -9
- package/src/d3.ts +16 -234
- package/src/dgmo-router.ts +97 -5
- package/src/diagnostics.ts +16 -6
- package/src/echarts.ts +43 -10
- package/src/er/parser.ts +22 -2
- package/src/gantt/renderer.ts +153 -91
- package/src/graph/flowchart-parser.ts +89 -52
- package/src/graph/state-parser.ts +60 -35
- package/src/index.ts +23 -18
- package/src/infra/parser.ts +9 -2
- package/src/kanban/renderer.ts +2 -2
- package/src/palettes/color-utils.ts +4 -12
- package/src/palettes/index.ts +0 -4
- package/src/render.ts +30 -16
- package/src/sequence/collapse.ts +169 -0
- package/src/sequence/parser.ts +21 -4
- package/src/sequence/renderer.ts +198 -52
- package/src/sharing.ts +86 -49
- package/src/sitemap/renderer.ts +1 -6
- package/src/utils/arrows.ts +180 -11
- package/src/utils/d3-types.ts +4 -0
- package/src/utils/legend-constants.ts +11 -4
- package/src/utils/legend-d3.ts +171 -0
- package/src/utils/legend-layout.ts +140 -13
- package/src/utils/legend-types.ts +45 -0
- package/src/utils/time-ticks.ts +213 -0
- package/src/branding.ts +0 -67
- package/src/dgmo-mermaid.ts +0 -262
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|