Transform
Interactive transforms allow users to manipulate scale, rotation, and position of elements through direct manipulation. This is essential for building intuitive editing interfaces.
Transform Types
Translation
Move elements by dragging:
import { InteractivePoint } from '@wangyaoshen/locus-interaction';
const translateHandle = new InteractivePoint({
position: [100, 100],
color: '#2196F3',
onMove: (pos) => {
// Apply translation to target element
targetElement.position = pos;
},
});
Rotation
Rotate elements by dragging a handle around a pivot:
const rotationHandle = new InteractivePoint({
position: [150, 100], // Initial position on rotation circle
constrain: circle([100, 100], 50), // Constrain to circle around pivot
onMove: (pos) => {
const pivot = [100, 100];
const angle = Math.atan2(pos[1] - pivot[1], pos[0] - pivot[0]);
targetElement.rotation = angle;
},
});
Scale
Scale elements by dragging corner handles:
const scaleHandle = new InteractivePoint({
position: [150, 150], // Corner of bounding box
onMove: (pos) => {
const center = [100, 100];
const distance = Math.sqrt(
Math.pow(pos[0] - center[0], 2) +
Math.pow(pos[1] - center[1], 2)
);
const scale = distance / 50; // Original distance was 50
targetElement.scale = [scale, scale];
},
});
Transform Handles
Create a complete transform control with multiple handles:
interface TransformHandles {
move: InteractivePoint;
rotate: InteractivePoint;
scaleCorners: InteractivePoint[];
}
function createTransformHandles(
target: { position: Vector2; rotation: number; scale: Vector2 }
): TransformHandles {
const center = target.position;
// Move handle (center)
const move = new InteractivePoint({
position: center,
color: '#4CAF50',
onMove: (pos) => {
target.position = pos;
updateAllHandles();
},
});
// Rotation handle
const rotateRadius = 60;
const rotate = new InteractivePoint({
position: [
center[0] + rotateRadius * Math.cos(target.rotation),
center[1] + rotateRadius * Math.sin(target.rotation),
],
color: '#FF9800',
constrain: circle(center, rotateRadius),
onMove: (pos) => {
target.rotation = Math.atan2(
pos[1] - center[1],
pos[0] - center[0]
);
},
});
// Scale handles (corners)
const corners = [[-1, -1], [1, -1], [1, 1], [-1, 1]];
const scaleCorners = corners.map((corner, i) =>
new InteractivePoint({
position: [
center[0] + corner[0] * 40 * target.scale[0],
center[1] + corner[1] * 40 * target.scale[1],
],
color: '#E91E63',
radius: 8,
onMove: (pos) => {
const dx = Math.abs(pos[0] - center[0]) / 40;
const dy = Math.abs(pos[1] - center[1]) / 40;
target.scale = [dx, dy];
updateAllHandles();
},
})
);
return { move, rotate, scaleCorners };
}
Transform Matrix
Apply transforms using a transformation matrix:
function applyTransform(
ctx: CanvasRenderingContext2D,
transform: { position: Vector2; rotation: number; scale: Vector2 }
) {
ctx.save();
// Translate to position
ctx.translate(transform.position[0], transform.position[1]);
// Rotate
ctx.rotate(transform.rotation);
// Scale
ctx.scale(transform.scale[0], transform.scale[1]);
// Draw content at origin
drawContent(ctx);
ctx.restore();
}
Constrained Transforms
Uniform Scale
const uniformScaleHandle = new InteractivePoint({
position: [150, 150],
onMove: (pos) => {
const center = [100, 100];
const distance = Math.sqrt(
Math.pow(pos[0] - center[0], 2) +
Math.pow(pos[1] - center[1], 2)
);
const scale = distance / originalDistance;
// Apply same scale to both axes
target.scale = [scale, scale];
},
});
Axis-Aligned Scale
// Horizontal scale only
const hScaleHandle = new InteractivePoint({
position: [150, 100],
constrain: horizontal(100), // Lock Y
onMove: (pos) => {
const scaleX = Math.abs(pos[0] - 100) / 50;
target.scale = [scaleX, target.scale[1]];
},
});
// Vertical scale only
const vScaleHandle = new InteractivePoint({
position: [100, 150],
constrain: vertical(100), // Lock X
onMove: (pos) => {
const scaleY = Math.abs(pos[1] - 100) / 50;
target.scale = [target.scale[0], scaleY];
},
});
Snap to Angles
const snapAngles = [0, 45, 90, 135, 180, 225, 270, 315].map(
d => d * Math.PI / 180
);
function snapToAngle(angle: number, threshold: number = 5): number {
const thresholdRad = threshold * Math.PI / 180;
for (const snap of snapAngles) {
if (Math.abs(angle - snap) < thresholdRad) {
return snap;
}
}
return angle;
}
const rotateHandle = new InteractivePoint({
position: [150, 100],
constrain: (pos) => {
const angle = Math.atan2(pos[1] - 100, pos[0] - 100);
const snapped = snapToAngle(angle);
return [
100 + 50 * Math.cos(snapped),
100 + 50 * Math.sin(snapped),
];
},
});
Visual Feedback
Show transform state visually:
function drawTransformGizmo(
ctx: CanvasRenderingContext2D,
transform: { position: Vector2; rotation: number; scale: Vector2 }
) {
const { position, rotation, scale } = transform;
ctx.save();
ctx.translate(position[0], position[1]);
ctx.rotate(rotation);
// Bounding box
ctx.strokeStyle = '#2196F3';
ctx.lineWidth = 1;
ctx.setLineDash([4, 4]);
ctx.strokeRect(
-40 * scale[0],
-40 * scale[1],
80 * scale[0],
80 * scale[1]
);
ctx.setLineDash([]);
// Rotation indicator
ctx.strokeStyle = '#FF9800';
ctx.beginPath();
ctx.arc(0, 0, 60, 0, rotation);
ctx.stroke();
ctx.restore();
}
Tips
- Pivot Point: Allow users to change the transform origin
- Snapping: Implement snap-to-grid and snap-to-angle
- Constraints: Support shift-drag for uniform scaling
- Feedback: Show numeric values during transforms
- Undo: Track transform history for undo/redo