@diagrammo/dgmo 0.5.2 → 0.5.4
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/README.md +16 -16
- package/dist/cli.cjs +159 -159
- package/dist/index.cjs +770 -322
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +37 -23
- package/dist/index.d.ts +37 -23
- package/dist/index.js +769 -322
- package/dist/index.js.map +1 -1
- package/docs/language-reference.md +20 -2
- package/package.json +1 -1
- package/src/d3.ts +254 -211
- package/src/index.ts +3 -0
- package/src/infra/compute.ts +88 -10
- package/src/infra/layout.ts +97 -12
- package/src/infra/parser.ts +47 -4
- package/src/infra/renderer.ts +216 -42
- package/src/infra/roles.ts +15 -0
- package/src/infra/types.ts +7 -0
- package/src/initiative-status/collapse.ts +76 -0
- package/src/initiative-status/layout.ts +193 -26
- package/src/initiative-status/renderer.ts +94 -46
- package/src/org/layout.ts +5 -2
- package/src/org/renderer.ts +65 -11
- package/src/org/resolver.ts +1 -1
- package/src/sharing.ts +12 -0
package/src/d3.ts
CHANGED
|
@@ -99,15 +99,13 @@ export interface TimelineMarker {
|
|
|
99
99
|
|
|
100
100
|
export interface VennSet {
|
|
101
101
|
name: string;
|
|
102
|
-
|
|
102
|
+
alias: string | null;
|
|
103
103
|
color: string | null;
|
|
104
|
-
label: string | null;
|
|
105
104
|
lineNumber: number;
|
|
106
105
|
}
|
|
107
106
|
|
|
108
107
|
export interface VennOverlap {
|
|
109
108
|
sets: string[];
|
|
110
|
-
size: number;
|
|
111
109
|
label: string | null;
|
|
112
110
|
lineNumber: number;
|
|
113
111
|
}
|
|
@@ -161,7 +159,6 @@ export interface ParsedD3 {
|
|
|
161
159
|
timelineSwimlanes: boolean;
|
|
162
160
|
vennSets: VennSet[];
|
|
163
161
|
vennOverlaps: VennOverlap[];
|
|
164
|
-
vennShowValues: boolean;
|
|
165
162
|
// Quadrant chart fields
|
|
166
163
|
quadrantLabels: QuadrantLabels;
|
|
167
164
|
quadrantPoints: QuadrantPoint[];
|
|
@@ -354,7 +351,6 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
|
|
|
354
351
|
timelineSwimlanes: false,
|
|
355
352
|
vennSets: [],
|
|
356
353
|
vennOverlaps: [],
|
|
357
|
-
vennShowValues: false,
|
|
358
354
|
quadrantLabels: {
|
|
359
355
|
topRight: null,
|
|
360
356
|
topLeft: null,
|
|
@@ -378,6 +374,10 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
|
|
|
378
374
|
return result;
|
|
379
375
|
};
|
|
380
376
|
|
|
377
|
+
const warn = (line: number, message: string): void => {
|
|
378
|
+
result.diagnostics.push(makeDgmoError(line, message, 'warning'));
|
|
379
|
+
};
|
|
380
|
+
|
|
381
381
|
if (!content || !content.trim()) {
|
|
382
382
|
return fail(0, 'Empty content');
|
|
383
383
|
}
|
|
@@ -622,35 +622,43 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
|
|
|
622
622
|
}
|
|
623
623
|
}
|
|
624
624
|
|
|
625
|
-
// Venn
|
|
625
|
+
// Venn diagram DSL
|
|
626
626
|
if (result.type === 'venn') {
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
.
|
|
634
|
-
.
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
627
|
+
// Intersection line: "A + B: Label" / "A + B" / "A + B + C: Label"
|
|
628
|
+
if (/\+/.test(line)) {
|
|
629
|
+
const colonIdx = line.indexOf(':');
|
|
630
|
+
let setsPart: string;
|
|
631
|
+
let label: string | null;
|
|
632
|
+
if (colonIdx >= 0) {
|
|
633
|
+
setsPart = line.substring(0, colonIdx).trim();
|
|
634
|
+
label = line.substring(colonIdx + 1).trim() || null;
|
|
635
|
+
} else {
|
|
636
|
+
setsPart = line.trim();
|
|
637
|
+
label = null;
|
|
638
|
+
}
|
|
639
|
+
const rawSets = setsPart.split('+').map((s) => s.trim()).filter(Boolean);
|
|
640
|
+
if (rawSets.length >= 2) {
|
|
641
|
+
result.vennOverlaps.push({ sets: rawSets, label, lineNumber });
|
|
642
|
+
continue;
|
|
643
|
+
}
|
|
640
644
|
}
|
|
641
645
|
|
|
642
|
-
//
|
|
643
|
-
const
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
646
|
+
// Set declaration: "Name(color) alias x" / "Name alias x" / "Name(color)" / "Name"
|
|
647
|
+
const setDeclMatch = line.match(/^([^(:]+?)(?:\(([^)]+)\))?(?:\s+alias\s+(\S+))?\s*$/i);
|
|
648
|
+
if (setDeclMatch) {
|
|
649
|
+
const name = setDeclMatch[1].trim();
|
|
650
|
+
const colorName = setDeclMatch[2]?.trim() ?? null;
|
|
651
|
+
let color: string | null = null;
|
|
652
|
+
if (colorName) {
|
|
653
|
+
const resolved = resolveColor(colorName, palette);
|
|
654
|
+
if (resolved.startsWith('#')) {
|
|
655
|
+
color = resolved;
|
|
656
|
+
} else {
|
|
657
|
+
warn(lineNumber, `Unknown color "${colorName}" on set "${name}". Using auto-assigned color.`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
const alias = setDeclMatch[3]?.trim() ?? null;
|
|
661
|
+
result.vennSets.push({ name, alias, color, lineNumber });
|
|
654
662
|
continue;
|
|
655
663
|
}
|
|
656
664
|
}
|
|
@@ -846,19 +854,6 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
|
|
|
846
854
|
continue;
|
|
847
855
|
}
|
|
848
856
|
|
|
849
|
-
if (key === 'values') {
|
|
850
|
-
const v = line
|
|
851
|
-
.substring(colonIndex + 1)
|
|
852
|
-
.trim()
|
|
853
|
-
.toLowerCase();
|
|
854
|
-
if (v === 'off') {
|
|
855
|
-
result.vennShowValues = false;
|
|
856
|
-
} else if (v === 'on') {
|
|
857
|
-
result.vennShowValues = true;
|
|
858
|
-
}
|
|
859
|
-
continue;
|
|
860
|
-
}
|
|
861
|
-
|
|
862
857
|
if (key === 'rotate') {
|
|
863
858
|
const v = line
|
|
864
859
|
.substring(colonIndex + 1)
|
|
@@ -972,10 +967,6 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
|
|
|
972
967
|
return result;
|
|
973
968
|
}
|
|
974
969
|
|
|
975
|
-
const warn = (line: number, message: string): void => {
|
|
976
|
-
result.diagnostics.push(makeDgmoError(line, message, 'warning'));
|
|
977
|
-
};
|
|
978
|
-
|
|
979
970
|
if (result.type === 'wordcloud') {
|
|
980
971
|
// If no structured words were found, parse freeform text as word frequencies
|
|
981
972
|
if (result.words.length === 0 && freeformLines.length > 0) {
|
|
@@ -1065,30 +1056,38 @@ export function parseD3(content: string, palette?: PaletteColors): ParsedD3 {
|
|
|
1065
1056
|
|
|
1066
1057
|
if (result.type === 'venn') {
|
|
1067
1058
|
if (result.vennSets.length < 2) {
|
|
1068
|
-
return fail(1, 'At least 2 sets are required. Add
|
|
1059
|
+
return fail(1, 'At least 2 sets are required. Add set names (e.g., "Apples", "Oranges")');
|
|
1069
1060
|
}
|
|
1070
1061
|
if (result.vennSets.length > 3) {
|
|
1071
|
-
return fail(1, '
|
|
1062
|
+
return fail(1, 'Venn diagrams support 2–3 sets');
|
|
1063
|
+
}
|
|
1064
|
+
// Build lookup: full name (lowercase) and alias → canonical name
|
|
1065
|
+
const setNameLower = new Map<string, string>(
|
|
1066
|
+
result.vennSets.map((s) => [s.name.toLowerCase(), s.name])
|
|
1067
|
+
);
|
|
1068
|
+
const aliasLower = new Map<string, string>();
|
|
1069
|
+
for (const s of result.vennSets) {
|
|
1070
|
+
if (s.alias) aliasLower.set(s.alias.toLowerCase(), s.name);
|
|
1072
1071
|
}
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1072
|
+
const resolveSetRef = (ref: string): string | null =>
|
|
1073
|
+
setNameLower.get(ref.toLowerCase()) ?? aliasLower.get(ref.toLowerCase()) ?? null;
|
|
1074
|
+
|
|
1075
|
+
// Resolve intersection set references; drop invalid ones with a diagnostic
|
|
1076
|
+
const validOverlaps: VennOverlap[] = [];
|
|
1076
1077
|
for (const ov of result.vennOverlaps) {
|
|
1078
|
+
const resolvedSets: string[] = [];
|
|
1077
1079
|
let valid = true;
|
|
1078
|
-
for (const
|
|
1079
|
-
|
|
1080
|
-
|
|
1080
|
+
for (const ref of ov.sets) {
|
|
1081
|
+
const resolved = resolveSetRef(ref);
|
|
1082
|
+
if (!resolved) {
|
|
1083
|
+
result.diagnostics.push(makeDgmoError(ov.lineNumber, `Intersection references unknown set or alias "${ref}"`));
|
|
1081
1084
|
if (!result.error) result.error = formatDgmoError(result.diagnostics[result.diagnostics.length - 1]);
|
|
1082
1085
|
valid = false;
|
|
1083
1086
|
break;
|
|
1084
1087
|
}
|
|
1088
|
+
resolvedSets.push(resolved);
|
|
1085
1089
|
}
|
|
1086
|
-
if (
|
|
1087
|
-
const minSetSize = Math.min(...ov.sets.map((s) => setMap.get(s)!));
|
|
1088
|
-
if (ov.size > minSetSize) {
|
|
1089
|
-
warn(ov.lineNumber, `Overlap size ${ov.size} exceeds smallest constituent set size ${minSetSize}`);
|
|
1090
|
-
}
|
|
1091
|
-
validOverlaps.push(ov);
|
|
1090
|
+
if (valid) validOverlaps.push({ ...ov, sets: resolvedSets.sort() });
|
|
1092
1091
|
}
|
|
1093
1092
|
result.vennOverlaps = validOverlaps;
|
|
1094
1093
|
return result;
|
|
@@ -4214,12 +4213,16 @@ export function renderTimeline(
|
|
|
4214
4213
|
// Remove previous legend
|
|
4215
4214
|
mainSvg.selectAll('.tl-tag-legend-group').remove();
|
|
4216
4215
|
|
|
4217
|
-
//
|
|
4216
|
+
// Effective color source: explicit color group > swimlane group
|
|
4217
|
+
const effectiveColorKey = (currentActiveGroup ?? currentSwimlaneGroup)?.toLowerCase() ?? null;
|
|
4218
|
+
|
|
4219
|
+
// In view mode, only show the color-driving tag group (expanded, non-interactive).
|
|
4220
|
+
// Skip the swimlane group if it's separate from the color group (lane headers already label it).
|
|
4218
4221
|
const visibleGroups = viewMode
|
|
4219
4222
|
? legendGroups.filter(
|
|
4220
4223
|
(lg) =>
|
|
4221
|
-
|
|
4222
|
-
lg.group.name.toLowerCase() ===
|
|
4224
|
+
effectiveColorKey != null &&
|
|
4225
|
+
lg.group.name.toLowerCase() === effectiveColorKey
|
|
4223
4226
|
)
|
|
4224
4227
|
: legendGroups;
|
|
4225
4228
|
|
|
@@ -4227,8 +4230,9 @@ export function renderTimeline(
|
|
|
4227
4230
|
|
|
4228
4231
|
// Compute total width and center horizontally in SVG
|
|
4229
4232
|
const totalW = visibleGroups.reduce((s, lg) => {
|
|
4230
|
-
const isActive =
|
|
4231
|
-
|
|
4233
|
+
const isActive = viewMode ||
|
|
4234
|
+
(currentActiveGroup != null &&
|
|
4235
|
+
lg.group.name.toLowerCase() === currentActiveGroup.toLowerCase());
|
|
4232
4236
|
return s + (isActive ? lg.expandedWidth : lg.minifiedWidth);
|
|
4233
4237
|
}, 0) + (visibleGroups.length - 1) * LG_GROUP_GAP;
|
|
4234
4238
|
|
|
@@ -4236,8 +4240,9 @@ export function renderTimeline(
|
|
|
4236
4240
|
|
|
4237
4241
|
for (const lg of visibleGroups) {
|
|
4238
4242
|
const groupKey = lg.group.name.toLowerCase();
|
|
4239
|
-
const isActive =
|
|
4240
|
-
currentActiveGroup
|
|
4243
|
+
const isActive = viewMode ||
|
|
4244
|
+
(currentActiveGroup != null &&
|
|
4245
|
+
currentActiveGroup.toLowerCase() === groupKey);
|
|
4241
4246
|
const isSwimActive = currentSwimlaneGroup != null &&
|
|
4242
4247
|
currentSwimlaneGroup.toLowerCase() === groupKey;
|
|
4243
4248
|
|
|
@@ -4791,67 +4796,34 @@ export function renderVenn(
|
|
|
4791
4796
|
onClickItem?: (lineNumber: number) => void,
|
|
4792
4797
|
exportDims?: D3ExportDimensions
|
|
4793
4798
|
): void {
|
|
4794
|
-
const { vennSets, vennOverlaps,
|
|
4799
|
+
const { vennSets, vennOverlaps, title } = parsed;
|
|
4795
4800
|
if (vennSets.length < 2) return;
|
|
4796
4801
|
|
|
4797
4802
|
const init = initD3Chart(container, palette, exportDims);
|
|
4798
4803
|
if (!init) return;
|
|
4799
4804
|
const { svg, width, height, textColor, colors } = init;
|
|
4800
4805
|
const titleHeight = title ? 40 : 0;
|
|
4806
|
+
const n = vennSets.length;
|
|
4801
4807
|
|
|
4802
|
-
//
|
|
4803
|
-
|
|
4804
|
-
|
|
4805
|
-
|
|
4806
|
-
const overlapMap = new Map<string, number>();
|
|
4807
|
-
for (const ov of vennOverlaps) {
|
|
4808
|
-
overlapMap.set(ov.sets.join('&'), ov.size);
|
|
4809
|
-
}
|
|
4808
|
+
// ── Equal-radius layout with ~30% overlap depth ──
|
|
4809
|
+
// All circles share the same base radius; center distance = 1.4r gives ~30% penetration
|
|
4810
|
+
const BASE_R = 100;
|
|
4811
|
+
const OVERLAP_DISTANCE = BASE_R * 1.4;
|
|
4810
4812
|
|
|
4811
|
-
// Layout circles
|
|
4812
4813
|
let rawCircles: Circle[];
|
|
4813
|
-
const n = vennSets.length;
|
|
4814
|
-
|
|
4815
4814
|
if (n === 2) {
|
|
4816
|
-
const d = distanceForOverlap(
|
|
4817
|
-
radii[0],
|
|
4818
|
-
radii[1],
|
|
4819
|
-
overlapMap.get([vennSets[0].name, vennSets[1].name].sort().join('&')) ?? 0
|
|
4820
|
-
);
|
|
4821
4815
|
rawCircles = [
|
|
4822
|
-
{ x: -
|
|
4823
|
-
{ x:
|
|
4816
|
+
{ x: -OVERLAP_DISTANCE / 2, y: 0, r: BASE_R },
|
|
4817
|
+
{ x: OVERLAP_DISTANCE / 2, y: 0, r: BASE_R },
|
|
4824
4818
|
];
|
|
4825
4819
|
} else {
|
|
4826
|
-
//
|
|
4827
|
-
const
|
|
4828
|
-
const
|
|
4829
|
-
[names[i], names[j]].sort().join('&');
|
|
4830
|
-
|
|
4831
|
-
const dAB = distanceForOverlap(
|
|
4832
|
-
radii[0],
|
|
4833
|
-
radii[1],
|
|
4834
|
-
overlapMap.get(pairKey(0, 1)) ?? 0
|
|
4835
|
-
);
|
|
4836
|
-
const dAC = distanceForOverlap(
|
|
4837
|
-
radii[0],
|
|
4838
|
-
radii[2],
|
|
4839
|
-
overlapMap.get(pairKey(0, 2)) ?? 0
|
|
4840
|
-
);
|
|
4841
|
-
const dBC = distanceForOverlap(
|
|
4842
|
-
radii[1],
|
|
4843
|
-
radii[2],
|
|
4844
|
-
overlapMap.get(pairKey(1, 2)) ?? 0
|
|
4845
|
-
);
|
|
4846
|
-
|
|
4847
|
-
const ax = -dAB / 2;
|
|
4848
|
-
const bx = dAB / 2;
|
|
4849
|
-
const cPos = thirdCirclePosition(ax, 0, dAC, bx, 0, dBC);
|
|
4850
|
-
|
|
4820
|
+
// Equilateral triangle with side = OVERLAP_DISTANCE
|
|
4821
|
+
const s = OVERLAP_DISTANCE;
|
|
4822
|
+
const h = (Math.sqrt(3) / 2) * s;
|
|
4851
4823
|
rawCircles = [
|
|
4852
|
-
{ x:
|
|
4853
|
-
{ x:
|
|
4854
|
-
{ x:
|
|
4824
|
+
{ x: -s / 2, y: h / 3, r: BASE_R },
|
|
4825
|
+
{ x: s / 2, y: h / 3, r: BASE_R },
|
|
4826
|
+
{ x: 0, y: -(2 * h) / 3, r: BASE_R },
|
|
4855
4827
|
];
|
|
4856
4828
|
}
|
|
4857
4829
|
|
|
@@ -4861,11 +4833,9 @@ export function renderVenn(
|
|
|
4861
4833
|
);
|
|
4862
4834
|
|
|
4863
4835
|
// ── Layout-aware centering with label space ──
|
|
4864
|
-
// Estimate per-side label widths and compute asymmetric margins
|
|
4865
4836
|
const clusterCx = rawCircles.reduce((s, c) => s + c.x, 0) / n;
|
|
4866
4837
|
const clusterCy = rawCircles.reduce((s, c) => s + c.y, 0) / n;
|
|
4867
4838
|
|
|
4868
|
-
// Estimate which side each set label falls on
|
|
4869
4839
|
let marginLeft = 30,
|
|
4870
4840
|
marginRight = 30,
|
|
4871
4841
|
marginTop = 30,
|
|
@@ -4875,17 +4845,13 @@ export function renderVenn(
|
|
|
4875
4845
|
const labelTextPad = 4;
|
|
4876
4846
|
|
|
4877
4847
|
for (let i = 0; i < n; i++) {
|
|
4878
|
-
const
|
|
4879
|
-
const estimatedWidth = displayName.length * 8.5 + stubLen + edgePad + labelTextPad;
|
|
4848
|
+
const estimatedWidth = vennSets[i].name.length * 8.5 + stubLen + edgePad + labelTextPad;
|
|
4880
4849
|
const dx = rawCircles[i].x - clusterCx;
|
|
4881
4850
|
const dy = rawCircles[i].y - clusterCy;
|
|
4882
|
-
|
|
4883
4851
|
if (Math.abs(dx) >= Math.abs(dy)) {
|
|
4884
|
-
// Label exits left or right
|
|
4885
4852
|
if (dx >= 0) marginRight = Math.max(marginRight, estimatedWidth);
|
|
4886
4853
|
else marginLeft = Math.max(marginLeft, estimatedWidth);
|
|
4887
4854
|
} else {
|
|
4888
|
-
// Label exits top or bottom
|
|
4889
4855
|
const halfEstimate = estimatedWidth * 0.5;
|
|
4890
4856
|
if (dy >= 0) marginBottom = Math.max(marginBottom, halfEstimate + 20);
|
|
4891
4857
|
else marginTop = Math.max(marginTop, halfEstimate + 20);
|
|
@@ -4903,13 +4869,15 @@ export function renderVenn(
|
|
|
4903
4869
|
marginBottom
|
|
4904
4870
|
).map((c) => ({ ...c, y: c.y + titleHeight }));
|
|
4905
4871
|
|
|
4906
|
-
|
|
4907
|
-
|
|
4872
|
+
const scaledR = circles[0].r;
|
|
4873
|
+
|
|
4874
|
+
// Suppress WebKit focus ring on interactive SVG elements
|
|
4875
|
+
svg.append('style').text('circle:focus, circle:focus-visible { outline: none !important; }');
|
|
4908
4876
|
|
|
4909
4877
|
// Title
|
|
4910
4878
|
renderChartTitle(svg, title, parsed.titleLineNumber, width, textColor, onClickItem);
|
|
4911
4879
|
|
|
4912
|
-
// ── Semi-transparent filled circles ──
|
|
4880
|
+
// ── Semi-transparent filled circles (non-interactive) ──
|
|
4913
4881
|
const circleEls: d3Selection.Selection<SVGCircleElement, unknown, null, undefined>[] = [];
|
|
4914
4882
|
const circleGroup = svg.append('g');
|
|
4915
4883
|
circles.forEach((c, i) => {
|
|
@@ -4922,6 +4890,8 @@ export function renderVenn(
|
|
|
4922
4890
|
.attr('fill-opacity', 0.35)
|
|
4923
4891
|
.attr('stroke', setColors[i])
|
|
4924
4892
|
.attr('stroke-width', 2)
|
|
4893
|
+
.attr('class', 'venn-fill-circle')
|
|
4894
|
+
.attr('data-line-number', String(vennSets[i].lineNumber))
|
|
4925
4895
|
.style('pointer-events', 'none') as d3Selection.Selection<
|
|
4926
4896
|
SVGCircleElement,
|
|
4927
4897
|
unknown,
|
|
@@ -4931,69 +4901,134 @@ export function renderVenn(
|
|
|
4931
4901
|
circleEls.push(el);
|
|
4932
4902
|
});
|
|
4933
4903
|
|
|
4934
|
-
// ──
|
|
4935
|
-
//
|
|
4936
|
-
const
|
|
4937
|
-
|
|
4904
|
+
// ── Per-region highlight overlays (section-only, not full circles) ──
|
|
4905
|
+
// Build SVG defs with clipPaths + masks so each region can be highlighted independently.
|
|
4906
|
+
const defs = svg.append('defs');
|
|
4907
|
+
|
|
4908
|
+
// Individual circle clipPaths
|
|
4909
|
+
circles.forEach((c, i) => {
|
|
4910
|
+
defs.append('clipPath')
|
|
4911
|
+
.attr('id', `vcp-${i}`)
|
|
4912
|
+
.append('circle')
|
|
4913
|
+
.attr('cx', c.x).attr('cy', c.y).attr('r', c.r);
|
|
4914
|
+
});
|
|
4938
4915
|
|
|
4939
|
-
//
|
|
4940
|
-
|
|
4941
|
-
|
|
4942
|
-
|
|
4943
|
-
|
|
4944
|
-
|
|
4945
|
-
c: Circle
|
|
4946
|
-
): number {
|
|
4947
|
-
const lx = ox - c.x;
|
|
4948
|
-
const ly = oy - c.y;
|
|
4949
|
-
const b = lx * dx + ly * dy;
|
|
4950
|
-
const det = b * b - (lx * lx + ly * ly - c.r * c.r);
|
|
4951
|
-
if (det < 0) return 0;
|
|
4952
|
-
return -b + Math.sqrt(det);
|
|
4916
|
+
// All region index-sets: exclusive then intersection subsets
|
|
4917
|
+
const regionIdxSets: number[][] = circles.map((_, i) => [i]);
|
|
4918
|
+
if (n === 2) {
|
|
4919
|
+
regionIdxSets.push([0, 1]);
|
|
4920
|
+
} else {
|
|
4921
|
+
regionIdxSets.push([0, 1], [0, 2], [1, 2], [0, 1, 2]);
|
|
4953
4922
|
}
|
|
4954
4923
|
|
|
4955
|
-
const
|
|
4924
|
+
const overlayGroup = svg.append('g').style('pointer-events', 'none');
|
|
4925
|
+
const overlayEls = new Map<string, d3Selection.Selection<SVGRectElement, unknown, null, undefined>>();
|
|
4926
|
+
|
|
4927
|
+
for (const idxs of regionIdxSets) {
|
|
4928
|
+
const key = idxs.join('-');
|
|
4929
|
+
const excluded = Array.from({ length: n }, (_, j) => j).filter(j => !idxs.includes(j));
|
|
4930
|
+
|
|
4931
|
+
// Build nested clipPath for intersection of all idxs
|
|
4932
|
+
let clipId = `vcp-${idxs[0]}`;
|
|
4933
|
+
for (let k = 1; k < idxs.length; k++) {
|
|
4934
|
+
const nestedId = `vcp-n-${idxs.slice(0, k + 1).join('-')}`;
|
|
4935
|
+
const ci = idxs[k];
|
|
4936
|
+
defs.append('clipPath')
|
|
4937
|
+
.attr('id', nestedId)
|
|
4938
|
+
.append('circle')
|
|
4939
|
+
.attr('cx', circles[ci].x).attr('cy', circles[ci].y).attr('r', circles[ci].r)
|
|
4940
|
+
.attr('clip-path', `url(#${clipId})`);
|
|
4941
|
+
clipId = nestedId;
|
|
4942
|
+
}
|
|
4943
|
+
|
|
4944
|
+
// Determine line number for this region (for editor sync)
|
|
4945
|
+
let regionLineNumber: number | null = null;
|
|
4946
|
+
if (idxs.length === 1) {
|
|
4947
|
+
regionLineNumber = vennSets[idxs[0]].lineNumber;
|
|
4948
|
+
} else {
|
|
4949
|
+
const sortedNames = idxs.map(i => vennSets[i].name).sort();
|
|
4950
|
+
const ov = vennOverlaps.find(
|
|
4951
|
+
(o) => o.sets.length === sortedNames.length && o.sets.every((s, k) => s === sortedNames[k])
|
|
4952
|
+
);
|
|
4953
|
+
regionLineNumber = ov?.lineNumber ?? null;
|
|
4954
|
+
}
|
|
4955
|
+
|
|
4956
|
+
const el = overlayGroup.append('rect')
|
|
4957
|
+
.attr('x', 0).attr('y', 0)
|
|
4958
|
+
.attr('width', width).attr('height', height)
|
|
4959
|
+
.attr('fill', 'white')
|
|
4960
|
+
.attr('fill-opacity', 0)
|
|
4961
|
+
.attr('class', 'venn-region-overlay')
|
|
4962
|
+
.attr('clip-path', `url(#${clipId})`);
|
|
4963
|
+
|
|
4964
|
+
if (regionLineNumber != null) {
|
|
4965
|
+
el.attr('data-line-number', String(regionLineNumber));
|
|
4966
|
+
}
|
|
4967
|
+
|
|
4968
|
+
if (excluded.length > 0) {
|
|
4969
|
+
// Mask subtracts excluded circles so only the exact region shape highlights
|
|
4970
|
+
const maskId = `vvm-${key}`;
|
|
4971
|
+
const mask = defs.append('mask').attr('id', maskId);
|
|
4972
|
+
mask.append('rect')
|
|
4973
|
+
.attr('x', 0).attr('y', 0)
|
|
4974
|
+
.attr('width', width).attr('height', height)
|
|
4975
|
+
.attr('fill', 'white');
|
|
4976
|
+
for (const j of excluded) {
|
|
4977
|
+
mask.append('circle')
|
|
4978
|
+
.attr('cx', circles[j].x).attr('cy', circles[j].y).attr('r', circles[j].r)
|
|
4979
|
+
.attr('fill', 'black');
|
|
4980
|
+
}
|
|
4981
|
+
el.attr('mask', `url(#${maskId})`);
|
|
4982
|
+
}
|
|
4983
|
+
|
|
4984
|
+
overlayEls.set(key, el);
|
|
4985
|
+
}
|
|
4986
|
+
|
|
4987
|
+
const showRegionOverlay = (idxs: number[]) => {
|
|
4988
|
+
const key = [...idxs].sort((a, b) => a - b).join('-');
|
|
4989
|
+
overlayEls.forEach((el, k) => el.attr('fill-opacity', k === key ? 0.3 : 0));
|
|
4990
|
+
};
|
|
4991
|
+
const hideAllOverlays = () => {
|
|
4992
|
+
overlayEls.forEach(el => el.attr('fill-opacity', 0));
|
|
4993
|
+
};
|
|
4994
|
+
|
|
4995
|
+
// ── Labels ──
|
|
4996
|
+
const gcx = circles.reduce((s, c) => s + c.x, 0) / n;
|
|
4997
|
+
const gcy = circles.reduce((s, c) => s + c.y, 0) / n;
|
|
4956
4998
|
|
|
4957
|
-
// ── Set name labels (inside exclusive region if they fit, else external leader line) ──
|
|
4958
|
-
// Helper: measure horizontal clearance at a point inside circle i but outside others
|
|
4959
4999
|
function exclusiveHSpan(px: number, py: number, ci: number): number {
|
|
4960
|
-
// Start with full chord width of circle i at height py
|
|
4961
5000
|
const dy = py - circles[ci].y;
|
|
4962
5001
|
const halfChord = Math.sqrt(Math.max(0, circles[ci].r * circles[ci].r - dy * dy));
|
|
4963
5002
|
let left = circles[ci].x - halfChord;
|
|
4964
5003
|
let right = circles[ci].x + halfChord;
|
|
4965
|
-
// Subtract any overlapping circle chord that covers this y
|
|
4966
5004
|
for (let j = 0; j < n; j++) {
|
|
4967
5005
|
if (j === ci) continue;
|
|
4968
5006
|
const djy = py - circles[j].y;
|
|
4969
|
-
if (Math.abs(djy) >= circles[j].r) continue;
|
|
5007
|
+
if (Math.abs(djy) >= circles[j].r) continue;
|
|
4970
5008
|
const hc = Math.sqrt(circles[j].r * circles[j].r - djy * djy);
|
|
4971
5009
|
const jLeft = circles[j].x - hc;
|
|
4972
5010
|
const jRight = circles[j].x + hc;
|
|
4973
|
-
|
|
4974
|
-
if (jLeft <= left && jRight >= right) return 0; // fully covered
|
|
5011
|
+
if (jLeft <= left && jRight >= right) return 0;
|
|
4975
5012
|
if (jLeft <= left && jRight > left) left = jRight;
|
|
4976
5013
|
if (jRight >= right && jLeft < right) right = jLeft;
|
|
4977
5014
|
}
|
|
4978
5015
|
return Math.max(0, right - left);
|
|
4979
5016
|
}
|
|
4980
5017
|
|
|
4981
|
-
// Font size scaling: 0.6 ch-width per character at a given font size
|
|
4982
5018
|
const CH_RATIO = 0.6;
|
|
4983
5019
|
const MIN_FONT = 10;
|
|
4984
5020
|
const MAX_FONT = 22;
|
|
4985
5021
|
const INTERNAL_PAD = 12;
|
|
4986
5022
|
|
|
4987
|
-
|
|
4988
|
-
const text = vennSets[i].label ?? vennSets[i].name;
|
|
5023
|
+
const labelGroup = svg.append('g').style('pointer-events', 'none');
|
|
4989
5024
|
|
|
4990
|
-
|
|
5025
|
+
// Set name labels: prefer inside exclusive region, fall back to external leader line
|
|
5026
|
+
circles.forEach((c, i) => {
|
|
5027
|
+
const text = vennSets[i].name;
|
|
4991
5028
|
const inside = circles.map((_, j) => j === i);
|
|
4992
5029
|
const centroid = regionCentroid(circles, inside);
|
|
4993
5030
|
|
|
4994
|
-
// Available width at centroid
|
|
4995
5031
|
const availW = exclusiveHSpan(centroid.x, centroid.y, i);
|
|
4996
|
-
// Font size that makes text fill ~80% of available width
|
|
4997
5032
|
const fitFont = Math.min(MAX_FONT, Math.max(MIN_FONT,
|
|
4998
5033
|
(availW - INTERNAL_PAD * 2) / (text.length * CH_RATIO)));
|
|
4999
5034
|
const estTextW = text.length * CH_RATIO * fitFont;
|
|
@@ -5014,17 +5049,10 @@ export function renderVenn(
|
|
|
5014
5049
|
.attr('font-weight', 'bold')
|
|
5015
5050
|
.text(text);
|
|
5016
5051
|
} else {
|
|
5017
|
-
// External label with leader line
|
|
5018
5052
|
let dx = c.x - gcx;
|
|
5019
5053
|
let dy = c.y - gcy;
|
|
5020
5054
|
const mag = Math.sqrt(dx * dx + dy * dy);
|
|
5021
|
-
if (mag < 1e-6) {
|
|
5022
|
-
dx = 1;
|
|
5023
|
-
dy = 0;
|
|
5024
|
-
} else {
|
|
5025
|
-
dx /= mag;
|
|
5026
|
-
dy /= mag;
|
|
5027
|
-
}
|
|
5055
|
+
if (mag < 1e-6) { dx = 1; dy = 0; } else { dx /= mag; dy /= mag; }
|
|
5028
5056
|
|
|
5029
5057
|
const exitX = c.x + dx * c.r;
|
|
5030
5058
|
const exitY = c.y + dy * c.r;
|
|
@@ -5035,10 +5063,8 @@ export function renderVenn(
|
|
|
5035
5063
|
|
|
5036
5064
|
labelGroup
|
|
5037
5065
|
.append('line')
|
|
5038
|
-
.attr('x1', edgeX)
|
|
5039
|
-
.attr('
|
|
5040
|
-
.attr('x2', stubEndX)
|
|
5041
|
-
.attr('y2', stubEndY)
|
|
5066
|
+
.attr('x1', edgeX).attr('y1', edgeY)
|
|
5067
|
+
.attr('x2', stubEndX).attr('y2', stubEndY)
|
|
5042
5068
|
.attr('stroke', textColor)
|
|
5043
5069
|
.attr('stroke-width', 1);
|
|
5044
5070
|
|
|
@@ -5046,13 +5072,9 @@ export function renderVenn(
|
|
|
5046
5072
|
const textAnchor = isRight ? 'start' : 'end';
|
|
5047
5073
|
let textX = stubEndX + (isRight ? labelTextPad : -labelTextPad);
|
|
5048
5074
|
const textY = stubEndY;
|
|
5049
|
-
|
|
5050
5075
|
const estW = text.length * 8.5;
|
|
5051
|
-
if (isRight)
|
|
5052
|
-
|
|
5053
|
-
} else {
|
|
5054
|
-
textX = Math.max(textX, estW + 4);
|
|
5055
|
-
}
|
|
5076
|
+
if (isRight) textX = Math.min(textX, width - estW - 4);
|
|
5077
|
+
else textX = Math.max(textX, estW + 4);
|
|
5056
5078
|
|
|
5057
5079
|
labelGroup
|
|
5058
5080
|
.append('text')
|
|
@@ -5067,11 +5089,9 @@ export function renderVenn(
|
|
|
5067
5089
|
}
|
|
5068
5090
|
});
|
|
5069
5091
|
|
|
5070
|
-
// ── Overlap labels (inline at region centroid
|
|
5071
|
-
// Helper: horizontal span at y inside all circles in idxs, outside others
|
|
5092
|
+
// ── Overlap labels (inline at region centroid) ──
|
|
5072
5093
|
function overlapHSpan(py: number, idxs: number[]): number {
|
|
5073
5094
|
let left = -Infinity, right = Infinity;
|
|
5074
|
-
// Intersect chords of all "inside" circles
|
|
5075
5095
|
for (const ci of idxs) {
|
|
5076
5096
|
const dy = py - circles[ci].y;
|
|
5077
5097
|
if (Math.abs(dy) >= circles[ci].r) return 0;
|
|
@@ -5080,7 +5100,6 @@ export function renderVenn(
|
|
|
5080
5100
|
right = Math.min(right, circles[ci].x + hc);
|
|
5081
5101
|
}
|
|
5082
5102
|
if (left >= right) return 0;
|
|
5083
|
-
// Subtract any "outside" circle that intrudes
|
|
5084
5103
|
for (let j = 0; j < n; j++) {
|
|
5085
5104
|
if (idxs.includes(j)) continue;
|
|
5086
5105
|
const dy = py - circles[j].y;
|
|
@@ -5096,18 +5115,14 @@ export function renderVenn(
|
|
|
5096
5115
|
}
|
|
5097
5116
|
|
|
5098
5117
|
for (const ov of vennOverlaps) {
|
|
5118
|
+
if (!ov.label) continue;
|
|
5099
5119
|
const idxs = ov.sets.map((s) => vennSets.findIndex((vs) => vs.name === s));
|
|
5100
5120
|
if (idxs.some((idx) => idx < 0)) continue;
|
|
5101
|
-
if (!ov.label) continue;
|
|
5102
|
-
|
|
5103
5121
|
const inside = circles.map((_, j) => idxs.includes(j));
|
|
5104
5122
|
const centroid = regionCentroid(circles, inside);
|
|
5105
|
-
const text = ov.label;
|
|
5106
|
-
|
|
5107
5123
|
const availW = overlapHSpan(centroid.y, idxs);
|
|
5108
5124
|
const fitFont = Math.min(MAX_FONT, Math.max(MIN_FONT,
|
|
5109
|
-
(availW - INTERNAL_PAD * 2) / (
|
|
5110
|
-
|
|
5125
|
+
(availW - INTERNAL_PAD * 2) / (ov.label.length * CH_RATIO)));
|
|
5111
5126
|
labelGroup
|
|
5112
5127
|
.append('text')
|
|
5113
5128
|
.attr('x', centroid.x)
|
|
@@ -5117,45 +5132,73 @@ export function renderVenn(
|
|
|
5117
5132
|
.attr('fill', textColor)
|
|
5118
5133
|
.attr('font-size', `${Math.round(fitFont)}px`)
|
|
5119
5134
|
.attr('font-weight', '600')
|
|
5120
|
-
.text(
|
|
5135
|
+
.text(ov.label);
|
|
5121
5136
|
}
|
|
5122
5137
|
|
|
5123
|
-
// ──
|
|
5138
|
+
// ── Hover targets ──
|
|
5139
|
+
// Exclusive circle targets first (lower z-order), then intersection targets (higher z-order)
|
|
5124
5140
|
const hoverGroup = svg.append('g');
|
|
5125
|
-
circles.forEach((c, i) => {
|
|
5126
|
-
const tipName = vennSets[i].label
|
|
5127
|
-
? `${vennSets[i].label} (${vennSets[i].name})`
|
|
5128
|
-
: vennSets[i].name;
|
|
5129
|
-
const tipHtml = `<strong>${tipName}</strong><br>Size: ${vennSets[i].size}`;
|
|
5130
5141
|
|
|
5142
|
+
circles.forEach((c, i) => {
|
|
5131
5143
|
hoverGroup
|
|
5132
5144
|
.append('circle')
|
|
5133
5145
|
.attr('cx', c.x)
|
|
5134
5146
|
.attr('cy', c.y)
|
|
5135
5147
|
.attr('r', c.r)
|
|
5136
5148
|
.attr('fill', 'transparent')
|
|
5149
|
+
.attr('stroke', 'none')
|
|
5150
|
+
.attr('class', 'venn-hit-target')
|
|
5137
5151
|
.attr('data-line-number', String(vennSets[i].lineNumber))
|
|
5138
5152
|
.style('cursor', onClickItem ? 'pointer' : 'default')
|
|
5139
|
-
.
|
|
5140
|
-
|
|
5141
|
-
|
|
5142
|
-
|
|
5143
|
-
|
|
5144
|
-
|
|
5145
|
-
.on('mousemove', (event: MouseEvent) => {
|
|
5146
|
-
showTooltip(tooltip, tipHtml, event);
|
|
5147
|
-
})
|
|
5148
|
-
.on('mouseleave', () => {
|
|
5149
|
-
circleEls.forEach((el) => {
|
|
5150
|
-
el.attr('fill-opacity', 0.35);
|
|
5151
|
-
});
|
|
5152
|
-
hideTooltip(tooltip);
|
|
5153
|
-
})
|
|
5154
|
-
.on('click', () => {
|
|
5155
|
-
if (onClickItem && vennSets[i].lineNumber)
|
|
5156
|
-
onClickItem(vennSets[i].lineNumber);
|
|
5153
|
+
.style('outline', 'none')
|
|
5154
|
+
.on('mouseenter', () => { showRegionOverlay([i]); })
|
|
5155
|
+
.on('mouseleave', () => { hideAllOverlays(); })
|
|
5156
|
+
.on('click', function () {
|
|
5157
|
+
(this as SVGElement).blur?.();
|
|
5158
|
+
if (onClickItem && vennSets[i].lineNumber) onClickItem(vennSets[i].lineNumber);
|
|
5157
5159
|
});
|
|
5158
5160
|
});
|
|
5161
|
+
|
|
5162
|
+
// Intersection targets: centroid-based circles for all overlap regions (declared + undeclared)
|
|
5163
|
+
const overlayR = scaledR * 0.35;
|
|
5164
|
+
|
|
5165
|
+
const subsets: { idxs: number[]; sets: string[] }[] = [];
|
|
5166
|
+
if (n === 2) {
|
|
5167
|
+
subsets.push({ idxs: [0, 1], sets: [vennSets[0].name, vennSets[1].name].sort() });
|
|
5168
|
+
} else {
|
|
5169
|
+
for (let a = 0; a < n; a++) {
|
|
5170
|
+
for (let b = a + 1; b < n; b++) {
|
|
5171
|
+
subsets.push({ idxs: [a, b], sets: [vennSets[a].name, vennSets[b].name].sort() });
|
|
5172
|
+
}
|
|
5173
|
+
}
|
|
5174
|
+
subsets.push({ idxs: [0, 1, 2], sets: [vennSets[0].name, vennSets[1].name, vennSets[2].name].sort() });
|
|
5175
|
+
}
|
|
5176
|
+
|
|
5177
|
+
for (const subset of subsets) {
|
|
5178
|
+
const { idxs, sets } = subset;
|
|
5179
|
+
const inside = circles.map((_, j) => idxs.includes(j));
|
|
5180
|
+
const centroid = regionCentroid(circles, inside);
|
|
5181
|
+
const declaredOv = vennOverlaps.find(
|
|
5182
|
+
(ov) => ov.sets.length === sets.length && ov.sets.every((s, k) => s === sets[k])
|
|
5183
|
+
);
|
|
5184
|
+
hoverGroup
|
|
5185
|
+
.append('circle')
|
|
5186
|
+
.attr('cx', centroid.x)
|
|
5187
|
+
.attr('cy', centroid.y)
|
|
5188
|
+
.attr('r', overlayR)
|
|
5189
|
+
.attr('fill', 'transparent')
|
|
5190
|
+
.attr('stroke', 'none')
|
|
5191
|
+
.attr('class', 'venn-hit-target')
|
|
5192
|
+
.attr('data-line-number', declaredOv ? String(declaredOv.lineNumber) : '')
|
|
5193
|
+
.style('cursor', onClickItem && declaredOv ? 'pointer' : 'default')
|
|
5194
|
+
.style('outline', 'none')
|
|
5195
|
+
.on('mouseenter', () => { showRegionOverlay(idxs); })
|
|
5196
|
+
.on('mouseleave', () => { hideAllOverlays(); })
|
|
5197
|
+
.on('click', function () {
|
|
5198
|
+
(this as SVGElement).blur?.();
|
|
5199
|
+
if (onClickItem && declaredOv) onClickItem(declaredOv.lineNumber);
|
|
5200
|
+
});
|
|
5201
|
+
}
|
|
5159
5202
|
}
|
|
5160
5203
|
|
|
5161
5204
|
// ============================================================
|