@diagrammo/dgmo 0.6.0 → 0.6.2
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/.claude/commands/dgmo.md +76 -0
- package/dist/cli.cjs +164 -162
- package/dist/index.cjs +1146 -647
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +9 -21
- package/dist/index.d.ts +9 -21
- package/dist/index.js +1146 -647
- package/dist/index.js.map +1 -1
- package/docs/ai-integration.md +33 -50
- package/package.json +4 -3
- package/src/c4/layout.ts +75 -72
- package/src/c4/renderer.ts +122 -119
- package/src/cli.ts +130 -40
- package/src/d3.ts +55 -35
- package/src/echarts.ts +24 -24
- package/src/er/classify.ts +206 -0
- package/src/er/layout.ts +259 -94
- package/src/er/renderer.ts +246 -26
- package/src/index.ts +2 -2
- package/src/infra/compute.ts +1 -21
- package/src/infra/layout.ts +60 -13
- package/src/infra/parser.ts +5 -32
- package/src/infra/renderer.ts +403 -196
- package/src/infra/types.ts +1 -11
- package/src/initiative-status/layout.ts +46 -27
- package/src/kanban/renderer.ts +28 -24
- package/src/org/renderer.ts +24 -23
- package/src/render.ts +2 -2
- package/src/sequence/renderer.ts +24 -19
- package/src/sitemap/layout.ts +7 -14
- package/src/sitemap/renderer.ts +30 -29
- package/src/utils/legend-constants.ts +25 -0
- package/.claude/skills/dgmo-chart/SKILL.md +0 -141
- package/.claude/skills/dgmo-flowchart/SKILL.md +0 -61
- package/.claude/skills/dgmo-generate/SKILL.md +0 -59
- package/.claude/skills/dgmo-sequence/SKILL.md +0 -104
package/src/d3.ts
CHANGED
|
@@ -184,6 +184,19 @@ import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
|
|
|
184
184
|
import { collectIndentedValues, extractColor, parsePipeMetadata } from './utils/parsing';
|
|
185
185
|
import { matchTagBlockHeading, validateTagValues, resolveTagColor } from './utils/tag-groups';
|
|
186
186
|
import type { TagGroup } from './utils/tag-groups';
|
|
187
|
+
import {
|
|
188
|
+
LEGEND_HEIGHT as TL_LEGEND_HEIGHT,
|
|
189
|
+
LEGEND_PILL_PAD as TL_LEGEND_PILL_PAD,
|
|
190
|
+
LEGEND_PILL_FONT_SIZE as TL_LEGEND_PILL_FONT_SIZE,
|
|
191
|
+
LEGEND_PILL_FONT_W as TL_LEGEND_PILL_FONT_W,
|
|
192
|
+
LEGEND_CAPSULE_PAD as TL_LEGEND_CAPSULE_PAD,
|
|
193
|
+
LEGEND_DOT_R as TL_LEGEND_DOT_R,
|
|
194
|
+
LEGEND_ENTRY_FONT_SIZE as TL_LEGEND_ENTRY_FONT_SIZE,
|
|
195
|
+
LEGEND_ENTRY_FONT_W as TL_LEGEND_ENTRY_FONT_W,
|
|
196
|
+
LEGEND_ENTRY_DOT_GAP as TL_LEGEND_ENTRY_DOT_GAP,
|
|
197
|
+
LEGEND_ENTRY_TRAIL as TL_LEGEND_ENTRY_TRAIL,
|
|
198
|
+
LEGEND_GROUP_GAP as TL_LEGEND_GROUP_GAP,
|
|
199
|
+
} from './utils/legend-constants';
|
|
187
200
|
|
|
188
201
|
// ============================================================
|
|
189
202
|
// Shared Rendering Helpers
|
|
@@ -3047,7 +3060,7 @@ export function renderTimeline(
|
|
|
3047
3060
|
}
|
|
3048
3061
|
}
|
|
3049
3062
|
|
|
3050
|
-
// Reserve space for tag legend
|
|
3063
|
+
// Reserve space for tag legend at the bottom of chart content
|
|
3051
3064
|
const tagLegendReserve = parsed.timelineTagGroups.length > 0 ? 36 : 0;
|
|
3052
3065
|
|
|
3053
3066
|
// ================================================================
|
|
@@ -3087,9 +3100,9 @@ export function renderTimeline(
|
|
|
3087
3100
|
const scaleMargin = timelineScale ? 40 : 0;
|
|
3088
3101
|
const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
|
|
3089
3102
|
const margin = {
|
|
3090
|
-
top: 104 + markerMargin
|
|
3103
|
+
top: 104 + markerMargin,
|
|
3091
3104
|
right: 40 + scaleMargin,
|
|
3092
|
-
bottom: 40,
|
|
3105
|
+
bottom: 40 + tagLegendReserve,
|
|
3093
3106
|
left: 60 + scaleMargin,
|
|
3094
3107
|
};
|
|
3095
3108
|
const innerWidth = width - margin.left - margin.right;
|
|
@@ -3309,9 +3322,9 @@ export function renderTimeline(
|
|
|
3309
3322
|
const scaleMargin = timelineScale ? 40 : 0;
|
|
3310
3323
|
const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
|
|
3311
3324
|
const margin = {
|
|
3312
|
-
top: 104 + markerMargin
|
|
3325
|
+
top: 104 + markerMargin,
|
|
3313
3326
|
right: 200,
|
|
3314
|
-
bottom: 40,
|
|
3327
|
+
bottom: 40 + tagLegendReserve,
|
|
3315
3328
|
left: 60 + scaleMargin,
|
|
3316
3329
|
};
|
|
3317
3330
|
const innerWidth = width - margin.left - margin.right;
|
|
@@ -3590,9 +3603,9 @@ export function renderTimeline(
|
|
|
3590
3603
|
// Group-sorted doesn't need legend space (group names shown on left)
|
|
3591
3604
|
const baseTopMargin = title ? 50 : 20;
|
|
3592
3605
|
const margin = {
|
|
3593
|
-
top: baseTopMargin + (timelineScale ? 40 : 0) + markerMargin
|
|
3606
|
+
top: baseTopMargin + (timelineScale ? 40 : 0) + markerMargin,
|
|
3594
3607
|
right: 40,
|
|
3595
|
-
bottom: 40 + scaleMargin,
|
|
3608
|
+
bottom: 40 + scaleMargin + tagLegendReserve,
|
|
3596
3609
|
left: dynamicLeftMargin,
|
|
3597
3610
|
};
|
|
3598
3611
|
const innerWidth = width - margin.left - margin.right;
|
|
@@ -3869,9 +3882,9 @@ export function renderTimeline(
|
|
|
3869
3882
|
const scaleMargin = timelineScale ? 24 : 0;
|
|
3870
3883
|
const markerMargin = timelineMarkers.length > 0 ? 30 : 0;
|
|
3871
3884
|
const margin = {
|
|
3872
|
-
top: 104 + (timelineScale ? 40 : 0) + markerMargin
|
|
3885
|
+
top: 104 + (timelineScale ? 40 : 0) + markerMargin,
|
|
3873
3886
|
right: 40,
|
|
3874
|
-
bottom: 40 + scaleMargin,
|
|
3887
|
+
bottom: 40 + scaleMargin + tagLegendReserve,
|
|
3875
3888
|
left: 60,
|
|
3876
3889
|
};
|
|
3877
3890
|
const innerWidth = width - margin.left - margin.right;
|
|
@@ -4121,23 +4134,23 @@ export function renderTimeline(
|
|
|
4121
4134
|
|
|
4122
4135
|
// ── Tag Legend (org-chart-style pills) ──
|
|
4123
4136
|
if (parsed.timelineTagGroups.length > 0) {
|
|
4124
|
-
const LG_HEIGHT =
|
|
4125
|
-
const LG_PILL_PAD =
|
|
4126
|
-
const LG_PILL_FONT_SIZE =
|
|
4127
|
-
const LG_PILL_FONT_W =
|
|
4128
|
-
const LG_CAPSULE_PAD =
|
|
4129
|
-
const LG_DOT_R =
|
|
4130
|
-
const LG_ENTRY_FONT_SIZE =
|
|
4131
|
-
const LG_ENTRY_FONT_W =
|
|
4132
|
-
const LG_ENTRY_DOT_GAP =
|
|
4133
|
-
const LG_ENTRY_TRAIL =
|
|
4134
|
-
const LG_GROUP_GAP =
|
|
4135
|
-
const LG_ICON_W = 20; // swimlane icon area (icon + surrounding space)
|
|
4137
|
+
const LG_HEIGHT = TL_LEGEND_HEIGHT;
|
|
4138
|
+
const LG_PILL_PAD = TL_LEGEND_PILL_PAD;
|
|
4139
|
+
const LG_PILL_FONT_SIZE = TL_LEGEND_PILL_FONT_SIZE;
|
|
4140
|
+
const LG_PILL_FONT_W = TL_LEGEND_PILL_FONT_W;
|
|
4141
|
+
const LG_CAPSULE_PAD = TL_LEGEND_CAPSULE_PAD;
|
|
4142
|
+
const LG_DOT_R = TL_LEGEND_DOT_R;
|
|
4143
|
+
const LG_ENTRY_FONT_SIZE = TL_LEGEND_ENTRY_FONT_SIZE;
|
|
4144
|
+
const LG_ENTRY_FONT_W = TL_LEGEND_ENTRY_FONT_W;
|
|
4145
|
+
const LG_ENTRY_DOT_GAP = TL_LEGEND_ENTRY_DOT_GAP;
|
|
4146
|
+
const LG_ENTRY_TRAIL = TL_LEGEND_ENTRY_TRAIL;
|
|
4147
|
+
const LG_GROUP_GAP = TL_LEGEND_GROUP_GAP;
|
|
4148
|
+
const LG_ICON_W = 20; // swimlane icon area (icon + surrounding space) — local
|
|
4136
4149
|
|
|
4137
4150
|
const mainSvg = d3Selection.select(container).select<SVGSVGElement>('svg');
|
|
4138
4151
|
const mainG = mainSvg.select<SVGGElement>('g');
|
|
4139
4152
|
if (!mainSvg.empty() && !mainG.empty()) {
|
|
4140
|
-
const legendY =
|
|
4153
|
+
const legendY = height - LG_HEIGHT - 4;
|
|
4141
4154
|
|
|
4142
4155
|
const groupBg = isDark
|
|
4143
4156
|
? mix(palette.surface, palette.bg, 50)
|
|
@@ -4212,6 +4225,7 @@ export function renderTimeline(
|
|
|
4212
4225
|
function drawLegend() {
|
|
4213
4226
|
// Remove previous legend
|
|
4214
4227
|
mainSvg.selectAll('.tl-tag-legend-group').remove();
|
|
4228
|
+
mainSvg.selectAll('.tl-tag-legend-container').remove();
|
|
4215
4229
|
|
|
4216
4230
|
// Effective color source: explicit color group > swimlane group
|
|
4217
4231
|
const effectiveColorKey = (currentActiveGroup ?? currentSwimlaneGroup)?.toLowerCase() ?? null;
|
|
@@ -4238,6 +4252,13 @@ export function renderTimeline(
|
|
|
4238
4252
|
|
|
4239
4253
|
let cx = (width - totalW) / 2;
|
|
4240
4254
|
|
|
4255
|
+
// Legend container for data-legend-active attribute
|
|
4256
|
+
const legendContainer = mainSvg.append('g')
|
|
4257
|
+
.attr('class', 'tl-tag-legend-container');
|
|
4258
|
+
if (currentActiveGroup) {
|
|
4259
|
+
legendContainer.attr('data-legend-active', currentActiveGroup.toLowerCase());
|
|
4260
|
+
}
|
|
4261
|
+
|
|
4241
4262
|
for (const lg of visibleGroups) {
|
|
4242
4263
|
const groupKey = lg.group.name.toLowerCase();
|
|
4243
4264
|
const isActive = viewMode ||
|
|
@@ -4249,7 +4270,7 @@ export function renderTimeline(
|
|
|
4249
4270
|
const pillLabel = lg.group.name;
|
|
4250
4271
|
const pillWidth = pillLabel.length * LG_PILL_FONT_W + LG_PILL_PAD;
|
|
4251
4272
|
|
|
4252
|
-
const gEl =
|
|
4273
|
+
const gEl = legendContainer
|
|
4253
4274
|
.append('g')
|
|
4254
4275
|
.attr('transform', `translate(${cx}, ${legendY})`)
|
|
4255
4276
|
.attr('class', 'tl-tag-legend-group tl-tag-legend-entry')
|
|
@@ -5781,7 +5802,7 @@ export async function renderForExport(
|
|
|
5781
5802
|
hiddenAttributes?: Set<string>;
|
|
5782
5803
|
swimlaneTagGroup?: string | null;
|
|
5783
5804
|
},
|
|
5784
|
-
options?: { branding?: boolean; c4Level?: 'context' | 'containers' | 'components' | 'deployment'; c4System?: string; c4Container?: string;
|
|
5805
|
+
options?: { branding?: boolean; c4Level?: 'context' | 'containers' | 'components' | 'deployment'; c4System?: string; c4Container?: string; tagGroup?: string }
|
|
5785
5806
|
): Promise<string> {
|
|
5786
5807
|
// Flowchart and org chart use their own parser pipelines — intercept before parseVisualization()
|
|
5787
5808
|
const { parseDgmoChartType } = await import('./dgmo-router');
|
|
@@ -5801,7 +5822,7 @@ export async function renderForExport(
|
|
|
5801
5822
|
|
|
5802
5823
|
// Apply interactive collapse state when provided
|
|
5803
5824
|
const collapsedNodes = orgExportState?.collapsedNodes;
|
|
5804
|
-
const activeTagGroup = orgExportState?.activeTagGroup ?? null;
|
|
5825
|
+
const activeTagGroup = orgExportState?.activeTagGroup ?? options?.tagGroup ?? null;
|
|
5805
5826
|
const hiddenAttributes = orgExportState?.hiddenAttributes;
|
|
5806
5827
|
|
|
5807
5828
|
const { parsed: effectiveParsed, hiddenCounts } =
|
|
@@ -5841,7 +5862,7 @@ export async function renderForExport(
|
|
|
5841
5862
|
|
|
5842
5863
|
// Apply interactive collapse state when provided
|
|
5843
5864
|
const collapsedNodes = orgExportState?.collapsedNodes;
|
|
5844
|
-
const activeTagGroup = orgExportState?.activeTagGroup ?? null;
|
|
5865
|
+
const activeTagGroup = orgExportState?.activeTagGroup ?? options?.tagGroup ?? null;
|
|
5845
5866
|
const hiddenAttributes = orgExportState?.hiddenAttributes;
|
|
5846
5867
|
|
|
5847
5868
|
const { parsed: effectiveParsed, hiddenCounts } =
|
|
@@ -5881,7 +5902,7 @@ export async function renderForExport(
|
|
|
5881
5902
|
container.style.left = '-9999px';
|
|
5882
5903
|
document.body.appendChild(container);
|
|
5883
5904
|
|
|
5884
|
-
renderKanban(container, kanbanParsed, effectivePalette, theme === 'dark');
|
|
5905
|
+
renderKanban(container, kanbanParsed, effectivePalette, theme === 'dark', undefined, undefined, options?.tagGroup);
|
|
5885
5906
|
return finalizeSvgExport(container, theme, effectivePalette, options);
|
|
5886
5907
|
}
|
|
5887
5908
|
|
|
@@ -5921,7 +5942,7 @@ export async function renderForExport(
|
|
|
5921
5942
|
const exportHeight = erLayout.height + PADDING * 2 + titleOffset;
|
|
5922
5943
|
const container = createExportContainer(exportWidth, exportHeight);
|
|
5923
5944
|
|
|
5924
|
-
renderERDiagram(container, erParsed, erLayout, effectivePalette, theme === 'dark', undefined, { width: exportWidth, height: exportHeight });
|
|
5945
|
+
renderERDiagram(container, erParsed, erLayout, effectivePalette, theme === 'dark', undefined, { width: exportWidth, height: exportHeight }, options?.tagGroup);
|
|
5925
5946
|
return finalizeSvgExport(container, theme, effectivePalette, options);
|
|
5926
5947
|
}
|
|
5927
5948
|
|
|
@@ -5979,7 +6000,7 @@ export async function renderForExport(
|
|
|
5979
6000
|
? renderC4Containers
|
|
5980
6001
|
: renderC4Context;
|
|
5981
6002
|
|
|
5982
|
-
renderFn(container, c4Parsed, c4Layout, effectivePalette, theme === 'dark', undefined, { width: exportWidth, height: exportHeight });
|
|
6003
|
+
renderFn(container, c4Parsed, c4Layout, effectivePalette, theme === 'dark', undefined, { width: exportWidth, height: exportHeight }, options?.tagGroup);
|
|
5983
6004
|
return finalizeSvgExport(container, theme, effectivePalette, options);
|
|
5984
6005
|
}
|
|
5985
6006
|
|
|
@@ -6009,11 +6030,9 @@ export async function renderForExport(
|
|
|
6009
6030
|
const infraParsed = parseInfra(content);
|
|
6010
6031
|
if (infraParsed.error || infraParsed.nodes.length === 0) return '';
|
|
6011
6032
|
|
|
6012
|
-
const
|
|
6013
|
-
? infraParsed.scenarios.find((s) => s.name.toLowerCase() === options.scenario!.toLowerCase()) ?? null
|
|
6014
|
-
: null;
|
|
6015
|
-
const infraComputed = computeInfra(infraParsed, selectedScenario ? { scenario: selectedScenario } : {});
|
|
6033
|
+
const infraComputed = computeInfra(infraParsed);
|
|
6016
6034
|
const infraLayout = layoutInfra(infraComputed);
|
|
6035
|
+
const activeTagGroup = options?.tagGroup ?? null;
|
|
6017
6036
|
|
|
6018
6037
|
const titleOffset = infraParsed.title ? 40 : 0;
|
|
6019
6038
|
const legendGroups = computeInfraLegendGroups(infraLayout.nodes, infraParsed.tagGroups, effectivePalette);
|
|
@@ -6022,7 +6041,7 @@ export async function renderForExport(
|
|
|
6022
6041
|
const exportHeight = infraLayout.height + titleOffset + legendOffset;
|
|
6023
6042
|
const container = createExportContainer(exportWidth, exportHeight);
|
|
6024
6043
|
|
|
6025
|
-
renderInfra(container, infraLayout, effectivePalette, theme === 'dark', infraParsed.title, infraParsed.titleLineNumber, infraParsed.tagGroups,
|
|
6044
|
+
renderInfra(container, infraLayout, effectivePalette, theme === 'dark', infraParsed.title, infraParsed.titleLineNumber, infraParsed.tagGroups, activeTagGroup, false, null, null, true);
|
|
6026
6045
|
// Restore explicit pixel dimensions for resvg (renderer uses 100%/viewBox for app scaling)
|
|
6027
6046
|
const infraSvg = container.querySelector('svg');
|
|
6028
6047
|
if (infraSvg) {
|
|
@@ -6079,6 +6098,7 @@ export async function renderForExport(
|
|
|
6079
6098
|
if (seqParsed.error || seqParsed.participants.length === 0) return '';
|
|
6080
6099
|
renderSequenceDiagram(container, seqParsed, effectivePalette, isDark, undefined, {
|
|
6081
6100
|
exportWidth: EXPORT_WIDTH,
|
|
6101
|
+
activeTagGroup: options?.tagGroup,
|
|
6082
6102
|
});
|
|
6083
6103
|
} else if (parsed.type === 'wordcloud') {
|
|
6084
6104
|
await renderWordCloudAsync(container, parsed, effectivePalette, isDark, dims);
|
|
@@ -6086,7 +6106,7 @@ export async function renderForExport(
|
|
|
6086
6106
|
renderArcDiagram(container, parsed, effectivePalette, isDark, undefined, dims);
|
|
6087
6107
|
} else if (parsed.type === 'timeline') {
|
|
6088
6108
|
renderTimeline(container, parsed, effectivePalette, isDark, undefined, dims,
|
|
6089
|
-
orgExportState?.activeTagGroup, orgExportState?.swimlaneTagGroup);
|
|
6109
|
+
orgExportState?.activeTagGroup ?? options?.tagGroup, orgExportState?.swimlaneTagGroup);
|
|
6090
6110
|
} else if (parsed.type === 'venn') {
|
|
6091
6111
|
renderVenn(container, parsed, effectivePalette, isDark, undefined, dims);
|
|
6092
6112
|
} else if (parsed.type === 'quadrant') {
|
package/src/echarts.ts
CHANGED
|
@@ -1323,7 +1323,7 @@ function makeGridAxis(
|
|
|
1323
1323
|
data?: string[],
|
|
1324
1324
|
nameGapOverride?: number,
|
|
1325
1325
|
chartWidthHint?: number,
|
|
1326
|
-
intervalOverride?:
|
|
1326
|
+
intervalOverride?: number
|
|
1327
1327
|
): Record<string, unknown> {
|
|
1328
1328
|
const defaultGap = type === 'value' ? 75 : 40;
|
|
1329
1329
|
|
|
@@ -1333,13 +1333,17 @@ function makeGridAxis(
|
|
|
1333
1333
|
if (type === 'category' && data && data.length > 0) {
|
|
1334
1334
|
const maxLabelLen = Math.max(...data.map((l) => l.length));
|
|
1335
1335
|
const count = data.length;
|
|
1336
|
+
// When interval skips labels, base sizing on visible count (≈ count / step)
|
|
1337
|
+
const step = intervalOverride != null && intervalOverride > 0 ? intervalOverride + 1 : 1;
|
|
1338
|
+
const visibleCount = Math.ceil(count / step);
|
|
1336
1339
|
// Reduce font size based on density and label length
|
|
1337
|
-
if (
|
|
1338
|
-
else if (
|
|
1340
|
+
if (visibleCount > 10 || maxLabelLen > 20) catFontSize = 10;
|
|
1341
|
+
else if (visibleCount > 5 || maxLabelLen > 14) catFontSize = 11;
|
|
1339
1342
|
else if (maxLabelLen > 8) catFontSize = 12;
|
|
1340
1343
|
|
|
1341
|
-
// Constrain labels to their allotted slot width so ECharts wraps instead of hiding
|
|
1342
|
-
|
|
1344
|
+
// Constrain labels to their allotted slot width so ECharts wraps instead of hiding.
|
|
1345
|
+
// Skip when interval > 0 — visible labels are spread out and need no constraint.
|
|
1346
|
+
if ((intervalOverride == null || intervalOverride === 0) && chartWidthHint && count > 0) {
|
|
1343
1347
|
const availPerLabel = Math.floor((chartWidthHint * 0.85) / count);
|
|
1344
1348
|
catLabelExtras = {
|
|
1345
1349
|
width: availPerLabel,
|
|
@@ -1358,6 +1362,9 @@ function makeGridAxis(
|
|
|
1358
1362
|
fontFamily: FONT_FAMILY,
|
|
1359
1363
|
...(type === 'category' && {
|
|
1360
1364
|
interval: intervalOverride ?? 0,
|
|
1365
|
+
// Prevent ECharts auto-rotation: it measures raw slot width (chartWidth/N),
|
|
1366
|
+
// which is too narrow when an interval skips most labels, and rotates to 90°.
|
|
1367
|
+
rotate: 0,
|
|
1361
1368
|
formatter: (value: string) =>
|
|
1362
1369
|
value.replace(/([a-z])([A-Z])/g, '$1\n$2'),
|
|
1363
1370
|
...catLabelExtras,
|
|
@@ -1477,24 +1484,17 @@ function buildBarOption(
|
|
|
1477
1484
|
|
|
1478
1485
|
// ── Era band helpers ──────────────────────────────────────────
|
|
1479
1486
|
|
|
1480
|
-
|
|
1481
|
-
|
|
1482
|
-
|
|
1483
|
-
|
|
1487
|
+
// Returns an integer interval for ECharts axisLabel.interval.
|
|
1488
|
+
// interval: N means show label at index 0, N+1, 2*(N+1), ...
|
|
1489
|
+
// For a desired step S we return S-1.
|
|
1490
|
+
// Targets ~5 visible labels — conservative enough to prevent ECharts stagger.
|
|
1491
|
+
function buildIntervalStep(labels: string[]): number {
|
|
1484
1492
|
const count = labels.length;
|
|
1485
|
-
if (count <=
|
|
1486
|
-
const snapSteps = [1, 2,
|
|
1487
|
-
const raw = Math.ceil(count /
|
|
1493
|
+
if (count <= 6) return 0; // show all
|
|
1494
|
+
const snapSteps = [1, 2, 5, 10, 25, 50, 100];
|
|
1495
|
+
const raw = Math.ceil(count / 5); // target ~5 visible labels
|
|
1488
1496
|
const N = [...snapSteps].reverse().find((s) => s <= raw) ?? 1; // snap down
|
|
1489
|
-
|
|
1490
|
-
for (let i = 0; i < count; i += N) pinned.add(i);
|
|
1491
|
-
for (const era of eras) {
|
|
1492
|
-
const si = labels.indexOf(era.start);
|
|
1493
|
-
const ei = labels.indexOf(era.end);
|
|
1494
|
-
if (si >= 0) pinned.add(si);
|
|
1495
|
-
if (ei >= 0) pinned.add(ei);
|
|
1496
|
-
}
|
|
1497
|
-
return (index: number) => pinned.has(index);
|
|
1497
|
+
return N - 1; // ECharts shows labels at indices 0, N, 2N, ...
|
|
1498
1498
|
}
|
|
1499
1499
|
|
|
1500
1500
|
function buildMarkArea(
|
|
@@ -1548,7 +1548,7 @@ function buildLineOption(
|
|
|
1548
1548
|
const labels = parsed.data.map((d) => d.label);
|
|
1549
1549
|
const values = parsed.data.map((d) => d.value);
|
|
1550
1550
|
const eras = parsed.eras ?? [];
|
|
1551
|
-
const interval =
|
|
1551
|
+
const interval = buildIntervalStep(labels);
|
|
1552
1552
|
const markArea = buildMarkArea(eras, labels, textColor, palette.colors.blue);
|
|
1553
1553
|
|
|
1554
1554
|
return {
|
|
@@ -1595,7 +1595,7 @@ function buildMultiLineOption(
|
|
|
1595
1595
|
const seriesNames = parsed.seriesNames ?? [];
|
|
1596
1596
|
const labels = parsed.data.map((d) => d.label);
|
|
1597
1597
|
const eras = parsed.eras ?? [];
|
|
1598
|
-
const interval =
|
|
1598
|
+
const interval = buildIntervalStep(labels);
|
|
1599
1599
|
const markArea = buildMarkArea(eras, labels, textColor, palette.colors.blue);
|
|
1600
1600
|
|
|
1601
1601
|
const series = seriesNames.map((name, idx) => {
|
|
@@ -1654,7 +1654,7 @@ function buildAreaOption(
|
|
|
1654
1654
|
const labels = parsed.data.map((d) => d.label);
|
|
1655
1655
|
const values = parsed.data.map((d) => d.value);
|
|
1656
1656
|
const eras = parsed.eras ?? [];
|
|
1657
|
-
const interval =
|
|
1657
|
+
const interval = buildIntervalStep(labels);
|
|
1658
1658
|
const markArea = buildMarkArea(eras, labels, textColor, palette.colors.blue);
|
|
1659
1659
|
|
|
1660
1660
|
return {
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// ER Diagram Entity Classification
|
|
3
|
+
// ============================================================
|
|
4
|
+
//
|
|
5
|
+
// Classifies each ER entity into a structural role using heuristics
|
|
6
|
+
// derived from column constraints, FK patterns, relationship in-degree,
|
|
7
|
+
// and naming conventions.
|
|
8
|
+
//
|
|
9
|
+
// Only used when no explicit colors or tag groups are defined.
|
|
10
|
+
|
|
11
|
+
import type { ERTable, ERRelationship } from './types';
|
|
12
|
+
import type { PaletteColors } from '../palettes/types';
|
|
13
|
+
|
|
14
|
+
export type EntityRole =
|
|
15
|
+
| 'core'
|
|
16
|
+
| 'dependent'
|
|
17
|
+
| 'junction'
|
|
18
|
+
| 'ambiguous'
|
|
19
|
+
| 'lookup'
|
|
20
|
+
| 'hub'
|
|
21
|
+
| 'self-referential'
|
|
22
|
+
| 'unclassified';
|
|
23
|
+
|
|
24
|
+
/** Maps each role to a key in PaletteColors['colors'] */
|
|
25
|
+
export const ROLE_COLORS: Record<EntityRole, keyof PaletteColors['colors']> = {
|
|
26
|
+
core: 'green',
|
|
27
|
+
dependent: 'blue',
|
|
28
|
+
junction: 'red',
|
|
29
|
+
ambiguous: 'purple',
|
|
30
|
+
lookup: 'yellow',
|
|
31
|
+
hub: 'orange',
|
|
32
|
+
'self-referential': 'teal',
|
|
33
|
+
unclassified: 'gray',
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Human-readable legend labels per role */
|
|
37
|
+
export const ROLE_LABELS: Record<EntityRole, string> = {
|
|
38
|
+
core: 'Core entity',
|
|
39
|
+
dependent: 'Dependent',
|
|
40
|
+
junction: 'Junction / M:M',
|
|
41
|
+
ambiguous: 'Bridge',
|
|
42
|
+
lookup: 'Lookup / Reference',
|
|
43
|
+
hub: 'Hub',
|
|
44
|
+
'self-referential': 'Self-referential',
|
|
45
|
+
unclassified: 'Unclassified',
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Stable display order for the semantic legend */
|
|
49
|
+
export const ROLE_ORDER: EntityRole[] = [
|
|
50
|
+
'core',
|
|
51
|
+
'dependent',
|
|
52
|
+
'junction',
|
|
53
|
+
'ambiguous',
|
|
54
|
+
'lookup',
|
|
55
|
+
'hub',
|
|
56
|
+
'self-referential',
|
|
57
|
+
'unclassified',
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const LOOKUP_NAME_SUFFIXES = ['_type', '_status', '_code', '_category'];
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Classify each ER entity into a structural role.
|
|
64
|
+
*
|
|
65
|
+
* Returns a Map of tableId → EntityRole.
|
|
66
|
+
* Pure logic — no palette or DOM dependencies.
|
|
67
|
+
*/
|
|
68
|
+
export function classifyEREntities(
|
|
69
|
+
tables: ERTable[],
|
|
70
|
+
relationships: ERRelationship[]
|
|
71
|
+
): Map<string, EntityRole> {
|
|
72
|
+
const result = new Map<string, EntityRole>();
|
|
73
|
+
if (tables.length === 0) return result;
|
|
74
|
+
|
|
75
|
+
// ── Pre-compute graph metrics ─────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
// indegreeMap: how many other tables have FK references pointing to this table.
|
|
78
|
+
// A table's indegree increments when it is on the exclusive '1' side of a 1:* or 1:?
|
|
79
|
+
// relationship. 1:1 relationships are skipped on both sides to avoid double-counting.
|
|
80
|
+
const indegreeMap: Record<string, number> = {};
|
|
81
|
+
for (const t of tables) indegreeMap[t.id] = 0;
|
|
82
|
+
for (const rel of relationships) {
|
|
83
|
+
if (rel.source === rel.target) continue; // skip self-loops
|
|
84
|
+
if (rel.cardinality.from === '1' && rel.cardinality.to !== '1') {
|
|
85
|
+
indegreeMap[rel.source] = (indegreeMap[rel.source] ?? 0) + 1;
|
|
86
|
+
}
|
|
87
|
+
if (rel.cardinality.to === '1' && rel.cardinality.from !== '1') {
|
|
88
|
+
indegreeMap[rel.target] = (indegreeMap[rel.target] ?? 0) + 1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// mmParticipants: tables with '*' cardinality connecting to 2+ distinct other tables.
|
|
93
|
+
// Catches wide junction tables (e.g. with surrogate PKs) that the FK-ratio alone misses.
|
|
94
|
+
const tableStarNeighbors = new Map<string, Set<string>>();
|
|
95
|
+
for (const rel of relationships) {
|
|
96
|
+
if (rel.source === rel.target) continue;
|
|
97
|
+
if (rel.cardinality.from === '*') {
|
|
98
|
+
if (!tableStarNeighbors.has(rel.source)) tableStarNeighbors.set(rel.source, new Set());
|
|
99
|
+
tableStarNeighbors.get(rel.source)!.add(rel.target);
|
|
100
|
+
}
|
|
101
|
+
if (rel.cardinality.to === '*') {
|
|
102
|
+
if (!tableStarNeighbors.has(rel.target)) tableStarNeighbors.set(rel.target, new Set());
|
|
103
|
+
tableStarNeighbors.get(rel.target)!.add(rel.source);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
const mmParticipants = new Set<string>();
|
|
107
|
+
for (const [id, neighbors] of tableStarNeighbors) {
|
|
108
|
+
if (neighbors.size >= 2) mmParticipants.add(id);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Hub outlier statistics
|
|
112
|
+
const indegreeValues = Object.values(indegreeMap);
|
|
113
|
+
const mean = indegreeValues.reduce((a, b) => a + b, 0) / indegreeValues.length;
|
|
114
|
+
const variance = indegreeValues.reduce((a, b) => a + (b - mean) ** 2, 0) / indegreeValues.length;
|
|
115
|
+
const stddev = Math.sqrt(variance);
|
|
116
|
+
|
|
117
|
+
// Median indegree (for lookup detection: table must be referenced above-median)
|
|
118
|
+
const sorted = [...indegreeValues].sort((a, b) => a - b);
|
|
119
|
+
const median =
|
|
120
|
+
sorted.length % 2 === 0
|
|
121
|
+
? (sorted[sorted.length / 2 - 1] + sorted[sorted.length / 2]) / 2
|
|
122
|
+
: sorted[Math.floor(sorted.length / 2)];
|
|
123
|
+
|
|
124
|
+
// ── Per-table classification ──────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
for (const table of tables) {
|
|
127
|
+
const id = table.id;
|
|
128
|
+
const cols = table.columns;
|
|
129
|
+
const fkCols = cols.filter((c) => c.constraints.includes('fk'));
|
|
130
|
+
const pkFkCols = cols.filter(
|
|
131
|
+
(c) => c.constraints.includes('pk') && c.constraints.includes('fk')
|
|
132
|
+
);
|
|
133
|
+
const fkCount = fkCols.length;
|
|
134
|
+
const fkRatio = cols.length === 0 ? 0 : fkCount / cols.length;
|
|
135
|
+
const indegree = indegreeMap[id] ?? 0;
|
|
136
|
+
const nameLower = table.name.toLowerCase();
|
|
137
|
+
|
|
138
|
+
// External relationships (exclude self-loops)
|
|
139
|
+
const externalRels = relationships.filter(
|
|
140
|
+
(r) => (r.source === id || r.target === id) && r.source !== r.target
|
|
141
|
+
);
|
|
142
|
+
const hasSelfRef = relationships.some((r) => r.source === id && r.target === id);
|
|
143
|
+
|
|
144
|
+
// Distinct external tables this table relates to
|
|
145
|
+
const externalTargets = new Set<string>();
|
|
146
|
+
for (const rel of externalRels) {
|
|
147
|
+
externalTargets.add(rel.source === id ? rel.target : rel.source);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ── Decision tree (priority order) ─────────────────────────────────────
|
|
151
|
+
|
|
152
|
+
// 1. Self-referential: has a self-loop relationship AND no external relationships
|
|
153
|
+
if (hasSelfRef && externalRels.length === 0) {
|
|
154
|
+
result.set(id, 'self-referential');
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// 2. Junction: any one of three signals qualifies.
|
|
159
|
+
// Inheritance exception: composite PK FKs all pointing to a single parent → skip ratio signal.
|
|
160
|
+
const isInheritancePattern = pkFkCols.length >= 2 && externalTargets.size === 1;
|
|
161
|
+
const junctionByRatio = fkRatio >= 0.6 && !isInheritancePattern;
|
|
162
|
+
const junctionByCompositePk = pkFkCols.length >= 2 && externalTargets.size >= 2;
|
|
163
|
+
const junctionByMm = mmParticipants.has(id);
|
|
164
|
+
if (junctionByRatio || junctionByCompositePk || junctionByMm) {
|
|
165
|
+
result.set(id, 'junction');
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 3. Ambiguous: FK ratio 0.4–0.59, no composite PK FKs, not M:M participant
|
|
170
|
+
if (fkRatio >= 0.4 && fkRatio < 0.6 && pkFkCols.length < 2 && !mmParticipants.has(id)) {
|
|
171
|
+
result.set(id, 'ambiguous');
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 4. Lookup: naming convention match + structural guards
|
|
176
|
+
// Naming only applies when cols ≤ 6 AND fkCount ≤ 1; structure overrides for larger tables.
|
|
177
|
+
const nameMatchesLookup = LOOKUP_NAME_SUFFIXES.some((s) => nameLower.endsWith(s));
|
|
178
|
+
if (nameMatchesLookup && cols.length <= 6 && fkCount <= 1 && indegree > median) {
|
|
179
|
+
result.set(id, 'lookup');
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 5. Hub: in-degree outlier in a schema of ≥ 6 tables.
|
|
184
|
+
// Dual condition: > mean + 1.5σ AND ≥ 2× mean (guards against near-zero mean edge cases).
|
|
185
|
+
if (
|
|
186
|
+
tables.length >= 6 &&
|
|
187
|
+
indegree > 0 &&
|
|
188
|
+
indegree > mean + 1.5 * stddev &&
|
|
189
|
+
indegree >= 2 * mean
|
|
190
|
+
) {
|
|
191
|
+
result.set(id, 'hub');
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// 6. Dependent: has FK columns
|
|
196
|
+
if (fkCount > 0) {
|
|
197
|
+
result.set(id, 'dependent');
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 7. Core: no FK columns (always true at this point)
|
|
202
|
+
result.set(id, 'core');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
return result;
|
|
206
|
+
}
|