From d494e8cd4605cd6d5559619224428175271e47f4 Mon Sep 17 00:00:00 2001 From: Orion Reed Date: Sun, 22 Dec 2024 22:03:50 -0500 Subject: [PATCH] alignment brush --- labs/folk-brush-field.ts | 527 ++++++++++++++++++ labs/standalone/folk-brush-field.ts | 5 + lib/Vector.ts | 72 +++ lib/folk-gizmos.ts | 90 ++- lib/index.ts | 1 + ...event-propagators-with-device-gravity.html | 4 +- website/canvas/field-effect-brush.html | 55 ++ website/canvas/physics-mobile-gravity.html | 4 +- 8 files changed, 730 insertions(+), 28 deletions(-) create mode 100644 labs/folk-brush-field.ts create mode 100644 labs/standalone/folk-brush-field.ts create mode 100644 website/canvas/field-effect-brush.html diff --git a/labs/folk-brush-field.ts b/labs/folk-brush-field.ts new file mode 100644 index 0000000..4c1b069 --- /dev/null +++ b/labs/folk-brush-field.ts @@ -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; + points: Map; + lineStart: Point; + lineEnd: Point; + isHorizontal: boolean; +} + +export class FolkBrushField extends FolkBaseSet { + static override tagName = 'folk-brush-field'; + + // Core structure + #alignments = new Set(); + #shapeToAlignments = new Map>(); + + // Interaction state + #isPointerDown = false; + #lastPointerPosition: Point | null = null; + #selectedShapes = new Set(); + + // 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(); + 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(); + 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) { + 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, + isHorizontal: boolean, + centerPoint: Point, + ): { + points: Map; + lineStart: Point; + lineEnd: Point; + } { + const targetPositions = new Map(); + + // 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): 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) { + // 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; + }; +} diff --git a/labs/standalone/folk-brush-field.ts b/labs/standalone/folk-brush-field.ts new file mode 100644 index 0000000..6a6a937 --- /dev/null +++ b/labs/standalone/folk-brush-field.ts @@ -0,0 +1,5 @@ +import { FolkBrushField } from '../folk-brush-field'; + +FolkBrushField.define(); + +export { FolkBrushField }; diff --git a/lib/Vector.ts b/lib/Vector.ts index 2118773..ce9365b 100644 --- a/lib/Vector.ts +++ b/lib/Vector.ts @@ -11,6 +11,38 @@ export class Vector { 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 * @param {Point} a - The first vector @@ -190,4 +222,44 @@ export class Vector { static magSquared(v: Point): number { 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); + } } diff --git a/lib/folk-gizmos.ts b/lib/folk-gizmos.ts index 862ae31..d403a17 100644 --- a/lib/folk-gizmos.ts +++ b/lib/folk-gizmos.ts @@ -1,5 +1,6 @@ import { DOMRectTransform, FolkElement, type Point } from '@lib'; import { html } from '@lib/tags'; +import { Vector } from '@lib/Vector'; import { css } from '@lit/reactive-element'; interface GizmoOptions { @@ -49,6 +50,7 @@ export class Gizmos extends FolkElement { { ctx: CanvasRenderingContext2D; canvas: HTMLCanvasElement; + hidden: boolean; } >(); static #defaultLayer = 'default'; @@ -75,10 +77,12 @@ export class Gizmos extends FolkElement { `; readonly #layer: string; + #hidden: boolean; constructor() { super(); this.#layer = this.getAttribute('layer') ?? Gizmos.#defaultLayer; + this.#hidden = this.hasAttribute('hidden'); } override createRenderRoot() { @@ -90,7 +94,7 @@ export class Gizmos extends FolkElement { const ctx = canvas?.getContext('2d') ?? null; if (canvas && ctx) { - Gizmos.#layers.set(this.#layer, { canvas, ctx }); + Gizmos.#layers.set(this.#layer, { canvas, ctx, hidden: this.#hidden }); } this.#handleResize(); @@ -155,37 +159,47 @@ export class Gizmos extends FolkElement { 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( origin: Point, - vector: Point, + localEndpoint: Point, { color = 'blue', width = 2, size = 10, layer = Gizmos.#defaultLayer }: VectorOptions = {}, ) { const ctx = Gizmos.#getContext(layer); if (!ctx) return; - // Calculate angle and length - const angle = Math.atan2(vector.y - origin.y, vector.x - origin.x); - const arrowAngle = Math.PI / 6; // 30 degrees + // Convert local endpoint to global coordinates + const globalEndpoint = Vector.add(origin, localEndpoint); + + // 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) - const lineEndX = vector.x - size * Math.cos(angle); - const lineEndY = vector.y - size * Math.sin(angle); + const lineEnd = Vector.add(origin, Vector.scale(Vector.normalized(localEndpoint), length - size)); // Draw the main line ctx.beginPath(); ctx.strokeStyle = color; ctx.lineWidth = width; ctx.moveTo(origin.x, origin.y); - ctx.lineTo(lineEndX, lineEndY); + ctx.lineTo(lineEnd.x, lineEnd.y); ctx.stroke(); // 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.moveTo(vector.x, vector.y); // Tip of the arrow - ctx.lineTo(vector.x - size * Math.cos(angle - arrowAngle), vector.y - size * Math.sin(angle - arrowAngle)); - ctx.lineTo(vector.x - size * Math.cos(angle + arrowAngle), vector.y - size * Math.sin(angle + arrowAngle)); - ctx.lineTo(vector.x, vector.y); // Back to the tip + ctx.moveTo(globalEndpoint.x, globalEndpoint.y); + ctx.lineTo(leftPoint.x, leftPoint.y); + ctx.lineTo(rightPoint.x, rightPoint.y); + ctx.lineTo(globalEndpoint.x, globalEndpoint.y); ctx.fillStyle = color; ctx.fill(); } @@ -219,19 +233,47 @@ export class Gizmos extends FolkElement { 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) { if (!Gizmos.#hasLoggedInitMessage) { const gizmos = document.querySelectorAll(Gizmos.tagName); - console.info( - '%cGizmos', - 'font-weight: bold; color: #4CAF50;', - '\n• Gizmo elements:', - gizmos.length, - '\n• Layers:', - `[${Array.from(Gizmos.#layers.keys()).join(', ')}]`, - '\n• Default layer:', - Gizmos.#defaultLayer, - ); + const layers = Array.from(Gizmos.#layers.entries()); + + const log = Gizmos.#log() + .color('Gizmos', '#4CAF50') + .text('\n• Gizmo elements: ' + gizmos.length) + .text('\n• Layers: ['); + + layers.forEach(([key, value], i) => { + if (i > 0) log.text(', '); + log.text(key); + if (value.hidden) { + log.color(' (hidden)', '#FFA500'); + } + }); + + log.text(']').print(); Gizmos.#hasLoggedInitMessage = true; } @@ -242,7 +284,7 @@ export class Gizmos extends FolkElement { return null; } - return layerData?.ctx; + return layerData?.hidden ? null : layerData?.ctx; } } diff --git a/lib/index.ts b/lib/index.ts index 5c1f3d9..494b92a 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -8,6 +8,7 @@ export * from './folk-observer'; export * from './resize-manger'; // Core utilities and types +export * from './folk-gizmos'; export * from './Matrix'; export * from './types'; export * from './Vector'; diff --git a/website/canvas/event-propagators-with-device-gravity.html b/website/canvas/event-propagators-with-device-gravity.html index c245d4b..2cc3de9 100644 --- a/website/canvas/event-propagators-with-device-gravity.html +++ b/website/canvas/event-propagators-with-device-gravity.html @@ -130,8 +130,8 @@ rotation: from.x" Gizmos.vector( center, { - x: center.x + gravity.x / 10, - y: center.y + gravity.y / 10, + x: gravity.x / 10, + y: gravity.y / 10, }, { color: 'grey', width: 3, size: 15 }, ); diff --git a/website/canvas/field-effect-brush.html b/website/canvas/field-effect-brush.html new file mode 100644 index 0000000..dd8f79c --- /dev/null +++ b/website/canvas/field-effect-brush.html @@ -0,0 +1,55 @@ + + + + + + Brush Field Demo + + + + + + + + + + + + + + + + + + + + + + + diff --git a/website/canvas/physics-mobile-gravity.html b/website/canvas/physics-mobile-gravity.html index 4c71af6..6e427c8 100644 --- a/website/canvas/physics-mobile-gravity.html +++ b/website/canvas/physics-mobile-gravity.html @@ -99,8 +99,8 @@ Gizmos.vector( center, { - x: center.x + gravity.x / 2, - y: center.y + gravity.y / 2, + x: gravity.x / 2, + y: gravity.y / 2, }, { color: 'grey', width: 3, size: 15 }, );