folk-canvas/lib/folk-gizmos.ts

294 lines
7.8 KiB
TypeScript

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 {
color?: string;
layer?: string;
}
interface PointOptions extends GizmoOptions {
size?: number;
}
interface LineOptions extends GizmoOptions {
width?: number;
}
interface RectOptions extends LineOptions {
fill?: string;
}
interface VectorOptions extends LineOptions {
size?: number;
}
/**
* Visual debugging system that renders canvas overlays in DOM containers.
*
* Creates full-size canvas overlays that can be placed anywhere in the DOM.
* Supports multiple instances with isolated drawing layers.
*
* Usage:
* ```html
* <folk-gizmos layer="debug"></folk-gizmos>
* ```
*
* Drawing methods:
* ```ts
* Gizmos.point({x, y});
* Gizmos.line(start, end, { color: 'red' });
* Gizmos.rect(domRect, { fill: 'blue' });
* Gizmos.vector(origin, vector, { color: 'blue', width: 2, size: 10 });
* ```
*/
export class Gizmos extends FolkElement {
static override tagName = 'folk-gizmos';
static #layers = new Map<
string,
{
ctx: CanvasRenderingContext2D;
canvas: HTMLCanvasElement;
hidden: boolean;
}
>();
static #defaultLayer = 'default';
static #hasLoggedDrawWarning = false;
static #hasLoggedInitMessage = false;
static styles = css`
:host {
display: block;
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: calc(Infinity);
}
.gizmos-canvas {
position: absolute;
width: 100%;
height: 100%;
}
`;
readonly #layer: string;
#hidden: boolean;
constructor() {
super();
this.#layer = this.getAttribute('layer') ?? Gizmos.#defaultLayer;
this.#hidden = this.hasAttribute('hidden');
}
override createRenderRoot() {
const root = super.createRenderRoot() as ShadowRoot;
root.setHTMLUnsafe(html` <canvas class="gizmos-canvas"></canvas> `);
const canvas = root.querySelector('.gizmos-canvas') as HTMLCanvasElement;
const ctx = canvas?.getContext('2d') ?? null;
if (canvas && ctx) {
Gizmos.#layers.set(this.#layer, { canvas, ctx, hidden: this.#hidden });
}
this.#handleResize();
window.addEventListener('resize', () => this.#handleResize());
return root;
}
/** Draws a point */
static point(point: Point, { color = 'red', size = 5, layer = Gizmos.#defaultLayer }: PointOptions = {}) {
const ctx = Gizmos.#getContext(layer);
if (!ctx) return;
ctx.beginPath();
ctx.fillStyle = color;
ctx.arc(point.x, point.y, size, 0, Math.PI * 2);
ctx.fill();
}
/** Draws a line between two points */
static line(start: Point, end: Point, { color = 'blue', width = 2, layer = Gizmos.#defaultLayer }: LineOptions = {}) {
const ctx = Gizmos.#getContext(layer);
if (!ctx) return;
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = width;
ctx.moveTo(start.x, start.y);
ctx.lineTo(end.x, end.y);
ctx.stroke();
}
/** Draws a rectangle, can be a regular DOMRect or a DOMRectTransform */
static rect(
rect: DOMRect | DOMRectTransform,
{ color = 'blue', width = 2, fill, layer = Gizmos.#defaultLayer }: RectOptions = {},
) {
const ctx = Gizmos.#getContext(layer);
if (!ctx) return;
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = width;
if (rect instanceof DOMRectTransform) {
// For transformed rectangles, draw using the vertices
const vertices = rect.vertices().map((p) => rect.toParentSpace(p));
ctx.moveTo(vertices[0].x, vertices[0].y);
for (let i = 1; i < vertices.length; i++) {
ctx.lineTo(vertices[i].x, vertices[i].y);
}
ctx.closePath();
} else {
// For regular DOMRects, draw a simple rectangle
ctx.rect(rect.x, rect.y, rect.width, rect.height);
}
if (fill) {
ctx.fillStyle = fill;
ctx.fill();
}
ctx.stroke();
}
/**
* 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,
localEndpoint: Point,
{ color = 'blue', width = 2, size = 10, layer = Gizmos.#defaultLayer }: VectorOptions = {},
) {
const ctx = Gizmos.#getContext(layer);
if (!ctx) return;
// 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 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(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(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();
}
/** Clears drawings from a specific layer or all layers if no layer specified */
static clear(layer?: string) {
if (layer) {
const layerData = Gizmos.#layers.get(layer);
if (!layerData) return;
const { ctx, canvas } = layerData;
ctx.clearRect(0, 0, canvas.width, canvas.height);
} else {
// Clear all layers
Gizmos.#layers.forEach(({ ctx, canvas }) => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
});
}
}
#handleResize() {
const layerData = Gizmos.#layers.get(this.#layer);
if (!layerData) return;
const { canvas, ctx } = layerData;
const rect = this.getBoundingClientRect();
const dpr = window.devicePixelRatio;
canvas.width = rect.width * dpr;
canvas.height = rect.height * 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) {
if (!Gizmos.#hasLoggedInitMessage) {
const gizmos = document.querySelectorAll<Gizmos>(Gizmos.tagName);
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;
}
const layerData = Gizmos.#layers.get(layer);
if (!layerData && !Gizmos.#hasLoggedDrawWarning) {
console.warn(`Gizmos cannot draw: layer "${layer}" not found`);
Gizmos.#hasLoggedDrawWarning = true;
return null;
}
return layerData?.hidden ? null : layerData?.ctx;
}
}
if (!customElements.get('folk-gizmos')) {
customElements.define('folk-gizmos', Gizmos);
}