(function (root, factory) {
  const winsBubbleMath = factory();
  if (typeof module === "object" && module.exports) {
    module.exports = winsBubbleMath;
  } else {
    root.WinsBubbleMath = winsBubbleMath;
  }
}(typeof globalThis !== "undefined" ? globalThis : this, function () {
  "use strict";

  const EPSILON = 0.000001;

  function clampBubbleValue(value, min = 0, max = 100) {
    const numeric = Number.isFinite(Number(value)) ? Number(value) : min;
    return Math.max(min, Math.min(max, numeric));
  }

  function normalizePercentages(items, total = 100) {
    const safeItems = Array.isArray(items) ? items : [];
    if (!safeItems.length) return [];
    const prepared = safeItems.map((item, index) => {
      const minPercentage = clampBubbleValue(item.minPercentage ?? 0, 0, total);
      const maxPercentage = clampBubbleValue(item.maxPercentage ?? total, minPercentage, total);
      const rawWeight = Number(item.basePercentage ?? item.baseWeight ?? item.weight ?? 1);
      return {
        ...item,
        id: item.id ?? String(index),
        minPercentage,
        maxPercentage,
        rawWeight: Number.isFinite(rawWeight) && rawWeight > 0 ? rawWeight : 1
      };
    });
    const weightTotal = prepared.reduce((sum, item) => sum + item.rawWeight, 0) || prepared.length;
    let normalized = prepared.map(item => ({
      ...item,
      currentPercentage: clampBubbleValue((item.rawWeight / weightTotal) * total, item.minPercentage, item.maxPercentage)
    }));
    normalized = reconcileToTotal(normalized, total);
    return normalized.map(({ rawWeight, ...item }) => Object.freeze(item));
  }

  function reconcileToTotal(items, total = 100) {
    let output = items.map(item => ({ ...item }));
    for (let pass = 0; pass < 8; pass += 1) {
      const sum = output.reduce((acc, item) => acc + item.currentPercentage, 0);
      const diff = total - sum;
      if (Math.abs(diff) < EPSILON) break;
      const candidates = output.filter(item => diff > 0
        ? item.currentPercentage < item.maxPercentage - EPSILON
        : item.currentPercentage > item.minPercentage + EPSILON);
      if (!candidates.length) break;
      const capacityTotal = candidates.reduce((acc, item) => acc + (diff > 0
        ? item.maxPercentage - item.currentPercentage
        : item.currentPercentage - item.minPercentage), 0);
      if (capacityTotal <= EPSILON) break;
      output = output.map(item => {
        if (!candidates.some(candidate => candidate.id === item.id)) return item;
        const capacity = diff > 0
          ? item.maxPercentage - item.currentPercentage
          : item.currentPercentage - item.minPercentage;
        const adjustment = Math.min(Math.abs(diff) * (capacity / capacityTotal), capacity) * Math.sign(diff);
        return { ...item, currentPercentage: item.currentPercentage + adjustment };
      });
    }
    return output.map(item => ({
      ...item,
      currentPercentage: clampBubbleValue(item.currentPercentage, item.minPercentage, item.maxPercentage)
    }));
  }

  function calculateBubbleRadius(percentage, minRadius = 24, sizeRange = 80) {
    const safePercentage = clampBubbleValue(percentage, 0, 100);
    const safeMin = Math.max(24, Number(minRadius) || 24);
    const safeRange = Math.max(0, Number(sizeRange) || 0);
    return safeMin + Math.sqrt(safePercentage / 100) * safeRange;
  }

  function calculateFocusFactor(distance, focusRadius = 520) {
    const safeDistance = Math.max(0, Number(distance) || 0);
    const safeRadius = Math.max(1, Number(focusRadius) || 1);
    return clampBubbleValue(1 - safeDistance / safeRadius, 0, 1);
  }

  function calculateViewportFocusScale(distance, options = {}) {
    const minScale = Number(options.minScale ?? 0.98);
    const maxScale = Number(options.maxScale ?? 1.86);
    const safeMin = Math.max(0.5, Math.min(minScale, maxScale));
    const safeMax = Math.max(safeMin, maxScale);
    const focusRadius = options.focusRadius ?? 620;
    const curve = Math.max(0.35, Number(options.curve ?? 1.35));
    const influence = Math.pow(calculateFocusFactor(distance, focusRadius), curve);
    return clampBubbleValue(safeMin + (safeMax - safeMin) * influence, safeMin, safeMax);
  }

  function calculateFocusedPercentage(basePercentage, focusFactor, focusBoost = 14, maxPercentage = 100) {
    const base = clampBubbleValue(basePercentage, 0, 100);
    const boost = clampBubbleValue(focusFactor, 0, 1) * Math.max(0, Number(focusBoost) || 0);
    return clampBubbleValue(base + boost, 0, maxPercentage);
  }

  function normalizeRelationshipWeights(weights) {
    const entries = Object.entries(weights || {})
      .map(([id, value]) => [id, Number(value)])
      .filter(([, value]) => Number.isFinite(value) && value > 0);
    const total = entries.reduce((sum, [, value]) => sum + value, 0);
    if (total <= EPSILON) return {};
    return Object.freeze(Object.fromEntries(entries.map(([id, value]) => [id, value / total])));
  }

  function redistributeBubbleDelta(bubbles, changedBubbleId, delta, relationshipWeights = null) {
    const items = normalizePercentages(bubbles.map(item => ({
      ...item,
      basePercentage: item.currentPercentage ?? item.basePercentage ?? item.baseWeight
    })));
    const changed = items.find(item => item.id === changedBubbleId);
    if (!changed || Math.abs(Number(delta) || 0) < EPSILON) return items;

    const requestedDelta = Number(delta) || 0;
    const boundedTarget = clampBubbleValue(
      changed.currentPercentage + requestedDelta,
      changed.minPercentage,
      changed.maxPercentage
    );
    const appliedDelta = boundedTarget - changed.currentPercentage;
    if (Math.abs(appliedDelta) < EPSILON) return items;

    const others = items.filter(item => item.id !== changedBubbleId);
    const normalizedWeights = normalizeRelationshipWeights(relationshipWeights || changed.relationshipWeights);
    const weightedCandidates = others.filter(item => normalizedWeights[item.id] > 0);
    const candidates = weightedCandidates.length ? weightedCandidates : others;

    const candidateWeightTotal = candidates.reduce((sum, item) => {
      if (weightedCandidates.length) return sum + normalizedWeights[item.id];
      return sum + Math.max(0, item.currentPercentage);
    }, 0);
    if (candidateWeightTotal <= EPSILON) return items;

    let output = items.map(item => {
      if (item.id === changedBubbleId) return { ...item, currentPercentage: boundedTarget };
      const candidate = candidates.find(candidateItem => candidateItem.id === item.id);
      if (!candidate) return { ...item };
      const share = weightedCandidates.length
        ? normalizedWeights[item.id] / candidateWeightTotal
        : Math.max(0, item.currentPercentage) / candidateWeightTotal;
      const adjustment = -appliedDelta * share;
      return {
        ...item,
        currentPercentage: clampBubbleValue(item.currentPercentage + adjustment, item.minPercentage, item.maxPercentage)
      };
    });

    output = reconcileToTotal(output, 100);
    return output.map(item => Object.freeze(item));
  }

  function roundPercentagesToTotal(items, total = 100) {
    const normalized = normalizePercentages(items.map(item => ({
      ...item,
      basePercentage: item.currentPercentage ?? item.basePercentage ?? item.baseWeight
    })), total);
    const floors = normalized.map(item => {
      const value = item.currentPercentage;
      return { ...item, roundedPercentage: Math.floor(value), remainder: value - Math.floor(value) };
    });
    let remaining = total - floors.reduce((sum, item) => sum + item.roundedPercentage, 0);
    const sortedIds = [...floors]
      .sort((a, b) => b.remainder - a.remainder || String(a.id).localeCompare(String(b.id)))
      .map(item => item.id);
    return floors.map(item => {
      const add = sortedIds.indexOf(item.id) < remaining ? 1 : 0;
      const { remainder, ...rest } = item;
      return Object.freeze({ ...rest, roundedPercentage: item.roundedPercentage + add });
    });
  }

  function deriveBubbleMapLayout(bubbles, viewport = {}, offset = {}, options = {}) {
    const centerWorldX = (Number(viewport.width) || 0) / 2 - (Number(offset.x) || 0);
    const centerWorldY = (Number(viewport.height) || 0) / 2 - (Number(offset.y) || 0);
    const focusRadius = options.focusRadius ?? 520;
    const focusBoost = options.focusBoost ?? 14;
    const minRadius = options.minRadius ?? 24;
    const sizeRange = options.sizeRange ?? 80;
    const normalized = normalizePercentages(bubbles);
    let focused = null;
    let closestDistance = Infinity;
    const measured = normalized.map(item => {
      const distanceFromViewportCenter = Math.hypot((Number(item.x) || 0) - centerWorldX, (Number(item.y) || 0) - centerWorldY);
      const focusFactor = calculateFocusFactor(distanceFromViewportCenter, focusRadius);
      if (distanceFromViewportCenter < closestDistance) {
        closestDistance = distanceFromViewportCenter;
        focused = item;
      }
      return { ...item, distanceFromViewportCenter, focusFactor };
    });

    if (!focused) return measured;
    const focusedMeasured = measured.find(item => item.id === focused.id);
    const nextPercentage = calculateFocusedPercentage(
      focused.currentPercentage,
      focusedMeasured.focusFactor,
      focusBoost,
      focused.maxPercentage
    );
    const redistributed = redistributeBubbleDelta(measured, focused.id, nextPercentage - focused.currentPercentage);
    return redistributed.map(item => {
      const measuredItem = measured.find(measuredBubble => measuredBubble.id === item.id) || item;
      return Object.freeze({
        ...item,
        x: measuredItem.x,
        y: measuredItem.y,
        label: measuredItem.label,
        category: measuredItem.category,
        distanceFromViewportCenter: measuredItem.distanceFromViewportCenter,
        focusFactor: measuredItem.focusFactor,
        radius: calculateBubbleRadius(item.currentPercentage, minRadius, sizeRange),
        isFocused: item.id === focused.id
      });
    });
  }

  function calculateBubbleCoverage(bubbles, viewport = {}) {
    const width = Math.max(1, Number(viewport.width) || 1);
    const height = Math.max(1, Number(viewport.height) || 1);
    const area = bubbles.reduce((sum, bubble) => {
      const radius = Math.max(0, Number(bubble.radius) || 0);
      return sum + Math.PI * radius * radius;
    }, 0);
    return area / (width * height);
  }

  function calculateRelationshipAttraction(a, b, options = {}) {
    const relationshipWeights = normalizeRelationshipWeights({
      ...(a.relationshipWeights || {}),
      ...(b.relationshipWeights || {})
    });
    const direct = Math.max(relationshipWeights[b.id] || 0, relationshipWeights[a.id] || 0);
    const sameCategory = a.category && b.category && a.category === b.category ? (options.sameCategoryAttraction ?? 0.18) : 0;
    const baseAttraction = options.globalAttraction ?? 0;
    return clampBubbleValue(Math.max(baseAttraction, direct, sameCategory), 0, 1);
  }

  function calculateIdealBubbleDistance(a, b, options = {}) {
    const desiredGap = Number(options.desiredGap ?? 4);
    const minDistance = calculateMinimumCenterDistance(a, b, options);
    const attraction = calculateRelationshipAttraction(a, b, options);
    const relationshipTightening = attraction * (options.relationshipTightening ?? 10);
    const preferredDistance = (Number(a.radius) || 0) + (Number(b.radius) || 0) + desiredGap - relationshipTightening;
    return Math.max(minDistance, preferredDistance);
  }

  function calculateMinimumCenterDistance(a, b, options = {}) {
    const minGap = Number(options.minGap ?? 0);
    return Math.max(4, (Number(a.radius) || 0) + (Number(b.radius) || 0) + minGap);
  }

  function applyCollisionSeparation(a, b, options = {}) {
    let dx = b.x - a.x;
    let dy = b.y - a.y;
    let distance = Math.hypot(dx, dy);
    if (distance < EPSILON) {
      dx = 1;
      dy = 0;
      distance = 1;
    }
    const idealDistance = calculateIdealBubbleDistance(a, b, options);
    if (distance >= idealDistance) {
      return Object.freeze({ ax: 0, ay: 0, bx: 0, by: 0, distance, idealDistance });
    }
    const strength = options.separationStrength ?? 0.55;
    const push = (idealDistance - distance) * strength * 0.5;
    const nx = dx / distance;
    const ny = dy / distance;
    return Object.freeze({
      ax: -nx * push,
      ay: -ny * push,
      bx: nx * push,
      by: ny * push,
      distance,
      idealDistance
    });
  }

  function applyCategoryAnchorForce(bubble, categoryCenter = {}, options = {}) {
    const strength = options.anchorStrength ?? 0.045;
    const anchorX = Number(categoryCenter.x ?? bubble.anchorX ?? bubble.x) || 0;
    const anchorY = Number(categoryCenter.y ?? bubble.anchorY ?? bubble.y) || 0;
    return Object.freeze({
      x: (anchorX - bubble.x) * strength,
      y: (anchorY - bubble.y) * strength
    });
  }

  function clampBubbleToWorld(bubble, world = {}, options = {}) {
    const cropAllowance = Number(options.cropAllowance ?? 0);
    const minX = Number(world.minX ?? 0) + bubble.radius - cropAllowance;
    const minY = Number(world.minY ?? 0) + bubble.radius - cropAllowance;
    const maxX = Number(world.maxX ?? world.width ?? 0) - bubble.radius + cropAllowance;
    const maxY = Number(world.maxY ?? world.height ?? 0) - bubble.radius + cropAllowance;
    return Object.freeze({
      ...bubble,
      x: clampBubbleValue(bubble.x, Math.min(minX, maxX), Math.max(minX, maxX)),
      y: clampBubbleValue(bubble.y, Math.min(minY, maxY), Math.max(minY, maxY))
    });
  }

  function relaxBubblePositions(bubbles, options = {}) {
    const iterations = Math.max(0, Number(options.iterations ?? 14));
    const world = options.world || {};
    const categoryCenters = options.categoryCenters || {};
    let output = bubbles.map(item => ({ ...item }));
    for (let iteration = 0; iteration < iterations; iteration += 1) {
      const deltas = output.map(() => ({ x: 0, y: 0 }));
      for (let i = 0; i < output.length; i += 1) {
        for (let j = i + 1; j < output.length; j += 1) {
          const a = output[i];
          const b = output[j];
          const separation = applyCollisionSeparation(a, b, options);
          deltas[i].x += separation.ax;
          deltas[i].y += separation.ay;
          deltas[j].x += separation.bx;
          deltas[j].y += separation.by;

          const attraction = calculateRelationshipAttraction(a, b, options);
          if (attraction > 0 && separation.distance > separation.idealDistance) {
            const range = options.attractionRange ?? 260;
            const excess = Math.min(range, separation.distance - separation.idealDistance);
            if (excess > 0 && separation.distance < separation.idealDistance + range) {
              const nx = (b.x - a.x) / separation.distance;
              const ny = (b.y - a.y) / separation.distance;
              const pull = excess * attraction * (options.attractionStrength ?? 0.016);
              deltas[i].x += nx * pull;
              deltas[i].y += ny * pull;
              deltas[j].x -= nx * pull;
              deltas[j].y -= ny * pull;
            }
          }
        }
      }

      output = output.map((bubble, index) => {
        const anchorForce = applyCategoryAnchorForce(bubble, categoryCenters[bubble.category], options);
        const focusForce = options.focusedId && bubble.id !== options.focusedId
          ? focusPressureForce(bubble, output.find(item => item.id === options.focusedId), options)
          : { x: 0, y: 0 };
        return clampBubbleToWorld({
          ...bubble,
          x: bubble.x + deltas[index].x + anchorForce.x + focusForce.x,
          y: bubble.y + deltas[index].y + anchorForce.y + focusForce.y
        }, world, options);
      });
    }
    output = resolveMembraneOverlaps(output, options);
    return output.map(item => Object.freeze(item));
  }

  function resolveMembraneOverlaps(bubbles, options = {}) {
    const iterations = Math.max(0, Number(options.overlapIterations ?? 8));
    const world = options.world || {};
    let output = bubbles.map(item => ({ ...item }));
    for (let iteration = 0; iteration < iterations; iteration += 1) {
      const deltas = output.map(() => ({ x: 0, y: 0 }));
      let maxOverlap = 0;
      for (let i = 0; i < output.length; i += 1) {
        for (let j = i + 1; j < output.length; j += 1) {
          const a = output[i];
          const b = output[j];
          let dx = b.x - a.x;
          let dy = b.y - a.y;
          let distance = Math.hypot(dx, dy);
          if (distance < EPSILON) {
            dx = 1;
            dy = 0;
            distance = 1;
          }
          const minimumDistance = calculateMinimumCenterDistance(a, b, options);
          if (distance >= minimumDistance - EPSILON) continue;
          const overlap = minimumDistance - distance;
          maxOverlap = Math.max(maxOverlap, overlap);
          const push = overlap * (options.overlapSeparationStrength ?? 0.64) * 0.5;
          const nx = dx / distance;
          const ny = dy / distance;
          deltas[i].x -= nx * push;
          deltas[i].y -= ny * push;
          deltas[j].x += nx * push;
          deltas[j].y += ny * push;
        }
      }
      output = output.map((bubble, index) => clampBubbleToWorld({
        ...bubble,
        x: bubble.x + deltas[index].x,
        y: bubble.y + deltas[index].y
      }, world, options));
      if (maxOverlap < (options.overlapTolerance ?? 0.35)) break;
    }
    return output.map(item => Object.freeze(item));
  }

  function calculateMembraneDisplacements(bubbles, focusedId = "", options = {}) {
    const iterations = Math.max(0, Number(options.iterations ?? 10));
    const minGap = Number(options.minGap ?? 2);
    const strength = options.strength ?? 0.72;
    const maxDisplacement = Math.max(0, Number(options.maxDisplacement ?? 48));
    const pinnedFocused = options.pinFocused !== false;
    let output = (Array.isArray(bubbles) ? bubbles : []).map((bubble, index) => ({
      id: bubble.id ?? String(index),
      x: Number(bubble.x) || 0,
      y: Number(bubble.y) || 0,
      radius: Math.max(0, Number(bubble.radius) || 0),
      scale: Math.max(0.01, Number(bubble.scale) || 1),
      dx: Number(bubble.dx) || 0,
      dy: Number(bubble.dy) || 0
    }));

    for (let iteration = 0; iteration < iterations; iteration += 1) {
      const deltas = output.map(() => ({ x: 0, y: 0 }));
      for (let i = 0; i < output.length; i += 1) {
        for (let j = i + 1; j < output.length; j += 1) {
          const a = output[i];
          const b = output[j];
          let deltaX = (b.x + b.dx) - (a.x + a.dx);
          let deltaY = (b.y + b.dy) - (a.y + a.dy);
          let distance = Math.hypot(deltaX, deltaY);
          if (distance < EPSILON) {
            deltaX = 1;
            deltaY = 0;
            distance = 1;
          }
          const minimumDistance = a.radius * a.scale + b.radius * b.scale + minGap;
          if (distance >= minimumDistance - EPSILON) continue;

          const push = (minimumDistance - distance) * strength * 0.5;
          const nx = deltaX / distance;
          const ny = deltaY / distance;
          const aPinned = pinnedFocused && a.id === focusedId;
          const bPinned = pinnedFocused && b.id === focusedId;

          if (aPinned && !bPinned) {
            deltas[j].x += nx * push * 2;
            deltas[j].y += ny * push * 2;
          } else if (bPinned && !aPinned) {
            deltas[i].x -= nx * push * 2;
            deltas[i].y -= ny * push * 2;
          } else {
            deltas[i].x -= nx * push;
            deltas[i].y -= ny * push;
            deltas[j].x += nx * push;
            deltas[j].y += ny * push;
          }
        }
      }

      output = output.map((bubble, index) => {
        if (pinnedFocused && bubble.id === focusedId) return bubble;
        return {
          ...bubble,
          dx: clampBubbleValue(bubble.dx + deltas[index].x, -maxDisplacement, maxDisplacement),
          dy: clampBubbleValue(bubble.dy + deltas[index].y, -maxDisplacement, maxDisplacement)
        };
      });
    }

    return Object.freeze(Object.fromEntries(output.map(item => [item.id, Object.freeze({
      x: item.dx,
      y: item.dy
    })])));
  }

  function focusPressureForce(bubble, focused, options = {}) {
    if (!focused) return { x: 0, y: 0 };
    let dx = bubble.x - focused.x;
    let dy = bubble.y - focused.y;
    let distance = Math.hypot(dx, dy);
    if (distance < EPSILON) {
      dx = 1;
      dy = 0;
      distance = 1;
    }
    const ringDistance = focused.radius + bubble.radius + (options.focusRingGap ?? 8);
    const range = options.focusPressureRange ?? 120;
    if (distance >= ringDistance + range) return { x: 0, y: 0 };
    const pressure = Math.max(0, ringDistance - distance) * (options.focusPressureStrength ?? 0.16);
    return {
      x: (dx / distance) * pressure,
      y: (dy / distance) * pressure
    };
  }

  function derivePackedBubbleLayout(bubbles, viewport = {}, options = {}) {
    const normalized = normalizePercentages(bubbles);
    const minRadius = options.minRadius ?? 34;
    const initialSizeRange = options.sizeRange ?? 108;
    const targetFillRatio = options.targetFillRatio ?? 0.76;
    const baseLayout = normalized.map(item => ({
      ...item,
      x: Number(item.x ?? item.anchorX ?? 0) || 0,
      y: Number(item.y ?? item.anchorY ?? 0) || 0,
      anchorX: Number(item.anchorX ?? item.x ?? 0) || 0,
      anchorY: Number(item.anchorY ?? item.y ?? 0) || 0,
      radius: calculateBubbleRadius(item.currentPercentage, minRadius, initialSizeRange)
    }));
    const maxSizeRangeMultiplier = options.maxSizeRangeMultiplier ?? 1.9;
    let sizeRange = initialSizeRange;
    if (calculateBubbleCoverage(baseLayout, viewport) < targetFillRatio) {
      let low = initialSizeRange;
      let high = initialSizeRange * maxSizeRangeMultiplier;
      for (let step = 0; step < 8; step += 1) {
        const midpoint = (low + high) / 2;
        const candidate = baseLayout.map(item => ({
          ...item,
          radius: calculateBubbleRadius(item.currentPercentage, minRadius, midpoint)
        }));
        if (calculateBubbleCoverage(candidate, viewport) < targetFillRatio) {
          low = midpoint;
        } else {
          high = midpoint;
        }
      }
      sizeRange = high;
    }
    const sized = baseLayout.map(item => ({
      ...item,
      radius: calculateBubbleRadius(item.currentPercentage, minRadius, sizeRange)
    }));
    const packed = relaxBubblePositions(sized, options);
    return packed.map(item => Object.freeze({
      ...item,
      coverageEstimate: calculateBubbleCoverage(packed, viewport)
    }));
  }

  return Object.freeze({
    clampBubbleValue,
    normalizePercentages,
    calculateBubbleRadius,
    calculateFocusFactor,
    calculateViewportFocusScale,
    calculateFocusedPercentage,
    redistributeBubbleDelta,
    normalizeRelationshipWeights,
    roundPercentagesToTotal,
    deriveBubbleMapLayout,
    calculateBubbleCoverage,
    calculateRelationshipAttraction,
    calculateIdealBubbleDistance,
    calculateMinimumCenterDistance,
    applyCollisionSeparation,
    applyCategoryAnchorForce,
    clampBubbleToWorld,
    relaxBubblePositions,
    resolveMembraneOverlaps,
    calculateMembraneDisplacements,
    derivePackedBubbleLayout
  });
}));
