@diagrammo/dgmo 0.5.3 → 0.5.5
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 +158 -158
- package/dist/index.cjs +762 -318
- 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 +761 -318
- package/dist/index.js.map +1 -1
- package/docs/language-reference.md +20 -2
- package/package.json +1 -1
- package/src/d3.ts +236 -204
- 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');
|
|
1072
1063
|
}
|
|
1073
|
-
//
|
|
1074
|
-
const
|
|
1075
|
-
|
|
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);
|
|
1071
|
+
}
|
|
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;
|
|
@@ -4797,67 +4796,34 @@ export function renderVenn(
|
|
|
4797
4796
|
onClickItem?: (lineNumber: number) => void,
|
|
4798
4797
|
exportDims?: D3ExportDimensions
|
|
4799
4798
|
): void {
|
|
4800
|
-
const { vennSets, vennOverlaps,
|
|
4799
|
+
const { vennSets, vennOverlaps, title } = parsed;
|
|
4801
4800
|
if (vennSets.length < 2) return;
|
|
4802
4801
|
|
|
4803
4802
|
const init = initD3Chart(container, palette, exportDims);
|
|
4804
4803
|
if (!init) return;
|
|
4805
4804
|
const { svg, width, height, textColor, colors } = init;
|
|
4806
4805
|
const titleHeight = title ? 40 : 0;
|
|
4806
|
+
const n = vennSets.length;
|
|
4807
4807
|
|
|
4808
|
-
//
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
const overlapMap = new Map<string, number>();
|
|
4813
|
-
for (const ov of vennOverlaps) {
|
|
4814
|
-
overlapMap.set(ov.sets.join('&'), ov.size);
|
|
4815
|
-
}
|
|
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;
|
|
4816
4812
|
|
|
4817
|
-
// Layout circles
|
|
4818
4813
|
let rawCircles: Circle[];
|
|
4819
|
-
const n = vennSets.length;
|
|
4820
|
-
|
|
4821
4814
|
if (n === 2) {
|
|
4822
|
-
const d = distanceForOverlap(
|
|
4823
|
-
radii[0],
|
|
4824
|
-
radii[1],
|
|
4825
|
-
overlapMap.get([vennSets[0].name, vennSets[1].name].sort().join('&')) ?? 0
|
|
4826
|
-
);
|
|
4827
4815
|
rawCircles = [
|
|
4828
|
-
{ x: -
|
|
4829
|
-
{ x:
|
|
4816
|
+
{ x: -OVERLAP_DISTANCE / 2, y: 0, r: BASE_R },
|
|
4817
|
+
{ x: OVERLAP_DISTANCE / 2, y: 0, r: BASE_R },
|
|
4830
4818
|
];
|
|
4831
4819
|
} else {
|
|
4832
|
-
//
|
|
4833
|
-
const
|
|
4834
|
-
const
|
|
4835
|
-
[names[i], names[j]].sort().join('&');
|
|
4836
|
-
|
|
4837
|
-
const dAB = distanceForOverlap(
|
|
4838
|
-
radii[0],
|
|
4839
|
-
radii[1],
|
|
4840
|
-
overlapMap.get(pairKey(0, 1)) ?? 0
|
|
4841
|
-
);
|
|
4842
|
-
const dAC = distanceForOverlap(
|
|
4843
|
-
radii[0],
|
|
4844
|
-
radii[2],
|
|
4845
|
-
overlapMap.get(pairKey(0, 2)) ?? 0
|
|
4846
|
-
);
|
|
4847
|
-
const dBC = distanceForOverlap(
|
|
4848
|
-
radii[1],
|
|
4849
|
-
radii[2],
|
|
4850
|
-
overlapMap.get(pairKey(1, 2)) ?? 0
|
|
4851
|
-
);
|
|
4852
|
-
|
|
4853
|
-
const ax = -dAB / 2;
|
|
4854
|
-
const bx = dAB / 2;
|
|
4855
|
-
const cPos = thirdCirclePosition(ax, 0, dAC, bx, 0, dBC);
|
|
4856
|
-
|
|
4820
|
+
// Equilateral triangle with side = OVERLAP_DISTANCE
|
|
4821
|
+
const s = OVERLAP_DISTANCE;
|
|
4822
|
+
const h = (Math.sqrt(3) / 2) * s;
|
|
4857
4823
|
rawCircles = [
|
|
4858
|
-
{ x:
|
|
4859
|
-
{ x:
|
|
4860
|
-
{ 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 },
|
|
4861
4827
|
];
|
|
4862
4828
|
}
|
|
4863
4829
|
|
|
@@ -4867,11 +4833,9 @@ export function renderVenn(
|
|
|
4867
4833
|
);
|
|
4868
4834
|
|
|
4869
4835
|
// ── Layout-aware centering with label space ──
|
|
4870
|
-
// Estimate per-side label widths and compute asymmetric margins
|
|
4871
4836
|
const clusterCx = rawCircles.reduce((s, c) => s + c.x, 0) / n;
|
|
4872
4837
|
const clusterCy = rawCircles.reduce((s, c) => s + c.y, 0) / n;
|
|
4873
4838
|
|
|
4874
|
-
// Estimate which side each set label falls on
|
|
4875
4839
|
let marginLeft = 30,
|
|
4876
4840
|
marginRight = 30,
|
|
4877
4841
|
marginTop = 30,
|
|
@@ -4881,17 +4845,13 @@ export function renderVenn(
|
|
|
4881
4845
|
const labelTextPad = 4;
|
|
4882
4846
|
|
|
4883
4847
|
for (let i = 0; i < n; i++) {
|
|
4884
|
-
const
|
|
4885
|
-
const estimatedWidth = displayName.length * 8.5 + stubLen + edgePad + labelTextPad;
|
|
4848
|
+
const estimatedWidth = vennSets[i].name.length * 8.5 + stubLen + edgePad + labelTextPad;
|
|
4886
4849
|
const dx = rawCircles[i].x - clusterCx;
|
|
4887
4850
|
const dy = rawCircles[i].y - clusterCy;
|
|
4888
|
-
|
|
4889
4851
|
if (Math.abs(dx) >= Math.abs(dy)) {
|
|
4890
|
-
// Label exits left or right
|
|
4891
4852
|
if (dx >= 0) marginRight = Math.max(marginRight, estimatedWidth);
|
|
4892
4853
|
else marginLeft = Math.max(marginLeft, estimatedWidth);
|
|
4893
4854
|
} else {
|
|
4894
|
-
// Label exits top or bottom
|
|
4895
4855
|
const halfEstimate = estimatedWidth * 0.5;
|
|
4896
4856
|
if (dy >= 0) marginBottom = Math.max(marginBottom, halfEstimate + 20);
|
|
4897
4857
|
else marginTop = Math.max(marginTop, halfEstimate + 20);
|
|
@@ -4909,13 +4869,15 @@ export function renderVenn(
|
|
|
4909
4869
|
marginBottom
|
|
4910
4870
|
).map((c) => ({ ...c, y: c.y + titleHeight }));
|
|
4911
4871
|
|
|
4912
|
-
|
|
4913
|
-
|
|
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; }');
|
|
4914
4876
|
|
|
4915
4877
|
// Title
|
|
4916
4878
|
renderChartTitle(svg, title, parsed.titleLineNumber, width, textColor, onClickItem);
|
|
4917
4879
|
|
|
4918
|
-
// ── Semi-transparent filled circles ──
|
|
4880
|
+
// ── Semi-transparent filled circles (non-interactive) ──
|
|
4919
4881
|
const circleEls: d3Selection.Selection<SVGCircleElement, unknown, null, undefined>[] = [];
|
|
4920
4882
|
const circleGroup = svg.append('g');
|
|
4921
4883
|
circles.forEach((c, i) => {
|
|
@@ -4937,69 +4899,131 @@ export function renderVenn(
|
|
|
4937
4899
|
circleEls.push(el);
|
|
4938
4900
|
});
|
|
4939
4901
|
|
|
4940
|
-
// ──
|
|
4941
|
-
//
|
|
4942
|
-
const
|
|
4943
|
-
|
|
4902
|
+
// ── Per-region highlight overlays (section-only, not full circles) ──
|
|
4903
|
+
// Build SVG defs with clipPaths + masks so each region can be highlighted independently.
|
|
4904
|
+
const defs = svg.append('defs');
|
|
4905
|
+
|
|
4906
|
+
// Individual circle clipPaths
|
|
4907
|
+
circles.forEach((c, i) => {
|
|
4908
|
+
defs.append('clipPath')
|
|
4909
|
+
.attr('id', `vcp-${i}`)
|
|
4910
|
+
.append('circle')
|
|
4911
|
+
.attr('cx', c.x).attr('cy', c.y).attr('r', c.r);
|
|
4912
|
+
});
|
|
4944
4913
|
|
|
4945
|
-
//
|
|
4946
|
-
|
|
4947
|
-
|
|
4948
|
-
|
|
4949
|
-
|
|
4950
|
-
|
|
4951
|
-
c: Circle
|
|
4952
|
-
): number {
|
|
4953
|
-
const lx = ox - c.x;
|
|
4954
|
-
const ly = oy - c.y;
|
|
4955
|
-
const b = lx * dx + ly * dy;
|
|
4956
|
-
const det = b * b - (lx * lx + ly * ly - c.r * c.r);
|
|
4957
|
-
if (det < 0) return 0;
|
|
4958
|
-
return -b + Math.sqrt(det);
|
|
4914
|
+
// All region index-sets: exclusive then intersection subsets
|
|
4915
|
+
const regionIdxSets: number[][] = circles.map((_, i) => [i]);
|
|
4916
|
+
if (n === 2) {
|
|
4917
|
+
regionIdxSets.push([0, 1]);
|
|
4918
|
+
} else {
|
|
4919
|
+
regionIdxSets.push([0, 1], [0, 2], [1, 2], [0, 1, 2]);
|
|
4959
4920
|
}
|
|
4960
4921
|
|
|
4961
|
-
const
|
|
4922
|
+
const overlayGroup = svg.append('g').style('pointer-events', 'none');
|
|
4923
|
+
const overlayEls = new Map<string, d3Selection.Selection<SVGRectElement, unknown, null, undefined>>();
|
|
4924
|
+
|
|
4925
|
+
for (const idxs of regionIdxSets) {
|
|
4926
|
+
const key = idxs.join('-');
|
|
4927
|
+
const excluded = Array.from({ length: n }, (_, j) => j).filter(j => !idxs.includes(j));
|
|
4928
|
+
|
|
4929
|
+
// Build nested clipPath for intersection of all idxs
|
|
4930
|
+
let clipId = `vcp-${idxs[0]}`;
|
|
4931
|
+
for (let k = 1; k < idxs.length; k++) {
|
|
4932
|
+
const nestedId = `vcp-n-${idxs.slice(0, k + 1).join('-')}`;
|
|
4933
|
+
const ci = idxs[k];
|
|
4934
|
+
defs.append('clipPath')
|
|
4935
|
+
.attr('id', nestedId)
|
|
4936
|
+
.append('circle')
|
|
4937
|
+
.attr('cx', circles[ci].x).attr('cy', circles[ci].y).attr('r', circles[ci].r)
|
|
4938
|
+
.attr('clip-path', `url(#${clipId})`);
|
|
4939
|
+
clipId = nestedId;
|
|
4940
|
+
}
|
|
4941
|
+
|
|
4942
|
+
// Determine line number for this region (for editor sync)
|
|
4943
|
+
let regionLineNumber: number | null = null;
|
|
4944
|
+
if (idxs.length === 1) {
|
|
4945
|
+
regionLineNumber = vennSets[idxs[0]].lineNumber;
|
|
4946
|
+
} else {
|
|
4947
|
+
const sortedNames = idxs.map(i => vennSets[i].name).sort();
|
|
4948
|
+
const ov = vennOverlaps.find(
|
|
4949
|
+
(o) => o.sets.length === sortedNames.length && o.sets.every((s, k) => s === sortedNames[k])
|
|
4950
|
+
);
|
|
4951
|
+
regionLineNumber = ov?.lineNumber ?? null;
|
|
4952
|
+
}
|
|
4953
|
+
|
|
4954
|
+
const el = overlayGroup.append('rect')
|
|
4955
|
+
.attr('x', 0).attr('y', 0)
|
|
4956
|
+
.attr('width', width).attr('height', height)
|
|
4957
|
+
.attr('fill', 'white')
|
|
4958
|
+
.attr('fill-opacity', 0)
|
|
4959
|
+
.attr('class', 'venn-region-overlay')
|
|
4960
|
+
.attr('data-line-number', regionLineNumber != null ? String(regionLineNumber) : '0')
|
|
4961
|
+
.attr('clip-path', `url(#${clipId})`);
|
|
4962
|
+
|
|
4963
|
+
if (excluded.length > 0) {
|
|
4964
|
+
// Mask subtracts excluded circles so only the exact region shape highlights
|
|
4965
|
+
const maskId = `vvm-${key}`;
|
|
4966
|
+
const mask = defs.append('mask').attr('id', maskId);
|
|
4967
|
+
mask.append('rect')
|
|
4968
|
+
.attr('x', 0).attr('y', 0)
|
|
4969
|
+
.attr('width', width).attr('height', height)
|
|
4970
|
+
.attr('fill', 'white');
|
|
4971
|
+
for (const j of excluded) {
|
|
4972
|
+
mask.append('circle')
|
|
4973
|
+
.attr('cx', circles[j].x).attr('cy', circles[j].y).attr('r', circles[j].r)
|
|
4974
|
+
.attr('fill', 'black');
|
|
4975
|
+
}
|
|
4976
|
+
el.attr('mask', `url(#${maskId})`);
|
|
4977
|
+
}
|
|
4978
|
+
|
|
4979
|
+
overlayEls.set(key, el);
|
|
4980
|
+
}
|
|
4981
|
+
|
|
4982
|
+
const showRegionOverlay = (idxs: number[]) => {
|
|
4983
|
+
const key = [...idxs].sort((a, b) => a - b).join('-');
|
|
4984
|
+
overlayEls.forEach((el, k) => el.attr('fill-opacity', k === key ? 0 : 0.55));
|
|
4985
|
+
};
|
|
4986
|
+
const hideAllOverlays = () => {
|
|
4987
|
+
overlayEls.forEach(el => el.attr('fill-opacity', 0));
|
|
4988
|
+
};
|
|
4989
|
+
|
|
4990
|
+
// ── Labels ──
|
|
4991
|
+
const gcx = circles.reduce((s, c) => s + c.x, 0) / n;
|
|
4992
|
+
const gcy = circles.reduce((s, c) => s + c.y, 0) / n;
|
|
4962
4993
|
|
|
4963
|
-
// ── Set name labels (inside exclusive region if they fit, else external leader line) ──
|
|
4964
|
-
// Helper: measure horizontal clearance at a point inside circle i but outside others
|
|
4965
4994
|
function exclusiveHSpan(px: number, py: number, ci: number): number {
|
|
4966
|
-
// Start with full chord width of circle i at height py
|
|
4967
4995
|
const dy = py - circles[ci].y;
|
|
4968
4996
|
const halfChord = Math.sqrt(Math.max(0, circles[ci].r * circles[ci].r - dy * dy));
|
|
4969
4997
|
let left = circles[ci].x - halfChord;
|
|
4970
4998
|
let right = circles[ci].x + halfChord;
|
|
4971
|
-
// Subtract any overlapping circle chord that covers this y
|
|
4972
4999
|
for (let j = 0; j < n; j++) {
|
|
4973
5000
|
if (j === ci) continue;
|
|
4974
5001
|
const djy = py - circles[j].y;
|
|
4975
|
-
if (Math.abs(djy) >= circles[j].r) continue;
|
|
5002
|
+
if (Math.abs(djy) >= circles[j].r) continue;
|
|
4976
5003
|
const hc = Math.sqrt(circles[j].r * circles[j].r - djy * djy);
|
|
4977
5004
|
const jLeft = circles[j].x - hc;
|
|
4978
5005
|
const jRight = circles[j].x + hc;
|
|
4979
|
-
|
|
4980
|
-
if (jLeft <= left && jRight >= right) return 0; // fully covered
|
|
5006
|
+
if (jLeft <= left && jRight >= right) return 0;
|
|
4981
5007
|
if (jLeft <= left && jRight > left) left = jRight;
|
|
4982
5008
|
if (jRight >= right && jLeft < right) right = jLeft;
|
|
4983
5009
|
}
|
|
4984
5010
|
return Math.max(0, right - left);
|
|
4985
5011
|
}
|
|
4986
5012
|
|
|
4987
|
-
// Font size scaling: 0.6 ch-width per character at a given font size
|
|
4988
5013
|
const CH_RATIO = 0.6;
|
|
4989
5014
|
const MIN_FONT = 10;
|
|
4990
5015
|
const MAX_FONT = 22;
|
|
4991
5016
|
const INTERNAL_PAD = 12;
|
|
4992
5017
|
|
|
4993
|
-
|
|
4994
|
-
const text = vennSets[i].label ?? vennSets[i].name;
|
|
5018
|
+
const labelGroup = svg.append('g').style('pointer-events', 'none');
|
|
4995
5019
|
|
|
4996
|
-
|
|
5020
|
+
// Set name labels: prefer inside exclusive region, fall back to external leader line
|
|
5021
|
+
circles.forEach((c, i) => {
|
|
5022
|
+
const text = vennSets[i].name;
|
|
4997
5023
|
const inside = circles.map((_, j) => j === i);
|
|
4998
5024
|
const centroid = regionCentroid(circles, inside);
|
|
4999
5025
|
|
|
5000
|
-
// Available width at centroid
|
|
5001
5026
|
const availW = exclusiveHSpan(centroid.x, centroid.y, i);
|
|
5002
|
-
// Font size that makes text fill ~80% of available width
|
|
5003
5027
|
const fitFont = Math.min(MAX_FONT, Math.max(MIN_FONT,
|
|
5004
5028
|
(availW - INTERNAL_PAD * 2) / (text.length * CH_RATIO)));
|
|
5005
5029
|
const estTextW = text.length * CH_RATIO * fitFont;
|
|
@@ -5020,17 +5044,10 @@ export function renderVenn(
|
|
|
5020
5044
|
.attr('font-weight', 'bold')
|
|
5021
5045
|
.text(text);
|
|
5022
5046
|
} else {
|
|
5023
|
-
// External label with leader line
|
|
5024
5047
|
let dx = c.x - gcx;
|
|
5025
5048
|
let dy = c.y - gcy;
|
|
5026
5049
|
const mag = Math.sqrt(dx * dx + dy * dy);
|
|
5027
|
-
if (mag < 1e-6) {
|
|
5028
|
-
dx = 1;
|
|
5029
|
-
dy = 0;
|
|
5030
|
-
} else {
|
|
5031
|
-
dx /= mag;
|
|
5032
|
-
dy /= mag;
|
|
5033
|
-
}
|
|
5050
|
+
if (mag < 1e-6) { dx = 1; dy = 0; } else { dx /= mag; dy /= mag; }
|
|
5034
5051
|
|
|
5035
5052
|
const exitX = c.x + dx * c.r;
|
|
5036
5053
|
const exitY = c.y + dy * c.r;
|
|
@@ -5041,10 +5058,8 @@ export function renderVenn(
|
|
|
5041
5058
|
|
|
5042
5059
|
labelGroup
|
|
5043
5060
|
.append('line')
|
|
5044
|
-
.attr('x1', edgeX)
|
|
5045
|
-
.attr('
|
|
5046
|
-
.attr('x2', stubEndX)
|
|
5047
|
-
.attr('y2', stubEndY)
|
|
5061
|
+
.attr('x1', edgeX).attr('y1', edgeY)
|
|
5062
|
+
.attr('x2', stubEndX).attr('y2', stubEndY)
|
|
5048
5063
|
.attr('stroke', textColor)
|
|
5049
5064
|
.attr('stroke-width', 1);
|
|
5050
5065
|
|
|
@@ -5052,13 +5067,9 @@ export function renderVenn(
|
|
|
5052
5067
|
const textAnchor = isRight ? 'start' : 'end';
|
|
5053
5068
|
let textX = stubEndX + (isRight ? labelTextPad : -labelTextPad);
|
|
5054
5069
|
const textY = stubEndY;
|
|
5055
|
-
|
|
5056
5070
|
const estW = text.length * 8.5;
|
|
5057
|
-
if (isRight)
|
|
5058
|
-
|
|
5059
|
-
} else {
|
|
5060
|
-
textX = Math.max(textX, estW + 4);
|
|
5061
|
-
}
|
|
5071
|
+
if (isRight) textX = Math.min(textX, width - estW - 4);
|
|
5072
|
+
else textX = Math.max(textX, estW + 4);
|
|
5062
5073
|
|
|
5063
5074
|
labelGroup
|
|
5064
5075
|
.append('text')
|
|
@@ -5073,11 +5084,9 @@ export function renderVenn(
|
|
|
5073
5084
|
}
|
|
5074
5085
|
});
|
|
5075
5086
|
|
|
5076
|
-
// ── Overlap labels (inline at region centroid
|
|
5077
|
-
// Helper: horizontal span at y inside all circles in idxs, outside others
|
|
5087
|
+
// ── Overlap labels (inline at region centroid) ──
|
|
5078
5088
|
function overlapHSpan(py: number, idxs: number[]): number {
|
|
5079
5089
|
let left = -Infinity, right = Infinity;
|
|
5080
|
-
// Intersect chords of all "inside" circles
|
|
5081
5090
|
for (const ci of idxs) {
|
|
5082
5091
|
const dy = py - circles[ci].y;
|
|
5083
5092
|
if (Math.abs(dy) >= circles[ci].r) return 0;
|
|
@@ -5086,7 +5095,6 @@ export function renderVenn(
|
|
|
5086
5095
|
right = Math.min(right, circles[ci].x + hc);
|
|
5087
5096
|
}
|
|
5088
5097
|
if (left >= right) return 0;
|
|
5089
|
-
// Subtract any "outside" circle that intrudes
|
|
5090
5098
|
for (let j = 0; j < n; j++) {
|
|
5091
5099
|
if (idxs.includes(j)) continue;
|
|
5092
5100
|
const dy = py - circles[j].y;
|
|
@@ -5102,18 +5110,14 @@ export function renderVenn(
|
|
|
5102
5110
|
}
|
|
5103
5111
|
|
|
5104
5112
|
for (const ov of vennOverlaps) {
|
|
5113
|
+
if (!ov.label) continue;
|
|
5105
5114
|
const idxs = ov.sets.map((s) => vennSets.findIndex((vs) => vs.name === s));
|
|
5106
5115
|
if (idxs.some((idx) => idx < 0)) continue;
|
|
5107
|
-
if (!ov.label) continue;
|
|
5108
|
-
|
|
5109
5116
|
const inside = circles.map((_, j) => idxs.includes(j));
|
|
5110
5117
|
const centroid = regionCentroid(circles, inside);
|
|
5111
|
-
const text = ov.label;
|
|
5112
|
-
|
|
5113
5118
|
const availW = overlapHSpan(centroid.y, idxs);
|
|
5114
5119
|
const fitFont = Math.min(MAX_FONT, Math.max(MIN_FONT,
|
|
5115
|
-
(availW - INTERNAL_PAD * 2) / (
|
|
5116
|
-
|
|
5120
|
+
(availW - INTERNAL_PAD * 2) / (ov.label.length * CH_RATIO)));
|
|
5117
5121
|
labelGroup
|
|
5118
5122
|
.append('text')
|
|
5119
5123
|
.attr('x', centroid.x)
|
|
@@ -5123,45 +5127,73 @@ export function renderVenn(
|
|
|
5123
5127
|
.attr('fill', textColor)
|
|
5124
5128
|
.attr('font-size', `${Math.round(fitFont)}px`)
|
|
5125
5129
|
.attr('font-weight', '600')
|
|
5126
|
-
.text(
|
|
5130
|
+
.text(ov.label);
|
|
5127
5131
|
}
|
|
5128
5132
|
|
|
5129
|
-
// ──
|
|
5133
|
+
// ── Hover targets ──
|
|
5134
|
+
// Exclusive circle targets first (lower z-order), then intersection targets (higher z-order)
|
|
5130
5135
|
const hoverGroup = svg.append('g');
|
|
5131
|
-
circles.forEach((c, i) => {
|
|
5132
|
-
const tipName = vennSets[i].label
|
|
5133
|
-
? `${vennSets[i].label} (${vennSets[i].name})`
|
|
5134
|
-
: vennSets[i].name;
|
|
5135
|
-
const tipHtml = `<strong>${tipName}</strong><br>Size: ${vennSets[i].size}`;
|
|
5136
5136
|
|
|
5137
|
+
circles.forEach((c, i) => {
|
|
5137
5138
|
hoverGroup
|
|
5138
5139
|
.append('circle')
|
|
5139
5140
|
.attr('cx', c.x)
|
|
5140
5141
|
.attr('cy', c.y)
|
|
5141
5142
|
.attr('r', c.r)
|
|
5142
5143
|
.attr('fill', 'transparent')
|
|
5144
|
+
.attr('stroke', 'none')
|
|
5145
|
+
.attr('class', 'venn-hit-target')
|
|
5143
5146
|
.attr('data-line-number', String(vennSets[i].lineNumber))
|
|
5144
5147
|
.style('cursor', onClickItem ? 'pointer' : 'default')
|
|
5145
|
-
.
|
|
5146
|
-
|
|
5147
|
-
|
|
5148
|
-
|
|
5149
|
-
|
|
5150
|
-
|
|
5151
|
-
.on('mousemove', (event: MouseEvent) => {
|
|
5152
|
-
showTooltip(tooltip, tipHtml, event);
|
|
5153
|
-
})
|
|
5154
|
-
.on('mouseleave', () => {
|
|
5155
|
-
circleEls.forEach((el) => {
|
|
5156
|
-
el.attr('fill-opacity', 0.35);
|
|
5157
|
-
});
|
|
5158
|
-
hideTooltip(tooltip);
|
|
5159
|
-
})
|
|
5160
|
-
.on('click', () => {
|
|
5161
|
-
if (onClickItem && vennSets[i].lineNumber)
|
|
5162
|
-
onClickItem(vennSets[i].lineNumber);
|
|
5148
|
+
.style('outline', 'none')
|
|
5149
|
+
.on('mouseenter', () => { showRegionOverlay([i]); })
|
|
5150
|
+
.on('mouseleave', () => { hideAllOverlays(); })
|
|
5151
|
+
.on('click', function () {
|
|
5152
|
+
(this as SVGElement).blur?.();
|
|
5153
|
+
if (onClickItem && vennSets[i].lineNumber) onClickItem(vennSets[i].lineNumber);
|
|
5163
5154
|
});
|
|
5164
5155
|
});
|
|
5156
|
+
|
|
5157
|
+
// Intersection targets: centroid-based circles for all overlap regions (declared + undeclared)
|
|
5158
|
+
const overlayR = scaledR * 0.35;
|
|
5159
|
+
|
|
5160
|
+
const subsets: { idxs: number[]; sets: string[] }[] = [];
|
|
5161
|
+
if (n === 2) {
|
|
5162
|
+
subsets.push({ idxs: [0, 1], sets: [vennSets[0].name, vennSets[1].name].sort() });
|
|
5163
|
+
} else {
|
|
5164
|
+
for (let a = 0; a < n; a++) {
|
|
5165
|
+
for (let b = a + 1; b < n; b++) {
|
|
5166
|
+
subsets.push({ idxs: [a, b], sets: [vennSets[a].name, vennSets[b].name].sort() });
|
|
5167
|
+
}
|
|
5168
|
+
}
|
|
5169
|
+
subsets.push({ idxs: [0, 1, 2], sets: [vennSets[0].name, vennSets[1].name, vennSets[2].name].sort() });
|
|
5170
|
+
}
|
|
5171
|
+
|
|
5172
|
+
for (const subset of subsets) {
|
|
5173
|
+
const { idxs, sets } = subset;
|
|
5174
|
+
const inside = circles.map((_, j) => idxs.includes(j));
|
|
5175
|
+
const centroid = regionCentroid(circles, inside);
|
|
5176
|
+
const declaredOv = vennOverlaps.find(
|
|
5177
|
+
(ov) => ov.sets.length === sets.length && ov.sets.every((s, k) => s === sets[k])
|
|
5178
|
+
);
|
|
5179
|
+
hoverGroup
|
|
5180
|
+
.append('circle')
|
|
5181
|
+
.attr('cx', centroid.x)
|
|
5182
|
+
.attr('cy', centroid.y)
|
|
5183
|
+
.attr('r', overlayR)
|
|
5184
|
+
.attr('fill', 'transparent')
|
|
5185
|
+
.attr('stroke', 'none')
|
|
5186
|
+
.attr('class', 'venn-hit-target')
|
|
5187
|
+
.attr('data-line-number', declaredOv ? String(declaredOv.lineNumber) : '')
|
|
5188
|
+
.style('cursor', onClickItem && declaredOv ? 'pointer' : 'default')
|
|
5189
|
+
.style('outline', 'none')
|
|
5190
|
+
.on('mouseenter', () => { showRegionOverlay(idxs); })
|
|
5191
|
+
.on('mouseleave', () => { hideAllOverlays(); })
|
|
5192
|
+
.on('click', function () {
|
|
5193
|
+
(this as SVGElement).blur?.();
|
|
5194
|
+
if (onClickItem && declaredOv) onClickItem(declaredOv.lineNumber);
|
|
5195
|
+
});
|
|
5196
|
+
}
|
|
5165
5197
|
}
|
|
5166
5198
|
|
|
5167
5199
|
// ============================================================
|