@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/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');
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
- // Validate overlap references and sizes skip invalid overlaps
1074
- const setMap = new Map(result.vennSets.map((s) => [s.name, s.size]));
1075
- const validOverlaps = [];
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;
@@ -4214,12 +4213,16 @@ export function renderTimeline(
4214
4213
  // Remove previous legend
4215
4214
  mainSvg.selectAll('.tl-tag-legend-group').remove();
4216
4215
 
4217
- // In view mode, only show the active color tag group (expanded, non-interactive)
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
- currentActiveGroup != null &&
4222
- lg.group.name.toLowerCase() === currentActiveGroup.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 = currentActiveGroup != null &&
4231
- lg.group.name.toLowerCase() === currentActiveGroup.toLowerCase();
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 = currentActiveGroup != null &&
4240
- currentActiveGroup.toLowerCase() === groupKey;
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, vennShowValues, title } = parsed;
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
- // Compute radii
4803
- const radii = vennSets.map((s) => radiusFromArea(s.size));
4804
-
4805
- // Build overlap map keyed by sorted set names
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: -d / 2, y: 0, r: radii[0] },
4823
- { 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 },
4824
4818
  ];
4825
4819
  } else {
4826
- // 3 sets: place A and B, then compute C position
4827
- const names = vennSets.map((s) => s.name);
4828
- const pairKey = (i: number, j: number) =>
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: ax, y: 0, r: radii[0] },
4853
- { x: bx, y: 0, r: radii[1] },
4854
- { 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 },
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 displayName = vennSets[i].label ?? vennSets[i].name;
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
- // Tooltip
4907
- 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; }');
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
- // ── Labels ──
4935
- // Global center of all circles (for projecting outward)
4936
- const gcx = circles.reduce((s, c) => s + c.x, 0) / n;
4937
- const gcy = circles.reduce((s, c) => s + c.y, 0) / n;
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
- // Helper: ray-circle exit distance (positive = forward along direction)
4940
- function rayCircleExit(
4941
- ox: number,
4942
- oy: number,
4943
- dx: number,
4944
- dy: number,
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 labelGroup = svg.append('g').style('pointer-events', 'none');
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; // circle j doesn't reach this y
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
- // Clamp our span to exclude the overlap with circle j
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
- circles.forEach((c, i) => {
4988
- const text = vennSets[i].label ?? vennSets[i].name;
5023
+ const labelGroup = svg.append('g').style('pointer-events', 'none');
4989
5024
 
4990
- // Compute exclusive region centroid
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('y1', edgeY)
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
- textX = Math.min(textX, width - estW - 4);
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, scaled to fit) ──
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) / (text.length * CH_RATIO)));
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(text);
5135
+ .text(ov.label);
5121
5136
  }
5122
5137
 
5123
- // ── Invisible hover targets (full circles) + interactions ──
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
- .on('mouseenter', (event: MouseEvent) => {
5140
- circleEls.forEach((el, ci) => {
5141
- el.attr('fill-opacity', ci === i ? 0.5 : 0.1);
5142
- });
5143
- showTooltip(tooltip, tipHtml, event);
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
  // ============================================================