@diagrammo/dgmo 0.8.9 → 0.8.11

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 (46) hide show
  1. package/AGENTS.md +3 -0
  2. package/dist/cli.cjs +245 -672
  3. package/dist/editor.cjs.map +1 -1
  4. package/dist/editor.d.cts +2 -3
  5. package/dist/editor.d.ts +2 -3
  6. package/dist/editor.js.map +1 -1
  7. package/dist/index.cjs +1623 -800
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.d.cts +153 -1
  10. package/dist/index.d.ts +153 -1
  11. package/dist/index.js +1619 -802
  12. package/dist/index.js.map +1 -1
  13. package/docs/language-reference.md +28 -2
  14. package/gallery/fixtures/sitemap-full.dgmo +1 -0
  15. package/package.json +14 -17
  16. package/src/boxes-and-lines/layout.ts +48 -8
  17. package/src/boxes-and-lines/parser.ts +59 -13
  18. package/src/boxes-and-lines/renderer.ts +34 -138
  19. package/src/c4/layout.ts +31 -10
  20. package/src/c4/renderer.ts +25 -138
  21. package/src/class/renderer.ts +185 -186
  22. package/src/d3.ts +194 -222
  23. package/src/echarts.ts +56 -57
  24. package/src/editor/index.ts +1 -2
  25. package/src/er/renderer.ts +52 -245
  26. package/src/gantt/renderer.ts +140 -182
  27. package/src/gantt/resolver.ts +19 -14
  28. package/src/index.ts +23 -1
  29. package/src/infra/renderer.ts +91 -244
  30. package/src/kanban/renderer.ts +29 -133
  31. package/src/label-layout.ts +286 -0
  32. package/src/org/renderer.ts +103 -170
  33. package/src/render.ts +39 -9
  34. package/src/sequence/parser.ts +4 -0
  35. package/src/sequence/renderer.ts +47 -154
  36. package/src/sitemap/layout.ts +180 -38
  37. package/src/sitemap/parser.ts +64 -23
  38. package/src/sitemap/renderer.ts +73 -161
  39. package/src/utils/arrows.ts +1 -1
  40. package/src/utils/legend-constants.ts +6 -0
  41. package/src/utils/legend-d3.ts +400 -0
  42. package/src/utils/legend-layout.ts +491 -0
  43. package/src/utils/legend-svg.ts +28 -2
  44. package/src/utils/legend-types.ts +166 -0
  45. package/src/utils/parsing.ts +1 -1
  46. package/src/utils/tag-groups.ts +1 -1
@@ -22,6 +22,12 @@ import {
22
22
  LEGEND_ICON_W,
23
23
  measureLegendText,
24
24
  } from '../utils/legend-constants';
25
+ import { renderLegendD3 } from '../utils/legend-d3';
26
+ import type {
27
+ LegendConfig,
28
+ LegendState,
29
+ LegendCallbacks,
30
+ } from '../utils/legend-types';
25
31
  import {
26
32
  TITLE_FONT_SIZE,
27
33
  TITLE_FONT_WEIGHT,
@@ -1893,7 +1899,7 @@ function renderTagLegend(
1893
1899
  const isSwimlane =
1894
1900
  currentSwimlaneGroup?.toLowerCase() === group.name.toLowerCase();
1895
1901
  const showIcon = !legendViewMode && tagGroups.length > 0;
1896
- const iconReserve = showIcon ? LEGEND_ICON_W : 0;
1902
+ const iconReserve = showIcon && isActive ? LEGEND_ICON_W : 0;
1897
1903
  const pillW =
1898
1904
  measureLegendText(group.name, LEGEND_PILL_FONT_SIZE) +
1899
1905
  LEGEND_PILL_PAD +
@@ -1940,196 +1946,148 @@ function renderTagLegend(
1940
1946
 
1941
1947
  let cursorX = 0;
1942
1948
 
1943
- for (let i = 0; i < visibleGroups.length; i++) {
1944
- const group = visibleGroups[i];
1945
- const isActive =
1946
- activeGroupName?.toLowerCase() === group.name.toLowerCase();
1947
- const isSwimlane =
1948
- currentSwimlaneGroup?.toLowerCase() === group.name.toLowerCase();
1949
+ // Render tag groups via centralized legend system
1950
+ if (visibleGroups.length > 0) {
1949
1951
  const showIcon = !legendViewMode && tagGroups.length > 0;
1950
1952
  const iconReserve = showIcon ? LEGEND_ICON_W : 0;
1951
- const pillW =
1952
- measureLegendText(group.name, LEGEND_PILL_FONT_SIZE) +
1953
- LEGEND_PILL_PAD +
1954
- iconReserve;
1955
- const pillH = LEGEND_HEIGHT - LEGEND_CAPSULE_PAD * 2;
1956
- const groupW = groupWidths[i];
1957
-
1958
- const gEl = legendRow
1959
- .append('g')
1960
- .attr('transform', `translate(${cursorX}, 0)`)
1961
- .attr('class', 'gantt-tag-legend-group')
1962
- .attr('data-tag-group', group.name)
1963
- .attr('data-line-number', String(group.lineNumber))
1964
- .style('cursor', 'pointer')
1965
- .on('click', () => {
1966
- if (onToggle) onToggle(group.name);
1967
- });
1968
-
1969
- if (isActive) {
1970
- // Outer capsule background
1971
- gEl
1972
- .append('rect')
1973
- .attr('width', groupW)
1974
- .attr('height', LEGEND_HEIGHT)
1975
- .attr('rx', LEGEND_HEIGHT / 2)
1976
- .attr('fill', groupBg);
1977
- }
1978
1953
 
1979
- const pillXOff = isActive ? LEGEND_CAPSULE_PAD : 0;
1980
- const pillYOff = LEGEND_CAPSULE_PAD;
1981
-
1982
- // Pill background
1983
- gEl
1984
- .append('rect')
1985
- .attr('x', pillXOff)
1986
- .attr('y', pillYOff)
1987
- .attr('width', pillW)
1988
- .attr('height', pillH)
1989
- .attr('rx', pillH / 2)
1990
- .attr('fill', isActive ? palette.bg : groupBg);
1991
-
1992
- // Active pill border
1993
- if (isActive) {
1994
- gEl
1995
- .append('rect')
1996
- .attr('x', pillXOff)
1997
- .attr('y', pillYOff)
1998
- .attr('width', pillW)
1999
- .attr('height', pillH)
2000
- .attr('rx', pillH / 2)
2001
- .attr('fill', 'none')
2002
- .attr('stroke', mix(palette.textMuted, palette.bg, 50))
2003
- .attr('stroke-width', 0.75);
2004
- }
1954
+ // Build groups with filtered entries
1955
+ const legendGroups = visibleGroups.map((g) => {
1956
+ const key = g.name.toLowerCase();
1957
+ const entries = filteredEntries.get(key) ?? g.entries;
1958
+ return {
1959
+ name: g.name,
1960
+ entries: entries.map((e) => ({ value: e.value, color: e.color })),
1961
+ };
1962
+ });
2005
1963
 
2006
- // Pill text (offset to leave room for icon on right)
2007
- const textW =
2008
- measureLegendText(group.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
2009
- gEl
2010
- .append('text')
2011
- .attr('x', pillXOff + textW / 2)
2012
- .attr('y', LEGEND_HEIGHT / 2 + LEGEND_PILL_FONT_SIZE / 2 - 2)
2013
- .attr('text-anchor', 'middle')
2014
- .attr('font-size', `${LEGEND_PILL_FONT_SIZE}px`)
2015
- .attr('font-weight', '500')
2016
- .attr('fill', isActive || isSwimlane ? palette.text : palette.textMuted)
2017
- .text(group.name);
2018
-
2019
- // swimlane icon (after pill name)
2020
- if (showIcon) {
2021
- const iconX = pillXOff + textW + 3;
2022
- const iconY = (LEGEND_HEIGHT - 10) / 2;
2023
- const iconEl = drawSwimlaneIcon(gEl, iconX, iconY, isSwimlane, palette);
2024
- iconEl.append('title').text(`Group by ${group.name}`);
2025
- iconEl.style('cursor', 'pointer').on('click', (event: Event) => {
2026
- event.stopPropagation();
2027
- if (onSwimlaneChange) {
2028
- onSwimlaneChange(
2029
- currentSwimlaneGroup?.toLowerCase() === group.name.toLowerCase()
2030
- ? null
2031
- : group.name
2032
- );
1964
+ const legendConfig: LegendConfig = {
1965
+ groups: legendGroups,
1966
+ position: {
1967
+ placement: 'top-center' as const,
1968
+ titleRelation: 'below-title' as const,
1969
+ },
1970
+ mode: 'fixed' as const,
1971
+ capsulePillAddonWidth: iconReserve,
1972
+ };
1973
+ const legendState: LegendState = { activeGroup: activeGroupName };
1974
+
1975
+ const tagGroupsW =
1976
+ visibleGroups.reduce((s, _, i) => s + groupWidths[i], 0) +
1977
+ Math.max(0, (visibleGroups.length - 1) * LEGEND_GROUP_GAP);
1978
+ const tagGroupG = legendRow.append('g');
1979
+
1980
+ const legendCallbacks: LegendCallbacks = {
1981
+ onGroupToggle: onToggle,
1982
+ onEntryHover: (groupName, entryValue) => {
1983
+ const tagKey = groupName.toLowerCase();
1984
+ if (entryValue) {
1985
+ const ev = entryValue.toLowerCase();
1986
+ chartG
1987
+ .selectAll<SVGGElement, unknown>('.gantt-task')
1988
+ .each(function () {
1989
+ const el = d3Selection.select(this);
1990
+ el.attr(
1991
+ 'opacity',
1992
+ el.attr(`data-tag-${tagKey}`) === ev ? 1 : FADE_OPACITY
1993
+ );
1994
+ });
1995
+ chartG
1996
+ .selectAll<SVGElement, unknown>('.gantt-milestone')
1997
+ .attr('opacity', FADE_OPACITY);
1998
+ chartG
1999
+ .selectAll<
2000
+ SVGElement,
2001
+ unknown
2002
+ >('.gantt-group-bar, .gantt-group-summary')
2003
+ .attr('opacity', FADE_OPACITY);
2004
+ svg
2005
+ .selectAll<SVGTextElement, unknown>('.gantt-task-label')
2006
+ .each(function () {
2007
+ const el = d3Selection.select(this);
2008
+ el.attr(
2009
+ 'opacity',
2010
+ el.attr(`data-tag-${tagKey}`) === ev ? 1 : FADE_OPACITY
2011
+ );
2012
+ });
2013
+ svg
2014
+ .selectAll<SVGGElement, unknown>('.gantt-group-label')
2015
+ .attr('opacity', FADE_OPACITY);
2016
+ svg
2017
+ .selectAll<SVGGElement, unknown>('.gantt-lane-header')
2018
+ .each(function () {
2019
+ const el = d3Selection.select(this);
2020
+ el.attr(
2021
+ 'opacity',
2022
+ el.attr(`data-tag-${tagKey}`) === ev ? 1 : FADE_OPACITY
2023
+ );
2024
+ });
2025
+ chartG
2026
+ .selectAll<
2027
+ SVGElement,
2028
+ unknown
2029
+ >('.gantt-lane-band, .gantt-lane-accent')
2030
+ .attr('opacity', FADE_OPACITY);
2031
+ } else {
2032
+ if (criticalPathActive) {
2033
+ applyCriticalPathHighlight(svg, chartG);
2034
+ } else {
2035
+ resetHighlightAll(svg, chartG);
2036
+ }
2033
2037
  }
2034
- });
2035
- }
2036
-
2037
- // Entries (when active expanded color group, only used values)
2038
- if (isActive) {
2039
- const tagKey = group.name.toLowerCase();
2040
- const entries = filteredEntries.get(tagKey) ?? group.entries;
2041
- let ex = pillXOff + pillW + LEGEND_CAPSULE_PAD + 4;
2042
- for (const entry of entries) {
2043
- const entryValue = entry.value.toLowerCase();
2044
-
2045
- // Wrap dot + label in a <g> for hover targeting
2046
- const entryG = gEl
2047
- .append('g')
2048
- .attr('class', 'gantt-legend-entry')
2049
- .attr('data-line-number', String(entry.lineNumber))
2050
- .style('cursor', 'pointer');
2051
-
2052
- // Dot
2053
- entryG
2054
- .append('circle')
2055
- .attr('cx', ex + LEGEND_DOT_R)
2056
- .attr('cy', LEGEND_HEIGHT / 2)
2057
- .attr('r', LEGEND_DOT_R)
2058
- .attr('fill', entry.color);
2059
-
2060
- // Label
2061
- entryG
2062
- .append('text')
2063
- .attr('x', ex + LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP)
2064
- .attr('y', LEGEND_HEIGHT / 2 + LEGEND_ENTRY_FONT_SIZE / 2 - 2)
2065
- .attr('text-anchor', 'start')
2066
- .attr('font-size', `${LEGEND_ENTRY_FONT_SIZE}px`)
2067
- .attr('fill', palette.textMuted)
2068
- .text(entry.value);
2069
-
2070
- // Hover: highlight matching tasks + labels + lane headers, fade others
2071
- entryG
2072
- .on('mouseenter', () => {
2073
- chartG
2074
- .selectAll<SVGGElement, unknown>('.gantt-task')
2075
- .each(function () {
2076
- const el = d3Selection.select(this);
2077
- const matches = el.attr(`data-tag-${tagKey}`) === entryValue;
2078
- el.attr('opacity', matches ? 1 : FADE_OPACITY);
2079
- });
2080
- chartG
2081
- .selectAll<SVGElement, unknown>('.gantt-milestone')
2082
- .attr('opacity', FADE_OPACITY);
2083
- chartG
2084
- .selectAll<
2085
- SVGElement,
2086
- unknown
2087
- >('.gantt-group-bar, .gantt-group-summary')
2088
- .attr('opacity', FADE_OPACITY);
2089
- // Fade left-side task labels
2090
- svg
2091
- .selectAll<SVGTextElement, unknown>('.gantt-task-label')
2092
- .each(function () {
2093
- const el = d3Selection.select(this);
2094
- const matches = el.attr(`data-tag-${tagKey}`) === entryValue;
2095
- el.attr('opacity', matches ? 1 : FADE_OPACITY);
2096
- });
2097
- // Fade group labels
2098
- svg
2099
- .selectAll<SVGGElement, unknown>('.gantt-group-label')
2100
- .attr('opacity', FADE_OPACITY);
2101
- // Fade non-matching lane headers + bands + accents
2102
- svg
2103
- .selectAll<SVGGElement, unknown>('.gantt-lane-header')
2104
- .each(function () {
2105
- const el = d3Selection.select(this);
2106
- const matches = el.attr(`data-tag-${tagKey}`) === entryValue;
2107
- el.attr('opacity', matches ? 1 : FADE_OPACITY);
2108
- });
2109
- chartG
2110
- .selectAll<
2111
- SVGElement,
2112
- unknown
2113
- >('.gantt-lane-band, .gantt-lane-accent')
2114
- .attr('opacity', FADE_OPACITY);
2115
- })
2116
- .on('mouseleave', () => {
2117
- if (criticalPathActive) {
2118
- applyCriticalPathHighlight(svg, chartG);
2119
- } else {
2120
- resetHighlightAll(svg, chartG);
2038
+ },
2039
+ onGroupRendered: (groupName, groupEl, _isActive) => {
2040
+ // Add swimlane icon and data attributes
2041
+ const group = visibleGroups.find((g) => g.name === groupName);
2042
+ if (group) {
2043
+ groupEl
2044
+ .attr('data-tag-group', group.name)
2045
+ .attr('data-line-number', String(group.lineNumber));
2046
+ }
2047
+ if (showIcon && _isActive) {
2048
+ const isSwimlane =
2049
+ currentSwimlaneGroup?.toLowerCase() === groupName.toLowerCase();
2050
+ const textW =
2051
+ measureLegendText(groupName, LEGEND_PILL_FONT_SIZE) +
2052
+ LEGEND_PILL_PAD;
2053
+ const pillXOff = LEGEND_CAPSULE_PAD;
2054
+ const iconX = pillXOff + textW + 3;
2055
+ const iconY = (LEGEND_HEIGHT - 10) / 2;
2056
+ const iconEl = drawSwimlaneIcon(
2057
+ groupEl,
2058
+ iconX,
2059
+ iconY,
2060
+ isSwimlane,
2061
+ palette
2062
+ );
2063
+ iconEl.append('title').text(`Group by ${groupName}`);
2064
+ iconEl.style('cursor', 'pointer').on('click', (event: Event) => {
2065
+ event.stopPropagation();
2066
+ if (onSwimlaneChange) {
2067
+ onSwimlaneChange(
2068
+ currentSwimlaneGroup?.toLowerCase() === groupName.toLowerCase()
2069
+ ? null
2070
+ : groupName
2071
+ );
2121
2072
  }
2122
2073
  });
2074
+ }
2075
+ },
2076
+ };
2123
2077
 
2124
- ex +=
2125
- LEGEND_DOT_R * 2 +
2126
- LEGEND_ENTRY_DOT_GAP +
2127
- measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) +
2128
- LEGEND_ENTRY_TRAIL;
2129
- }
2130
- }
2078
+ renderLegendD3(
2079
+ tagGroupG,
2080
+ legendConfig,
2081
+ legendState,
2082
+ palette,
2083
+ isDark,
2084
+ legendCallbacks,
2085
+ tagGroupsW
2086
+ );
2131
2087
 
2132
- cursorX += groupW + LEGEND_GROUP_GAP;
2088
+ for (let i = 0; i < visibleGroups.length; i++) {
2089
+ cursorX += groupWidths[i] + LEGEND_GROUP_GAP;
2090
+ }
2133
2091
  }
2134
2092
 
2135
2093
  // Critical Path pill
@@ -7,16 +7,16 @@
7
7
 
8
8
  import type { GanttTask, GanttNode } from './types';
9
9
 
10
- export interface ResolverMatch {
10
+ interface ResolverMatch {
11
11
  task: GanttTask;
12
12
  }
13
13
 
14
- export interface ResolverError {
14
+ interface ResolverError {
15
15
  kind: 'not_found' | 'ambiguous';
16
16
  message: string;
17
17
  }
18
18
 
19
- export type ResolverResult = ResolverMatch | ResolverError;
19
+ type ResolverResult = ResolverMatch | ResolverError;
20
20
 
21
21
  export function isResolverError(r: ResolverResult): r is ResolverError {
22
22
  return 'kind' in r;
@@ -53,23 +53,23 @@ export function collectTasks(nodes: GanttNode[]): GanttTask[] {
53
53
  */
54
54
  export function resolveTaskName(
55
55
  name: string,
56
- allTasks: GanttTask[],
56
+ allTasks: GanttTask[]
57
57
  ): ResolverResult {
58
58
  const trimmed = name.trim();
59
59
 
60
60
  // 1. Try exact label match (no dots involved)
61
- const exactMatches = allTasks.filter(t => t.label === trimmed);
61
+ const exactMatches = allTasks.filter((t) => t.label === trimmed);
62
62
  if (exactMatches.length === 1) {
63
63
  return { task: exactMatches[0] };
64
64
  }
65
65
  if (exactMatches.length > 1) {
66
66
  // Multiple tasks with same name — need disambiguation
67
- const suggestions = exactMatches.map(t =>
67
+ const suggestions = exactMatches.map((t) =>
68
68
  t.groupPath.length > 0 ? `${t.groupPath.join('.')}.${t.label}` : t.label
69
69
  );
70
70
  return {
71
71
  kind: 'ambiguous',
72
- message: `Multiple tasks match "${trimmed}". Did you mean ${suggestions.map(s => `\`${s}\``).join(' or ')}?`,
72
+ message: `Multiple tasks match "${trimmed}". Did you mean ${suggestions.map((s) => `\`${s}\``).join(' or ')}?`,
73
73
  };
74
74
  }
75
75
 
@@ -80,7 +80,7 @@ export function resolveTaskName(
80
80
  const taskLabel = trimmed.substring(lastDotIdx + 1);
81
81
 
82
82
  // Find tasks whose label matches and whose group path ends with the prefix
83
- const matches = allTasks.filter(t => {
83
+ const matches = allTasks.filter((t) => {
84
84
  if (t.label !== taskLabel) return false;
85
85
  return matchesGroupPath(t.groupPath, groupPrefix);
86
86
  });
@@ -89,12 +89,12 @@ export function resolveTaskName(
89
89
  return { task: matches[0] };
90
90
  }
91
91
  if (matches.length > 1) {
92
- const suggestions = matches.map(t =>
92
+ const suggestions = matches.map((t) =>
93
93
  t.groupPath.length > 0 ? `${t.groupPath.join('.')}.${t.label}` : t.label
94
94
  );
95
95
  return {
96
96
  kind: 'ambiguous',
97
- message: `Multiple tasks match "${trimmed}". Did you mean ${suggestions.map(s => `\`${s}\``).join(' or ')}?`,
97
+ message: `Multiple tasks match "${trimmed}". Did you mean ${suggestions.map((s) => `\`${s}\``).join(' or ')}?`,
98
98
  };
99
99
  }
100
100
 
@@ -106,8 +106,8 @@ export function resolveTaskName(
106
106
  }
107
107
 
108
108
  // 3. No match found — try case-insensitive as a fallback for suggestions
109
- const caseInsensitive = allTasks.filter(t =>
110
- t.label.toLowerCase() === trimmed.toLowerCase()
109
+ const caseInsensitive = allTasks.filter(
110
+ (t) => t.label.toLowerCase() === trimmed.toLowerCase()
111
111
  );
112
112
  if (caseInsensitive.length > 0) {
113
113
  return {
@@ -134,11 +134,16 @@ export function resolveTaskName(
134
134
  function matchesGroupPath(groupPath: string[], prefix: string): boolean {
135
135
  // Simple case: prefix is a single segment
136
136
  if (!prefix.includes('.')) {
137
- return groupPath.some(g => g === prefix);
137
+ return groupPath.some((g) => g === prefix);
138
138
  }
139
139
 
140
140
  // Multi-segment prefix: try matching from the start of the group path
141
141
  const pathStr = groupPath.join('.');
142
142
  // Check if the full prefix matches any contiguous section of the path
143
- return pathStr === prefix || pathStr.endsWith('.' + prefix) || pathStr.startsWith(prefix + '.') || pathStr.includes('.' + prefix + '.');
143
+ return (
144
+ pathStr === prefix ||
145
+ pathStr.endsWith('.' + prefix) ||
146
+ pathStr.startsWith(prefix + '.') ||
147
+ pathStr.includes('.' + prefix + '.')
148
+ );
144
149
  }
package/src/index.ts CHANGED
@@ -356,8 +356,28 @@ export {
356
356
  computeScatterLabelGraphics,
357
357
  } from './echarts';
358
358
  export type { ScatterLabelPoint } from './echarts';
359
- export { renderLegendSvg, type LegendGroupData } from './utils/legend-svg';
359
+ export {
360
+ renderLegendSvg,
361
+ renderLegendSvgFromConfig,
362
+ type LegendGroupData,
363
+ } from './utils/legend-svg';
360
364
  export { LEGEND_HEIGHT } from './utils/legend-constants';
365
+ export { renderLegendD3 } from './utils/legend-d3';
366
+ export {
367
+ computeLegendLayout,
368
+ getLegendReservedHeight,
369
+ } from './utils/legend-layout';
370
+ export type {
371
+ LegendConfig,
372
+ LegendState,
373
+ LegendCallbacks,
374
+ LegendPosition,
375
+ LegendMode,
376
+ LegendControl,
377
+ LegendLayout,
378
+ LegendHandle,
379
+ LegendPalette,
380
+ } from './utils/legend-types';
361
381
  export { buildMermaidQuadrant } from './dgmo-mermaid';
362
382
 
363
383
  // ============================================================
@@ -420,6 +440,8 @@ export {
420
440
  tokyoNightPalette,
421
441
  oneDarkPalette,
422
442
  boldPalette,
443
+ draculaPalette,
444
+ monokaiPalette,
423
445
  // Mermaid bridge
424
446
  buildMermaidThemeVars,
425
447
  buildThemeCSS,