/** * @class LayerRenderer * Handles rendering of active and inactive spline layers on the Protovis canvas. * * This is a wrapper class that coordinates multiple specialized modules: * - geometry-calculator: Pure geometric calculations * - animation-system: Dash animations and visual effects * - svg-renderer: Direct SVG DOM manipulation * - style-manager: Styling rules and layer type detection * - layer-drawer: Layer rendering orchestration * - hit-detection: Mouse-based layer selection */ import { BOX_BASE_RADIUS, POINT_BASE_RADIUS, transformVideoToCanvasSpace } from './spline_utils.js'; import { BOX_TIMELINE_MAX_POINTS } from './canvas/canvas_constants.js'; // Import all modules import * as GeometryCalc from './layer_renderer/geometry-calculator.js'; import * as AnimationSys from './layer_renderer/animation-system.js'; import * as SVGRenderer from './layer_renderer/svg-renderer.js'; import * as StyleManager from './layer_renderer/style-manager.js'; import * as LayerDrawer from './layer_renderer/layer-drawer.js'; import * as HitDetection from './layer_renderer/hit-detection.js'; export class LayerRenderer { constructor(splineEditor, vis) { this.splineEditor = splineEditor; this.vis = vis; this.node = splineEditor.node; this.inactiveLayerPanels = []; this.activeLayerPanel = this.vis.add(pv.Panel); this.inactiveLayerMetadata = []; // Render counter to force Protovis re-evaluation this._renderCounter = 0; // Direct SVG groups for bypass Protovis caching this.svgGroups = { activeDots: null, inactiveDots: null }; // Dash animation state this._dashAnimOffset = 0; this._lastDashUpdateMs = 0; this._dashTimer = setInterval(() => { this._dashAnimOffset = (this._dashAnimOffset + 2) % 1000; try { this.updateInactiveDash(); } catch {} }, 80); // Storage for custom SVG shapes to render after vis.render() this._pendingShapes = { activeLines: [], activeDots: [], inactiveDots: [], activeBoxDots: [], inactiveBoxDots: [], activeBoxImages: [], inactiveBoxImages: [] }; // Storage for rendered SVG groups (to re-attach after vis.render()) this._renderedGroups = { activeLines: null, activeDots: null, inactiveDots: null, activeBoxDots: null, inactiveBoxDots: null, activeBoxImages: null, inactiveBoxImages: null }; // Guard to prevent multiple requestAnimationFrame calls this._renderPending = false; // Helpers to temporarily hide spline visuals and keep box manipulators visible this._boxPlayVisibility = false; this._boxRefOnlyMode = false; // Cache for ref image URLs to prevent blinking during timeline scrub this._refImageCache = new Map(); // Global cache bust counter - incremented when cache is cleared to force new URLs this._cacheBustCounter = Date.now(); } // === PUBLIC UTILITY METHODS === setBoxPlayVisibility(enabled) { this._boxPlayVisibility = !!enabled; const svg = this.vis?.canvas?.(); if (!svg) return; // Hide/show green box squares, rotation controls, and rotation text during playback const boxSquares = svg.querySelectorAll('g#active-box-dots-direct > path, g#inactive-box-dots-direct > path'); const rotationTexts = svg.querySelectorAll('g#active-box-dots-direct > g[data-rotation-text], g#inactive-box-dots-direct > g[data-rotation-text]'); if (enabled) { boxSquares.forEach(el => { el.dataset.prevDisplay = el.style.display || ''; el.style.display = 'none'; }); rotationTexts.forEach(el => { el.dataset.prevDisplay = el.style.display || ''; el.style.display = 'none'; }); } else { boxSquares.forEach(el => { if (el.dataset.prevDisplay !== undefined) { el.style.display = el.dataset.prevDisplay; delete el.dataset.prevDisplay; } }); rotationTexts.forEach(el => { if (el.dataset.prevDisplay !== undefined) { el.style.display = el.dataset.prevDisplay; delete el.dataset.prevDisplay; } }); } // Hide other spline elements including rotation handles const applyToggle = (elements) => { elements.forEach(el => { if (enabled) { el.dataset.prevDisplay = el.style.display || ''; el.style.display = 'none'; } else if (el.dataset.prevDisplay !== undefined) { el.style.display = el.dataset.prevDisplay; delete el.dataset.prevDisplay; } }); }; applyToggle(Array.from(svg.querySelectorAll('path:not(#active-box-dots-direct > path):not(#inactive-box-dots-direct > path):not(#active-box-images > *)'))); applyToggle(Array.from(svg.querySelectorAll('circle'))); applyToggle(Array.from(svg.querySelectorAll('g#active-dots-direct g path, g#inactive-dots-direct g path, g#active-dots-direct g circle, g#inactive-dots-direct g circle'))); } setBoxRefOnlyMode(enabled) { this._boxRefOnlyMode = !!enabled; } clearRefImageCache() { this._cacheBustCounter = GeometryCalc.clearRefImageCache(this._refImageCache); } // === GEOMETRY CALCULATION METHODS (delegated to geometry-calculator) === clampPointScale(value) { return GeometryCalc.clampPointScale(value); } getPointScale(point, forBox = false) { return GeometryCalc.getPointScale(point, forBox); } getCanvasScale() { return GeometryCalc.getCanvasScale(this.splineEditor); } getScaledBoxRadius(point) { return GeometryCalc.getScaledBoxRadius(point, this.splineEditor); } _getBoxRotationValue(point) { return GeometryCalc.getBoxRotationValue(point); } _computeBoxHandleGeometry(point) { return GeometryCalc.computeBoxHandleGeometry(point, this.splineEditor); } _getSelectedRefAttachment(widget) { return GeometryCalc.getSelectedRefAttachment(widget); } _getRefImageUrl(widget, attachment) { return GeometryCalc.getRefImageUrl(widget, attachment, this._refImageCache, this._cacheBustCounter); } _sanitizeBoxKeys(widget) { return GeometryCalc.sanitizeBoxKeys(widget, this.splineEditor); } _safeDenormalizePoints(points) { return GeometryCalc.safeDenormalizePoints(points, this.splineEditor); } distanceToLineSegment(px, py, x1, y1, x2, y2) { return GeometryCalc.distanceToLineSegment(px, py, x1, y1, x2, y2); } // === STYLE MANAGEMENT METHODS (delegated to style-manager) === _getLayerType(widget) { return StyleManager.getLayerType(widget); } _normalizeInterpolation(interpolation, layerType) { return StyleManager.normalizeInterpolation(interpolation, layerType); } _getLayerStyles(layerType, state, isPointMode = false, isOff = false) { return StyleManager.getLayerStyles(layerType, state, isPointMode, isOff); } _getDotShape(dot, interpolation, layerType) { const allPoints = this.splineEditor.points || []; const resolvePointIndex = this.splineEditor.resolvePointIndex?.bind(this.splineEditor); return StyleManager.getDotShape(dot, interpolation, layerType, allPoints, resolvePointIndex); } _getDotAngle(dot, interpolation, layerType) { const allPoints = this.splineEditor.points || []; const resolvePointIndex = this.splineEditor.resolvePointIndex?.bind(this.splineEditor); return StyleManager.getDotAngle(dot, interpolation, layerType, allPoints, resolvePointIndex); } _getDotRadius(dot, interpolation, layerType, styles) { return StyleManager.getDotRadius(dot, interpolation, layerType, styles); } _getDotStrokeStyle(dot, interpolation, isPointMode, layerType, styles, allPoints) { const resolvePointIndex = this.splineEditor.resolvePointIndex?.bind(this.splineEditor); return StyleManager.getDotStrokeStyle(dot, interpolation, isPointMode, layerType, styles, allPoints, resolvePointIndex); } _getDotFillStyle(dot, interpolation, isPointMode, layerType, styles, allPoints) { const resolvePointIndex = this.splineEditor.resolvePointIndex?.bind(this.splineEditor); return StyleManager.getDotFillStyle(dot, interpolation, isPointMode, layerType, styles, allPoints, resolvePointIndex); } // === ANIMATION METHODS (delegated to animation-system) === updateInactiveDash() { const svg = this.vis?.canvas?.(); if (!svg) return; const state = { dashAnimOffset: this._dashAnimOffset, lastDashUpdateMs: this._lastDashUpdateMs, inactiveLayerMetadata: this.inactiveLayerMetadata }; AnimationSys.updateInactiveDash(state, svg, this.splineEditor); this._lastDashUpdateMs = state.lastDashUpdateMs; } updateActiveHanddrawDash() { const svg = this.vis?.canvas?.(); if (!svg) return; const state = { dashAnimOffset: this._dashAnimOffset, lastDashUpdateMs: this._lastDashUpdateMs }; AnimationSys.updateActiveHanddrawDash(state, svg, this.splineEditor, this.node); this._lastDashUpdateMs = state.lastDashUpdateMs; } // === SVG RENDERING METHODS (delegated to svg-renderer) === _getOrCreateSVGGroup(groupId) { const canvas = this.vis.canvas(); if (!canvas) return null; const svg = canvas.tagName === 'svg' ? canvas : canvas.querySelector('svg'); return SVGRenderer.getOrCreateSVGGroup(svg, groupId); } _createSVGPath(shapeType, point, styles, rotation = 0) { return SVGRenderer.createSVGPath(shapeType, point, styles, rotation, this.splineEditor); } _createSVGCircle(point, styles) { return SVGRenderer.createSVGCircle(point, styles, this.splineEditor); } _renderCustomSVGShapes() { const canvas = this.vis.canvas(); if (!canvas) return; const svg = canvas.tagName === 'svg' ? canvas : canvas.querySelector('svg'); if (!svg) return; const state = { pendingShapes: this._pendingShapes, renderedGroups: this._renderedGroups }; SVGRenderer.renderCustomSVGShapes(state, svg); this._pendingShapes = state.pendingShapes; this._renderedGroups = state.renderedGroups; } _updateActiveDotPositions() { const points = this.splineEditor.points || []; const activeWidget = this.node.layerManager.getActiveWidget(); const layerType = this._getLayerType(activeWidget); const interpolation = activeWidget?.value?.interpolation || 'linear'; const state = { renderedGroups: this._renderedGroups }; const layerInfo = { layerType, interpolation }; SVGRenderer.updateActiveDotPositions(state, points, this.splineEditor, layerInfo); } // === HIT DETECTION METHODS (delegated to hit-detection) === findInactiveLayerAtPosition(mouseX, mouseY, threshold = 15) { return HitDetection.findInactiveLayerAtPosition(this.inactiveLayerMetadata, mouseX, mouseY, threshold); } // === LAYER DRAWING METHODS (delegated to layer-drawer) === drawActiveLayer(widget) { const context = this._getDrawingContext(); LayerDrawer.drawActiveLayer(context, widget); } drawInactiveLayer(widget) { const context = this._getDrawingContext(); LayerDrawer.drawInactiveLayer(context, widget); } // Helper to build context object for layer drawer _getDrawingContext() { return { splineEditor: this.splineEditor, vis: this.vis, node: this.node, activeLayerPanel: this.activeLayerPanel, inactiveLayerPanels: this.inactiveLayerPanels, inactiveLayerMetadata: this.inactiveLayerMetadata, _pendingShapes: this._pendingShapes, _renderedGroups: this._renderedGroups, _boxRefOnlyMode: this._boxRefOnlyMode, _refImageCache: this._refImageCache, _cacheBustCounter: this._cacheBustCounter, // Provide bound methods for module functions _getLayerType: (w) => this._getLayerType(w), _normalizeInterpolation: (i, t) => this._normalizeInterpolation(i, t), _getLayerStyles: (t, s, p, o) => this._getLayerStyles(t, s, p, o), _safeDenormalizePoints: (p) => this._safeDenormalizePoints(p), _getDotShape: (d, i, t) => this._getDotShape(d, i, t), _getDotAngle: (d, i, t) => this._getDotAngle(d, i, t), _getDotRadius: (d, i, t, s) => this._getDotRadius(d, i, t, s), _getDotStrokeStyle: (d, i, p, t, s, a) => this._getDotStrokeStyle(d, i, p, t, s, a), _getDotFillStyle: (d, i, p, t, s, a) => this._getDotFillStyle(d, i, p, t, s, a), getScaledBoxRadius: (p) => this.getScaledBoxRadius(p), _getBoxRotationValue: (p) => this._getBoxRotationValue(p), _computeBoxHandleGeometry: (p) => this._computeBoxHandleGeometry(p), _getSelectedRefAttachment: (w) => this._getSelectedRefAttachment(w), _getRefImageUrl: (w, a) => this._getRefImageUrl(w, a), _sanitizeBoxKeys: (w) => this._sanitizeBoxKeys(w), _createSVGPath: (t, p, s, r) => this._createSVGPath(t, p, s, r), _createSVGCircle: (p, s) => this._createSVGCircle(p, s), clampPointScale: (v) => this.clampPointScale(v) }; } // === MAIN RENDER === render() { const editor = this.splineEditor; const points = editor.points || []; const forceRebuild = !!this.splineEditor._forceRebuildNextRender; if (forceRebuild) this.splineEditor._forceRebuildNextRender = false; // Don't rebuild during drag operations if (!forceRebuild && (this.splineEditor.isDragging || this.splineEditor.isDraggingAll || this.splineEditor.isScalingAll || this.splineEditor.isRotatingAll)) { this.vis.render(); // Fast path: just update positions of existing SVG elements if (!this._renderPending) { this._renderPending = true; requestAnimationFrame(() => { this._renderPending = false; this._updateActiveDotPositions(); // Re-attach custom SVG groups if needed this._renderCustomSVGShapes(); }); } return; } this.clearLayers(); // Clear any pending shapes from previous render cycle this._pendingShapes = { activeLines: [], activeDots: [], inactiveDots: [], activeBoxDots: [], inactiveBoxDots: [], activeBoxImages: [], inactiveBoxImages: [] }; // Increment render counter to force Protovis callbacks to re-evaluate this._renderCounter++; const activeWidget = this.node.layerManager.getActiveWidget(); const allWidgets = this.node.layerManager.getSplineWidgets(); const isDrawing = !!this.splineEditor._handdrawActive; const handdrawMode = this.splineEditor._handdrawMode || 'off'; const boxPlayMode = !!this._boxPlayVisibility; if (boxPlayMode) { // Only render box layers that are visible (on === true) // Render in reverse order so top layers in list draw on top const boxWidgets = allWidgets.filter(w => w?.value?.type === 'box_layer' && w?.value?.on !== false); const context = this._getDrawingContext(); [...boxWidgets].reverse().forEach(w => LayerDrawer.queueBoxManipulator(context, w)); this._ensureActivePanelOnTop(); this.vis.render(); if (!this._renderPending) { this._renderPending = true; requestAnimationFrame(() => { this._renderPending = false; this._renderCustomSVGShapes(); }); } return; } // Render inactive layers // Render inactive layers in reverse order so top layers in list draw on top [...allWidgets].reverse().forEach(widget => { const treatActiveAsInactive = isDrawing && handdrawMode === 'create' && widget === activeWidget; if (widget !== activeWidget || treatActiveAsInactive) { this.drawInactiveLayer(widget); } }); // Render active layer if (activeWidget && !(isDrawing && handdrawMode === 'create')) { this.drawActiveLayer(activeWidget); } if (this.splineEditor._handdrawActive && this.splineEditor.points?.length > 0) { const pseudoWidget = { value: { interpolation: 'linear', type: 'handdraw' } }; this.drawActiveLayer(pseudoWidget); } // Ensure active panel renders on top this._ensureActivePanelOnTop(); this.vis.render(); // IMPORTANT: Render custom SVG shapes AFTER vis.render() to prevent them from being wiped // Use requestAnimationFrame to ensure Protovis rendering is complete if (!this._renderPending) { this._renderPending = true; requestAnimationFrame(() => { this._renderPending = false; this._renderCustomSVGShapes(); }); } if (!this.splineEditor._handdrawActive) { this.updateInactiveDash(); this.updateActiveHanddrawDash(); } else { this.updateActiveHanddrawDash(); } } _ensureActivePanelOnTop() { const idx = this.vis.children.indexOf(this.activeLayerPanel); if (idx > -1) { this.vis.children.splice(idx, 1); this.vis.children.push(this.activeLayerPanel); } } clearLayers() { this.inactiveLayerPanels.forEach(panel => { const idx = this.vis.children.indexOf(panel); if (idx > -1) this.vis.children.splice(idx, 1); }); this.inactiveLayerPanels = []; this.inactiveLayerMetadata = []; // Remove and recreate activeLayerPanel to clear Protovis cache const idx = this.vis.children.indexOf(this.activeLayerPanel); if (idx > -1) { this.vis.children.splice(idx, 1); // Force removal of associated SVG elements if (this.activeLayerPanel.$) { this.activeLayerPanel.$.remove(); } } this.activeLayerPanel = this.vis.add(pv.Panel); // Clear rendered SVG groups so they can be recreated for the new layer if (this._renderedGroups.activeDots && this._renderedGroups.activeDots.parentNode) { this._renderedGroups.activeDots.parentNode.removeChild(this._renderedGroups.activeDots); } if (this._renderedGroups.activeBoxDots && this._renderedGroups.activeBoxDots.parentNode) { this._renderedGroups.activeBoxDots.parentNode.removeChild(this._renderedGroups.activeBoxDots); } if (this._renderedGroups.inactiveDots && this._renderedGroups.inactiveDots.parentNode) { this._renderedGroups.inactiveDots.parentNode.removeChild(this._renderedGroups.inactiveDots); } if (this._renderedGroups.inactiveBoxDots && this._renderedGroups.inactiveBoxDots.parentNode) { this._renderedGroups.inactiveBoxDots.parentNode.removeChild(this._renderedGroups.inactiveBoxDots); } // Reset the groups so new ones will be created this._renderedGroups = { activeLines: null, activeDots: null, inactiveDots: null, activeBoxDots: null, inactiveBoxDots: null, activeBoxImages: null, inactiveBoxImages: null }; } }