@diagrammo/dgmo 0.2.20 → 0.2.21
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 +99 -97
- package/dist/index.cjs +949 -262
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +70 -1
- package/dist/index.d.ts +70 -1
- package/dist/index.js +942 -261
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/d3.ts +44 -1
- package/src/dgmo-router.ts +8 -1
- package/src/index.ts +11 -0
- package/src/kanban/mutations.ts +183 -0
- package/src/kanban/parser.ts +389 -0
- package/src/kanban/renderer.ts +564 -0
- package/src/kanban/types.ts +45 -0
- package/src/org/layout.ts +61 -55
- package/src/org/parser.ts +15 -1
- package/src/org/renderer.ts +79 -160
- package/src/sequence/renderer.ts +7 -5
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Kanban Board SVG Renderer
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import * as d3Selection from 'd3-selection';
|
|
6
|
+
import { FONT_FAMILY } from '../fonts';
|
|
7
|
+
import type { PaletteColors } from '../palettes';
|
|
8
|
+
import type { ParsedKanban, KanbanColumn, KanbanCard, KanbanTagGroup } from './types';
|
|
9
|
+
import { parseKanban } from './parser';
|
|
10
|
+
import { isArchiveColumn } from './mutations';
|
|
11
|
+
|
|
12
|
+
// ============================================================
|
|
13
|
+
// Constants
|
|
14
|
+
// ============================================================
|
|
15
|
+
|
|
16
|
+
const DIAGRAM_PADDING = 20;
|
|
17
|
+
const COLUMN_GAP = 16;
|
|
18
|
+
const COLUMN_HEADER_HEIGHT = 36;
|
|
19
|
+
const COLUMN_PADDING = 12;
|
|
20
|
+
const COLUMN_MIN_WIDTH = 200;
|
|
21
|
+
const CARD_HEADER_HEIGHT = 24;
|
|
22
|
+
const CARD_META_LINE_HEIGHT = 14;
|
|
23
|
+
const CARD_SEPARATOR_GAP = 4;
|
|
24
|
+
const CARD_GAP = 8;
|
|
25
|
+
const CARD_RADIUS = 6;
|
|
26
|
+
const CARD_PADDING_X = 10;
|
|
27
|
+
const CARD_PADDING_Y = 6;
|
|
28
|
+
const CARD_STROKE_WIDTH = 1.5;
|
|
29
|
+
const TITLE_HEIGHT = 30;
|
|
30
|
+
const TITLE_FONT_SIZE = 18;
|
|
31
|
+
const COLUMN_HEADER_FONT_SIZE = 13;
|
|
32
|
+
const CARD_TITLE_FONT_SIZE = 12;
|
|
33
|
+
const CARD_META_FONT_SIZE = 10;
|
|
34
|
+
const WIP_FONT_SIZE = 10;
|
|
35
|
+
const COLUMN_RADIUS = 8;
|
|
36
|
+
const COLUMN_HEADER_RADIUS = 8;
|
|
37
|
+
const LEGEND_HEIGHT = 28;
|
|
38
|
+
const LEGEND_FONT_SIZE = 11;
|
|
39
|
+
const LEGEND_DOT_R = 4;
|
|
40
|
+
const LEGEND_ENTRY_FONT_SIZE = 10;
|
|
41
|
+
|
|
42
|
+
// ============================================================
|
|
43
|
+
// Color helpers
|
|
44
|
+
// ============================================================
|
|
45
|
+
|
|
46
|
+
function mix(a: string, b: string, pct: number): string {
|
|
47
|
+
const parse = (h: string) => {
|
|
48
|
+
const r = h.replace('#', '');
|
|
49
|
+
const f = r.length === 3 ? r[0] + r[0] + r[1] + r[1] + r[2] + r[2] : r;
|
|
50
|
+
return [
|
|
51
|
+
parseInt(f.substring(0, 2), 16),
|
|
52
|
+
parseInt(f.substring(2, 4), 16),
|
|
53
|
+
parseInt(f.substring(4, 6), 16),
|
|
54
|
+
];
|
|
55
|
+
};
|
|
56
|
+
const [ar, ag, ab] = parse(a);
|
|
57
|
+
const [br, bg, bb] = parse(b);
|
|
58
|
+
const t = pct / 100;
|
|
59
|
+
const c = (x: number, y: number) =>
|
|
60
|
+
Math.round(x * t + y * (1 - t))
|
|
61
|
+
.toString(16)
|
|
62
|
+
.padStart(2, '0');
|
|
63
|
+
return `#${c(ar, br)}${c(ag, bg)}${c(ab, bb)}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ============================================================
|
|
67
|
+
// Tag color resolution
|
|
68
|
+
// ============================================================
|
|
69
|
+
|
|
70
|
+
function resolveCardTagMeta(
|
|
71
|
+
card: KanbanCard,
|
|
72
|
+
tagGroups: KanbanTagGroup[]
|
|
73
|
+
): { label: string; value: string; color?: string }[] {
|
|
74
|
+
const meta: { label: string; value: string; color?: string }[] = [];
|
|
75
|
+
for (const group of tagGroups) {
|
|
76
|
+
const tagValue = card.tags[group.name.toLowerCase()];
|
|
77
|
+
const value = tagValue ?? group.defaultValue;
|
|
78
|
+
if (!value) continue;
|
|
79
|
+
const entry = group.entries.find(
|
|
80
|
+
(e) => e.value.toLowerCase() === value.toLowerCase()
|
|
81
|
+
);
|
|
82
|
+
meta.push({ label: group.name, value, color: entry?.color });
|
|
83
|
+
}
|
|
84
|
+
return meta;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function resolveCardTagColor(
|
|
88
|
+
card: KanbanCard,
|
|
89
|
+
tagGroups: KanbanTagGroup[],
|
|
90
|
+
activeTagGroup: string | null
|
|
91
|
+
): string | undefined {
|
|
92
|
+
if (!activeTagGroup) return card.color;
|
|
93
|
+
const group = tagGroups.find(
|
|
94
|
+
(g) => g.name.toLowerCase() === activeTagGroup.toLowerCase()
|
|
95
|
+
);
|
|
96
|
+
if (!group) return card.color;
|
|
97
|
+
const tagValue = card.tags[group.name.toLowerCase()];
|
|
98
|
+
const value = tagValue ?? group.defaultValue;
|
|
99
|
+
if (!value) return undefined;
|
|
100
|
+
const entry = group.entries.find(
|
|
101
|
+
(e) => e.value.toLowerCase() === value.toLowerCase()
|
|
102
|
+
);
|
|
103
|
+
return entry?.color;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ============================================================
|
|
107
|
+
// Layout computation
|
|
108
|
+
// ============================================================
|
|
109
|
+
|
|
110
|
+
interface ColumnLayout {
|
|
111
|
+
x: number;
|
|
112
|
+
y: number;
|
|
113
|
+
width: number;
|
|
114
|
+
height: number;
|
|
115
|
+
column: KanbanColumn;
|
|
116
|
+
cardLayouts: CardLayout[];
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface CardLayout {
|
|
120
|
+
x: number;
|
|
121
|
+
y: number;
|
|
122
|
+
width: number;
|
|
123
|
+
height: number;
|
|
124
|
+
card: KanbanCard;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function computeLayout(
|
|
128
|
+
parsed: ParsedKanban,
|
|
129
|
+
_palette: PaletteColors
|
|
130
|
+
): { columns: ColumnLayout[]; totalWidth: number; totalHeight: number } {
|
|
131
|
+
// Title and legend share one row
|
|
132
|
+
const hasHeader = !!parsed.title || parsed.tagGroups.length > 0;
|
|
133
|
+
const headerHeight = hasHeader ? Math.max(TITLE_HEIGHT, LEGEND_HEIGHT) + 8 : 0;
|
|
134
|
+
const startY = DIAGRAM_PADDING + headerHeight;
|
|
135
|
+
|
|
136
|
+
// Estimate column widths based on content
|
|
137
|
+
const charWidth = CARD_TITLE_FONT_SIZE * 0.6;
|
|
138
|
+
const columnLayouts: ColumnLayout[] = [];
|
|
139
|
+
|
|
140
|
+
let maxColumnHeight = 0;
|
|
141
|
+
|
|
142
|
+
// Filter out the archive column — it's a drop target only, not rendered
|
|
143
|
+
const visibleColumns = parsed.columns.filter((c) => !isArchiveColumn(c.name));
|
|
144
|
+
|
|
145
|
+
for (const col of visibleColumns) {
|
|
146
|
+
// Compute card heights and column width
|
|
147
|
+
let maxCardTextWidth = col.name.length * (COLUMN_HEADER_FONT_SIZE * 0.65);
|
|
148
|
+
|
|
149
|
+
const cardLayouts: CardLayout[] = [];
|
|
150
|
+
let cardY = COLUMN_HEADER_HEIGHT + COLUMN_PADDING;
|
|
151
|
+
|
|
152
|
+
for (const card of col.cards) {
|
|
153
|
+
const titleWidth = card.title.length * charWidth;
|
|
154
|
+
maxCardTextWidth = Math.max(
|
|
155
|
+
maxCardTextWidth,
|
|
156
|
+
titleWidth + CARD_PADDING_X * 2
|
|
157
|
+
);
|
|
158
|
+
|
|
159
|
+
// Count metadata rows (tag groups + detail lines)
|
|
160
|
+
const tagMeta = resolveCardTagMeta(card, parsed.tagGroups);
|
|
161
|
+
const metaCount = tagMeta.length + card.details.length;
|
|
162
|
+
const metaHeight =
|
|
163
|
+
metaCount > 0
|
|
164
|
+
? CARD_SEPARATOR_GAP + 1 + CARD_PADDING_Y + metaCount * CARD_META_LINE_HEIGHT
|
|
165
|
+
: 0;
|
|
166
|
+
const cardHeight = CARD_HEADER_HEIGHT + CARD_PADDING_Y + metaHeight;
|
|
167
|
+
|
|
168
|
+
// Account for meta label widths
|
|
169
|
+
for (const m of tagMeta) {
|
|
170
|
+
const metaW = (m.label.length + 2 + m.value.length) * CARD_META_FONT_SIZE * 0.6 + CARD_PADDING_X * 2;
|
|
171
|
+
maxCardTextWidth = Math.max(maxCardTextWidth, metaW);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
cardLayouts.push({
|
|
175
|
+
x: COLUMN_PADDING,
|
|
176
|
+
y: cardY,
|
|
177
|
+
width: 0, // set after column width computed
|
|
178
|
+
height: cardHeight,
|
|
179
|
+
card,
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
cardY += cardHeight + CARD_GAP;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const colWidth = Math.max(COLUMN_MIN_WIDTH, maxCardTextWidth + COLUMN_PADDING * 2);
|
|
186
|
+
|
|
187
|
+
// Set card widths
|
|
188
|
+
for (const cl of cardLayouts) {
|
|
189
|
+
cl.width = colWidth - COLUMN_PADDING * 2;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const colHeight = cardY + COLUMN_PADDING;
|
|
193
|
+
maxColumnHeight = Math.max(maxColumnHeight, colHeight);
|
|
194
|
+
|
|
195
|
+
columnLayouts.push({
|
|
196
|
+
x: 0, // set below
|
|
197
|
+
y: startY,
|
|
198
|
+
width: colWidth,
|
|
199
|
+
height: colHeight,
|
|
200
|
+
column: col,
|
|
201
|
+
cardLayouts,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Normalize column heights and compute x positions
|
|
206
|
+
let currentX = DIAGRAM_PADDING;
|
|
207
|
+
for (const cl of columnLayouts) {
|
|
208
|
+
cl.x = currentX;
|
|
209
|
+
cl.height = maxColumnHeight;
|
|
210
|
+
currentX += cl.width + COLUMN_GAP;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const totalWidth = currentX - COLUMN_GAP + DIAGRAM_PADDING;
|
|
214
|
+
const totalHeight = startY + maxColumnHeight + DIAGRAM_PADDING;
|
|
215
|
+
|
|
216
|
+
return { columns: columnLayouts, totalWidth, totalHeight };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ============================================================
|
|
220
|
+
// Render function
|
|
221
|
+
// ============================================================
|
|
222
|
+
|
|
223
|
+
export function renderKanban(
|
|
224
|
+
container: HTMLElement,
|
|
225
|
+
parsed: ParsedKanban,
|
|
226
|
+
palette: PaletteColors,
|
|
227
|
+
isDark: boolean,
|
|
228
|
+
_onNavigateToLine?: (line: number) => void,
|
|
229
|
+
exportDims?: { width: number; height: number },
|
|
230
|
+
activeTagGroup?: string | null
|
|
231
|
+
): void {
|
|
232
|
+
const layout = computeLayout(parsed, palette);
|
|
233
|
+
|
|
234
|
+
const width = exportDims?.width ?? layout.totalWidth;
|
|
235
|
+
const height = exportDims?.height ?? layout.totalHeight;
|
|
236
|
+
|
|
237
|
+
container.innerHTML = '';
|
|
238
|
+
|
|
239
|
+
const svg = d3Selection
|
|
240
|
+
.select(container)
|
|
241
|
+
.append('svg')
|
|
242
|
+
.attr('xmlns', 'http://www.w3.org/2000/svg')
|
|
243
|
+
.attr('width', width)
|
|
244
|
+
.attr('height', height)
|
|
245
|
+
.attr('font-family', FONT_FAMILY)
|
|
246
|
+
.style('background', palette.bg);
|
|
247
|
+
|
|
248
|
+
// Title
|
|
249
|
+
if (parsed.title) {
|
|
250
|
+
svg
|
|
251
|
+
.append('text')
|
|
252
|
+
.attr('class', 'chart-title')
|
|
253
|
+
.attr('data-line-number', parsed.titleLineNumber ?? 0)
|
|
254
|
+
.attr('x', DIAGRAM_PADDING)
|
|
255
|
+
.attr('y', DIAGRAM_PADDING + TITLE_FONT_SIZE)
|
|
256
|
+
.attr('font-size', TITLE_FONT_SIZE)
|
|
257
|
+
.attr('font-weight', 'bold')
|
|
258
|
+
.attr('fill', palette.text)
|
|
259
|
+
.text(parsed.title);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Legend (same row as title)
|
|
263
|
+
if (parsed.tagGroups.length > 0) {
|
|
264
|
+
const legendY = DIAGRAM_PADDING;
|
|
265
|
+
// Start legend after title text
|
|
266
|
+
const titleTextWidth = parsed.title
|
|
267
|
+
? parsed.title.length * TITLE_FONT_SIZE * 0.6 + 16
|
|
268
|
+
: 0;
|
|
269
|
+
let legendX = DIAGRAM_PADDING + titleTextWidth;
|
|
270
|
+
const groupBg = mix(palette.surface, palette.bg, isDark ? 35 : 20);
|
|
271
|
+
const capsulePad = 4;
|
|
272
|
+
|
|
273
|
+
for (const group of parsed.tagGroups) {
|
|
274
|
+
const isActive =
|
|
275
|
+
activeTagGroup?.toLowerCase() === group.name.toLowerCase();
|
|
276
|
+
|
|
277
|
+
// When a group is active, skip all other groups entirely
|
|
278
|
+
if (activeTagGroup != null && !isActive) continue;
|
|
279
|
+
|
|
280
|
+
const pillTextWidth = group.name.length * LEGEND_FONT_SIZE * 0.6;
|
|
281
|
+
const pillWidth = pillTextWidth + 16;
|
|
282
|
+
|
|
283
|
+
// Measure total capsule width for active groups (pill + entries)
|
|
284
|
+
let capsuleContentWidth = pillWidth;
|
|
285
|
+
if (isActive) {
|
|
286
|
+
capsuleContentWidth += 4; // gap after pill
|
|
287
|
+
for (const entry of group.entries) {
|
|
288
|
+
capsuleContentWidth += LEGEND_DOT_R * 2 + 4 + entry.value.length * LEGEND_ENTRY_FONT_SIZE * 0.6 + 8;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
const capsuleWidth = capsuleContentWidth + capsulePad * 2;
|
|
292
|
+
|
|
293
|
+
// Outer capsule background for active group
|
|
294
|
+
if (isActive) {
|
|
295
|
+
svg
|
|
296
|
+
.append('rect')
|
|
297
|
+
.attr('x', legendX)
|
|
298
|
+
.attr('y', legendY)
|
|
299
|
+
.attr('width', capsuleWidth)
|
|
300
|
+
.attr('height', LEGEND_HEIGHT)
|
|
301
|
+
.attr('rx', LEGEND_HEIGHT / 2)
|
|
302
|
+
.attr('fill', groupBg);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const pillX = legendX + (isActive ? capsulePad : 0);
|
|
306
|
+
|
|
307
|
+
// Pill background
|
|
308
|
+
const pillBg = isActive ? palette.bg : groupBg;
|
|
309
|
+
svg
|
|
310
|
+
.append('rect')
|
|
311
|
+
.attr('x', pillX)
|
|
312
|
+
.attr('y', legendY + (isActive ? capsulePad : 0))
|
|
313
|
+
.attr('width', pillWidth)
|
|
314
|
+
.attr('height', LEGEND_HEIGHT - (isActive ? capsulePad * 2 : 0))
|
|
315
|
+
.attr('rx', (LEGEND_HEIGHT - (isActive ? capsulePad * 2 : 0)) / 2)
|
|
316
|
+
.attr('fill', pillBg)
|
|
317
|
+
.attr('class', 'kanban-legend-group')
|
|
318
|
+
.attr('data-legend-group', group.name.toLowerCase());
|
|
319
|
+
|
|
320
|
+
if (isActive) {
|
|
321
|
+
svg
|
|
322
|
+
.append('rect')
|
|
323
|
+
.attr('x', pillX)
|
|
324
|
+
.attr('y', legendY + capsulePad)
|
|
325
|
+
.attr('width', pillWidth)
|
|
326
|
+
.attr('height', LEGEND_HEIGHT - capsulePad * 2)
|
|
327
|
+
.attr('rx', (LEGEND_HEIGHT - capsulePad * 2) / 2)
|
|
328
|
+
.attr('fill', 'none')
|
|
329
|
+
.attr('stroke', mix(palette.textMuted, palette.bg, 50))
|
|
330
|
+
.attr('stroke-width', 0.75);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Pill text
|
|
334
|
+
svg
|
|
335
|
+
.append('text')
|
|
336
|
+
.attr('x', pillX + pillWidth / 2)
|
|
337
|
+
.attr('y', legendY + LEGEND_HEIGHT / 2 + LEGEND_FONT_SIZE / 2 - 2)
|
|
338
|
+
.attr('font-size', LEGEND_FONT_SIZE)
|
|
339
|
+
.attr('font-weight', '500')
|
|
340
|
+
.attr('fill', isActive ? palette.text : palette.textMuted)
|
|
341
|
+
.attr('text-anchor', 'middle')
|
|
342
|
+
.text(group.name);
|
|
343
|
+
|
|
344
|
+
// Show entries inside capsule when active
|
|
345
|
+
if (isActive) {
|
|
346
|
+
let entryX = pillX + pillWidth + 4;
|
|
347
|
+
for (const entry of group.entries) {
|
|
348
|
+
svg
|
|
349
|
+
.append('circle')
|
|
350
|
+
.attr('cx', entryX + LEGEND_DOT_R)
|
|
351
|
+
.attr('cy', legendY + LEGEND_HEIGHT / 2)
|
|
352
|
+
.attr('r', LEGEND_DOT_R)
|
|
353
|
+
.attr('fill', entry.color);
|
|
354
|
+
|
|
355
|
+
const entryTextX = entryX + LEGEND_DOT_R * 2 + 4;
|
|
356
|
+
svg
|
|
357
|
+
.append('text')
|
|
358
|
+
.attr('x', entryTextX)
|
|
359
|
+
.attr('y', legendY + LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 1)
|
|
360
|
+
.attr('font-size', LEGEND_ENTRY_FONT_SIZE)
|
|
361
|
+
.attr('fill', palette.textMuted)
|
|
362
|
+
.text(entry.value);
|
|
363
|
+
|
|
364
|
+
entryX = entryTextX + entry.value.length * LEGEND_ENTRY_FONT_SIZE * 0.6 + 8;
|
|
365
|
+
}
|
|
366
|
+
legendX += capsuleWidth + 12;
|
|
367
|
+
} else {
|
|
368
|
+
legendX += pillWidth + 12;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Columns
|
|
374
|
+
const defaultColBg = isDark
|
|
375
|
+
? mix(palette.surface, palette.bg, 50)
|
|
376
|
+
: mix(palette.surface, palette.bg, 30);
|
|
377
|
+
const defaultColHeaderBg = isDark
|
|
378
|
+
? mix(palette.surface, palette.bg, 70)
|
|
379
|
+
: mix(palette.surface, palette.bg, 50);
|
|
380
|
+
|
|
381
|
+
const cardBaseBg = isDark ? palette.surface : palette.bg;
|
|
382
|
+
|
|
383
|
+
for (const colLayout of layout.columns) {
|
|
384
|
+
const col = colLayout.column;
|
|
385
|
+
const g = svg
|
|
386
|
+
.append('g')
|
|
387
|
+
.attr('class', 'kanban-column')
|
|
388
|
+
.attr('data-column-id', col.id)
|
|
389
|
+
.attr('data-line-number', col.lineNumber);
|
|
390
|
+
|
|
391
|
+
// Column body: always neutral
|
|
392
|
+
const thisColBg = defaultColBg;
|
|
393
|
+
// Column header: tinted if column has explicit color
|
|
394
|
+
const thisColHeaderBg = col.color
|
|
395
|
+
? mix(col.color, palette.bg, 25)
|
|
396
|
+
: defaultColHeaderBg;
|
|
397
|
+
|
|
398
|
+
// Column background
|
|
399
|
+
g.append('rect')
|
|
400
|
+
.attr('x', colLayout.x)
|
|
401
|
+
.attr('y', colLayout.y)
|
|
402
|
+
.attr('width', colLayout.width)
|
|
403
|
+
.attr('height', colLayout.height)
|
|
404
|
+
.attr('rx', COLUMN_RADIUS)
|
|
405
|
+
.attr('fill', thisColBg);
|
|
406
|
+
|
|
407
|
+
// Column header background
|
|
408
|
+
g.append('rect')
|
|
409
|
+
.attr('x', colLayout.x)
|
|
410
|
+
.attr('y', colLayout.y)
|
|
411
|
+
.attr('width', colLayout.width)
|
|
412
|
+
.attr('height', COLUMN_HEADER_HEIGHT)
|
|
413
|
+
.attr('rx', COLUMN_HEADER_RADIUS)
|
|
414
|
+
.attr('fill', thisColHeaderBg);
|
|
415
|
+
|
|
416
|
+
// Column title
|
|
417
|
+
g.append('text')
|
|
418
|
+
.attr('x', colLayout.x + COLUMN_PADDING)
|
|
419
|
+
.attr(
|
|
420
|
+
'y',
|
|
421
|
+
colLayout.y + COLUMN_HEADER_HEIGHT / 2 + COLUMN_HEADER_FONT_SIZE / 2 - 2
|
|
422
|
+
)
|
|
423
|
+
.attr('font-size', COLUMN_HEADER_FONT_SIZE)
|
|
424
|
+
.attr('font-weight', 'bold')
|
|
425
|
+
.attr('fill', palette.text)
|
|
426
|
+
.text(col.name);
|
|
427
|
+
|
|
428
|
+
// WIP limit badge
|
|
429
|
+
if (col.wipLimit != null) {
|
|
430
|
+
const wipExceeded = col.cards.length > col.wipLimit;
|
|
431
|
+
const badgeText = `${col.cards.length}/${col.wipLimit}`;
|
|
432
|
+
const nameWidth = col.name.length * COLUMN_HEADER_FONT_SIZE * 0.65;
|
|
433
|
+
g.append('text')
|
|
434
|
+
.attr('x', colLayout.x + COLUMN_PADDING + nameWidth + 8)
|
|
435
|
+
.attr(
|
|
436
|
+
'y',
|
|
437
|
+
colLayout.y +
|
|
438
|
+
COLUMN_HEADER_HEIGHT / 2 +
|
|
439
|
+
WIP_FONT_SIZE / 2 -
|
|
440
|
+
1
|
|
441
|
+
)
|
|
442
|
+
.attr('font-size', WIP_FONT_SIZE)
|
|
443
|
+
.attr('fill', wipExceeded ? palette.colors.red : palette.textMuted)
|
|
444
|
+
.attr('font-weight', wipExceeded ? 'bold' : 'normal')
|
|
445
|
+
.text(badgeText);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Cards
|
|
449
|
+
for (const cardLayout of colLayout.cardLayouts) {
|
|
450
|
+
const card = cardLayout.card;
|
|
451
|
+
const resolvedColor = resolveCardTagColor(card, parsed.tagGroups, activeTagGroup ?? null);
|
|
452
|
+
const tagMeta = resolveCardTagMeta(card, parsed.tagGroups);
|
|
453
|
+
const hasMeta = tagMeta.length > 0 || card.details.length > 0;
|
|
454
|
+
|
|
455
|
+
// Org-chart-style fill: 15% blend of color into bg
|
|
456
|
+
const cardFill = resolvedColor
|
|
457
|
+
? mix(resolvedColor, cardBaseBg, 15)
|
|
458
|
+
: mix(palette.primary, cardBaseBg, 15);
|
|
459
|
+
const cardStroke = resolvedColor ?? palette.textMuted;
|
|
460
|
+
|
|
461
|
+
const cg = g
|
|
462
|
+
.append('g')
|
|
463
|
+
.attr('class', 'kanban-card')
|
|
464
|
+
.attr('data-card-id', card.id)
|
|
465
|
+
.attr('data-line-number', card.lineNumber);
|
|
466
|
+
|
|
467
|
+
const cx = colLayout.x + cardLayout.x;
|
|
468
|
+
const cy = colLayout.y + cardLayout.y;
|
|
469
|
+
|
|
470
|
+
// Card background
|
|
471
|
+
cg.append('rect')
|
|
472
|
+
.attr('x', cx)
|
|
473
|
+
.attr('y', cy)
|
|
474
|
+
.attr('width', cardLayout.width)
|
|
475
|
+
.attr('height', cardLayout.height)
|
|
476
|
+
.attr('rx', CARD_RADIUS)
|
|
477
|
+
.attr('fill', cardFill)
|
|
478
|
+
.attr('stroke', cardStroke)
|
|
479
|
+
.attr('stroke-width', CARD_STROKE_WIDTH);
|
|
480
|
+
|
|
481
|
+
// Card title
|
|
482
|
+
cg.append('text')
|
|
483
|
+
.attr('x', cx + CARD_PADDING_X)
|
|
484
|
+
.attr('y', cy + CARD_PADDING_Y + CARD_TITLE_FONT_SIZE)
|
|
485
|
+
.attr('font-size', CARD_TITLE_FONT_SIZE)
|
|
486
|
+
.attr('font-weight', '500')
|
|
487
|
+
.attr('fill', palette.text)
|
|
488
|
+
.text(card.title);
|
|
489
|
+
|
|
490
|
+
// Separator + metadata
|
|
491
|
+
if (hasMeta) {
|
|
492
|
+
const separatorY = cy + CARD_HEADER_HEIGHT;
|
|
493
|
+
|
|
494
|
+
cg.append('line')
|
|
495
|
+
.attr('x1', cx)
|
|
496
|
+
.attr('y1', separatorY)
|
|
497
|
+
.attr('x2', cx + cardLayout.width)
|
|
498
|
+
.attr('y2', separatorY)
|
|
499
|
+
.attr('stroke', cardStroke)
|
|
500
|
+
.attr('stroke-opacity', 0.3)
|
|
501
|
+
.attr('stroke-width', 1);
|
|
502
|
+
|
|
503
|
+
let metaY = separatorY + CARD_SEPARATOR_GAP + CARD_META_FONT_SIZE;
|
|
504
|
+
|
|
505
|
+
// Tag metadata rows
|
|
506
|
+
for (const meta of tagMeta) {
|
|
507
|
+
cg.append('text')
|
|
508
|
+
.attr('x', cx + CARD_PADDING_X)
|
|
509
|
+
.attr('y', metaY)
|
|
510
|
+
.attr('font-size', CARD_META_FONT_SIZE)
|
|
511
|
+
.attr('fill', palette.textMuted)
|
|
512
|
+
.text(`${meta.label}: `);
|
|
513
|
+
|
|
514
|
+
const labelWidth = (meta.label.length + 2) * CARD_META_FONT_SIZE * 0.6;
|
|
515
|
+
cg.append('text')
|
|
516
|
+
.attr('x', cx + CARD_PADDING_X + labelWidth)
|
|
517
|
+
.attr('y', metaY)
|
|
518
|
+
.attr('font-size', CARD_META_FONT_SIZE)
|
|
519
|
+
.attr('fill', palette.text)
|
|
520
|
+
.text(meta.value);
|
|
521
|
+
|
|
522
|
+
metaY += CARD_META_LINE_HEIGHT;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// Detail lines
|
|
526
|
+
for (const detail of card.details) {
|
|
527
|
+
cg.append('text')
|
|
528
|
+
.attr('x', cx + CARD_PADDING_X)
|
|
529
|
+
.attr('y', metaY)
|
|
530
|
+
.attr('font-size', CARD_META_FONT_SIZE)
|
|
531
|
+
.attr('fill', palette.textMuted)
|
|
532
|
+
.text(detail);
|
|
533
|
+
|
|
534
|
+
metaY += CARD_META_LINE_HEIGHT;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
// ============================================================
|
|
542
|
+
// Export convenience function
|
|
543
|
+
// ============================================================
|
|
544
|
+
|
|
545
|
+
export function renderKanbanForExport(
|
|
546
|
+
content: string,
|
|
547
|
+
theme: 'light' | 'dark' | 'transparent',
|
|
548
|
+
palette: PaletteColors
|
|
549
|
+
): string {
|
|
550
|
+
const parsed = parseKanban(content, palette);
|
|
551
|
+
if (parsed.error || parsed.columns.length === 0) return '';
|
|
552
|
+
|
|
553
|
+
const isDark = theme === 'dark';
|
|
554
|
+
const layout = computeLayout(parsed, palette);
|
|
555
|
+
|
|
556
|
+
const container = document.createElement('div');
|
|
557
|
+
renderKanban(container, parsed, palette, isDark, undefined, {
|
|
558
|
+
width: layout.totalWidth,
|
|
559
|
+
height: layout.totalHeight,
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
const svgEl = container.querySelector('svg');
|
|
563
|
+
return svgEl?.outerHTML ?? '';
|
|
564
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { DgmoError } from '../diagnostics';
|
|
2
|
+
|
|
3
|
+
export interface KanbanTagEntry {
|
|
4
|
+
value: string;
|
|
5
|
+
color: string;
|
|
6
|
+
lineNumber: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface KanbanTagGroup {
|
|
10
|
+
name: string;
|
|
11
|
+
alias?: string;
|
|
12
|
+
entries: KanbanTagEntry[];
|
|
13
|
+
defaultValue?: string;
|
|
14
|
+
lineNumber: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface KanbanCard {
|
|
18
|
+
id: string;
|
|
19
|
+
title: string;
|
|
20
|
+
tags: Record<string, string>; // groupName → value
|
|
21
|
+
details: string[]; // freeform indented lines
|
|
22
|
+
lineNumber: number; // first line of the card (1-based)
|
|
23
|
+
endLineNumber: number; // last line inclusive (1-based)
|
|
24
|
+
color?: string; // explicit color override via (color) suffix
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface KanbanColumn {
|
|
28
|
+
id: string;
|
|
29
|
+
name: string;
|
|
30
|
+
wipLimit?: number;
|
|
31
|
+
color?: string;
|
|
32
|
+
cards: KanbanCard[];
|
|
33
|
+
lineNumber: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface ParsedKanban {
|
|
37
|
+
type: 'kanban';
|
|
38
|
+
title?: string;
|
|
39
|
+
titleLineNumber?: number;
|
|
40
|
+
columns: KanbanColumn[];
|
|
41
|
+
tagGroups: KanbanTagGroup[];
|
|
42
|
+
options: Record<string, string>;
|
|
43
|
+
diagnostics: DgmoError[];
|
|
44
|
+
error?: string;
|
|
45
|
+
}
|