@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/src/d3.ts CHANGED
@@ -99,15 +99,13 @@ export interface TimelineMarker {
99
99
 
100
100
  export interface VennSet {
101
101
  name: string;
102
- size: number;
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 overlap line: "A & B: size" or "A & B & C: size \"label\""
625
+ // Venn diagram DSL
626
626
  if (result.type === 'venn') {
627
- const overlapMatch = line.match(
628
- /^(.+?&.+?)\s*:\s*(\d+(?:\.\d+)?)\s*(?:"([^"]*)")?\s*$/
629
- );
630
- if (overlapMatch) {
631
- const sets = overlapMatch[1]
632
- .split('&')
633
- .map((s) => s.trim())
634
- .filter(Boolean)
635
- .sort();
636
- const size = parseFloat(overlapMatch[2]);
637
- const label = overlapMatch[3] ?? null;
638
- result.vennOverlaps.push({ sets, size, label, lineNumber });
639
- continue;
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
- // Venn set line: "Name: size" or "Name(color): size \"label\""
643
- const setMatch = line.match(
644
- /^(.+?)(?:\(([^)]+)\))?\s*:\s*(\d+(?:\.\d+)?)\s*(?:"([^"]*)")?\s*$/
645
- );
646
- if (setMatch) {
647
- const name = setMatch[1].trim();
648
- const color = setMatch[2]
649
- ? resolveColor(setMatch[2].trim(), palette)
650
- : null;
651
- const size = parseFloat(setMatch[3]);
652
- const label = setMatch[4] ?? null;
653
- result.vennSets.push({ name, size, color, label, lineNumber });
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 sets as "Name: size" (e.g., "Math: 100")');
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, 'At most 3 sets are supported. Remove extra sets.');
1062
+ return fail(1, 'Venn diagrams support 2–3 sets');
1072
1063
  }
1073
- // Validate overlap references and sizes skip invalid overlaps
1074
- const setMap = new Map(result.vennSets.map((s) => [s.name, s.size]));
1075
- const validOverlaps = [];
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 setName of ov.sets) {
1079
- if (!setMap.has(setName)) {
1080
- result.diagnostics.push(makeDgmoError(ov.lineNumber, `Overlap references unknown set "${setName}". Define it first as "${setName}: <size>"`));
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 (!valid) continue;
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, vennShowValues, title } = parsed;
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
- // Compute radii
4809
- const radii = vennSets.map((s) => radiusFromArea(s.size));
4810
-
4811
- // Build overlap map keyed by sorted set names
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: -d / 2, y: 0, r: radii[0] },
4829
- { x: d / 2, y: 0, r: radii[1] },
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
- // 3 sets: place A and B, then compute C position
4833
- const names = vennSets.map((s) => s.name);
4834
- const pairKey = (i: number, j: number) =>
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: ax, y: 0, r: radii[0] },
4859
- { x: bx, y: 0, r: radii[1] },
4860
- { x: cPos.x, y: cPos.y, r: radii[2] },
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 displayName = vennSets[i].label ?? vennSets[i].name;
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
- // Tooltip
4913
- const tooltip = createTooltip(container, palette, isDark);
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
- // ── Labels ──
4941
- // Global center of all circles (for projecting outward)
4942
- const gcx = circles.reduce((s, c) => s + c.x, 0) / n;
4943
- const gcy = circles.reduce((s, c) => s + c.y, 0) / n;
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
- // Helper: ray-circle exit distance (positive = forward along direction)
4946
- function rayCircleExit(
4947
- ox: number,
4948
- oy: number,
4949
- dx: number,
4950
- dy: number,
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 labelGroup = svg.append('g').style('pointer-events', 'none');
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; // circle j doesn't reach this y
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
- // Clamp our span to exclude the overlap with circle j
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
- circles.forEach((c, i) => {
4994
- const text = vennSets[i].label ?? vennSets[i].name;
5018
+ const labelGroup = svg.append('g').style('pointer-events', 'none');
4995
5019
 
4996
- // Compute exclusive region centroid
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('y1', edgeY)
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
- textX = Math.min(textX, width - estW - 4);
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, scaled to fit) ──
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) / (text.length * CH_RATIO)));
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(text);
5130
+ .text(ov.label);
5127
5131
  }
5128
5132
 
5129
- // ── Invisible hover targets (full circles) + interactions ──
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
- .on('mouseenter', (event: MouseEvent) => {
5146
- circleEls.forEach((el, ci) => {
5147
- el.attr('fill-opacity', ci === i ? 0.5 : 0.1);
5148
- });
5149
- showTooltip(tooltip, tipHtml, event);
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
  // ============================================================