Reference: screen recording link
I’m working on a feature similar to Figma or Lucidchart where an ellipse (or circle) is placed inside a reference box (a square) with four corner handles for resizing.
Current functionality:
Dragging a corner handle resizes the ellipse while keeping the opposite corner fixed.
Dragging a side handle adjusts the width or height proportionally.
This works fine for ellipses.
Now, I want to add:
- Rotation of the ellipse using a rotation handle.
I tried multiple approaches, including using SVG <ellipse> and experimenting with <path> for more flexibility, but I couldn’t get rotation to work properly.
Question:
Is using an SVG
<path>the right approach for enabling rotation and node-based reshaping?Are there any recommended patterns or libraries for implementing this functionality?
Any guidance or code examples would be greatly appreciated.
Method used for resize on corner, side edge
_handleCircleReferenceBoxResize(state: any, cur: {x: number, y: number}, hit: any) {
const shape = state.shape;
const center = shape.startPoint;
const proportional = !!state.shiftKey;
if (!state.hasStartedResize) {
const dxMove = Math.hypot(cur.x - state.startSvgPoint.x, cur.y - state.startSvgPoint.y);
if (dxMove > 3) {
this.toggleOriginalShapeArea(shape, true);
state.hasStartedResize = true;
}
}
// Get original ellipse data and bounds
const origEllipse = state.originalEllipse || (shape.__ellipse ? { ...shape.__ellipse } : { rx: 40, ry: 40 });
const origRx = origEllipse.rx || 40;
const origRy = origEllipse.ry || 40;
// Use the stored original bounds from createReferenceBox
const origBounds = state.originalBounds;
let newRx = origRx;
let newRy = origRy;
let newCenterX = center.x;
let newCenterY = center.y;
if (hit.type === 'edge') {
// Edge resize - opposite edge should remain fixed
switch (hit.edge) {
case 'left':
// Left edge resize - right edge stays fixed
newRx = Math.max(2, (origBounds.right - cur.x) / 2);
newCenterX = (origBounds.right + cur.x) / 2;
if (proportional) {
const scale = newRx / origRx;
newRy = Math.max(2, origRy * scale);
newCenterY = center.y;
} else {
newRy = origRy;
newCenterY = center.y;
}
break;
case 'right':
// Right edge resize - left edge stays fixed
newRx = Math.max(2, (cur.x - origBounds.left) / 2);
newCenterX = (origBounds.left + cur.x) / 2;
if (proportional) {
const scale = newRx / origRx;
newRy = Math.max(2, origRy * scale);
newCenterY = center.y;
} else {
newRy = origRy;
newCenterY = center.y;
}
break;
case 'top':
// Top edge resize - bottom edge stays fixed
newRy = Math.max(2, (origBounds.bottom - cur.y) / 2);
newCenterY = (origBounds.bottom + cur.y) / 2;
if (proportional) {
const scale = newRy / origRy;
newRx = Math.max(2, origRx * scale);
newCenterX = center.x;
} else {
newRx = origRx;
newCenterX = center.x;
}
break;
case 'bottom':
// Bottom edge resize - top edge stays fixed
newRy = Math.max(2, (cur.y - origBounds.top) / 2);
newCenterY = (origBounds.top + cur.y) / 2;
if (proportional) {
const scale = newRy / origRy;
newRx = Math.max(2, origRx * scale);
newCenterX = center.x;
} else {
newRx = origRx;
newCenterX = center.x;
}
break;
}
} else if (hit.type === 'corner') {
// Corner resize - opposite corner should remain fixed
const oppositeCorner = this._getOppositeCorner(hit.cornerIndex, origBounds);
switch (hit.cornerIndex) {
case 0: // Top-left corner - bottom-right fixed
newRx = Math.max(2, (oppositeCorner.x - cur.x) / 2);
newRy = Math.max(2, (oppositeCorner.y - cur.y) / 2);
newCenterX = (oppositeCorner.x + cur.x) / 2;
newCenterY = (oppositeCorner.y + cur.y) / 2;
break;
case 1: // Top-right corner - bottom-left fixed
newRx = Math.max(2, (cur.x - oppositeCorner.x) / 2);
newRy = Math.max(2, (oppositeCorner.y - cur.y) / 2);
newCenterX = (oppositeCorner.x + cur.x) / 2;
newCenterY = (oppositeCorner.y + cur.y) / 2;
break;
case 2: // Bottom-right corner - top-left fixed
newRx = Math.max(2, (cur.x - oppositeCorner.x) / 2);
newRy = Math.max(2, (cur.y - oppositeCorner.y) / 2);
newCenterX = (oppositeCorner.x + cur.x) / 2;
newCenterY = (oppositeCorner.y + cur.y) / 2;
break;
case 3: // Bottom-left corner - top-right fixed
newRx = Math.max(2, (oppositeCorner.x - cur.x) / 2);
newRy = Math.max(2, (cur.y - oppositeCorner.y) / 2);
newCenterX = (oppositeCorner.x + cur.x) / 2;
newCenterY = (oppositeCorner.y + cur.y) / 2;
break;
}
if (proportional) {
// Maintain aspect ratio - use the smaller scale for both axes
const scaleX = newRx / origRx;
const scaleY = newRy / origRy;
const scale = Math.min(scaleX, scaleY);
// Recalculate based on proportional scale while keeping opposite corner fixed
newRx = Math.max(2, origRx * scale);
newRy = Math.max(2, origRy * scale);
// Recalculate center based on new radii and opposite corner
switch (hit.cornerIndex) {
case 0: // Top-left
newCenterX = oppositeCorner.x - newRx;
newCenterY = oppositeCorner.y - newRy;
break;
case 1: // Top-right
newCenterX = oppositeCorner.x + newRx;
newCenterY = oppositeCorner.y - newRy;
break;
case 2: // Bottom-right
newCenterX = oppositeCorner.x + newRx;
newCenterY = oppositeCorner.y + newRy;
break;
case 3: // Bottom-left
newCenterX = oppositeCorner.x - newRx;
newCenterY = oppositeCorner.y + newRy;
break;
}
}
}
// Update preview state and shape center
(shape as any).__tempEllipse = { rx: newRx, ry: newRy };
shape.startPoint = { x: newCenterX, y: newCenterY, id: shape.startPoint?.id };
// Update drop points to maintain consistency
if (shape.dropPoints && shape.dropPoints.length > 0) {
const lastPoint = shape.dropPoints[shape.dropPoints.length - 1];
lastPoint.x = newCenterX + newRx;
lastPoint.y = newCenterY;
}
// Update visuals and reference box
this.updateCircleVisuals(shape);
this.updateReferenceBox(shape);
}
updateCircleVisuals(shape: any) {
try {
if (!shape || !shape.group) return;
const g = shape.group;
const start = shape.startPoint;
// canonical radii (persisted or from last dropPoint)
const persisted = (shape as any).__ellipse;
const last = shape.dropPoints?.length ? shape.dropPoints[shape.dropPoints.length - 1] : null;
const canonicalRx = persisted?.rx ?? (last ? Math.abs(last.x - start.x) : 40);
const canonicalRy = persisted?.ry ?? (last ? Math.abs(last.y - start.y) : canonicalRx);
// preview temp ellipse (set during pointermove)
const temp = (shape as any).__tempEllipse;
let rx = temp && typeof temp.rx === 'number' ? Math.max(1, temp.rx) : Math.max(1, canonicalRx);
let ry = temp && typeof temp.ry === 'number' ? Math.max(1, temp.ry) : Math.max(1, canonicalRy);
// avoid collapsed axes
if (!ry || ry < 1) ry = Math.max(2, rx);
if (!rx || rx < 1) rx = Math.max(2, ry);
const rootStyle = this.getRootStyles();
// --- Update the "authoritative" shapeArea (the element other code may query) ---
// If you intentionally hide shapeArea while editing, keep but update attributes so getBBox etc is correct.
let shapeArea = g.select('.shapeArea');
if (shapeArea.empty()) {
// if missing, create a proper shapeArea (keep pointer-events consistent with your UX)
shapeArea = g.append('ellipse').attr('class', 'shapeArea');
}
shapeArea
.datum(shape.id)
.attr('cx', start.x)
.attr('cy', start.y)
.attr('rx', rx)
.attr('ry', ry)
.style('stroke', rootStyle.primary)
.style('fill', `rgba(${rootStyle.primaryrgb},0.1)`)
.style('fill-opacity', 0.88);
// If you previously toggled display: none for shapeArea when showing a preview, keep that behaviour
// but still update its attributes. Example: keep hidden via style when toggled
if ((shape as any).__hideShapeAreaDuringPreview) {
shapeArea.style('display', 'none').style('pointer-events', 'none');
} else {
shapeArea.style('display', null).style('pointer-events', null);
}
// --- visible ellipse (preview visual) ---
let visible = g.select('ellipse.shape-visible');
if (visible.empty()) {
visible = g.append('ellipse').attr('class', 'shape-visible').style('pointer-events', 'none');
}
visible
.attr('cx', start.x)
.attr('cy', start.y)
.attr('rx', rx)
.attr('ry', ry)
.style('stroke', rootStyle.primary)
.style('fill', `rgba(${rootStyle.primaryrgb},0.1)`)
.style('fill-opacity', 0.88);
// --- hit ellipse (interaction) ---
let hit = g.select('ellipse.shape-hit-ellipse');
const HIT_EXTRA = 12;
const hitRx = Math.max(1, rx + HIT_EXTRA);
const hitRy = Math.max(1, ry + HIT_EXTRA);
if (hit.empty()) {
hit = g.append('ellipse')
.attr('class', 'shape-hit-ellipse')
.style('fill', 'transparent')
.style('pointer-events', 'all');
// attach handlers only if not attached elsewhere
// hit.on('pointermove', (ev) => this.onCircleHitPointerMove(ev as PointerEvent, shape, hit.node()));
// hit.on('pointerdown', (ev) => this.onCircleHitPointerDown(ev as PointerEvent, shape, hit.node()));
}
hit
.attr('data-shape-id', shape.id)
.attr('cx', start.x)
.attr('cy', start.y)
.attr('rx', hitRx)
.attr('ry', hitRy)
.attr('stroke-width', Math.max(8, HIT_EXTRA * 2));
// If your bbox overlay is handling pointer events, you may want to disable hit ellipse temporarily:
if ((shape as any).__hitWasDisabledForBBox) {
hit.attr('pointer-events', 'none');
(hit.node() as HTMLElement).style.cursor = '';
} else {
hit.attr('pointer-events', 'all');
}
// ensure hit is topmost within this shape group
const hitNode = g.select('ellipse.shape-hit-ellipse').node();
if (hitNode && hitNode.parentNode) hitNode.parentNode.appendChild(hitNode);
// --- update zone-name path + foreignObject if you use them (so label follows) ---
const zonePath = g.select(`#${shape.id}zone-name`);
if (!zonePath.empty()) {
// set path to span left/right extremes of the ellipse center ± rx along x axis
const leftX = start.x - rx;
const rightX = start.x + rx;
zonePath.attr('d', `M ${leftX} ${start.y} L ${rightX} ${start.y}`);
}
const fo = g.select('foreignObject');
if (!fo.empty()) {
// center foreignObject (approx)
const widthAttr = parseFloat((fo.attr('width') || '0') as string) || 75;
const heightAttr = parseFloat((fo.attr('height') || '0') as string) || 20;
fo.attr('x', String(Math.round(start.x - widthAttr / 2))).attr('y', String(Math.round(start.y - heightAttr / 2)));
}
} catch (err) {
console.debug('updateCircleVisuals err', err);
}
}
<path>. You can also translate transforms to explicit path commands - see "Baking transforms into SVG Path Element commands". However, you should prefer transforms for scaling and rotation and flatten transformed coordinates only when editing the shape itself - otherwise you get too many accumulating rounding errors. You also need a structured pathdata object that can be manipulated