diff --git a/libs/ui/AGENTS.md b/libs/ui/AGENTS.md index d3c8cfa..f220aee 100644 --- a/libs/ui/AGENTS.md +++ b/libs/ui/AGENTS.md @@ -504,9 +504,9 @@ Individual quadrant representing 90° of the radar (one quarter circle). **Features:** - Four concentric rings representing adoption stages (defined in `radarUtils.ts`): - - **Adopt** (innermost, 25% radius) - - **Trial** (50% radius) - - **Assess** (75% radius) + - **Adopt** (innermost, 37.5% radius) + - **Trial** (60% radius) + - **Assess** (81% radius) - **Hold** (outermost, 100% radius) - Items displayed as numbered circles (12px radius) - Deterministic positioning using golden angle distribution @@ -523,6 +523,9 @@ Individual quadrant representing 90° of the radar (one quarter circle). - Pseudo-random but deterministic distribution using item number as seed - Golden angle (137.508°) for optimal spread - Radius variation within ring (30-90% of ring width) to reduce overlap +- Collision resolution: iteratively pushes overlapping items apart (up to 50 iterations) +- Items are kept within quadrant bounds during collision resolution +- If collisions cannot be fully resolved (too many items), remaining overlaps are accepted - Optimized for 20-30 items per quadrant **Item Links:** @@ -600,15 +603,16 @@ The radar components share common utilities defined in `radarUtils.ts`: **Constants:** ```typescript export const RINGS: readonly { readonly label: RadarRing; readonly radiusPosition: number }[] = [ - { label: "Adopt", radiusPosition: 25 }, - { label: "Trial", radiusPosition: 50 }, - { label: "Assess", radiusPosition: 75 }, + { label: "Adopt", radiusPosition: 37.5 }, + { label: "Trial", radiusPosition: 60 }, + { label: "Assess", radiusPosition: 81 }, { label: "Hold", radiusPosition: 100 }, ] as const; ``` - Single source of truth for ring configuration - Used by RadarChart, RadarQuadrant, and RadarQuadrantItemList - Ensures consistent ring ordering and positioning across all components +- Radius values are a blend between equal-radius and equal-area distributions for visual balance **Functions:** diff --git a/libs/ui/src/radar/RadarQuadrant.astro b/libs/ui/src/radar/RadarQuadrant.astro index 482a0dc..dfe8aa9 100644 --- a/libs/ui/src/radar/RadarQuadrant.astro +++ b/libs/ui/src/radar/RadarQuadrant.astro @@ -104,7 +104,7 @@ const getRingRadiusRange = (ring: RadarRing): { min: number; max: number } => { }; // Position items within their rings using a simple grid-like distribution -const positionedItems = items.map((item, index) => { +const initialPositions = items.map((item, index) => { const { min, max } = getRingRadiusRange(item.ring); // Use a pseudo-random but deterministic position based on item number @@ -142,6 +142,75 @@ const positionedItems = items.map((item, index) => { return { ...item, x, y }; }); + +// Collision resolution: push overlapping items apart +const minDistance = circleRadius * 2 + 4; // Minimum distance between circle centers (with small padding) +const maxIterations = 50; + +// Helper to check if position is within quadrant bounds +const isInQuadrant = (x: number, y: number): boolean => { + const dx = x - origin.x; + const dy = y - origin.y; + const distFromOrigin = Math.sqrt(dx * dx + dy * dy); + + // Check if within the outer ring + if (distFromOrigin > size) return false; + + // Check if in correct quadrant based on position + if (position === 0) return dx >= 0 && dy <= 0; + if (position === 1) return dx <= 0 && dy <= 0; + if (position === 2) return dx <= 0 && dy >= 0; + return dx >= 0 && dy >= 0; +}; + +// Resolve collisions iteratively +const positionedItems = [...initialPositions]; +for (let iteration = 0; iteration < maxIterations; iteration++) { + let hasCollision = false; + + for (let i = 0; i < positionedItems.length; i++) { + for (let j = i + 1; j < positionedItems.length; j++) { + const a = positionedItems[i]; + const b = positionedItems[j]; + + const dx = b.x - a.x; + const dy = b.y - a.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < minDistance && distance > 0) { + hasCollision = true; + + // Calculate push direction and amount + const overlap = minDistance - distance; + const pushX = (dx / distance) * (overlap / 2 + 0.5); + const pushY = (dy / distance) * (overlap / 2 + 0.5); + + // Push items apart + let newAx = a.x - pushX; + let newAy = a.y - pushY; + let newBx = b.x + pushX; + let newBy = b.y + pushY; + + // Clamp to SVG bounds + newAx = Math.max(maxCircleRadius, Math.min(size - maxCircleRadius, newAx)); + newAy = Math.max(maxCircleRadius, Math.min(size - maxCircleRadius, newAy)); + newBx = Math.max(maxCircleRadius, Math.min(size - maxCircleRadius, newBx)); + newBy = Math.max(maxCircleRadius, Math.min(size - maxCircleRadius, newBy)); + + // Only apply if still in quadrant + if (isInQuadrant(newAx, newAy)) { + positionedItems[i] = { ...a, x: newAx, y: newAy }; + } + if (isInQuadrant(newBx, newBy)) { + positionedItems[j] = { ...b, x: newBx, y: newBy }; + } + } + } + } + + // Stop early if no collisions found + if (!hasCollision) break; +} ---