@diagrammo/dgmo 0.4.3 → 0.5.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/cli.cjs +152 -152
- package/dist/index.cjs +546 -154
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +25 -20
- package/dist/index.d.ts +25 -20
- package/dist/index.js +546 -154
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/chart.ts +5 -2
- package/src/d3.ts +388 -21
- package/src/echarts.ts +13 -12
- package/src/er/parser.ts +88 -3
- package/src/er/renderer.ts +91 -2
- package/src/er/types.ts +3 -0
- package/src/infra/layout.ts +6 -3
- package/src/infra/parser.ts +2 -2
- package/src/infra/renderer.ts +52 -8
- package/src/kanban/mutations.ts +1 -1
- package/src/kanban/parser.ts +55 -36
- package/src/sharing.ts +8 -0
package/src/er/parser.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { resolveColor } from '../colors';
|
|
2
2
|
import type { PaletteColors } from '../palettes';
|
|
3
3
|
import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
|
|
4
|
-
import { measureIndent } from '../utils/parsing';
|
|
4
|
+
import { measureIndent, extractColor, parsePipeMetadata } from '../utils/parsing';
|
|
5
|
+
import { matchTagBlockHeading, validateTagValues } from '../utils/tag-groups';
|
|
6
|
+
import type { TagGroup } from '../utils/tag-groups';
|
|
5
7
|
import type {
|
|
6
8
|
ParsedERDiagram,
|
|
7
9
|
ERTable,
|
|
@@ -22,9 +24,9 @@ function tableId(name: string): string {
|
|
|
22
24
|
// Regex patterns
|
|
23
25
|
// ============================================================
|
|
24
26
|
|
|
25
|
-
// Table declaration: table_name or table_name (color)
|
|
27
|
+
// Table declaration: table_name or table_name (color) or table_name | key: value
|
|
26
28
|
// Allows lowercase, uppercase, underscores, digits — must start with letter or underscore
|
|
27
|
-
const TABLE_DECL_RE = /^([a-zA-Z_]\w*)(?:\s
|
|
29
|
+
const TABLE_DECL_RE = /^([a-zA-Z_]\w*)(?:\s*\(([^)]+)\))?(?:\s*\|(.+))?$/;
|
|
28
30
|
|
|
29
31
|
// Column: name: type [constraints] or name [constraints] or name: type or name
|
|
30
32
|
const COLUMN_RE = /^(\w+)(?:\s*:\s*(\w[\w()]*(?:\s*\[\])?))?(?:\s+\[([^\]]+)\])?\s*$/;
|
|
@@ -143,6 +145,7 @@ export function parseERDiagram(
|
|
|
143
145
|
options: {},
|
|
144
146
|
tables: [],
|
|
145
147
|
relationships: [],
|
|
148
|
+
tagGroups: [],
|
|
146
149
|
diagnostics: [],
|
|
147
150
|
error: null,
|
|
148
151
|
};
|
|
@@ -163,6 +166,8 @@ export function parseERDiagram(
|
|
|
163
166
|
const tableMap = new Map<string, ERTable>();
|
|
164
167
|
let currentTable: ERTable | null = null;
|
|
165
168
|
let contentStarted = false;
|
|
169
|
+
let currentTagGroup: TagGroup | null = null;
|
|
170
|
+
const aliasMap = new Map<string, string>();
|
|
166
171
|
|
|
167
172
|
function getOrCreateTable(name: string, lineNumber: number): ERTable {
|
|
168
173
|
const id = tableId(name);
|
|
@@ -173,6 +178,7 @@ export function parseERDiagram(
|
|
|
173
178
|
id,
|
|
174
179
|
name,
|
|
175
180
|
columns: [],
|
|
181
|
+
metadata: {},
|
|
176
182
|
lineNumber,
|
|
177
183
|
};
|
|
178
184
|
tableMap.set(id, table);
|
|
@@ -195,6 +201,52 @@ export function parseERDiagram(
|
|
|
195
201
|
// Skip comments
|
|
196
202
|
if (trimmed.startsWith('//')) continue;
|
|
197
203
|
|
|
204
|
+
// Tag group heading — `tag: Name` or deprecated `## Name`
|
|
205
|
+
if (!contentStarted && indent === 0) {
|
|
206
|
+
const tagBlockMatch = matchTagBlockHeading(trimmed);
|
|
207
|
+
if (tagBlockMatch) {
|
|
208
|
+
if (tagBlockMatch.deprecated) {
|
|
209
|
+
result.diagnostics.push(makeDgmoError(lineNumber,
|
|
210
|
+
`'## ${tagBlockMatch.name}' is deprecated for tag groups — use 'tag: ${tagBlockMatch.name}' instead`, 'warning'));
|
|
211
|
+
}
|
|
212
|
+
currentTagGroup = {
|
|
213
|
+
name: tagBlockMatch.name,
|
|
214
|
+
alias: tagBlockMatch.alias,
|
|
215
|
+
entries: [],
|
|
216
|
+
lineNumber,
|
|
217
|
+
};
|
|
218
|
+
if (tagBlockMatch.alias) {
|
|
219
|
+
aliasMap.set(tagBlockMatch.alias.toLowerCase(), tagBlockMatch.name.toLowerCase());
|
|
220
|
+
}
|
|
221
|
+
result.tagGroups.push(currentTagGroup);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Tag group entries (indented under tag: heading)
|
|
227
|
+
if (currentTagGroup && !contentStarted && indent > 0) {
|
|
228
|
+
const isDefault = /\bdefault\s*$/.test(trimmed);
|
|
229
|
+
const entryText = isDefault
|
|
230
|
+
? trimmed.replace(/\s+default\s*$/, '').trim()
|
|
231
|
+
: trimmed;
|
|
232
|
+
const { label, color } = extractColor(entryText, palette);
|
|
233
|
+
if (!color) {
|
|
234
|
+
result.diagnostics.push(makeDgmoError(lineNumber,
|
|
235
|
+
`Expected 'Value(color)' in tag group '${currentTagGroup.name}'`, 'warning'));
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (isDefault) {
|
|
239
|
+
currentTagGroup.defaultValue = label;
|
|
240
|
+
}
|
|
241
|
+
currentTagGroup.entries.push({ value: label, color, lineNumber });
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// End tag group on non-indented line
|
|
246
|
+
if (currentTagGroup && indent === 0) {
|
|
247
|
+
currentTagGroup = null;
|
|
248
|
+
}
|
|
249
|
+
|
|
198
250
|
// Metadata directives (before content)
|
|
199
251
|
if (!contentStarted && indent === 0 && /^[a-z][a-z0-9-]*\s*:/i.test(trimmed)) {
|
|
200
252
|
const colonIdx = trimmed.indexOf(':');
|
|
@@ -296,6 +348,14 @@ export function parseERDiagram(
|
|
|
296
348
|
if (color) table.color = color;
|
|
297
349
|
table.lineNumber = lineNumber;
|
|
298
350
|
|
|
351
|
+
// Parse pipe metadata: TableName(color) | key: value, key2: value2
|
|
352
|
+
const pipeStr = tableDecl[3]?.trim();
|
|
353
|
+
if (pipeStr) {
|
|
354
|
+
// parsePipeMetadata skips index 0 (name segment), so prepend empty
|
|
355
|
+
const meta = parsePipeMetadata(['', pipeStr], aliasMap);
|
|
356
|
+
Object.assign(table.metadata, meta);
|
|
357
|
+
}
|
|
358
|
+
|
|
299
359
|
currentTable = table;
|
|
300
360
|
continue;
|
|
301
361
|
}
|
|
@@ -308,6 +368,31 @@ export function parseERDiagram(
|
|
|
308
368
|
result.error = formatDgmoError(diag);
|
|
309
369
|
}
|
|
310
370
|
|
|
371
|
+
// Validate tag values on tables
|
|
372
|
+
if (result.tagGroups.length > 0) {
|
|
373
|
+
const tagEntities = result.tables.map((t) => ({
|
|
374
|
+
metadata: t.metadata,
|
|
375
|
+
lineNumber: t.lineNumber,
|
|
376
|
+
}));
|
|
377
|
+
validateTagValues(
|
|
378
|
+
tagEntities,
|
|
379
|
+
result.tagGroups,
|
|
380
|
+
(line, msg) => result.diagnostics.push(makeDgmoError(line, msg, 'warning')),
|
|
381
|
+
suggest,
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
// Inject defaults for tables without explicit tags
|
|
385
|
+
for (const group of result.tagGroups) {
|
|
386
|
+
if (!group.defaultValue) continue;
|
|
387
|
+
const key = group.name.toLowerCase();
|
|
388
|
+
for (const table of result.tables) {
|
|
389
|
+
if (!table.metadata[key]) {
|
|
390
|
+
table.metadata[key] = group.defaultValue;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
311
396
|
// Warn about isolated tables (not in any relationship)
|
|
312
397
|
if (result.tables.length >= 2 && result.relationships.length >= 1 && !result.error) {
|
|
313
398
|
const connectedIds = new Set<string>();
|
package/src/er/renderer.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { FONT_FAMILY } from '../fonts';
|
|
|
8
8
|
import type { PaletteColors } from '../palettes';
|
|
9
9
|
import { mix } from '../palettes/color-utils';
|
|
10
10
|
import { getSeriesColors } from '../palettes';
|
|
11
|
+
import { resolveTagColor } from '../utils/tag-groups';
|
|
11
12
|
import type { ParsedERDiagram, ERConstraint } from './types';
|
|
12
13
|
import type { ERLayoutResult, ERLayoutNode, ERLayoutEdge } from './layout';
|
|
13
14
|
import { parseERDiagram } from './parser';
|
|
@@ -200,7 +201,8 @@ export function renderERDiagram(
|
|
|
200
201
|
palette: PaletteColors,
|
|
201
202
|
isDark: boolean,
|
|
202
203
|
onClickItem?: (lineNumber: number) => void,
|
|
203
|
-
exportDims?: { width?: number; height?: number }
|
|
204
|
+
exportDims?: { width?: number; height?: number },
|
|
205
|
+
activeTagGroup?: string | null
|
|
204
206
|
): void {
|
|
205
207
|
d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
|
|
206
208
|
|
|
@@ -338,7 +340,8 @@ export function renderERDiagram(
|
|
|
338
340
|
// ── Nodes (top layer) ──
|
|
339
341
|
for (let ni = 0; ni < layout.nodes.length; ni++) {
|
|
340
342
|
const node = layout.nodes[ni];
|
|
341
|
-
const
|
|
343
|
+
const tagColor = resolveTagColor(node.metadata, parsed.tagGroups, activeTagGroup ?? null);
|
|
344
|
+
const nodeColor = node.color ?? tagColor ?? seriesColors[ni % seriesColors.length];
|
|
342
345
|
|
|
343
346
|
const nodeG = contentG
|
|
344
347
|
.append('g')
|
|
@@ -347,6 +350,15 @@ export function renderERDiagram(
|
|
|
347
350
|
.attr('data-line-number', String(node.lineNumber))
|
|
348
351
|
.attr('data-node-id', node.id);
|
|
349
352
|
|
|
353
|
+
// Set data-tag-* attributes for legend hover
|
|
354
|
+
if (activeTagGroup) {
|
|
355
|
+
const tagKey = activeTagGroup.toLowerCase();
|
|
356
|
+
const tagValue = node.metadata[tagKey];
|
|
357
|
+
if (tagValue) {
|
|
358
|
+
nodeG.attr(`data-tag-${tagKey}`, tagValue.toLowerCase());
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
350
362
|
if (onClickItem) {
|
|
351
363
|
nodeG.style('cursor', 'pointer').on('click', () => {
|
|
352
364
|
onClickItem(node.lineNumber);
|
|
@@ -430,6 +442,83 @@ export function renderERDiagram(
|
|
|
430
442
|
}
|
|
431
443
|
}
|
|
432
444
|
}
|
|
445
|
+
|
|
446
|
+
// ── Tag Legend ──
|
|
447
|
+
if (parsed.tagGroups.length > 0) {
|
|
448
|
+
const LEGEND_Y_PAD = 16;
|
|
449
|
+
const LEGEND_PILL_H = 22;
|
|
450
|
+
const LEGEND_PILL_RX = 11;
|
|
451
|
+
const LEGEND_PILL_PAD = 10;
|
|
452
|
+
const LEGEND_GAP = 8;
|
|
453
|
+
const LEGEND_FONT_SIZE = 11;
|
|
454
|
+
const LEGEND_GROUP_GAP = 16;
|
|
455
|
+
|
|
456
|
+
const legendG = svg.append('g')
|
|
457
|
+
.attr('class', 'er-tag-legend');
|
|
458
|
+
|
|
459
|
+
let legendX = DIAGRAM_PADDING;
|
|
460
|
+
let legendY = height - DIAGRAM_PADDING;
|
|
461
|
+
|
|
462
|
+
for (const group of parsed.tagGroups) {
|
|
463
|
+
const groupG = legendG.append('g')
|
|
464
|
+
.attr('data-legend-group', group.name.toLowerCase());
|
|
465
|
+
|
|
466
|
+
// Group label
|
|
467
|
+
const labelText = groupG.append('text')
|
|
468
|
+
.attr('x', legendX)
|
|
469
|
+
.attr('y', legendY + LEGEND_PILL_H / 2)
|
|
470
|
+
.attr('dominant-baseline', 'central')
|
|
471
|
+
.attr('fill', palette.textMuted)
|
|
472
|
+
.attr('font-size', LEGEND_FONT_SIZE)
|
|
473
|
+
.attr('font-family', FONT_FAMILY)
|
|
474
|
+
.text(`${group.name}:`);
|
|
475
|
+
|
|
476
|
+
const labelWidth = (labelText.node()?.getComputedTextLength?.() ?? group.name.length * 7) + 6;
|
|
477
|
+
legendX += labelWidth;
|
|
478
|
+
|
|
479
|
+
// Entries
|
|
480
|
+
for (const entry of group.entries) {
|
|
481
|
+
const pillG = groupG.append('g')
|
|
482
|
+
.attr('data-legend-entry', entry.value.toLowerCase())
|
|
483
|
+
.style('cursor', 'pointer');
|
|
484
|
+
|
|
485
|
+
// Estimate text width
|
|
486
|
+
const tmpText = legendG.append('text')
|
|
487
|
+
.attr('font-size', LEGEND_FONT_SIZE)
|
|
488
|
+
.attr('font-family', FONT_FAMILY)
|
|
489
|
+
.text(entry.value);
|
|
490
|
+
const textW = tmpText.node()?.getComputedTextLength?.() ?? entry.value.length * 7;
|
|
491
|
+
tmpText.remove();
|
|
492
|
+
|
|
493
|
+
const pillW = textW + LEGEND_PILL_PAD * 2;
|
|
494
|
+
|
|
495
|
+
pillG.append('rect')
|
|
496
|
+
.attr('x', legendX)
|
|
497
|
+
.attr('y', legendY)
|
|
498
|
+
.attr('width', pillW)
|
|
499
|
+
.attr('height', LEGEND_PILL_H)
|
|
500
|
+
.attr('rx', LEGEND_PILL_RX)
|
|
501
|
+
.attr('ry', LEGEND_PILL_RX)
|
|
502
|
+
.attr('fill', mix(entry.color, isDark ? palette.surface : palette.bg, 25))
|
|
503
|
+
.attr('stroke', entry.color)
|
|
504
|
+
.attr('stroke-width', 1);
|
|
505
|
+
|
|
506
|
+
pillG.append('text')
|
|
507
|
+
.attr('x', legendX + pillW / 2)
|
|
508
|
+
.attr('y', legendY + LEGEND_PILL_H / 2)
|
|
509
|
+
.attr('text-anchor', 'middle')
|
|
510
|
+
.attr('dominant-baseline', 'central')
|
|
511
|
+
.attr('fill', palette.text)
|
|
512
|
+
.attr('font-size', LEGEND_FONT_SIZE)
|
|
513
|
+
.attr('font-family', FONT_FAMILY)
|
|
514
|
+
.text(entry.value);
|
|
515
|
+
|
|
516
|
+
legendX += pillW + LEGEND_GAP;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
legendX += LEGEND_GROUP_GAP;
|
|
520
|
+
}
|
|
521
|
+
}
|
|
433
522
|
}
|
|
434
523
|
|
|
435
524
|
// ============================================================
|
package/src/er/types.ts
CHANGED
|
@@ -18,6 +18,7 @@ export interface ERTable {
|
|
|
18
18
|
name: string;
|
|
19
19
|
color?: string;
|
|
20
20
|
columns: ERColumn[];
|
|
21
|
+
metadata: Record<string, string>;
|
|
21
22
|
lineNumber: number;
|
|
22
23
|
}
|
|
23
24
|
|
|
@@ -30,6 +31,7 @@ export interface ERRelationship {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
import type { DgmoError } from '../diagnostics';
|
|
34
|
+
import type { TagGroup } from '../utils/tag-groups';
|
|
33
35
|
|
|
34
36
|
export interface ParsedERDiagram {
|
|
35
37
|
type: 'er';
|
|
@@ -38,6 +40,7 @@ export interface ParsedERDiagram {
|
|
|
38
40
|
options: Record<string, string>;
|
|
39
41
|
tables: ERTable[];
|
|
40
42
|
relationships: ERRelationship[];
|
|
43
|
+
tagGroups: TagGroup[];
|
|
41
44
|
diagnostics: DgmoError[];
|
|
42
45
|
error: string | null;
|
|
43
46
|
}
|
package/src/infra/layout.ts
CHANGED
|
@@ -335,7 +335,7 @@ function formatUptime(fraction: number): string {
|
|
|
335
335
|
// Layout engine
|
|
336
336
|
// ============================================================
|
|
337
337
|
|
|
338
|
-
export function layoutInfra(computed: ComputedInfraModel, selectedNodeId?: string | null): InfraLayoutResult {
|
|
338
|
+
export function layoutInfra(computed: ComputedInfraModel, selectedNodeId?: string | null, collapsedNodes?: Set<string> | null): InfraLayoutResult {
|
|
339
339
|
if (computed.nodes.length === 0) {
|
|
340
340
|
return { nodes: [], edges: [], groups: [], options: {}, width: 0, height: 0 };
|
|
341
341
|
}
|
|
@@ -363,9 +363,12 @@ export function layoutInfra(computed: ComputedInfraModel, selectedNodeId?: strin
|
|
|
363
363
|
const widthMap = new Map<string, number>();
|
|
364
364
|
const heightMap = new Map<string, number>();
|
|
365
365
|
for (const node of computed.nodes) {
|
|
366
|
-
const
|
|
366
|
+
const isNodeCollapsed = collapsedNodes?.has(node.id) ?? false;
|
|
367
|
+
const expanded = !isNodeCollapsed && node.id === selectedNodeId;
|
|
367
368
|
const width = computeNodeWidth(node, expanded, computed.options);
|
|
368
|
-
const height =
|
|
369
|
+
const height = isNodeCollapsed
|
|
370
|
+
? NODE_HEADER_HEIGHT + NODE_PAD_BOTTOM
|
|
371
|
+
: computeNodeHeight(node, expanded, computed.options);
|
|
369
372
|
widthMap.set(node.id, width);
|
|
370
373
|
heightMap.set(node.id, height);
|
|
371
374
|
const inGroup = groupedNodeIds.has(node.id);
|
package/src/infra/parser.ts
CHANGED
|
@@ -43,8 +43,8 @@ const TAG_VALUE_RE = /^(\w[\w\s]*?)(?:\(([^)]+)\))?(\s+default)?\s*$/;
|
|
|
43
43
|
// Component line: ComponentName or ComponentName | t: Backend | env: Prod
|
|
44
44
|
const COMPONENT_RE = /^([a-zA-Z_][\w]*)(.*)$/;
|
|
45
45
|
|
|
46
|
-
// Pipe metadata: | key: value
|
|
47
|
-
const PIPE_META_RE =
|
|
46
|
+
// Pipe metadata: | key: value or | k1: v1, k2: v2 (comma-separated)
|
|
47
|
+
const PIPE_META_RE = /[|,]\s*(\w+)\s*:\s*([^|,]+)/g;
|
|
48
48
|
|
|
49
49
|
// Property: key: value
|
|
50
50
|
const PROPERTY_RE = /^([\w-]+)\s*:\s*(.+)$/;
|
package/src/infra/renderer.ts
CHANGED
|
@@ -606,6 +606,23 @@ function renderEdgeLabels(
|
|
|
606
606
|
}
|
|
607
607
|
}
|
|
608
608
|
|
|
609
|
+
/** Returns the resolved tag color for a node's active tag group, or null if not applicable. */
|
|
610
|
+
function resolveActiveTagStroke(
|
|
611
|
+
node: InfraLayoutNode,
|
|
612
|
+
activeGroup: string,
|
|
613
|
+
tagGroups: InfraTagGroup[],
|
|
614
|
+
palette: PaletteColors,
|
|
615
|
+
): string | null {
|
|
616
|
+
const tg = tagGroups.find((t) => t.name.toLowerCase() === activeGroup.toLowerCase());
|
|
617
|
+
if (!tg) return null;
|
|
618
|
+
const tagKey = (tg.alias ?? tg.name).toLowerCase();
|
|
619
|
+
const tagVal = node.tags[tagKey];
|
|
620
|
+
if (!tagVal) return null;
|
|
621
|
+
const tv = tg.values.find((v) => v.name.toLowerCase() === tagVal.toLowerCase());
|
|
622
|
+
if (!tv?.color) return null;
|
|
623
|
+
return resolveColor(tv.color, palette);
|
|
624
|
+
}
|
|
625
|
+
|
|
609
626
|
function renderNodes(
|
|
610
627
|
svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
|
|
611
628
|
nodes: InfraLayoutNode[],
|
|
@@ -615,11 +632,22 @@ function renderNodes(
|
|
|
615
632
|
selectedNodeId?: string | null,
|
|
616
633
|
activeGroup?: string | null,
|
|
617
634
|
diagramOptions?: Record<string, string>,
|
|
635
|
+
collapsedNodes?: Set<string> | null,
|
|
636
|
+
tagGroups?: InfraTagGroup[],
|
|
618
637
|
) {
|
|
619
638
|
const mutedColor = palette.textMuted;
|
|
620
639
|
|
|
621
640
|
for (const node of nodes) {
|
|
622
|
-
|
|
641
|
+
let { fill, stroke, textFill } = nodeColor(node, palette, isDark);
|
|
642
|
+
|
|
643
|
+
// When a tag legend is active, override border color with tag color
|
|
644
|
+
if (activeGroup && tagGroups && !node.isEdge) {
|
|
645
|
+
const tagStroke = resolveActiveTagStroke(node, activeGroup, tagGroups, palette);
|
|
646
|
+
if (tagStroke) {
|
|
647
|
+
stroke = tagStroke;
|
|
648
|
+
fill = mix(palette.bg, tagStroke, isDark ? 88 : 94);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
623
651
|
let cls = 'infra-node';
|
|
624
652
|
if (animate && node.isEdge) {
|
|
625
653
|
cls += ' infra-node-edge-throb';
|
|
@@ -632,12 +660,13 @@ function renderNodes(
|
|
|
632
660
|
const g = svg.append('g')
|
|
633
661
|
.attr('class', cls)
|
|
634
662
|
.attr('data-line-number', node.lineNumber)
|
|
635
|
-
.attr('data-infra-node', node.id)
|
|
663
|
+
.attr('data-infra-node', node.id)
|
|
664
|
+
.attr('data-node-collapse', node.id)
|
|
665
|
+
.style('cursor', 'pointer');
|
|
636
666
|
|
|
637
|
-
// Collapsed group nodes: toggle attribute
|
|
667
|
+
// Collapsed group nodes: toggle attribute
|
|
638
668
|
if (node.id.startsWith('[')) {
|
|
639
|
-
g.attr('data-node-toggle', node.id)
|
|
640
|
-
.style('cursor', 'pointer');
|
|
669
|
+
g.attr('data-node-toggle', node.id);
|
|
641
670
|
}
|
|
642
671
|
|
|
643
672
|
// Expose tag values for legend hover dimming
|
|
@@ -681,8 +710,22 @@ function renderNodes(
|
|
|
681
710
|
.attr('fill', textFill)
|
|
682
711
|
.text(node.label);
|
|
683
712
|
|
|
684
|
-
// --- Key-value rows below header ---
|
|
685
|
-
|
|
713
|
+
// --- Key-value rows below header (skipped for collapsed nodes) ---
|
|
714
|
+
const isNodeCollapsed = collapsedNodes?.has(node.id) ?? false;
|
|
715
|
+
if (isNodeCollapsed) {
|
|
716
|
+
// Collapsed: show a subtle chevron indicator at the bottom of the header
|
|
717
|
+
const chevronY = y + node.height - 6;
|
|
718
|
+
g.append('text')
|
|
719
|
+
.attr('x', node.x)
|
|
720
|
+
.attr('y', chevronY)
|
|
721
|
+
.attr('text-anchor', 'middle')
|
|
722
|
+
.attr('font-family', FONT_FAMILY)
|
|
723
|
+
.attr('font-size', 8)
|
|
724
|
+
.attr('fill', textFill)
|
|
725
|
+
.attr('opacity', 0.5)
|
|
726
|
+
.text('▼');
|
|
727
|
+
}
|
|
728
|
+
if (!isNodeCollapsed) {
|
|
686
729
|
const expanded = node.id === selectedNodeId;
|
|
687
730
|
// Declared properties only shown when node is selected (expanded)
|
|
688
731
|
const displayProps = (!node.isEdge && expanded) ? getDisplayProps(node, expanded, diagramOptions) : [];
|
|
@@ -1380,6 +1423,7 @@ export function renderInfra(
|
|
|
1380
1423
|
playback?: InfraPlaybackState | null,
|
|
1381
1424
|
selectedNodeId?: string | null,
|
|
1382
1425
|
exportMode?: boolean,
|
|
1426
|
+
collapsedNodes?: Set<string> | null,
|
|
1383
1427
|
) {
|
|
1384
1428
|
// Clear previous render (preserve tooltips if any)
|
|
1385
1429
|
d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
|
|
@@ -1470,7 +1514,7 @@ export function renderInfra(
|
|
|
1470
1514
|
// Render layers: groups (back), edge paths, nodes, reject particles, edge labels (front)
|
|
1471
1515
|
renderGroups(svg, layout.groups, palette, isDark);
|
|
1472
1516
|
renderEdgePaths(svg, layout.edges, layout.nodes, palette, isDark, shouldAnimate);
|
|
1473
|
-
renderNodes(svg, layout.nodes, palette, isDark, shouldAnimate, selectedNodeId, activeGroup, layout.options);
|
|
1517
|
+
renderNodes(svg, layout.nodes, palette, isDark, shouldAnimate, selectedNodeId, activeGroup, layout.options, collapsedNodes, tagGroups ?? []);
|
|
1474
1518
|
if (shouldAnimate) {
|
|
1475
1519
|
renderRejectParticles(svg, layout.nodes);
|
|
1476
1520
|
}
|
package/src/kanban/mutations.ts
CHANGED
package/src/kanban/parser.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { PaletteColors } from '../palettes';
|
|
2
2
|
import type { DgmoError } from '../diagnostics';
|
|
3
3
|
import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
|
|
4
|
+
import { resolveColor } from '../colors';
|
|
4
5
|
import { matchTagBlockHeading } from '../utils/tag-groups';
|
|
5
6
|
import {
|
|
6
7
|
measureIndent,
|
|
@@ -21,7 +22,10 @@ import type {
|
|
|
21
22
|
// Regex patterns
|
|
22
23
|
// ============================================================
|
|
23
24
|
|
|
24
|
-
|
|
25
|
+
// [Column Name], [Column Name](color), [Column Name] | wip: 3, etc.
|
|
26
|
+
const COLUMN_RE = /^\[(.+?)\](?:\s*\(([^)]+)\))?\s*(?:\|\s*(.+))?$/;
|
|
27
|
+
// Legacy delimiter
|
|
28
|
+
const LEGACY_COLUMN_RE = /^==\s+(.+?)\s*(?:\[wip:\s*(\d+)\])?\s*==$/;
|
|
25
29
|
|
|
26
30
|
// ============================================================
|
|
27
31
|
// Parser
|
|
@@ -60,6 +64,7 @@ export function parseKanban(
|
|
|
60
64
|
let currentTagGroup: KanbanTagGroup | null = null;
|
|
61
65
|
let currentColumn: KanbanColumn | null = null;
|
|
62
66
|
let currentCard: KanbanCard | null = null;
|
|
67
|
+
let cardBaseIndent = 0; // indent level of current card (for detail detection)
|
|
63
68
|
let columnCounter = 0;
|
|
64
69
|
let cardCounter = 0;
|
|
65
70
|
|
|
@@ -155,7 +160,7 @@ export function parseKanban(
|
|
|
155
160
|
}
|
|
156
161
|
}
|
|
157
162
|
|
|
158
|
-
// Tag group entries (indented Value(color) [default] under
|
|
163
|
+
// Tag group entries (indented Value(color) [default] under tag: heading)
|
|
159
164
|
if (currentTagGroup && !contentStarted) {
|
|
160
165
|
const indent = measureIndent(line);
|
|
161
166
|
if (indent > 0) {
|
|
@@ -187,8 +192,18 @@ export function parseKanban(
|
|
|
187
192
|
|
|
188
193
|
// --- Content phase ---
|
|
189
194
|
|
|
190
|
-
|
|
191
|
-
|
|
195
|
+
const indent = measureIndent(line);
|
|
196
|
+
|
|
197
|
+
// Reject legacy == Column == syntax
|
|
198
|
+
if (LEGACY_COLUMN_RE.test(trimmed)) {
|
|
199
|
+
const legacyMatch = trimmed.match(LEGACY_COLUMN_RE)!;
|
|
200
|
+
const name = legacyMatch[1].replace(/\s*\(.*\)\s*$/, '').trim();
|
|
201
|
+
warn(lineNumber, `'== ${name} ==' is no longer supported. Use '[${name}]' instead`);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// [Column] header at indent 0
|
|
206
|
+
const columnMatch = indent === 0 ? trimmed.match(COLUMN_RE) : null;
|
|
192
207
|
if (columnMatch) {
|
|
193
208
|
contentStarted = true;
|
|
194
209
|
currentTagGroup = null;
|
|
@@ -196,7 +211,6 @@ export function parseKanban(
|
|
|
196
211
|
// Finalize previous card's endLineNumber
|
|
197
212
|
if (currentCard) {
|
|
198
213
|
currentCard.endLineNumber = lineNumber - 1;
|
|
199
|
-
// Walk back over trailing empty lines
|
|
200
214
|
while (
|
|
201
215
|
currentCard.endLineNumber > currentCard.lineNumber &&
|
|
202
216
|
!lines[currentCard.endLineNumber - 1].trim()
|
|
@@ -207,16 +221,25 @@ export function parseKanban(
|
|
|
207
221
|
currentCard = null;
|
|
208
222
|
|
|
209
223
|
columnCounter++;
|
|
210
|
-
const
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
)
|
|
224
|
+
const colName = columnMatch[1].trim();
|
|
225
|
+
const colColor = columnMatch[2]
|
|
226
|
+
? resolveColor(columnMatch[2].trim(), palette)
|
|
227
|
+
: undefined;
|
|
228
|
+
|
|
229
|
+
// Parse WIP limit from pipe metadata (e.g., "| wip: 3")
|
|
230
|
+
let wipLimit: number | undefined;
|
|
231
|
+
const pipeStr = columnMatch[3];
|
|
232
|
+
if (pipeStr) {
|
|
233
|
+
const wipMatch = pipeStr.match(/\bwip\s*:\s*(\d+)\b/i);
|
|
234
|
+
if (wipMatch) {
|
|
235
|
+
wipLimit = parseInt(wipMatch[1], 10);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
216
239
|
currentColumn = {
|
|
217
240
|
id: `col-${columnCounter}`,
|
|
218
241
|
name: colName,
|
|
219
|
-
wipLimit
|
|
242
|
+
wipLimit,
|
|
220
243
|
color: colColor,
|
|
221
244
|
cards: [],
|
|
222
245
|
lineNumber,
|
|
@@ -226,45 +249,41 @@ export function parseKanban(
|
|
|
226
249
|
}
|
|
227
250
|
|
|
228
251
|
// If we hit a non-column, non-header line and haven't started content yet,
|
|
229
|
-
//
|
|
252
|
+
// skip silently (blank lines or whitespace between header and columns)
|
|
230
253
|
if (!contentStarted) {
|
|
231
|
-
// Could be the first column, or an error
|
|
232
|
-
// For permissiveness, skip these lines silently (they might be blank
|
|
233
|
-
// lines or just whitespace between header and columns)
|
|
234
254
|
continue;
|
|
235
255
|
}
|
|
236
256
|
|
|
237
257
|
if (!currentColumn) {
|
|
238
|
-
// Content line before any column — skip with warning
|
|
239
258
|
warn(lineNumber, 'Card line found before any column');
|
|
240
259
|
continue;
|
|
241
260
|
}
|
|
242
261
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
// Detail lines: indented under a card
|
|
246
|
-
if (indent > 0 && currentCard) {
|
|
262
|
+
// Detail lines: indented deeper than the card
|
|
263
|
+
if (currentCard && indent > cardBaseIndent) {
|
|
247
264
|
currentCard.details.push(trimmed);
|
|
248
265
|
currentCard.endLineNumber = lineNumber;
|
|
249
266
|
continue;
|
|
250
267
|
}
|
|
251
268
|
|
|
252
|
-
//
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
269
|
+
// Card line: indented under a [Column]
|
|
270
|
+
if (indent > 0) {
|
|
271
|
+
cardCounter++;
|
|
272
|
+
const card = parseCardLine(
|
|
273
|
+
trimmed,
|
|
274
|
+
lineNumber,
|
|
275
|
+
cardCounter,
|
|
276
|
+
aliasMap,
|
|
277
|
+
palette
|
|
278
|
+
);
|
|
279
|
+
cardBaseIndent = indent;
|
|
280
|
+
currentCard = card;
|
|
281
|
+
currentColumn.cards.push(card);
|
|
282
|
+
continue;
|
|
256
283
|
}
|
|
257
284
|
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
trimmed,
|
|
261
|
-
lineNumber,
|
|
262
|
-
cardCounter,
|
|
263
|
-
aliasMap,
|
|
264
|
-
palette
|
|
265
|
-
);
|
|
266
|
-
currentCard = card;
|
|
267
|
-
currentColumn.cards.push(card);
|
|
285
|
+
// Un-indented non-column line in content phase — could be stray text
|
|
286
|
+
// For permissiveness, skip silently
|
|
268
287
|
}
|
|
269
288
|
|
|
270
289
|
// Finalize last card's endLineNumber
|
|
@@ -310,7 +329,7 @@ export function parseKanban(
|
|
|
310
329
|
}
|
|
311
330
|
|
|
312
331
|
if (result.columns.length === 0 && !result.error) {
|
|
313
|
-
return fail(1, 'No columns found. Use
|
|
332
|
+
return fail(1, 'No columns found. Use [Column Name] to define columns');
|
|
314
333
|
}
|
|
315
334
|
|
|
316
335
|
return result;
|
package/src/sharing.ts
CHANGED
|
@@ -8,6 +8,7 @@ const COMPRESSED_SIZE_LIMIT = 8192; // 8 KB
|
|
|
8
8
|
|
|
9
9
|
export interface DiagramViewState {
|
|
10
10
|
activeTagGroup?: string;
|
|
11
|
+
collapsedGroups?: string[];
|
|
11
12
|
}
|
|
12
13
|
|
|
13
14
|
export interface DecodedDiagramUrl {
|
|
@@ -47,6 +48,10 @@ export function encodeDiagramUrl(
|
|
|
47
48
|
hash += `&tag=${encodeURIComponent(options.viewState.activeTagGroup)}`;
|
|
48
49
|
}
|
|
49
50
|
|
|
51
|
+
if (options?.viewState?.collapsedGroups?.length) {
|
|
52
|
+
hash += `&cg=${encodeURIComponent(options.viewState.collapsedGroups.join(','))}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
50
55
|
// Encode in both query param AND hash fragment — some share mechanisms
|
|
51
56
|
// strip one or the other (iOS share sheet strips #, AirDrop strips ?)
|
|
52
57
|
return { url: `${baseUrl}?${hash}#${hash}` };
|
|
@@ -89,6 +94,9 @@ export function decodeDiagramUrl(hash: string): DecodedDiagramUrl {
|
|
|
89
94
|
if (key === 'tag' && val) {
|
|
90
95
|
viewState.activeTagGroup = val;
|
|
91
96
|
}
|
|
97
|
+
if (key === 'cg' && val) {
|
|
98
|
+
viewState.collapsedGroups = val.split(',').filter(Boolean);
|
|
99
|
+
}
|
|
92
100
|
}
|
|
93
101
|
|
|
94
102
|
// Strip 'dgmo=' prefix
|