DEV Community

orca_forge
orca_forge

Posted on • Originally published at forge.workstyle.tech

Bidirectional Sync Between Radar Chart Vertex Dragging and Sliders

📝 Originally published (in Japanese) at forge.workstyle.tech.

Introduction to Interactive Radar Charts

In a UI for adjusting multi-dimensional parameters, radar charts (also known as spider graphs) are very intuitive. Being able to see the current balance at a glance through the area and shape of an octagon is more intuitive than having eight sliders lined up.

However, in many implementations, radar charts are read-only. Values are input through sliders, and the radar chart only displays the results. Taking it a step further, making it possible to directly drag the vertices of the radar chart would greatly improve the operation's feel. Moreover, if the sliders and radar chart are bidirectionally synchronized, users can operate from either side.

This article records the pattern for implementing SVG radar chart vertex drag editing and bidirectional synchronization with N sliders using React. Key points include coordinate transformation and pointer event handling.

Design Approach: One State, Two UIs

When it comes to bidirectional synchronization, one might imagine a complex picture where the "state of the radar chart" and the "state of the sliders" reflect each other. However, that is a trap. Having two states makes it ambiguous which one is the source of truth, leading to infinite loops and inconsistencies.

The correct approach is simple: have only one state. Both the radar chart and the sliders read from this single state and draw themselves, and when operated, they call the same update function. Here, we consider the store (sliders) that holds the values for each axis key and the update function (setSlider) as the single source of truth.

export default function RadarChart({ axes, sliders }: { axes: Axis[]; sliders: Sliders }) {
  const setSlider = useCanvaStore((s) => s.setSlider);
  // Read sliders and draw, and call setSlider when dragged
}
Enter fullscreen mode Exit fullscreen mode

By doing so, bidirectional synchronization is automatically achieved. If a slider is moved, sliders is updated, and the radar chart is redrawn. If the radar chart is dragged, it calls the same setSlider, so the sliders follow immediately. The synchronization logic is essentially zero. The essence of this design is to create a structure where synchronization is not needed, rather than trying to achieve synchronization.

From Values to Vertex Coordinates (Drawing Direction)

First, we move in the forward direction, seeking the coordinates of each vertex from the sliders values. The axes are arranged at equal angles. The angle of the i-th axis is the position of dividing the circle into N parts, starting from the top (-π/2).

const angleOf = (i: number) => (Math.PI * 2 * i) / n - Math.PI / 2;
Enter fullscreen mode Exit fullscreen mode

Values (0-100) are made to correspond to the distance from the center. The value is normalized to 0-1, multiplied by the radius R, and the point in the direction of that angle is found in polar → Cartesian coordinates.

const pts = axes.map((a, i) => {
  const ang = angleOf(i);
  const v = Math.max(0, Math.min(100, sliders[a.key] ?? 50)) / 100;
  return {
    x: cx + Math.cos(ang) * R * v,   // Vertex
    y: cy + Math.sin(ang) * R * v,
    lx: cx + Math.cos(ang) * (R + 26), // Label position (slightly outside the perimeter)
    ly: cy + Math.sin(ang) * (R + 26),
    val: Math.round(sliders[a.key] ?? 50),
  };
});
Enter fullscreen mode Exit fullscreen mode

By depending on sliders with useMemo, the vertices are recalculated whenever the values change, and the polygon and labels are updated. It's safe to fall back to 50 (the center) if the value is undefined. The number of axes n is taken from axes.length, so the same code works for 6-axis or 8-axis.

From Vertex Coordinates to Values (Drag Direction)

The challenging part is the reverse direction, where we find the "value of that axis" from the pointer's position. Here, two coordinate transformations are necessary.

(1) Screen coordinates → SVG coordinates. SVG has an internal coordinate system with viewBox, and the actual drawing size changes with CSS. The pointer's clientX/Y is in screen pixels, so we divide by the element's rectangle (getBoundingClientRect) and convert back to the viewBox scale.

(2) SVG coordinates → axis direction value. The user does not always drag exactly along the axis line. The pointer comes to a position that is diagonally shifted. So, we project the vector from the center to the pointer onto the direction vector of that axis. By taking the inner product, we get the component in the axis direction (signed distance), and dividing by R gives us a value of 0-1.

const updateFromEvent = (e: React.PointerEvent) => {
  const i = dragRef.current;
  if (i == null || !svgRef.current) return;
  const rect = svgRef.current.getBoundingClientRect();
  const px = ((e.clientX - rect.left) / rect.width) * size;   // (1) → SVG coordinates
  const py = ((e.clientY - rect.top) / rect.height) * size;
  const ang = angleOf(i);
  const proj = (px - cx) * Math.cos(ang) + (py - cy) * Math.sin(ang); // (2) Project onto axis direction
  const v = Math.max(0, Math.min(1, proj / R));
  setSlider(axes[i].key, Math.round(v * 100));
};
Enter fullscreen mode Exit fullscreen mode

By using projection, the user can "roughly pull in that axis's direction" without needing to exactly follow the axis line. The value is clamped to 0-1 to prevent it from going out of range. And finally, setSlider is called — here, the single state is updated, and both the radar chart and the sliders follow simultaneously.

Pointer Events: Capture and Hit Detection

To make the drag operation seamless, two techniques are necessary.

Pointer Capture. After starting a drag from a vertex, even if the pointer moves quickly and goes outside the vertex or the SVG, we want to continue capturing the events. setPointerCapture allows an element to exclusively capture events for a given pointer. We set the capture on the SVG element when the drag starts and record which axis is being dragged in dragRef.

const onVertexDown = (i: number, e: React.PointerEvent) => {
  e.preventDefault();
  dragRef.current = i;                          // Record the axis being dragged
  svgRef.current?.setPointerCapture(e.pointerId);
  updateFromEvent(e);                           // Reflect the drag start position immediately
};
Enter fullscreen mode Exit fullscreen mode

We listen for movements in the onPointerMove of the SVG and call updateFromEvent only when dragRef is set. When the drag ends, we clear dragRef and release the capture. By unifying the handling under Pointer Events, we can cover touch and pen inputs with the same code, as opposed to handling mouse mousedown/move/up separately.

Hit Detection Expansion. The visible circle of the vertex (about 5px in radius) is too small to grab with a finger. So, we overlay a large, transparent circle (about 16px in radius) on each vertex. The appearance remains delicate, while the grabbable area is expanded.

<circle cx={p.x} cy={p.y} r={16} fill="transparent"
        style={{ cursor: 'grab' }}
        onPointerDown={(e) => onVertexDown(i, e)} />
<circle cx={p.x} cy={p.y} r={5} fill="#b9a7ff" pointerEvents="none" />
Enter fullscreen mode Exit fullscreen mode

Don't forget to set pointerEvents="none" on the visible circle to concentrate hit detection on the transparent circle.

Pitfalls and Learnings

  • Prevent touch scrolling. If you don't specify touchAction: 'none' on the SVG, dragging on mobile devices will scroll the page instead. This is a must for drag UIs.
  • Don't have two states. Having separate states for the radar chart and sliders leads to synchronization hell. If you structure it so both read from a single truth, the synchronization code itself disappears.
  • Use projection to not require "exact axis". By projecting the vector from the center to the pointer onto the axis direction, rather than using the vertex's x, y coordinates directly as values, the operation feels more natural even when not exactly on the axis line.
  • Release capture defensively. releasePointerCapture may throw exceptions under certain conditions, so wrap it in a try/catch, and ensure that the drag state is cleared regardless.
  • Separate appearance from hit detection. By distinguishing between the small visible vertices and larger, transparent hit areas, you can achieve both precision and operability.

Conclusion

  • Making radar charts editable instead of just for display greatly improves the operation feel for multi-dimensional parameter adjustments.
  • Bidirectional synchronization is achieved by having both UIs share a single state, eliminating the need for synchronization logic.
  • The forward direction involves converting values to vertices using polar coordinates, and the reverse direction involves projecting the center-to-pointer vector onto the axis direction to convert vertices to values.
  • Drags are unified under Pointer Events + setPointerCapture, and touchAction: 'none' prevents scrolling.
  • Large, transparent hit areas are overlaid on the visible vertices to achieve both delicate appearance and ease of grabbing.

Top comments (0)