alignment brush
This commit is contained in:
parent
161de351d8
commit
d494e8cd46
|
|
@ -0,0 +1,527 @@
|
||||||
|
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 FolkBrushField extends FolkBaseSet {
|
||||||
|
static override tagName = 'folk-brush-field';
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { FolkBrushField } from '../folk-brush-field';
|
||||||
|
|
||||||
|
FolkBrushField.define();
|
||||||
|
|
||||||
|
export { FolkBrushField };
|
||||||
|
|
@ -11,6 +11,38 @@ export class Vector {
|
||||||
return { x: 0, y: 0 };
|
return { x: 0, y: 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit vector pointing right (1,0)
|
||||||
|
* @returns {Point} A point representing a right vector
|
||||||
|
*/
|
||||||
|
static right(): Point {
|
||||||
|
return { x: 1, y: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit vector pointing left (-1,0)
|
||||||
|
* @returns {Point} A point representing a left vector
|
||||||
|
*/
|
||||||
|
static left(): Point {
|
||||||
|
return { x: -1, y: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit vector pointing up (0,-1)
|
||||||
|
* @returns {Point} A point representing an up vector
|
||||||
|
*/
|
||||||
|
static up(): Point {
|
||||||
|
return { x: 0, y: -1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit vector pointing down (0,1)
|
||||||
|
* @returns {Point} A point representing a down vector
|
||||||
|
*/
|
||||||
|
static down(): Point {
|
||||||
|
return { x: 0, y: 1 };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Subtracts vector b from vector a
|
* Subtracts vector b from vector a
|
||||||
* @param {Point} a - The first vector
|
* @param {Point} a - The first vector
|
||||||
|
|
@ -190,4 +222,44 @@ export class Vector {
|
||||||
static magSquared(v: Point): number {
|
static magSquared(v: Point): number {
|
||||||
return v.x * v.x + v.y * v.y;
|
return v.x * v.x + v.y * v.y;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the bounding box of a set of points
|
||||||
|
* @param {Point[]} points - Array of points to find bounds for
|
||||||
|
* @returns {{ min: Point, max: Point }} Object containing min and max points of the bounds
|
||||||
|
*/
|
||||||
|
static bounds(points: Point[]): { min: Point; max: Point } {
|
||||||
|
return points.reduce(
|
||||||
|
(acc, p) => ({
|
||||||
|
min: { x: Math.min(acc.min.x, p.x), y: Math.min(acc.min.y, p.y) },
|
||||||
|
max: { x: Math.max(acc.max.x, p.x), y: Math.max(acc.max.y, p.y) },
|
||||||
|
}),
|
||||||
|
{ min: { x: Infinity, y: Infinity }, max: { x: -Infinity, y: -Infinity } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the center point of a set of points
|
||||||
|
* @param {Point[]} points - Array of points to find center for
|
||||||
|
* @returns {Point} The center point
|
||||||
|
*/
|
||||||
|
static center(points: Point[]): Point {
|
||||||
|
const bounds = Vector.bounds(points);
|
||||||
|
return {
|
||||||
|
x: (bounds.min.x + bounds.max.x) / 2,
|
||||||
|
y: (bounds.min.y + bounds.max.y) / 2,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Projects a point onto an axis
|
||||||
|
* @param {Point} point - The point to project
|
||||||
|
* @param {Point} axis - The axis to project onto
|
||||||
|
* @returns {Point} The projected point
|
||||||
|
*/
|
||||||
|
static project(point: Point, axis: Point): Point {
|
||||||
|
const normalized = Vector.normalized(axis);
|
||||||
|
const dot = point.x * normalized.x + point.y * normalized.y;
|
||||||
|
return Vector.scale(normalized, dot);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { DOMRectTransform, FolkElement, type Point } from '@lib';
|
import { DOMRectTransform, FolkElement, type Point } from '@lib';
|
||||||
import { html } from '@lib/tags';
|
import { html } from '@lib/tags';
|
||||||
|
import { Vector } from '@lib/Vector';
|
||||||
import { css } from '@lit/reactive-element';
|
import { css } from '@lit/reactive-element';
|
||||||
|
|
||||||
interface GizmoOptions {
|
interface GizmoOptions {
|
||||||
|
|
@ -49,6 +50,7 @@ export class Gizmos extends FolkElement {
|
||||||
{
|
{
|
||||||
ctx: CanvasRenderingContext2D;
|
ctx: CanvasRenderingContext2D;
|
||||||
canvas: HTMLCanvasElement;
|
canvas: HTMLCanvasElement;
|
||||||
|
hidden: boolean;
|
||||||
}
|
}
|
||||||
>();
|
>();
|
||||||
static #defaultLayer = 'default';
|
static #defaultLayer = 'default';
|
||||||
|
|
@ -75,10 +77,12 @@ export class Gizmos extends FolkElement {
|
||||||
`;
|
`;
|
||||||
|
|
||||||
readonly #layer: string;
|
readonly #layer: string;
|
||||||
|
#hidden: boolean;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
this.#layer = this.getAttribute('layer') ?? Gizmos.#defaultLayer;
|
this.#layer = this.getAttribute('layer') ?? Gizmos.#defaultLayer;
|
||||||
|
this.#hidden = this.hasAttribute('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
override createRenderRoot() {
|
override createRenderRoot() {
|
||||||
|
|
@ -90,7 +94,7 @@ export class Gizmos extends FolkElement {
|
||||||
const ctx = canvas?.getContext('2d') ?? null;
|
const ctx = canvas?.getContext('2d') ?? null;
|
||||||
|
|
||||||
if (canvas && ctx) {
|
if (canvas && ctx) {
|
||||||
Gizmos.#layers.set(this.#layer, { canvas, ctx });
|
Gizmos.#layers.set(this.#layer, { canvas, ctx, hidden: this.#hidden });
|
||||||
}
|
}
|
||||||
|
|
||||||
this.#handleResize();
|
this.#handleResize();
|
||||||
|
|
@ -155,37 +159,47 @@ export class Gizmos extends FolkElement {
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Draws a vector with an arrow head */
|
/**
|
||||||
|
* Draws a vector with an arrow head starting from `origin`.
|
||||||
|
* @param origin The starting point
|
||||||
|
* @param vector The vector endpoint in local coordinates relative to origin
|
||||||
|
*/
|
||||||
static vector(
|
static vector(
|
||||||
origin: Point,
|
origin: Point,
|
||||||
vector: Point,
|
localEndpoint: Point,
|
||||||
{ color = 'blue', width = 2, size = 10, layer = Gizmos.#defaultLayer }: VectorOptions = {},
|
{ color = 'blue', width = 2, size = 10, layer = Gizmos.#defaultLayer }: VectorOptions = {},
|
||||||
) {
|
) {
|
||||||
const ctx = Gizmos.#getContext(layer);
|
const ctx = Gizmos.#getContext(layer);
|
||||||
if (!ctx) return;
|
if (!ctx) return;
|
||||||
|
|
||||||
// Calculate angle and length
|
// Convert local endpoint to global coordinates
|
||||||
const angle = Math.atan2(vector.y - origin.y, vector.x - origin.x);
|
const globalEndpoint = Vector.add(origin, localEndpoint);
|
||||||
const arrowAngle = Math.PI / 6; // 30 degrees
|
|
||||||
|
// Calculate angle and normalized direction
|
||||||
|
const angle = Vector.angle(localEndpoint);
|
||||||
|
const length = Vector.mag(localEndpoint);
|
||||||
|
|
||||||
// Calculate where the line should end (where arrow head begins)
|
// Calculate where the line should end (where arrow head begins)
|
||||||
const lineEndX = vector.x - size * Math.cos(angle);
|
const lineEnd = Vector.add(origin, Vector.scale(Vector.normalized(localEndpoint), length - size));
|
||||||
const lineEndY = vector.y - size * Math.sin(angle);
|
|
||||||
|
|
||||||
// Draw the main line
|
// Draw the main line
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.strokeStyle = color;
|
ctx.strokeStyle = color;
|
||||||
ctx.lineWidth = width;
|
ctx.lineWidth = width;
|
||||||
ctx.moveTo(origin.x, origin.y);
|
ctx.moveTo(origin.x, origin.y);
|
||||||
ctx.lineTo(lineEndX, lineEndY);
|
ctx.lineTo(lineEnd.x, lineEnd.y);
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
// Draw arrow head as a connected triangle
|
// Draw arrow head as a connected triangle
|
||||||
|
const arrowAngle = Math.PI / 6; // 30 degrees
|
||||||
|
const leftPoint = Vector.add(globalEndpoint, Vector.rotate({ x: -size, y: 0 }, angle - arrowAngle));
|
||||||
|
const rightPoint = Vector.add(globalEndpoint, Vector.rotate({ x: -size, y: 0 }, angle + arrowAngle));
|
||||||
|
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.moveTo(vector.x, vector.y); // Tip of the arrow
|
ctx.moveTo(globalEndpoint.x, globalEndpoint.y);
|
||||||
ctx.lineTo(vector.x - size * Math.cos(angle - arrowAngle), vector.y - size * Math.sin(angle - arrowAngle));
|
ctx.lineTo(leftPoint.x, leftPoint.y);
|
||||||
ctx.lineTo(vector.x - size * Math.cos(angle + arrowAngle), vector.y - size * Math.sin(angle + arrowAngle));
|
ctx.lineTo(rightPoint.x, rightPoint.y);
|
||||||
ctx.lineTo(vector.x, vector.y); // Back to the tip
|
ctx.lineTo(globalEndpoint.x, globalEndpoint.y);
|
||||||
ctx.fillStyle = color;
|
ctx.fillStyle = color;
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
@ -219,19 +233,47 @@ export class Gizmos extends FolkElement {
|
||||||
ctx.scale(dpr, dpr);
|
ctx.scale(dpr, dpr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static #log() {
|
||||||
|
return {
|
||||||
|
message: '',
|
||||||
|
styles: [] as string[],
|
||||||
|
|
||||||
|
text(str: string) {
|
||||||
|
this.message += str;
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
color(str: string, color: string) {
|
||||||
|
this.message += '%c' + str + '%c';
|
||||||
|
this.styles.push(`font-weight: bold; color: ${color}`, '');
|
||||||
|
return this;
|
||||||
|
},
|
||||||
|
|
||||||
|
print() {
|
||||||
|
console.info(this.message, ...this.styles);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
static #getContext(layer = Gizmos.#defaultLayer) {
|
static #getContext(layer = Gizmos.#defaultLayer) {
|
||||||
if (!Gizmos.#hasLoggedInitMessage) {
|
if (!Gizmos.#hasLoggedInitMessage) {
|
||||||
const gizmos = document.querySelectorAll<Gizmos>(Gizmos.tagName);
|
const gizmos = document.querySelectorAll<Gizmos>(Gizmos.tagName);
|
||||||
console.info(
|
const layers = Array.from(Gizmos.#layers.entries());
|
||||||
'%cGizmos',
|
|
||||||
'font-weight: bold; color: #4CAF50;',
|
const log = Gizmos.#log()
|
||||||
'\n• Gizmo elements:',
|
.color('Gizmos', '#4CAF50')
|
||||||
gizmos.length,
|
.text('\n• Gizmo elements: ' + gizmos.length)
|
||||||
'\n• Layers:',
|
.text('\n• Layers: [');
|
||||||
`[${Array.from(Gizmos.#layers.keys()).join(', ')}]`,
|
|
||||||
'\n• Default layer:',
|
layers.forEach(([key, value], i) => {
|
||||||
Gizmos.#defaultLayer,
|
if (i > 0) log.text(', ');
|
||||||
);
|
log.text(key);
|
||||||
|
if (value.hidden) {
|
||||||
|
log.color(' (hidden)', '#FFA500');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
log.text(']').print();
|
||||||
Gizmos.#hasLoggedInitMessage = true;
|
Gizmos.#hasLoggedInitMessage = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -242,7 +284,7 @@ export class Gizmos extends FolkElement {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return layerData?.ctx;
|
return layerData?.hidden ? null : layerData?.ctx;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ export * from './folk-observer';
|
||||||
export * from './resize-manger';
|
export * from './resize-manger';
|
||||||
|
|
||||||
// Core utilities and types
|
// Core utilities and types
|
||||||
|
export * from './folk-gizmos';
|
||||||
export * from './Matrix';
|
export * from './Matrix';
|
||||||
export * from './types';
|
export * from './types';
|
||||||
export * from './Vector';
|
export * from './Vector';
|
||||||
|
|
|
||||||
|
|
@ -130,8 +130,8 @@ rotation: from.x"
|
||||||
Gizmos.vector(
|
Gizmos.vector(
|
||||||
center,
|
center,
|
||||||
{
|
{
|
||||||
x: center.x + gravity.x / 10,
|
x: gravity.x / 10,
|
||||||
y: center.y + gravity.y / 10,
|
y: gravity.y / 10,
|
||||||
},
|
},
|
||||||
{ color: 'grey', width: 3, size: 15 },
|
{ color: 'grey', width: 3, size: 15 },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,55 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Brush Field Demo</title>
|
||||||
|
<style>
|
||||||
|
html {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
min-height: 100%;
|
||||||
|
position: relative;
|
||||||
|
margin: 0;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
folk-shape {
|
||||||
|
position: absolute;
|
||||||
|
background-color: rgba(255, 255, 255, 0.8);
|
||||||
|
border-radius: 2px;
|
||||||
|
border: 2px solid rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
folk-brush-field {
|
||||||
|
pointer-events: all;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<folk-gizmos></folk-gizmos>
|
||||||
|
<folk-brush-field>
|
||||||
|
<folk-shape x="100" y="100" width="50" height="50"></folk-shape>
|
||||||
|
<folk-shape x="100" y="200" width="50" height="50"></folk-shape>
|
||||||
|
<folk-shape x="100" y="300" width="50" height="50"></folk-shape>
|
||||||
|
<folk-shape x="300" y="150" width="80" height="40"></folk-shape>
|
||||||
|
<folk-shape x="400" y="250" width="60" height="90"></folk-shape>
|
||||||
|
<folk-shape x="200" y="400" width="100" height="100"></folk-shape>
|
||||||
|
<folk-shape x="500" y="100" width="30" height="70"></folk-shape>
|
||||||
|
<folk-shape x="600" y="300" width="70" height="70"></folk-shape>
|
||||||
|
<folk-shape x="250" y="50" width="45" height="65"></folk-shape>
|
||||||
|
<folk-shape x="450" y="400" width="90" height="40"></folk-shape>
|
||||||
|
<folk-shape x="550" y="200" width="40" height="120"></folk-shape>
|
||||||
|
<folk-shape x="150" y="500" width="55" height="55"></folk-shape>
|
||||||
|
<folk-shape x="350" y="350" width="75" height="45"></folk-shape>
|
||||||
|
</folk-brush-field>
|
||||||
|
|
||||||
|
<script type="module">
|
||||||
|
import '@labs/standalone/folk-shape.ts';
|
||||||
|
import '@labs/standalone/folk-brush-field.ts';
|
||||||
|
import { Gizmos } from '@lib/folk-gizmos.ts';
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -99,8 +99,8 @@
|
||||||
Gizmos.vector(
|
Gizmos.vector(
|
||||||
center,
|
center,
|
||||||
{
|
{
|
||||||
x: center.x + gravity.x / 2,
|
x: gravity.x / 2,
|
||||||
y: center.y + gravity.y / 2,
|
y: gravity.y / 2,
|
||||||
},
|
},
|
||||||
{ color: 'grey', width: 3, size: 15 },
|
{ color: 'grey', width: 3, size: 15 },
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue