528 lines
18 KiB
TypeScript
528 lines
18 KiB
TypeScript
import { Gizmos, Point, Vector } from '@lib';
|
|
import { FolkBaseSet } from './folk-base-set';
|
|
import { FolkShape } from './folk-shape';
|
|
|
|
interface AlignmentLine {
|
|
shapes: Set<FolkShape>;
|
|
points: Map<FolkShape, Point>;
|
|
lineStart: Point;
|
|
lineEnd: Point;
|
|
isHorizontal: boolean;
|
|
}
|
|
|
|
export class FolkAlignmentBrush extends FolkBaseSet {
|
|
static override tagName = 'folk-alignment-brush';
|
|
|
|
// Core structure
|
|
#alignments = new Set<AlignmentLine>();
|
|
#shapeToAlignments = new Map<FolkShape, Set<AlignmentLine>>();
|
|
|
|
// Interaction state
|
|
#isPointerDown = false;
|
|
#lastPointerPosition: Point | null = null;
|
|
#selectedShapes = new Set<FolkShape>();
|
|
|
|
// Canvas for brush visualization
|
|
#canvas!: HTMLCanvasElement;
|
|
#ctx!: CanvasRenderingContext2D;
|
|
|
|
// Brush settings
|
|
readonly #BRUSH_RADIUS = 60;
|
|
readonly #TARGET_PADDING = 20;
|
|
|
|
// Add new constants for removal threshold
|
|
readonly #REMOVAL_THRESHOLD_MULTIPLIER = 1.0; // Multiplied by shape size
|
|
|
|
// Add property to track dragging state
|
|
#draggedShape: FolkShape | null = null;
|
|
|
|
connectedCallback() {
|
|
super.connectedCallback();
|
|
this.#setupCanvas();
|
|
this.#setupEventListeners();
|
|
requestAnimationFrame(this.#updateCanvas);
|
|
}
|
|
|
|
disconnectedCallback() {
|
|
super.disconnectedCallback();
|
|
window.removeEventListener('resize', this.#handleResize);
|
|
}
|
|
|
|
#setupCanvas() {
|
|
this.#canvas = document.createElement('canvas');
|
|
this.#canvas.style.cssText = `
|
|
position: absolute;
|
|
top: 0;
|
|
left: 0;
|
|
width: 100%;
|
|
height: 100%;
|
|
pointer-events: none;
|
|
`;
|
|
const ctx = this.#canvas.getContext('2d');
|
|
if (!ctx) throw new Error('Could not get canvas context');
|
|
this.#ctx = ctx;
|
|
this.renderRoot.prepend(this.#canvas);
|
|
this.#handleResize();
|
|
}
|
|
|
|
#setupEventListeners() {
|
|
this.addEventListener('pointerdown', this.#handlePointerDown);
|
|
this.addEventListener('pointermove', this.#handlePointerMove);
|
|
this.addEventListener('pointerup', this.#handlePointerUp);
|
|
this.addEventListener('pointerleave', this.#handlePointerUp);
|
|
window.addEventListener('resize', this.#handleResize);
|
|
this.addEventListener('pointerdown', this.#handleShapePointerDown, true);
|
|
this.addEventListener('pointerup', this.#handleShapePointerUp, true);
|
|
}
|
|
|
|
#handleResize = () => {
|
|
const { width, height } = this.getBoundingClientRect();
|
|
this.#canvas.width = width;
|
|
this.#canvas.height = height;
|
|
};
|
|
|
|
#handlePointerDown = (event: PointerEvent) => {
|
|
if (event.button !== 0) return;
|
|
if (event.target !== this) return;
|
|
|
|
const rect = this.#canvas.getBoundingClientRect();
|
|
const point = {
|
|
x: event.clientX - rect.left,
|
|
y: event.clientY - rect.top,
|
|
};
|
|
|
|
this.#selectedShapes.clear();
|
|
this.#isPointerDown = true;
|
|
this.#lastPointerPosition = point;
|
|
|
|
// Find shapes under initial point
|
|
this.sourceElements.forEach((element) => {
|
|
if (element instanceof FolkShape) {
|
|
const rect = element.getTransformDOMRect();
|
|
if (
|
|
point.x >= rect.x &&
|
|
point.x <= rect.x + rect.width &&
|
|
point.y >= rect.y &&
|
|
point.y <= rect.y + rect.height
|
|
) {
|
|
this.#selectedShapes.add(element);
|
|
}
|
|
}
|
|
});
|
|
};
|
|
|
|
#handlePointerMove = (event: PointerEvent) => {
|
|
if (!this.#isPointerDown || !this.#lastPointerPosition) return;
|
|
|
|
const rect = this.#canvas.getBoundingClientRect();
|
|
const point = {
|
|
x: event.clientX - rect.left,
|
|
y: event.clientY - rect.top,
|
|
};
|
|
|
|
// Check for shapes under the new line segment
|
|
this.sourceElements.forEach((element) => {
|
|
if (element instanceof FolkShape) {
|
|
const shapeRect = element.getTransformDOMRect();
|
|
if (this.#isLineIntersectingRect(this.#lastPointerPosition!, point, shapeRect)) {
|
|
this.#selectedShapes.add(element);
|
|
}
|
|
}
|
|
});
|
|
|
|
this.#drawBrushStroke(this.#lastPointerPosition, point);
|
|
this.#lastPointerPosition = point;
|
|
};
|
|
|
|
#handlePointerUp = () => {
|
|
if (this.#selectedShapes.size >= 2) {
|
|
// Find existing alignments that overlap with selected shapes
|
|
const overlappingAlignments = new Set<AlignmentLine>();
|
|
const isHorizontal = this.#determineAlignment(this.#selectedShapes);
|
|
|
|
// Find all overlapping alignments with the same orientation
|
|
for (const shape of this.#selectedShapes) {
|
|
const shapeAlignments = this.#shapeToAlignments.get(shape);
|
|
if (shapeAlignments) {
|
|
for (const alignment of shapeAlignments) {
|
|
if (alignment.isHorizontal === isHorizontal) {
|
|
overlappingAlignments.add(alignment);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check if we've selected all shapes from multiple alignments
|
|
const allShapesSelected = Array.from(overlappingAlignments).every((alignment) =>
|
|
Array.from(alignment.shapes).every((shape) => this.#selectedShapes.has(shape)),
|
|
);
|
|
|
|
if (overlappingAlignments.size === 0) {
|
|
// Case: No overlapping alignments - create new alignment
|
|
this.#createAlignment(this.#selectedShapes);
|
|
} else if (overlappingAlignments.size === 1 || !allShapesSelected) {
|
|
// Case: One overlapping alignment or partial selection - merge into it
|
|
const existingAlignment = overlappingAlignments.values().next().value;
|
|
if (existingAlignment) {
|
|
this.#mergeIntoAlignment(existingAlignment, this.#selectedShapes);
|
|
}
|
|
} else {
|
|
// Case: Multiple complete alignments selected - merge all into new alignment
|
|
const allShapes = new Set<FolkShape>();
|
|
this.#selectedShapes.forEach((shape) => allShapes.add(shape));
|
|
overlappingAlignments.forEach((alignment) => {
|
|
alignment.shapes.forEach((shape) => allShapes.add(shape));
|
|
this.#removeAlignment(alignment);
|
|
});
|
|
this.#createAlignment(allShapes);
|
|
}
|
|
}
|
|
|
|
this.#isPointerDown = false;
|
|
this.#lastPointerPosition = null;
|
|
this.#selectedShapes.clear();
|
|
};
|
|
|
|
#isLineIntersectingRect(lineStart: Point, lineEnd: Point, rect: DOMRect): boolean {
|
|
// Simple AABB check
|
|
const minX = Math.min(lineStart.x, lineEnd.x);
|
|
const maxX = Math.max(lineStart.x, lineEnd.x);
|
|
const minY = Math.min(lineStart.y, lineEnd.y);
|
|
const maxY = Math.max(lineStart.y, lineEnd.y);
|
|
|
|
return !(maxX < rect.left || minX > rect.right || maxY < rect.top || minY > rect.bottom);
|
|
}
|
|
|
|
#createAlignment(shapes: Set<FolkShape>) {
|
|
if (shapes.size < 2) {
|
|
return;
|
|
}
|
|
|
|
// Calculate axis based on shape distribution
|
|
const centers = Array.from(shapes).map((shape) => shape.getTransformDOMRect().center);
|
|
const bounds = Vector.bounds(centers);
|
|
const isHorizontal = bounds.max.x - bounds.min.x > bounds.max.y - bounds.min.y;
|
|
const center = Vector.center(centers);
|
|
|
|
const positions = this.#calculateLinePoints(shapes, isHorizontal, center);
|
|
const alignment: AlignmentLine = {
|
|
shapes: new Set(shapes), // Create a new Set to avoid reference issues
|
|
isHorizontal,
|
|
...positions,
|
|
};
|
|
|
|
// Update lookups
|
|
this.#alignments.add(alignment);
|
|
shapes.forEach((shape) => {
|
|
if (!this.#shapeToAlignments.has(shape)) {
|
|
this.#shapeToAlignments.set(shape, new Set());
|
|
}
|
|
this.#shapeToAlignments.get(shape)!.add(alignment);
|
|
});
|
|
}
|
|
|
|
#drawBrushStroke(from: Point, to: Point) {
|
|
this.#ctx.beginPath();
|
|
this.#ctx.moveTo(from.x, from.y);
|
|
this.#ctx.lineTo(to.x, to.y);
|
|
this.#ctx.lineWidth = this.#BRUSH_RADIUS;
|
|
this.#ctx.strokeStyle = 'rgba(150, 190, 255, 0.3)';
|
|
this.#ctx.lineCap = 'round';
|
|
this.#ctx.stroke();
|
|
}
|
|
|
|
#calculateLinePoints(
|
|
shapes: Set<FolkShape>,
|
|
isHorizontal: boolean,
|
|
centerPoint: Point,
|
|
): {
|
|
points: Map<FolkShape, Point>;
|
|
lineStart: Point;
|
|
lineEnd: Point;
|
|
} {
|
|
const targetPositions = new Map<FolkShape, Point>();
|
|
|
|
// Get centers and calculate total extent
|
|
const shapeInfo = Array.from(shapes).map((shape) => {
|
|
const rect = shape.getTransformDOMRect();
|
|
return {
|
|
shape,
|
|
rect,
|
|
center: rect.center,
|
|
size: isHorizontal ? rect.width : rect.height,
|
|
};
|
|
});
|
|
|
|
// Sort shapes along the primary axis
|
|
shapeInfo.sort((a, b) => (isHorizontal ? a.center.x - b.center.x : a.center.y - b.center.y));
|
|
|
|
// Calculate total length including padding between shapes
|
|
const totalLength = shapeInfo.reduce((sum, info, index) => {
|
|
return sum + info.size + (index < shapeInfo.length - 1 ? this.#TARGET_PADDING : 0);
|
|
}, 0);
|
|
|
|
// Start position (centered around centerPoint)
|
|
let currentPos = -totalLength / 2;
|
|
|
|
// Calculate positions along the line
|
|
shapeInfo.forEach((info) => {
|
|
const point = isHorizontal
|
|
? { x: centerPoint.x + currentPos + info.size / 2, y: centerPoint.y }
|
|
: { x: centerPoint.x, y: centerPoint.y + currentPos + info.size / 2 };
|
|
|
|
targetPositions.set(info.shape, point);
|
|
currentPos += info.size + this.#TARGET_PADDING;
|
|
});
|
|
|
|
// Calculate line extent (add padding to ends)
|
|
const halfLength = totalLength / 2;
|
|
const lineStart = isHorizontal
|
|
? { x: centerPoint.x - halfLength, y: centerPoint.y }
|
|
: { x: centerPoint.x, y: centerPoint.y - halfLength };
|
|
|
|
const lineEnd = isHorizontal
|
|
? { x: centerPoint.x + halfLength, y: centerPoint.y }
|
|
: { x: centerPoint.x, y: centerPoint.y + halfLength };
|
|
|
|
return {
|
|
points: targetPositions,
|
|
lineStart,
|
|
lineEnd,
|
|
};
|
|
}
|
|
|
|
#updateCanvas = () => {
|
|
// Clear canvas with fade effect
|
|
this.#ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
|
|
this.#ctx.fillRect(0, 0, this.#canvas.width, this.#canvas.height);
|
|
|
|
// Update shape positions
|
|
this.#lerpShapesTowardsTargets();
|
|
|
|
this.#visualizeAlignments();
|
|
requestAnimationFrame(this.#updateCanvas);
|
|
};
|
|
|
|
#lerpShapesTowardsTargets() {
|
|
// Check for dragged shapes that could join existing alignments
|
|
const activeShape = document.activeElement instanceof FolkShape ? document.activeElement : null;
|
|
if (activeShape && !this.#shapeToAlignments.has(activeShape)) {
|
|
const rect = activeShape.getTransformDOMRect();
|
|
|
|
// Find closest alignment within threshold
|
|
for (const alignment of this.#alignments) {
|
|
const perpDistance = alignment.isHorizontal
|
|
? Math.abs(rect.center.y - alignment.lineStart.y)
|
|
: Math.abs(rect.center.x - alignment.lineStart.x);
|
|
|
|
const linePos = alignment.isHorizontal ? rect.center.x : rect.center.y;
|
|
const lineStart = alignment.isHorizontal ? alignment.lineStart.x : alignment.lineStart.y;
|
|
const lineEnd = alignment.isHorizontal ? alignment.lineEnd.x : alignment.lineEnd.y;
|
|
|
|
const threshold = (alignment.isHorizontal ? rect.height : rect.width) * this.#REMOVAL_THRESHOLD_MULTIPLIER;
|
|
|
|
if (perpDistance <= threshold && linePos >= lineStart && linePos <= lineEnd) {
|
|
this.#mergeIntoAlignment(alignment, new Set([activeShape]));
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Existing alignment handling
|
|
for (const alignment of this.#alignments) {
|
|
alignment.shapes.forEach((shape) => {
|
|
if (shape === this.#draggedShape) {
|
|
// Handle dragged shape logic
|
|
const rect = shape.getTransformDOMRect();
|
|
const current = rect.center;
|
|
|
|
// Calculate perpendicular and parallel distances from line
|
|
const perpDistance = alignment.isHorizontal
|
|
? Math.abs(current.y - alignment.lineStart.y)
|
|
: Math.abs(current.x - alignment.lineStart.x);
|
|
|
|
const linePos = alignment.isHorizontal ? current.x : current.y;
|
|
const lineStart = alignment.isHorizontal ? alignment.lineStart.x : alignment.lineStart.y;
|
|
const lineEnd = alignment.isHorizontal ? alignment.lineEnd.x : alignment.lineEnd.y;
|
|
|
|
// Calculate removal thresholds
|
|
const perpThreshold =
|
|
(alignment.isHorizontal ? rect.height : rect.width) * this.#REMOVAL_THRESHOLD_MULTIPLIER;
|
|
|
|
// Remove if too far perpendicular to line or beyond line endpoints
|
|
if (perpDistance > perpThreshold || linePos < lineStart || linePos > lineEnd) {
|
|
this.#removeShapeFromAlignment(shape, alignment);
|
|
} else {
|
|
// Recalculate positions while keeping the line fixed
|
|
const positions = this.#calculateLinePoints(alignment.shapes, alignment.isHorizontal, {
|
|
x: alignment.isHorizontal ? (alignment.lineStart.x + alignment.lineEnd.x) / 2 : alignment.lineStart.x,
|
|
y: alignment.isHorizontal ? alignment.lineStart.y : (alignment.lineStart.y + alignment.lineEnd.y) / 2,
|
|
});
|
|
alignment.points = positions.points;
|
|
}
|
|
return;
|
|
} else {
|
|
// Move non-dragged shapes
|
|
const target = alignment.points.get(shape)!;
|
|
const current = shape.getTransformDOMRect().center;
|
|
shape.x += (target.x - current.x) * 0.25;
|
|
shape.y += (target.y - current.y) * 0.25;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
#removeShapeFromAlignment(shape: FolkShape, alignment: AlignmentLine) {
|
|
alignment.shapes.delete(shape);
|
|
alignment.points.delete(shape);
|
|
|
|
const shapeAlignments = this.#shapeToAlignments.get(shape);
|
|
if (shapeAlignments) {
|
|
shapeAlignments.delete(alignment);
|
|
if (shapeAlignments.size === 0) {
|
|
this.#shapeToAlignments.delete(shape);
|
|
}
|
|
}
|
|
|
|
// Remove alignment if less than 2 shapes remain
|
|
if (alignment.shapes.size < 2) {
|
|
this.#removeAlignment(alignment);
|
|
} else {
|
|
// Recalculate positions while keeping line fixed
|
|
const center = {
|
|
x: alignment.isHorizontal ? (alignment.lineStart.x + alignment.lineEnd.x) / 2 : alignment.lineStart.x,
|
|
y: alignment.isHorizontal ? alignment.lineStart.y : (alignment.lineStart.y + alignment.lineEnd.y) / 2,
|
|
};
|
|
|
|
const positions = this.#calculateLinePoints(alignment.shapes, alignment.isHorizontal, center);
|
|
alignment.points = positions.points;
|
|
alignment.lineStart = positions.lineStart;
|
|
alignment.lineEnd = positions.lineEnd;
|
|
}
|
|
}
|
|
|
|
#visualizeAlignments() {
|
|
Gizmos.clear();
|
|
|
|
// Show active alignments
|
|
for (const alignment of this.#alignments) {
|
|
const style = { color: 'blue', width: 2 };
|
|
|
|
// Draw alignment line
|
|
Gizmos.line(alignment.lineStart, alignment.lineEnd, style);
|
|
|
|
// Draw shape connections and targets
|
|
alignment.shapes.forEach((shape) => {
|
|
const rect = shape.getTransformDOMRect();
|
|
const current = rect.center;
|
|
const target = alignment.points.get(shape)!;
|
|
|
|
Gizmos.line(current, target, {
|
|
color: 'rgba(150, 150, 150, 0.5)',
|
|
width: 1,
|
|
});
|
|
|
|
Gizmos.point(target, {
|
|
color: style.color,
|
|
size: 4,
|
|
});
|
|
});
|
|
}
|
|
|
|
// Show potential alignment
|
|
if (this.#isPointerDown && this.#selectedShapes.size >= 2) {
|
|
const centers = Array.from(this.#selectedShapes).map((shape) => shape.getTransformDOMRect().center);
|
|
const bounds = Vector.bounds(centers);
|
|
const isHorizontal = bounds.max.x - bounds.min.x > bounds.max.y - bounds.min.y;
|
|
const center = Vector.center(centers);
|
|
|
|
const positions = this.#calculateLinePoints(this.#selectedShapes, isHorizontal, center);
|
|
const potential = {
|
|
shapes: this.#selectedShapes,
|
|
isHorizontal,
|
|
lineStart: positions.lineStart,
|
|
lineEnd: positions.lineEnd,
|
|
points: positions.points,
|
|
};
|
|
|
|
// Draw potential alignment
|
|
const style = { color: 'green', width: 2 };
|
|
Gizmos.line(potential.lineStart, potential.lineEnd, style);
|
|
|
|
potential.shapes.forEach((shape) => {
|
|
const rect = shape.getTransformDOMRect();
|
|
const current = rect.center;
|
|
const target = potential.points.get(shape)!;
|
|
|
|
Gizmos.line(current, target, {
|
|
color: 'rgba(150, 150, 150, 0.5)',
|
|
width: 1,
|
|
});
|
|
|
|
Gizmos.point(target, {
|
|
color: style.color,
|
|
size: 4,
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
// Helper methods to support the new functionality
|
|
#determineAlignment(shapes: Set<FolkShape>): boolean {
|
|
const centers = Array.from(shapes).map((shape) => shape.getTransformDOMRect().center);
|
|
const bounds = Vector.bounds(centers);
|
|
return bounds.max.x - bounds.min.x > bounds.max.y - bounds.min.y;
|
|
}
|
|
|
|
#mergeIntoAlignment(alignment: AlignmentLine, newShapes: Set<FolkShape>) {
|
|
// Add new shapes to existing alignment
|
|
newShapes.forEach((shape) => alignment.shapes.add(shape));
|
|
|
|
// Recalculate alignment positions while keeping line fixed
|
|
const center = {
|
|
x: alignment.isHorizontal ? (alignment.lineStart.x + alignment.lineEnd.x) / 2 : alignment.lineStart.x,
|
|
y: alignment.isHorizontal ? alignment.lineStart.y : (alignment.lineStart.y + alignment.lineEnd.y) / 2,
|
|
};
|
|
|
|
const positions = this.#calculateLinePoints(alignment.shapes, alignment.isHorizontal, center);
|
|
alignment.points = positions.points;
|
|
alignment.lineStart = positions.lineStart;
|
|
alignment.lineEnd = positions.lineEnd;
|
|
|
|
// Update shape-to-alignment mappings
|
|
newShapes.forEach((shape) => {
|
|
if (!this.#shapeToAlignments.has(shape)) {
|
|
this.#shapeToAlignments.set(shape, new Set());
|
|
}
|
|
this.#shapeToAlignments.get(shape)!.add(alignment);
|
|
});
|
|
}
|
|
|
|
#removeAlignment(alignment: AlignmentLine) {
|
|
// Remove from main set
|
|
this.#alignments.delete(alignment);
|
|
|
|
// Remove from all shape mappings
|
|
alignment.shapes.forEach((shape) => {
|
|
const alignments = this.#shapeToAlignments.get(shape);
|
|
if (alignments) {
|
|
alignments.delete(alignment);
|
|
if (alignments.size === 0) {
|
|
this.#shapeToAlignments.delete(shape);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// gross but here it is
|
|
#handleShapePointerDown = (event: PointerEvent) => {
|
|
if (event.target instanceof FolkShape) {
|
|
this.#draggedShape = event.target;
|
|
}
|
|
};
|
|
|
|
#handleShapePointerUp = () => {
|
|
this.#draggedShape = null;
|
|
};
|
|
}
|