Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 10 additions & 6 deletions libs/ui/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:**
Expand Down Expand Up @@ -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:**

Expand Down
71 changes: 70 additions & 1 deletion libs/ui/src/radar/RadarQuadrant.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
---

<svg
Expand Down
6 changes: 3 additions & 3 deletions libs/ui/src/radar/radarUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@ import type { RadarRing } from "@xprtz/cms";
* Ordered from center (Adopt) to outside (Hold)
*/
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;

Expand Down
Loading