@diagrammo/dgmo 0.6.3 → 0.7.1

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 (44) hide show
  1. package/dist/cli.cjs +180 -178
  2. package/dist/index.cjs +5447 -2229
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +236 -16
  5. package/dist/index.d.ts +236 -16
  6. package/dist/index.js +5439 -2228
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/c4/parser.ts +3 -2
  10. package/src/c4/renderer.ts +6 -6
  11. package/src/class/renderer.ts +183 -7
  12. package/src/cli.ts +3 -11
  13. package/src/colors.ts +3 -3
  14. package/src/d3.ts +132 -29
  15. package/src/dgmo-router.ts +3 -1
  16. package/src/er/parser.ts +5 -3
  17. package/src/er/renderer.ts +11 -5
  18. package/src/gantt/calculator.ts +717 -0
  19. package/src/gantt/parser.ts +767 -0
  20. package/src/gantt/renderer.ts +2251 -0
  21. package/src/gantt/resolver.ts +144 -0
  22. package/src/gantt/types.ts +168 -0
  23. package/src/index.ts +27 -0
  24. package/src/infra/renderer.ts +48 -12
  25. package/src/initiative-status/filter.ts +63 -0
  26. package/src/initiative-status/layout.ts +319 -67
  27. package/src/initiative-status/parser.ts +200 -25
  28. package/src/initiative-status/renderer.ts +293 -10
  29. package/src/initiative-status/types.ts +6 -0
  30. package/src/org/layout.ts +22 -55
  31. package/src/org/parser.ts +7 -5
  32. package/src/org/renderer.ts +4 -8
  33. package/src/palettes/dracula.ts +60 -0
  34. package/src/palettes/index.ts +8 -6
  35. package/src/palettes/monokai.ts +60 -0
  36. package/src/palettes/registry.ts +4 -2
  37. package/src/sequence/parser.ts +10 -9
  38. package/src/sequence/renderer.ts +5 -4
  39. package/src/sharing.ts +8 -0
  40. package/src/sitemap/parser.ts +5 -3
  41. package/src/sitemap/renderer.ts +4 -4
  42. package/src/utils/duration.ts +212 -0
  43. package/src/utils/legend-constants.ts +1 -0
  44. package/src/utils/parsing.ts +23 -12
package/src/d3.ts CHANGED
@@ -181,7 +181,7 @@ import { getSeriesColors } from './palettes';
181
181
  import { mix } from './palettes/color-utils';
182
182
  import type { DgmoError } from './diagnostics';
183
183
  import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
184
- import { collectIndentedValues, extractColor, parsePipeMetadata } from './utils/parsing';
184
+ import { collectIndentedValues, extractColor, parsePipeMetadata, MULTIPLE_PIPE_WARNING } from './utils/parsing';
185
185
  import { matchTagBlockHeading, validateTagValues, resolveTagColor } from './utils/tag-groups';
186
186
  import type { TagGroup } from './utils/tag-groups';
187
187
  import {
@@ -545,9 +545,9 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
545
545
  continue;
546
546
  }
547
547
 
548
- // Timeline marker lines: marker YYYY: Label (color)
548
+ // Timeline marker lines: marker: YYYY Label (color)
549
549
  const markerMatch = line.match(
550
- /^marker\s+(\d{4}(?:-\d{2})?(?:-\d{2})?)\s*:\s*(.+?)(?:\s*\(([^)]+)\))?\s*$/
550
+ /^marker:\s+(\d{4}(?:-\d{2})?(?:-\d{2})?)\s+(.+?)(?:\s*\(([^)]+)\))?\s*$/
551
551
  );
552
552
  if (markerMatch) {
553
553
  const colorAnnotation = markerMatch[3]?.trim() || null;
@@ -579,7 +579,7 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
579
579
  const endDate = addDurationToDate(startDate, amount, unit);
580
580
  const segments = durationMatch[5].split('|');
581
581
  const metadata = segments.length > 1
582
- ? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap)
582
+ ? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap, () => warn(lineNumber, MULTIPLE_PIPE_WARNING))
583
583
  : {};
584
584
  result.timelineEvents.push({
585
585
  date: startDate,
@@ -600,7 +600,7 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
600
600
  if (rangeMatch) {
601
601
  const segments = rangeMatch[4].split('|');
602
602
  const metadata = segments.length > 1
603
- ? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap)
603
+ ? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap, () => warn(lineNumber, MULTIPLE_PIPE_WARNING))
604
604
  : {};
605
605
  result.timelineEvents.push({
606
606
  date: rangeMatch[1],
@@ -621,7 +621,7 @@ export function parseVisualization(content: string, palette?: PaletteColors): Pa
621
621
  if (pointMatch) {
622
622
  const segments = pointMatch[2].split('|');
623
623
  const metadata = segments.length > 1
624
- ? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap)
624
+ ? parsePipeMetadata(['', ...segments.slice(1)], timelineAliasMap, () => warn(lineNumber, MULTIPLE_PIPE_WARNING))
625
625
  : {};
626
626
  result.timelineEvents.push({
627
627
  date: pointMatch[1],
@@ -3059,7 +3059,7 @@ export function renderTimeline(
3059
3059
  }
3060
3060
  }
3061
3061
 
3062
- // Reserve space for tag legend at the bottom of chart content
3062
+ // Reserve space for tag legend at the top of chart content (below title/headers)
3063
3063
  const tagLegendReserve = parsed.timelineTagGroups.length > 0 ? 36 : 0;
3064
3064
 
3065
3065
  // ================================================================
@@ -3099,9 +3099,9 @@ export function renderTimeline(
3099
3099
  const scaleMargin = timelineScale ? 40 : 0;
3100
3100
  const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
3101
3101
  const margin = {
3102
- top: 104 + markerMargin,
3102
+ top: 104 + markerMargin + tagLegendReserve,
3103
3103
  right: 40 + scaleMargin,
3104
- bottom: 40 + tagLegendReserve,
3104
+ bottom: 40,
3105
3105
  left: 60 + scaleMargin,
3106
3106
  };
3107
3107
  const innerWidth = width - margin.left - margin.right;
@@ -3254,12 +3254,14 @@ export function renderTimeline(
3254
3254
  const rectH = Math.max(y2 - y, 4);
3255
3255
 
3256
3256
  let fill: string = mix(evColor, bg, 30);
3257
+ let stroke: string = evColor;
3257
3258
  if (ev.uncertain) {
3258
3259
  const gradientId = `uncertain-vg-${ev.lineNumber}`;
3260
+ const strokeGradientId = `uncertain-vg-s-${ev.lineNumber}`;
3259
3261
  const defs =
3260
3262
  svg.select('defs').node() || svg.append('defs').node();
3261
- d3Selection
3262
- .select(defs as Element)
3263
+ const defsEl = d3Selection.select(defs as Element);
3264
+ defsEl
3263
3265
  .append('linearGradient')
3264
3266
  .attr('id', gradientId)
3265
3267
  .attr('x1', '0%')
@@ -3277,7 +3279,26 @@ export function renderTimeline(
3277
3279
  .attr('offset', (d) => d.offset)
3278
3280
  .attr('stop-color', mix(laneColor, bg, 30))
3279
3281
  .attr('stop-opacity', (d) => d.opacity);
3282
+ defsEl
3283
+ .append('linearGradient')
3284
+ .attr('id', strokeGradientId)
3285
+ .attr('x1', '0%')
3286
+ .attr('y1', '0%')
3287
+ .attr('x2', '0%')
3288
+ .attr('y2', '100%')
3289
+ .selectAll('stop')
3290
+ .data([
3291
+ { offset: '0%', opacity: 1 },
3292
+ { offset: '80%', opacity: 1 },
3293
+ { offset: '100%', opacity: 0 },
3294
+ ])
3295
+ .enter()
3296
+ .append('stop')
3297
+ .attr('offset', (d) => d.offset)
3298
+ .attr('stop-color', evColor)
3299
+ .attr('stop-opacity', (d) => d.opacity);
3280
3300
  fill = `url(#${gradientId})`;
3301
+ stroke = `url(#${strokeGradientId})`;
3281
3302
  }
3282
3303
 
3283
3304
  evG
@@ -3288,7 +3309,7 @@ export function renderTimeline(
3288
3309
  .attr('height', rectH)
3289
3310
  .attr('rx', 4)
3290
3311
  .attr('fill', fill)
3291
- .attr('stroke', evColor)
3312
+ .attr('stroke', stroke)
3292
3313
  .attr('stroke-width', 2);
3293
3314
  evG
3294
3315
  .append('text')
@@ -3323,9 +3344,9 @@ export function renderTimeline(
3323
3344
  const scaleMargin = timelineScale ? 40 : 0;
3324
3345
  const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
3325
3346
  const margin = {
3326
- top: 104 + markerMargin,
3347
+ top: 104 + markerMargin + tagLegendReserve,
3327
3348
  right: 200,
3328
- bottom: 40 + tagLegendReserve,
3349
+ bottom: 40,
3329
3350
  left: 60 + scaleMargin,
3330
3351
  };
3331
3352
  const innerWidth = width - margin.left - margin.right;
@@ -3474,12 +3495,14 @@ export function renderTimeline(
3474
3495
  const rectH = Math.max(y2 - y, 4);
3475
3496
 
3476
3497
  let fill: string = mix(color, bg, 30);
3498
+ let stroke: string = color;
3477
3499
  if (ev.uncertain) {
3478
3500
  const gradientId = `uncertain-v-${ev.lineNumber}`;
3501
+ const strokeGradientId = `uncertain-v-s-${ev.lineNumber}`;
3479
3502
  const defs =
3480
3503
  svg.select('defs').node() || svg.append('defs').node();
3481
- d3Selection
3482
- .select(defs as Element)
3504
+ const defsEl = d3Selection.select(defs as Element);
3505
+ defsEl
3483
3506
  .append('linearGradient')
3484
3507
  .attr('id', gradientId)
3485
3508
  .attr('x1', '0%')
@@ -3497,7 +3520,26 @@ export function renderTimeline(
3497
3520
  .attr('offset', (d) => d.offset)
3498
3521
  .attr('stop-color', mix(color, bg, 30))
3499
3522
  .attr('stop-opacity', (d) => d.opacity);
3523
+ defsEl
3524
+ .append('linearGradient')
3525
+ .attr('id', strokeGradientId)
3526
+ .attr('x1', '0%')
3527
+ .attr('y1', '0%')
3528
+ .attr('x2', '0%')
3529
+ .attr('y2', '100%')
3530
+ .selectAll('stop')
3531
+ .data([
3532
+ { offset: '0%', opacity: 1 },
3533
+ { offset: '80%', opacity: 1 },
3534
+ { offset: '100%', opacity: 0 },
3535
+ ])
3536
+ .enter()
3537
+ .append('stop')
3538
+ .attr('offset', (d) => d.offset)
3539
+ .attr('stop-color', color)
3540
+ .attr('stop-opacity', (d) => d.opacity);
3500
3541
  fill = `url(#${gradientId})`;
3542
+ stroke = `url(#${strokeGradientId})`;
3501
3543
  }
3502
3544
 
3503
3545
  evG
@@ -3508,7 +3550,7 @@ export function renderTimeline(
3508
3550
  .attr('height', rectH)
3509
3551
  .attr('rx', 4)
3510
3552
  .attr('fill', fill)
3511
- .attr('stroke', color)
3553
+ .attr('stroke', stroke)
3512
3554
  .attr('stroke-width', 2);
3513
3555
  evG
3514
3556
  .append('text')
@@ -3606,9 +3648,9 @@ export function renderTimeline(
3606
3648
  // Group-sorted doesn't need legend space (group names shown on left)
3607
3649
  const baseTopMargin = title ? 50 : 20;
3608
3650
  const margin = {
3609
- top: baseTopMargin + (timelineScale ? 40 : 0) + markerMargin,
3651
+ top: baseTopMargin + (timelineScale ? 40 : 0) + markerMargin + tagLegendReserve,
3610
3652
  right: 40,
3611
- bottom: 40 + scaleMargin + tagLegendReserve,
3653
+ bottom: 40 + scaleMargin,
3612
3654
  left: dynamicLeftMargin,
3613
3655
  };
3614
3656
  const innerWidth = width - margin.left - margin.right;
@@ -3785,12 +3827,14 @@ export function renderTimeline(
3785
3827
  const labelFitsInside = rectW >= estLabelWidth;
3786
3828
 
3787
3829
  let fill: string = mix(evColor, bg, 30);
3830
+ let stroke: string = evColor;
3788
3831
  if (ev.uncertain) {
3789
3832
  // Create gradient for uncertain end - fades last 20%
3790
3833
  const gradientId = `uncertain-${ev.lineNumber}`;
3834
+ const strokeGradientId = `uncertain-s-${ev.lineNumber}`;
3791
3835
  const defs = svg.select('defs').node() || svg.append('defs').node();
3792
- d3Selection
3793
- .select(defs as Element)
3836
+ const defsEl = d3Selection.select(defs as Element);
3837
+ defsEl
3794
3838
  .append('linearGradient')
3795
3839
  .attr('id', gradientId)
3796
3840
  .attr('x1', '0%')
@@ -3808,7 +3852,26 @@ export function renderTimeline(
3808
3852
  .attr('offset', (d) => d.offset)
3809
3853
  .attr('stop-color', mix(evColor, bg, 30))
3810
3854
  .attr('stop-opacity', (d) => d.opacity);
3855
+ defsEl
3856
+ .append('linearGradient')
3857
+ .attr('id', strokeGradientId)
3858
+ .attr('x1', '0%')
3859
+ .attr('y1', '0%')
3860
+ .attr('x2', '100%')
3861
+ .attr('y2', '0%')
3862
+ .selectAll('stop')
3863
+ .data([
3864
+ { offset: '0%', opacity: 1 },
3865
+ { offset: '80%', opacity: 1 },
3866
+ { offset: '100%', opacity: 0 },
3867
+ ])
3868
+ .enter()
3869
+ .append('stop')
3870
+ .attr('offset', (d) => d.offset)
3871
+ .attr('stop-color', evColor)
3872
+ .attr('stop-opacity', (d) => d.opacity);
3811
3873
  fill = `url(#${gradientId})`;
3874
+ stroke = `url(#${strokeGradientId})`;
3812
3875
  }
3813
3876
 
3814
3877
  evG
@@ -3819,7 +3882,7 @@ export function renderTimeline(
3819
3882
  .attr('height', BAR_H)
3820
3883
  .attr('rx', 4)
3821
3884
  .attr('fill', fill)
3822
- .attr('stroke', evColor)
3885
+ .attr('stroke', stroke)
3823
3886
  .attr('stroke-width', 2);
3824
3887
 
3825
3888
  if (labelFitsInside) {
@@ -3887,9 +3950,9 @@ export function renderTimeline(
3887
3950
  const scaleMargin = timelineScale ? 24 : 0;
3888
3951
  const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
3889
3952
  const margin = {
3890
- top: 104 + (timelineScale ? 40 : 0) + markerMargin,
3953
+ top: 104 + (timelineScale ? 40 : 0) + markerMargin + tagLegendReserve,
3891
3954
  right: 40,
3892
- bottom: 40 + scaleMargin + tagLegendReserve,
3955
+ bottom: 40 + scaleMargin,
3893
3956
  left: 60,
3894
3957
  };
3895
3958
  const innerWidth = width - margin.left - margin.right;
@@ -4047,12 +4110,14 @@ export function renderTimeline(
4047
4110
  const labelFitsInside = rectW >= estLabelWidth;
4048
4111
 
4049
4112
  let fill: string = mix(color, bg, 30);
4113
+ let stroke: string = color;
4050
4114
  if (ev.uncertain) {
4051
4115
  // Create gradient for uncertain end - fades last 20%
4052
4116
  const gradientId = `uncertain-ts-${ev.lineNumber}`;
4117
+ const strokeGradientId = `uncertain-ts-s-${ev.lineNumber}`;
4053
4118
  const defs = svg.select('defs').node() || svg.append('defs').node();
4054
- d3Selection
4055
- .select(defs as Element)
4119
+ const defsEl = d3Selection.select(defs as Element);
4120
+ defsEl
4056
4121
  .append('linearGradient')
4057
4122
  .attr('id', gradientId)
4058
4123
  .attr('x1', '0%')
@@ -4070,7 +4135,26 @@ export function renderTimeline(
4070
4135
  .attr('offset', (d) => d.offset)
4071
4136
  .attr('stop-color', mix(color, bg, 30))
4072
4137
  .attr('stop-opacity', (d) => d.opacity);
4138
+ defsEl
4139
+ .append('linearGradient')
4140
+ .attr('id', strokeGradientId)
4141
+ .attr('x1', '0%')
4142
+ .attr('y1', '0%')
4143
+ .attr('x2', '100%')
4144
+ .attr('y2', '0%')
4145
+ .selectAll('stop')
4146
+ .data([
4147
+ { offset: '0%', opacity: 1 },
4148
+ { offset: '80%', opacity: 1 },
4149
+ { offset: '100%', opacity: 0 },
4150
+ ])
4151
+ .enter()
4152
+ .append('stop')
4153
+ .attr('offset', (d) => d.offset)
4154
+ .attr('stop-color', color)
4155
+ .attr('stop-opacity', (d) => d.opacity);
4073
4156
  fill = `url(#${gradientId})`;
4157
+ stroke = `url(#${strokeGradientId})`;
4074
4158
  }
4075
4159
 
4076
4160
  evG
@@ -4081,7 +4165,7 @@ export function renderTimeline(
4081
4165
  .attr('height', BAR_H)
4082
4166
  .attr('rx', 4)
4083
4167
  .attr('fill', fill)
4084
- .attr('stroke', color)
4168
+ .attr('stroke', stroke)
4085
4169
  .attr('stroke-width', 2);
4086
4170
 
4087
4171
  if (labelFitsInside) {
@@ -4157,7 +4241,8 @@ export function renderTimeline(
4157
4241
  const mainSvg = d3Selection.select(container).select<SVGSVGElement>('svg');
4158
4242
  const mainG = mainSvg.select<SVGGElement>('g');
4159
4243
  if (!mainSvg.empty() && !mainG.empty()) {
4160
- const legendY = height - LG_HEIGHT - 4;
4244
+ // Position legend at top, below title
4245
+ const legendY = title ? 50 : 10;
4161
4246
 
4162
4247
  const groupBg = isDark
4163
4248
  ? mix(palette.surface, palette.bg, 50)
@@ -5914,7 +5999,7 @@ export async function renderForExport(
5914
5999
  const exportHeight = isLayout.height + PADDING * 2 + titleOffset;
5915
6000
  const container = createExportContainer(exportWidth, exportHeight);
5916
6001
 
5917
- renderInitiativeStatus(container, isParsed, isLayout, effectivePalette, theme === 'dark', undefined, { width: exportWidth, height: exportHeight });
6002
+ renderInitiativeStatus(container, isParsed, isLayout, effectivePalette, theme === 'dark', { exportDims: { width: exportWidth, height: exportHeight } });
5918
6003
  return finalizeSvgExport(container, theme, effectivePalette, options);
5919
6004
  }
5920
6005
 
@@ -6003,6 +6088,24 @@ export async function renderForExport(
6003
6088
  return finalizeSvgExport(container, theme, effectivePalette, options);
6004
6089
  }
6005
6090
 
6091
+ if (detectedType === 'gantt') {
6092
+ const { parseGantt } = await import('./gantt/parser');
6093
+ const { calculateSchedule } = await import('./gantt/calculator');
6094
+ const { renderGantt } = await import('./gantt/renderer');
6095
+
6096
+ const effectivePalette = await resolveExportPalette(theme, palette);
6097
+ const ganttParsed = parseGantt(content, effectivePalette);
6098
+ const resolved = calculateSchedule(ganttParsed);
6099
+ if (resolved.tasks.length === 0) return '';
6100
+
6101
+ const EXPORT_W = 1200;
6102
+ const EXPORT_H = 800;
6103
+ const container = createExportContainer(EXPORT_W, EXPORT_H);
6104
+
6105
+ renderGantt(container, resolved, effectivePalette, theme === 'dark', undefined, { width: EXPORT_W, height: EXPORT_H });
6106
+ return finalizeSvgExport(container, theme, effectivePalette, options);
6107
+ }
6108
+
6006
6109
  if (detectedType === 'state') {
6007
6110
  const { parseState } = await import('./graph/state-parser');
6008
6111
  const { layoutGraph } = await import('./graph/layout');
@@ -16,6 +16,7 @@ import { parseC4 } from './c4/parser';
16
16
  import { looksLikeInitiativeStatus, parseInitiativeStatus } from './initiative-status/parser';
17
17
  import { looksLikeSitemap, parseSitemap } from './sitemap/parser';
18
18
  import { parseInfra } from './infra/parser';
19
+ import { parseGantt } from './gantt/parser';
19
20
  import type { DgmoError } from './diagnostics';
20
21
 
21
22
  /**
@@ -65,7 +66,7 @@ const VISUALIZATION_TYPES = new Set([
65
66
  ]);
66
67
  const DIAGRAM_TYPES = new Set([
67
68
  'sequence', 'flowchart', 'class', 'er', 'org', 'kanban', 'c4',
68
- 'initiative-status', 'state', 'sitemap', 'infra',
69
+ 'initiative-status', 'state', 'sitemap', 'infra', 'gantt',
69
70
  ]);
70
71
  const EXTENDED_CHART_TYPES = new Set([
71
72
  'scatter', 'sankey', 'chord', 'function', 'heatmap', 'funnel',
@@ -128,6 +129,7 @@ const PARSE_DISPATCH = new Map<string, (content: string) => { diagnostics: DgmoE
128
129
  ['state', (c) => parseState(c)],
129
130
  ['sitemap', (c) => parseSitemap(c)],
130
131
  ['infra', (c) => parseInfra(c)],
132
+ ['gantt', (c) => parseGantt(c)],
131
133
  ]);
132
134
 
133
135
  /**
package/src/er/parser.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { resolveColor } from '../colors';
2
2
  import type { PaletteColors } from '../palettes';
3
3
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
4
- import { measureIndent, extractColor, parsePipeMetadata } from '../utils/parsing';
4
+ import { measureIndent, extractColor, parsePipeMetadata, MULTIPLE_PIPE_WARNING } from '../utils/parsing';
5
5
  import { matchTagBlockHeading, validateTagValues } from '../utils/tag-groups';
6
6
  import type { TagGroup } from '../utils/tag-groups';
7
7
  import type {
@@ -350,8 +350,10 @@ export function parseERDiagram(
350
350
  // Parse pipe metadata: TableName(color) | key: value, key2: value2
351
351
  const pipeStr = tableDecl[3]?.trim();
352
352
  if (pipeStr) {
353
- // parsePipeMetadata skips index 0 (name segment), so prepend empty
354
- const meta = parsePipeMetadata(['', pipeStr], aliasMap);
353
+ // Split on additional pipes (treated as commas) and warn if found
354
+ const pipeSegments = pipeStr.split('|');
355
+ const meta = parsePipeMetadata(['', ...pipeSegments], aliasMap,
356
+ () => result.diagnostics.push(makeDgmoError(lineNumber, MULTIPLE_PIPE_WARNING, 'warning')));
355
357
  Object.assign(table.metadata, meta);
356
358
  }
357
359
 
@@ -224,7 +224,13 @@ export function renderERDiagram(
224
224
 
225
225
  const useSemanticColors =
226
226
  parsed.tagGroups.length === 0 && layout.nodes.every((n) => !n.color);
227
- const legendReserveH = useSemanticColors ? LEGEND_HEIGHT + DIAGRAM_PADDING : 0;
227
+ const LEGEND_FIXED_GAP = 8;
228
+ const hasTagLegend = parsed.tagGroups.length > 0;
229
+ const legendReserveH = useSemanticColors
230
+ ? LEGEND_HEIGHT + LEGEND_FIXED_GAP
231
+ : hasTagLegend
232
+ ? LEGEND_HEIGHT + LEGEND_FIXED_GAP
233
+ : 0;
228
234
 
229
235
  const titleHeight = parsed.title ? 40 : 0;
230
236
  const diagramW = layout.width;
@@ -254,13 +260,13 @@ export function renderERDiagram(
254
260
  scale = Math.min(MAX_SCALE, scaleX, scaleY);
255
261
  const scaledW = diagramW * scale;
256
262
  offsetX = (viewW - scaledW) / 2;
257
- offsetY = titleHeight + DIAGRAM_PADDING;
263
+ offsetY = titleHeight + legendReserveH + DIAGRAM_PADDING;
258
264
  } else {
259
265
  viewW = naturalW;
260
266
  viewH = naturalH;
261
267
  scale = 1;
262
268
  offsetX = DIAGRAM_PADDING;
263
- offsetY = titleHeight + DIAGRAM_PADDING;
269
+ offsetY = titleHeight + legendReserveH + DIAGRAM_PADDING;
264
270
  }
265
271
 
266
272
  if (viewW <= 0 || viewH <= 0) return;
@@ -521,7 +527,7 @@ export function renderERDiagram(
521
527
  }
522
528
 
523
529
  let legendX = DIAGRAM_PADDING;
524
- let legendY = viewH - DIAGRAM_PADDING;
530
+ let legendY = DIAGRAM_PADDING + titleHeight;
525
531
 
526
532
  for (const group of parsed.tagGroups) {
527
533
  const groupG = legendG.append('g')
@@ -639,7 +645,7 @@ export function renderERDiagram(
639
645
  }
640
646
 
641
647
  const legendX = (viewW - totalWidth) / 2;
642
- const legendY = viewH - DIAGRAM_PADDING - LEGEND_HEIGHT;
648
+ const legendY = DIAGRAM_PADDING + titleHeight;
643
649
 
644
650
  const semanticLegendG = svg
645
651
  .append('g')