UNPKG

peepee

Version:

Visual Programming Language Where You Connect Ports Of One EventEmitter to Ports Of Another EventEmitter

431 lines (314 loc) 10.5 kB
import { Signal } from 'signals'; // Main PanZoomEngine class export class PanZoomEngine { debug = false; constructor(app, svgElement) { this.app = app; this.svg = svgElement; this.viewport = svgElement.querySelector("#viewport"); // Reactive signals this.mousePosX = new Signal(0, {label: 'mousePosX'}); this.mousePosY = new Signal(0, {label: 'mousePosY'}); this.worldPosX = new Signal(0, {label: 'worldPosX'}); this.worldPosY = new Signal(0, {label: 'worldPosY'}); this.scale = new Signal(1, {label: 'scale'}); this.panX = new Signal(0, {label: 'panX'}); this.panY = new Signal(0, {label: 'panY'}); this.tileSize = 40; // Plugin system this.plugins = []; this.isActive = true; this.activatingToolName = 'panZoom'; //this.app.selectedTool.subscribe(selectedToolName=>this.isActive=selectedToolName==this.activatingToolName); this.isRunning = false; // Subscribe to signal changes this.panX.subscribe(() => this.updateTransform()); this.panY.subscribe(() => this.updateTransform()); this.scale.subscribe(() => this.updateTransform()); // Configuration this.minScale = 0.1; this.maxScale = 10; this.zoomStep = 1.2; this.wheelSensitivity = 0.0015; } // Plugin management use(plugin) { plugin.engine = this; this.plugins.push(plugin); if (this.isRunning) { plugin.start(); } return this; } start() { if (this.isRunning) return; this.isRunning = true; this.plugins.forEach((plugin) => plugin.start()); return this; } stop() { if (!this.isRunning) return; this.isRunning = false; this.plugins.forEach((plugin) => plugin.stop()); return this; } // Transform management updateTransform() { const transform = `translate(${this.panX.value}, ${this.panY.value}) scale(${this.scale.value})`; this.viewport.setAttribute("transform", transform); } // Pan operations pan(x, y) { this.panX.value = x; this.panY.value = y; } panBy(deltaX, deltaY) { this.panX.value = this.panX.value + deltaX; this.panY.value = this.panY.value + deltaY; } // Zoom operations zoom(newScale) { this.scale.value = this.clampScale(newScale); } zoomBy(deltaScale) { this.zoom(this.scale.value * deltaScale); } zoomAt(newScale, x, y) { const oldScale = this.scale.value; const clampedScale = this.clampScale(newScale); const scaleFactor = clampedScale / oldScale; const svgPoint = this.clientToSVG(x, y); const dx = (svgPoint.x - this.panX.value) * (1 - scaleFactor); const dy = (svgPoint.y - this.panY.value) * (1 - scaleFactor); this.scale.value = clampedScale; this.panBy(dx, dy); } zoomAtBy(deltaScale, x, y) { this.zoomAt(this.scale.value * deltaScale, x, y); } zoomIn() { this.zoomBy(this.zoomStep); } zoomOut() { this.zoomBy(1 / this.zoomStep); } resetZoom() { this.pan(0, 0); this.zoom(1); } // Geometry helpers clampScale(scale) { return Math.max(this.minScale, Math.min(this.maxScale, scale)); } calculateWheelDelta(event) { return Math.exp(-event.deltaY * this.wheelSensitivity); } clientToSVG(clientX, clientY) { const rect = this.svg.getBoundingClientRect(); const svgX = ((clientX - rect.left) / rect.width) * this.svg.viewBox.baseVal.width; const svgY = ((clientY - rect.top) / rect.height) * this.svg.viewBox.baseVal.height; return { x: svgX, y: svgY }; } svgToWorld(svgX, svgY) { const worldX = (svgX - this.panX.value) / this.scale.value; const worldY = (svgY - this.panY.value) / this.scale.value; return { x: worldX, y: worldY }; } worldToSVG(worldX, worldY) { const svgX = worldX * this.scale.value + this.panX.value; const svgY = worldY * this.scale.value + this.panY.value; return { x: svgX, y: svgY }; } clientToWorld(clientX, clientY) { const svgPoint = this.clientToSVG(clientX, clientY); return this.svgToWorld(svgPoint.x, svgPoint.y); } getViewportBounds() { const vb = this.svg.viewBox.baseVal; const topLeft = this.svgToWorld(0, 0); const bottomRight = this.svgToWorld(vb.width, vb.height); return { left: topLeft.x, top: topLeft.y, right: bottomRight.x, bottom: bottomRight.y, width: bottomRight.x - topLeft.x, height: bottomRight.y - topLeft.y, }; } // Called when viewBox changes - hook for coordinate system updates onViewBoxChanged(width, height) { // Update grid labels when viewport changes if (this.isRunning) { // Debounce rapid resize events clearTimeout(this.resizeTimeout); this.resizeTimeout = setTimeout(() => { if(this.updateGridLabels) this.updateGridLabels(); }, 100); } } normalizeAngle(angle) { return ((angle % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); } radiansToDegrees(radians) { return radians * (180 / Math.PI); } degreesToRadians(degrees) { return degrees * (Math.PI / 180); } distance(x1, y1, x2, y2) { return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2); } lerp(a, b, t) { return a + (b - a) * t; } clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } snapToGrid(x, y) { return { x: Math.round(x / this.tileSize) * this.tileSize, y: Math.round(y / this.tileSize) * this.tileSize }; } // SVG explicitOriginalTarget polyfill getExplicitOriginalTarget(event) { // Firefox native support if (event.explicitOriginalTarget) { return event.explicitOriginalTarget; } // Polyfill for other browsers const target = event.target; // If target is already a graphic element, return it if (isGraphicElement(target)) { return target; } // Use elementFromPoint as fallback const point = getEventPoint(event); if (point) { const elementAtPoint = document.elementFromPoint(point.x, point.y); if (elementAtPoint && isGraphicElement(elementAtPoint)) { return elementAtPoint; } } // Last resort: traverse up from target to find graphic element let current = target; while (current && current !== document) { if (isGraphicElement(current)) { return current; } current = current.parentNode; } return target; // Fallback to original target } isGraphicElement(element) { if (!element || !element.tagName) return false; const graphicElements = [ 'circle', 'ellipse', 'line', 'path', 'polygon', 'polyline', 'rect', 'text', 'tspan', 'textPath', 'image', 'use' ]; return graphicElements.includes(element.tagName.toLowerCase()); } getEventPoint(event) { // Try to get coordinates from the event if (event.clientX !== undefined && event.clientY !== undefined) { return { x: event.clientX, y: event.clientY }; } // Handle touch events if (event.touches && event.touches.length > 0) { return { x: event.touches[0].clientX, y: event.touches[0].clientY }; } // Handle changed touches for touchend if (event.changedTouches && event.changedTouches.length > 0) { return { x: event.changedTouches[0].clientX, y: event.changedTouches[0].clientY }; } return null; } // Enhanced version that handles nested SVG and shadow DOM getExplicitOriginalTargetAdvanced(event) { if (event.explicitOriginalTarget) { return event.explicitOriginalTarget; } const point = getEventPoint(event); if (!point) return event.target; // Get all elements at the point (including those in shadow DOM) const elementsAtPoint = document.elementsFromPoint(point.x, point.y); // Find the first graphic element for (const element of elementsAtPoint) { if (isGraphicElement(element)) { return element; } } return event.target; } // Usage example: handleSVGClick(event) { const actualTarget = getExplicitOriginalTarget(event); //console.log('Clicked element:', actualTarget); //console.log('Element type:', actualTarget.tagName); // You can now work with the actual graphic element if (actualTarget.tagName === 'circle') { //console.log('Circle radius:', actualTarget.getAttribute('r')); } } //USAGE Add event listener //document.addEventListener('click', function(event) { // Only handle clicks on SVG elements //if (event.target.closest('svg')) { //handleSVGClick(event); // } //}); // Alternative: More robust version using intersection with bounding boxes getExplicitOriginalTargetByBounds(event) { if (event.explicitOriginalTarget) { return event.explicitOriginalTarget; } const point = getEventPoint(event); if (!point) return event.target; const svg = event.target.closest('svg'); if (!svg) return event.target; // Convert screen coordinates to SVG coordinates const svgPoint = screenToSVGPoint(svg, point.x, point.y); // Find all graphic elements and check which one contains the point const graphicElements = svg.querySelectorAll('circle, ellipse, line, path, polygon, polyline, rect, text, tspan, textPath, image, use'); for (const element of graphicElements) { if (elementContainsPoint(element, svgPoint)) { return element; } } return event.target; } screenToSVGPoint(svg, screenX, screenY) { const point = svg.createSVGPoint(); point.x = screenX; point.y = screenY; return point.matrixTransform(svg.getScreenCTM().inverse()); } elementContainsPoint(element, point) { try { // For basic shapes, check bounding box const bbox = element.getBBox(); if (point.x >= bbox.x && point.x <= bbox.x + bbox.width && point.y >= bbox.y && point.y <= bbox.y + bbox.height) { // For paths, do more precise hit testing if available if (element.tagName === 'path' && element.isPointInFill) { return element.isPointInFill(point); } return true; } } catch (e) { // getBBox might fail for some elements return false; } return false; } placeCircleOnCircumference(angle, circleX, circleY, circleRadius) { // Convert the start angle from degrees to radians const angleInRadians = angle * (Math.PI / 180); // Calculate the x and y coordinates of the point on the circumference const x = circleX + circleRadius * Math.cos(angleInRadians); const y = circleY + circleRadius * Math.sin(angleInRadians); // Return the coordinates as an object for destructuring return { x, y }; } }