Files
siraxe-ComfyUI-WanVideoWrap…/web/power_spline_editor/layer_renderer.js
T
2025-11-24 21:34:05 +00:00

2045 lines
86 KiB
JavaScript

import { BOX_BASE_RADIUS, POINT_BASE_RADIUS } from './spline_utils.js';
import { BOX_TIMELINE_MAX_POINTS } from './canvas/canvas_constants.js';
/**
* @class LayerRenderer
* Handles rendering of active and inactive spline layers on the Protovis canvas.
*/
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 = {
activeDots: [],
inactiveDots: [],
activeBoxDots: [],
inactiveBoxDots: [],
boxImages: []
};
// Storage for rendered SVG groups (to re-attach after vis.render())
this._renderedGroups = {
activeDots: null,
inactiveDots: null,
activeBoxDots: null,
inactiveBoxDots: null,
boxImages: 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
// Key format: "widgetName_refSelection" -> cached URL with timestamp
this._refImageCache = new Map();
// Global cache bust counter - incremented when cache is cleared to force new URLs
this._cacheBustCounter = Date.now();
}
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) {
// Hide green boxes and rotation text during playback
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 {
// Restore visibility when not in playback
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 (circles and paths)
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;
}
});
};
// Hide all paths except box squares and box images
applyToggle(Array.from(svg.querySelectorAll('path:not(#active-box-dots-direct > path):not(#inactive-box-dots-direct > path):not(#active-box-images > *)')));
// Hide all circles (this includes rotation handle tips)
applyToggle(Array.from(svg.querySelectorAll('circle')));
// Hide dots from direct groups
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;
}
// === DIRECT SVG MANIPULATION HELPERS ===
_updateActiveDotPositions() {
// Fast path: update positions of existing SVG elements during drag
if (!this._renderedGroups.activeDots && !this._renderedGroups.activeBoxDots) return;
const currentPoints = this.splineEditor.points || [];
const validPoints = currentPoints.filter(p => p && Number.isFinite(p.x) && Number.isFinite(p.y));
// Get active widget to determine layer type
const activeWidget = this.node.layerManager.getActiveWidget();
const layerType = this._getLayerType(activeWidget);
const interpolation = activeWidget?.value?.interpolation || 'linear';
// Update active dots (circles/triangles)
if (this._renderedGroups.activeDots) {
const children = Array.from(this._renderedGroups.activeDots.children);
children.forEach((element, idx) => {
if (idx >= validPoints.length) return;
const point = validPoints[idx];
if (element.tagName === 'circle') {
element.setAttribute('cx', point.x);
element.setAttribute('cy', point.y);
} else if (element.tagName === 'path') {
// For paths (triangles), recalculate rotation for first point
let rotationDeg = 0;
if (idx === 0 && validPoints.length > 1 && layerType === 'normal' && interpolation !== 'points') {
const dx = validPoints[1].x - validPoints[0].x;
const dy = validPoints[1].y - validPoints[0].y;
const rotationRad = (dx !== 0 || dy !== 0) ? (Math.atan2(dy, dx) - Math.PI / 2 + Math.PI + 2 * Math.PI) % (2 * Math.PI) : 0;
rotationDeg = rotationRad * (180 / Math.PI);
}
element.setAttribute('transform', `translate(${point.x},${point.y}) rotate(${rotationDeg})`);
}
});
}
// Update active box dots (squares) and rotation text
if (this._renderedGroups.activeBoxDots) {
const children = Array.from(this._renderedGroups.activeBoxDots.children);
const activeWidget = this.node.layerManager.getActiveWidget();
const radius = validPoints.length > 0 ? this.getScaledBoxRadius(validPoints[0]) : (BOX_BASE_RADIUS * this.getCanvasScale());
children.forEach((element, idx) => {
if (element.tagName === 'path' && idx < validPoints.length) {
const point = validPoints[idx];
const rotationRad = this._getBoxRotationValue(point);
const rotationDeg = rotationRad * (180 / Math.PI);
element.setAttribute('transform', `translate(${point.x},${point.y}) rotate(${rotationDeg})`);
} else if (element.tagName === 'g' && element.hasAttribute('data-rotation-text') && validPoints.length > 0) {
// Update rotation text group
const point = validPoints[0];
const rotationRad = this._getBoxRotationValue(point);
const rotationDeg = rotationRad * (180 / Math.PI);
// Update group position
element.setAttribute('transform', `translate(${point.x},${point.y})`);
// Update text content
const textElement = element.querySelector('text');
if (textElement) {
const offsetX = -radius;
const offsetY = -radius - 5;
textElement.setAttribute('x', offsetX);
textElement.setAttribute('y', offsetY);
textElement.textContent = `${rotationDeg.toFixed(1)}°`;
}
}
});
}
// Update attached box images
if (this._renderedGroups.boxImages) {
const images = Array.from(this._renderedGroups.boxImages.children);
if (validPoints.length > 0 && images.length > 0) {
const point = validPoints[0];
const rotationDeg = this._getBoxRotationValue(point) * (180 / Math.PI);
images.forEach((imgEl) => {
imgEl.setAttribute('transform', `translate(${point.x},${point.y}) rotate(${rotationDeg})`);
});
}
}
}
_renderCustomSVGShapes() {
const canvas = this.vis.canvas();
if (!canvas) return;
const svg = canvas.tagName === 'svg' ? canvas : canvas.querySelector('svg');
if (!svg) return;
const hasPendingShapes = this._pendingShapes.activeDots.length > 0 ||
this._pendingShapes.inactiveDots.length > 0 ||
this._pendingShapes.activeBoxDots.length > 0 ||
this._pendingShapes.inactiveBoxDots.length > 0 ||
this._pendingShapes.boxImages.length > 0;
// If we have pending shapes, create new groups
if (hasPendingShapes) {
// Create active dots group
if (this._pendingShapes.activeDots.length > 0) {
// Remove old group if it exists
if (this._renderedGroups.activeDots && this._renderedGroups.activeDots.parentNode) {
this._renderedGroups.activeDots.parentNode.removeChild(this._renderedGroups.activeDots);
}
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
group.setAttribute('id', 'active-dots-direct');
this._pendingShapes.activeDots.forEach(shape => group.appendChild(shape));
this._renderedGroups.activeDots = group;
}
// Create inactive dots group
if (this._pendingShapes.inactiveDots.length > 0) {
// Remove old group if it exists
if (this._renderedGroups.inactiveDots && this._renderedGroups.inactiveDots.parentNode) {
this._renderedGroups.inactiveDots.parentNode.removeChild(this._renderedGroups.inactiveDots);
}
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
group.setAttribute('id', 'inactive-dots-direct');
this._pendingShapes.inactiveDots.forEach(shape => group.appendChild(shape));
this._renderedGroups.inactiveDots = group;
}
// Create active box dots group
if (this._pendingShapes.activeBoxDots.length > 0) {
// Remove old group if it exists
if (this._renderedGroups.activeBoxDots && this._renderedGroups.activeBoxDots.parentNode) {
this._renderedGroups.activeBoxDots.parentNode.removeChild(this._renderedGroups.activeBoxDots);
}
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
group.setAttribute('id', 'active-box-dots-direct');
this._pendingShapes.activeBoxDots.forEach(shape => group.appendChild(shape));
this._renderedGroups.activeBoxDots = group;
}
// Create inactive box dots group
if (this._pendingShapes.inactiveBoxDots.length > 0) {
// Remove old group if it exists
if (this._renderedGroups.inactiveBoxDots && this._renderedGroups.inactiveBoxDots.parentNode) {
this._renderedGroups.inactiveBoxDots.parentNode.removeChild(this._renderedGroups.inactiveBoxDots);
}
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
group.setAttribute('id', 'inactive-box-dots-direct');
this._pendingShapes.inactiveBoxDots.forEach(shape => group.appendChild(shape));
this._renderedGroups.inactiveBoxDots = group;
}
// Create box images group
if (this._pendingShapes.boxImages.length > 0) {
if (this._renderedGroups.boxImages && this._renderedGroups.boxImages.parentNode) {
this._renderedGroups.boxImages.parentNode.removeChild(this._renderedGroups.boxImages);
}
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
group.setAttribute('id', 'active-box-images');
this._pendingShapes.boxImages.forEach(shape => group.appendChild(shape));
this._renderedGroups.boxImages = group;
}
// Clear pending shapes
this._pendingShapes = {
activeDots: [],
inactiveDots: [],
activeBoxDots: [],
inactiveBoxDots: [],
boxImages: []
};
}
// ALWAYS re-attach rendered groups to SVG to ensure they're on top
// We need to remove and re-append even if they're already in the SVG
// to ensure they render above Protovis elements
if (this._renderedGroups.inactiveDots) {
if (this._renderedGroups.inactiveDots.parentNode) {
this._renderedGroups.inactiveDots.parentNode.removeChild(this._renderedGroups.inactiveDots);
}
svg.appendChild(this._renderedGroups.inactiveDots);
}
if (this._renderedGroups.inactiveBoxDots) {
if (this._renderedGroups.inactiveBoxDots.parentNode) {
this._renderedGroups.inactiveBoxDots.parentNode.removeChild(this._renderedGroups.inactiveBoxDots);
}
svg.appendChild(this._renderedGroups.inactiveBoxDots);
}
if (this._renderedGroups.activeDots) {
if (this._renderedGroups.activeDots.parentNode) {
this._renderedGroups.activeDots.parentNode.removeChild(this._renderedGroups.activeDots);
}
svg.appendChild(this._renderedGroups.activeDots);
}
if (this._renderedGroups.activeBoxDots) {
if (this._renderedGroups.activeBoxDots.parentNode) {
this._renderedGroups.activeBoxDots.parentNode.removeChild(this._renderedGroups.activeBoxDots);
}
svg.appendChild(this._renderedGroups.activeBoxDots);
}
if (this._renderedGroups.boxImages) {
if (this._renderedGroups.boxImages.parentNode) {
this._renderedGroups.boxImages.parentNode.removeChild(this._renderedGroups.boxImages);
}
svg.appendChild(this._renderedGroups.boxImages);
}
}
_getOrCreateSVGGroup(groupId) {
let canvas = this.vis.canvas();
if (!canvas) {
console.warn('[WARN] Canvas not found for group:', groupId);
return null;
}
// Protovis returns a SPAN that contains the SVG, not the SVG itself
let svg = canvas.tagName === 'svg' ? canvas : canvas.querySelector('svg');
if (!svg) {
console.warn('[WARN] SVG element not found in canvas for group:', groupId);
return null;
}
let group = svg.querySelector(`#${groupId}`);
if (!group) {
group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
group.setAttribute('id', groupId);
svg.appendChild(group);
}
return group;
}
_createSVGPath(shapeType, point, styles, rotation = 0) {
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
let d, radius;
switch (shapeType) {
case 'square':
radius = styles.radius || 40;
d = `M-${radius},-${radius}L${radius},-${radius} ${radius},${radius} -${radius},${radius}Z`;
break;
case 'triangle':
radius = styles.radius || 6;
const side = radius * 2;
const triHeight = side * Math.sqrt(3) / 2; // equilateral triangle
const halfWidth = side / 2;
d = `M0,-${triHeight / 2} L${halfWidth},${triHeight / 2} -${halfWidth},${triHeight / 2}Z`;
break;
default:
return null;
}
path.setAttribute('d', d);
// Convert rotation from radians to degrees for SVG
const rotationDeg = rotation * (180 / Math.PI);
path.setAttribute('transform', `translate(${point.x},${point.y}) rotate(${rotationDeg})`);
path.setAttribute('stroke', styles.stroke || 'rgb(31,119,180)');
path.setAttribute('stroke-width', styles.strokeWidth || 3);
path.setAttribute('fill', styles.fill || 'none');
path.setAttribute('cursor', styles.cursor || 'move');
// Ensure visibility
path.style.pointerEvents = 'auto';
path.style.visibility = 'visible';
path.style.opacity = '1';
return path;
}
_createSVGCircle(point, styles) {
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
circle.setAttribute('cx', point.x);
circle.setAttribute('cy', point.y);
circle.setAttribute('r', styles.radius || 6);
circle.setAttribute('stroke', styles.stroke || 'rgb(31,119,180)');
circle.setAttribute('stroke-width', styles.strokeWidth || 3);
circle.setAttribute('fill', styles.fill || 'none');
circle.setAttribute('cursor', styles.cursor || 'move');
// Ensure visibility
circle.style.pointerEvents = 'auto';
circle.style.visibility = 'visible';
circle.style.opacity = '1';
return circle;
}
// === SCALE & GEOMETRY HELPERS ===
_getLayerType(widget) {
const type = widget?.value?.type;
const result = type === 'handdraw' ? 'handdraw' : (type === 'box_layer' ? 'box' : 'normal');
return result;
}
_normalizeInterpolation(interpolation, layerType) {
const raw = interpolation || 'linear';
if (layerType !== 'box' && raw === 'box') return 'points';
return raw;
}
_getLayerStyles(layerType, state, isPointMode = false, isOff = false) {
if (isOff) {
return {
lineStroke: "rgba(255, 255, 255, 0.1)",
lineWidth: isPointMode ? 1 : 2,
pointStroke: "rgba(255, 255, 255, 0.1)",
pointFill: "rgba(255, 255, 255, 0.1)",
};
}
if (layerType === 'handdraw') {
if (state === 'active') {
return {
lineStroke: '#d7c400',
lineWidth: 3,
pointStroke: '#d7c400',
pointFill: 'rgba(215, 196, 0, 0.45)',
pointRadius: 2,
};
}
return {
lineStroke: "rgba(120, 70, 180, 0.85)",
lineWidth: 3,
pointStroke: "rgba(120, 70, 180, 0.85)",
pointFill: "rgba(120, 70, 180, 0.6)",
};
}
if (layerType === 'box') {
if (state === 'active') {
return {
lineStroke: '#139613',
lineWidth: 2,
pointStroke: '#139613',
pointFill: 'rgba(19, 150, 19, 0.1)',
};
}
return {
lineStroke: '#f04d3a',
lineWidth: 2,
pointStroke: '#f04d3a',
pointFill: 'rgba(240, 77, 58, 0.2)',
};
}
// normal layers
if (state === 'active') {
const base = {
lineStroke: '#1f77b4',
lineWidth: isPointMode ? 1 : 2,
pointStroke: '#1f77b4',
pointFill: 'rgba(100, 100, 100, 0.5)',
pointStrokePoint: '#1f77b4',
pointFillPoint: 'rgba(100, 100, 100, 0.5)',
pointRadius: 5,
firstFill: 'rgba(100, 100, 100, 0.5)',
};
if (isPointMode) {
const pointStroke = 'rgba(45, 242, 109, 0.5)';
const pointFill = 'rgba(45, 242, 109, 0.5)';
return {
...base,
pointStroke,
pointFill,
pointStrokePoint: pointStroke,
pointFillPoint: pointFill,
strokeWidth: 2,
};
}
return base;
}
const baseStroke = isPointMode ? 'rgba(255, 127, 14, 0.2)' : 'rgba(255, 127, 14, 0.5)';
if (isPointMode) {
return {
lineStroke: baseStroke,
lineWidth: 1,
pointStroke: 'rgba(45, 242, 109, 0.5)',
pointFill: 'rgba(45, 242, 109, 0.5)',
pointRadius: 4,
strokeWidth: 1.5,
};
}
return {
lineStroke: baseStroke,
lineWidth: isPointMode ? 1 : 2,
pointStroke: baseStroke,
pointFill: isPointMode ? 'rgba(255, 127, 14, 0.2)' : 'rgba(255, 127, 14, 0.4)',
pointRadius: 4,
};
}
_getSelectedRefAttachment(widget) {
const ref = widget?.value?.ref_attachment;
const selection = widget?.value?.ref_selection || 'no_ref';
if (!ref || selection === 'no_ref') return null;
// New multi-entry format
if (Array.isArray(ref.entries)) {
const parts = selection.split('_');
const idx = parts.length > 1 ? parseInt(parts[1], 10) : 1;
const arrayIndex = Number.isFinite(idx) ? Math.max(0, idx - 1) : 0;
return ref.entries[arrayIndex] || null;
}
// Legacy single entry
if (ref.base64) return ref;
return null;
}
_getRefImageUrl(widget, attachment) {
if (!attachment) return null;
// For base64, return directly (no caching needed)
if (attachment.base64) {
return `data:${attachment.type || 'image/png'};base64,${attachment.base64}`;
}
// For path-based images, use cache to prevent blinking during timeline scrub
if (attachment.path) {
const widgetName = widget?.value?.name || widget?.name || 'unknown';
const selection = widget?.value?.ref_selection || 'no_ref';
// Include attachment name/path in cache key to differentiate between different ref images
const attachmentId = attachment.name || attachment.path;
const cacheKey = `${widgetName}_${selection}_${attachmentId}`;
// Check if we have a cached URL for this widget+selection+attachment
if (!this._refImageCache.has(cacheKey)) {
// Create new URL with cache bust counter to force browser refresh when cache is cleared
// Using cacheBustCounter ensures all URLs get new timestamps when clearRefImageCache() is called
const url = new URL(`${attachment.path}?v=${this._cacheBustCounter}`, import.meta.url).href;
this._refImageCache.set(cacheKey, url);
}
return this._refImageCache.get(cacheKey);
}
return null;
}
clearRefImageCache() {
this._refImageCache.clear();
// Update cache bust counter to ensure new URLs are generated with fresh timestamps
// Add random component to make it even more unique and bypass aggressive browser caching
this._cacheBustCounter = Date.now() + Math.random().toString(36).substring(2, 9);
}
clampPointScale(value) {
return (typeof value === 'number' && !Number.isNaN(value))
? Math.max(0.2, Math.min(3.0, value))
: 1;
}
getPointScale(point, forBox = false) {
if (!point) return 1;
if (forBox && typeof point.boxScale === 'number') return this.clampPointScale(point.boxScale);
if (!forBox && typeof point.pointScale === 'number') return this.clampPointScale(point.pointScale);
if (typeof point.scale === 'number') return this.clampPointScale(point.scale);
return 1;
}
getCanvasScale() {
// Returns the canvas scaling factor used for background image
const editor = this.splineEditor;
return (editor.originalImageWidth && editor.originalImageHeight && editor.scale > 0)
? editor.scale
: 1;
}
getScaledBoxRadius(point) {
// Combines canvas scale with individual box scale
const pointScale = this.getPointScale(point, true);
const canvasScale = this.getCanvasScale();
return BOX_BASE_RADIUS * pointScale * canvasScale;
}
_getBoxRotationValue(point) {
if (!point) return 0;
if (typeof point.boxRotation === 'number' && !Number.isNaN(point.boxRotation)) return point.boxRotation;
if (typeof point.rotation === 'number' && !Number.isNaN(point.rotation)) return point.rotation;
return 0;
}
_computeBoxHandleGeometry(point) {
if (!point || !Number.isFinite(point.x) || !Number.isFinite(point.y)) return null;
const rotation = this._getBoxRotationValue(point);
const radius = this.getScaledBoxRadius(point);
if (!Number.isFinite(radius)) return null;
const extra = Math.max(18, radius * 0.5);
const rotatePoint = (px, py) => ({
x: point.x + px * Math.cos(rotation) - py * Math.sin(rotation),
y: point.y + px * Math.sin(rotation) + py * Math.cos(rotation),
});
return {
base: rotatePoint(0, -radius),
tip: rotatePoint(0, -radius - extra),
};
}
// === DASH ANIMATION ===
updateInactiveDash() {
if (!this.splineEditor) return;
const svg = this.vis?.canvas?.();
if (!svg) return;
// If disabled, clean up and return
if (this.splineEditor._inactiveFlowEnabled === false) {
this._removeDashStyling(svg);
return;
}
// Throttle updates
const now = Date.now();
const minInterval = this.splineEditor._handdrawActive ? 120 : 60;
const needsInitialApply = this._needsInitialDashApply(svg);
if (!needsInitialApply && (now - this._lastDashUpdateMs < minInterval)) return;
this._lastDashUpdateMs = now;
this._applyDashAnimation(svg);
}
_removeDashStyling(svg) {
const paths = svg.getElementsByTagName('path');
for (const p of paths) {
p.removeAttribute('stroke-dasharray');
p.removeAttribute('stroke-dashoffset');
if (p.dataset) delete p.dataset.dashOffset;
}
}
_needsInitialDashApply(svg) {
const paths = svg.getElementsByTagName('path');
for (const el of paths) {
const stroke = (el.getAttribute('stroke') || '').toLowerCase();
const sw = Number(el.getAttribute('stroke-width') || '0');
if (this._isInactiveStroke(stroke) && sw > 1 && !el.hasAttribute('stroke-dasharray')) {
return true;
}
}
return false;
}
_isInactiveStroke(stroke) {
return stroke.includes('120,70,180') || stroke.includes('#7846b4') ||
stroke.includes('255,127,14') || stroke.includes('#ff7f0e');
}
_applyDashAnimation(svg) {
const paths = Array.from(svg.getElementsByTagName('path'));
const phase = ((this._dashAnimOffset % 200) / 200);
const { purple: purplePaths, orange: orangePaths } = this._categorizePaths(paths);
const purpleMetas = this.inactiveLayerMetadata.filter(d => d?.widget?.value?.type === 'handdraw');
const orangeMetas = this.inactiveLayerMetadata.filter(d => {
const interp = d?.widget?.value?.interpolation || 'linear';
const layerType = d?.widget?.value?.type || d?.widget?.type || '';
// Never dash box layers or points-mode layers
if (layerType === 'box_layer' || layerType === 'box') return false;
return layerType !== 'handdraw' && interp !== 'points' && interp !== 'box';
});
this._applyDashForPaths(purplePaths, purpleMetas, phase);
this._applyDashForPaths(orangePaths, orangeMetas, phase);
}
_categorizePaths(paths) {
const purple = [], orange = [];
const norm = s => (s || '').replace(/\s+/g, '').toLowerCase();
for (const p of paths) {
if (!this._isInactiveTargetPath(p)) continue;
const stroke = norm(p.getAttribute('stroke'));
const isPurple = stroke.includes('120,70,180') || stroke.includes('#7846b4');
const len = (typeof p.getTotalLength === 'function') ? p.getTotalLength() : 0;
(isPurple ? purple : orange).push({ p, len });
}
return { purple, orange };
}
_isInactiveTargetPath(p) {
const norm = s => (s || '').replace(/\s+/g, '').toLowerCase();
const stroke = norm(p.getAttribute('stroke'));
// Exclude active colors
if (stroke === '#1f77b4' || stroke === '#d7c400') return false;
const isOrange = stroke.includes('255,127,14') || stroke.includes('#ff7f0e') ||
stroke.includes('rgba(255,127,14') || stroke.includes('rgb(255,127,14)');
const isPurple = stroke.includes('120,70,180') || stroke.includes('#7846b4');
if (!(isOrange || isPurple)) return false;
// Exclude thin orange lines (points mode)
const sw = Number(p.getAttribute('stroke-width') || '0');
return !(isOrange && sw <= 1.01);
}
_applyDashForPaths(pairs, metas, phase) {
const count = Math.min(pairs.length, metas.length);
for (let idx = 0; idx < count; idx++) {
const { p, len: pathLength } = pairs[idx];
const easingMode = metas[idx]?.widget?.value?.easing || 'linear';
const pattern = this._generateDashPattern(pathLength, phase, easingMode);
p.setAttribute('stroke-dasharray', pattern.join(' '));
p.setAttribute('stroke-dashoffset', String(-phase * (pathLength || 1)));
}
}
_generateDashPattern(pathLength, phase, easingMode) {
const segments = Math.max(14, Math.min(100, Math.round(pathLength / 36)));
const baseDash = 10, baseGap = 6;
const minFactor = 0.25, maxFactor = 1.6;
const pattern = [];
let sum = 0;
for (let i = 0; i < segments; i++) {
const t = (i / segments + phase) % 1;
const f = this._easeValue(t, easingMode);
const dash = Math.max(2, baseDash * (minFactor + (maxFactor - minFactor) * f));
const gap = Math.max(2, baseGap * (1.0 - 0.2 * f));
pattern.push(dash, gap);
sum += dash + gap;
}
// Scale to fit path length
if (sum > 0 && pathLength > 0) {
const scale = pathLength / sum;
for (let k = 0; k < pattern.length; k++) {
pattern[k] = Math.max(1, pattern[k] * scale);
}
}
return pattern;
}
_easeValue(t, mode) {
t = Math.max(0, Math.min(1, t));
switch (mode) {
case 'in': return t;
case 'out': return 1 - t;
case 'in_out': return Math.sin(Math.PI * t);
case 'out_in': return 1 - Math.sin(Math.PI * t);
default: return 1;
}
}
updateActiveHanddrawDash() {
if (!this.splineEditor || this.splineEditor._inactiveFlowEnabled === false) return;
const now = Date.now();
const minInterval = this.splineEditor._handdrawActive ? 120 : 60;
if ((this._lastDashUpdateMs || 0) && (now - this._lastDashUpdateMs < minInterval)) return;
this._lastDashUpdateMs = now;
const svg = this.vis?.canvas?.();
if (!svg) return;
const active = this.node?.layerManager?.getActiveWidget?.();
if (!active || active.value?.type !== 'handdraw') return;
const easingMode = active.value?.easing || 'linear';
const phase = ((this._dashAnimOffset % 200) / 200);
const paths = svg.getElementsByTagName('path');
const norm = s => (s || '').replace(/\s+/g, '').toLowerCase();
for (const p of paths) {
const stroke = norm(p.getAttribute('stroke'));
if (stroke !== '#d7c400') continue;
const pathLength = (typeof p.getTotalLength === 'function') ? p.getTotalLength() : 0;
const pattern = this._generateActiveHanddrawPattern(pathLength, phase, easingMode);
p.setAttribute('stroke-dasharray', pattern.join(' '));
p.setAttribute('stroke-dashoffset', String(-phase * (pathLength || 1)));
}
}
_generateActiveHanddrawPattern(pathLength, phase, easingMode) {
const segments = Math.max(14, Math.min(100, Math.round(pathLength / 36)));
const baseDash = 10, baseGap = 6;
const minFactor = 0.35, maxFactor = 1.45;
const pattern = [];
let sum = 0;
for (let i = 0; i < segments; i++) {
const t = (i / segments + phase) % 1;
const f = this._easeValue(t, easingMode);
const dash = Math.max(2, baseDash * (minFactor + (maxFactor - minFactor) * f));
pattern.push(dash, baseGap);
sum += dash + baseGap;
}
if (sum > 0 && pathLength > 0) {
const scale = pathLength / sum;
for (let k = 0; k < pattern.length; k++) {
pattern[k] = Math.max(1, pattern[k] * scale);
}
}
return pattern;
}
// === MAIN RENDER ===
render() {
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 = {
activeDots: [],
inactiveDots: [],
activeBoxDots: [],
inactiveBoxDots: [],
boxImages: []
};
// 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);
[...boxWidgets].reverse().forEach(w => this._queueBoxManipulator(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();
// NOTE: We no longer need to clear cached paths since we're using direct SVG manipulation
// Our custom SVG groups handle their own clearing in each draw function
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();
});
}
const svg = this.vis.canvas();
if (svg) {
const paths = svg.querySelectorAll('path[transform]');
const circles = svg.querySelectorAll('circle');
const customGroups = svg.querySelectorAll('g[id$="-direct"]');
}
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
// Note: This is safe because clearLayers() is only called during full rebuilds,
// not during drag operations (which use the fast path in render())
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 = {
activeDots: null,
inactiveDots: null,
activeBoxDots: null,
inactiveBoxDots: null
};
}
// === ACTIVE LAYER DRAWING ===
drawActiveLayer(widget) {
const points = Array.isArray(this.splineEditor.points) ? this.splineEditor.points : [];
const hasPoints = points.length > 0;
const layerType = this._getLayerType(widget);
const isBoxLayer = layerType === 'box';
const safePoints = hasPoints
? points.filter(p => p && Number.isFinite(p.x) && Number.isFinite(p.y))
: [];
// For non-box layers, we need points to render anything
if (!safePoints.length && !isBoxLayer) return;
// Strictly determine interpolation based on widget type to prevent bleeding
const interpolation = isBoxLayer
? (widget.value.interpolation || 'linear')
: this._normalizeInterpolation(widget.value.interpolation, layerType);
const isPointMode = layerType === 'box' ? true : interpolation === 'points';
const styles = this._getLayerStyles(layerType, 'active', isPointMode);
// Draw lines and dots for regular spline layers (not box layers)
if (hasPoints && !isBoxLayer) {
this._drawActiveLine(interpolation, isPointMode, layerType, styles);
this._drawActiveDots(interpolation, isPointMode, layerType, styles, points);
}
if (isBoxLayer) {
// First: Draw the current interpolated box visualization
// We pass the widget, but we WON'T pass safePoints anymore.
// The function will fetch them fresh.
this._drawCurrentBoxVisualization(widget);
// Second: Draw the box layer keyframe timeline visualization (red dots and connecting lines)
this._drawBoxKeyPath(this.activeLayerPanel, widget, true);
// Third: Get the actual keyframe points from box_keys for rotation handles and green center dots
const sortedKeys = this._sanitizeBoxKeys(widget);
if (sortedKeys.length > 0) {
const keyframeNormPoints = sortedKeys.map(k => ({ x: k.x, y: k.y }));
const keyframeDenormPoints = this._safeDenormalizePoints(keyframeNormPoints);
if (keyframeDenormPoints && keyframeDenormPoints.length > 0) {
const validKeyframePoints = keyframeDenormPoints.filter(p =>
p && Number.isFinite(p.x) && Number.isFinite(p.y)
);
if (validKeyframePoints.length > 0) {
// Temporarily removed rotation handles for all keyframes to avoid visual duplication
// with orange keyframe indicators. Will be added back with hover-based display.
// this._drawBoxRotationHandles(this.activeLayerPanel, validKeyframePoints, sortedKeys);
// Note: Removed duplicate green center dots as they conflict with orange keyframe indicators
}
}
}
// Fourth: Draw the preview hover state
this._drawBoxPreview(widget);
}
}
_drawCurrentBoxVisualization(widget) {
const styles = this._getLayerStyles('box', 'active', true, false);
// HELPER: Fetch fresh points directly from the source
const getFreshBoxPoints = () => {
const rawPoints = this.splineEditor.points || [];
return rawPoints.filter(p => p && Number.isFinite(p.x) && Number.isFinite(p.y));
};
const pts = getFreshBoxPoints();
const sortedPoints = [...pts].sort((a, b) =>
this.splineEditor.getBoxPointRadius(b) - this.splineEditor.getBoxPointRadius(a)
);
// 1. Queue the GREEN SQUARES
const attachment = this._getSelectedRefAttachment(widget);
// During preview mode (_boxRefOnlyMode), don't render green boxes or rotation controls
if (!this._boxRefOnlyMode) {
sortedPoints.forEach((dot, index) => {
const radius = this.getScaledBoxRadius(dot);
const rotation = this._getBoxRotationValue(dot);
const svgStyles = {
radius: radius,
stroke: styles.pointStroke || "#139613",
strokeWidth: styles.lineWidth || 2,
fill: styles.pointFill || "rgba(19, 150, 19, 0.1)",
cursor: "default"
};
const element = this._createSVGPath('square', dot, svgStyles, rotation);
if (element) {
// Add event handlers
element.addEventListener('mousedown', (e) => {
if (this.splineEditor.handlePointPointerDown) {
this.splineEditor.handlePointPointerDown(dot, e);
}
});
element.addEventListener('mouseover', (e) => {
if (this.splineEditor.mouseOverHandler) {
this.splineEditor.mouseOverHandler.call(dot);
}
});
element.addEventListener('mouseout', (e) => {
if (this.splineEditor.mouseOutHandler) {
this.splineEditor.mouseOutHandler.call(dot);
}
});
// Queue for rendering after vis.render()
this._pendingShapes.activeBoxDots.push(element);
}
// Add rotation text display (green with 60% opacity) - only for first box
if (index === 0) {
const rotationRad = this._getBoxRotationValue(dot);
const rotationDeg = rotationRad * (180 / Math.PI);
// Create a group to hold the text so we can position it correctly
const textGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
textGroup.setAttribute('data-rotation-text', 'true');
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
// Position at top-left corner of the box (relative to box center)
const offsetX = -radius;
const offsetY = -radius - 5;
text.setAttribute('x', offsetX);
text.setAttribute('y', offsetY);
text.setAttribute('fill', 'rgba(19, 150, 19, 0.6)'); // Green with 60% opacity
text.setAttribute('font-size', '14');
text.setAttribute('font-family', 'monospace');
text.setAttribute('font-weight', 'bold');
text.setAttribute('text-anchor', 'start');
text.setAttribute('pointer-events', 'none');
text.textContent = `${rotationDeg.toFixed(1)}°`;
textGroup.appendChild(text);
textGroup.setAttribute('transform', `translate(${dot.x},${dot.y})`);
textGroup.style.pointerEvents = 'none';
this._pendingShapes.activeBoxDots.push(textGroup);
}
});
// Draw rotation handle and center dot when not in ref-only mode
this._drawCurrentBoxRotationHandle(() => {
const pts = getFreshBoxPoints();
return pts.length > 0 ? pts[0] : null;
}, styles);
this._drawCurrentBoxCenterDot(() => {
const pts = getFreshBoxPoints();
return pts.length > 0 ? pts[0] : null;
}, styles);
}
// Queue attached reference image (fit inside box, preserve aspect)
if (attachment && (attachment.base64 || attachment.path) && sortedPoints.length > 0) {
const point = sortedPoints[0];
const boxRadius = this.getScaledBoxRadius(point);
const boxSize = boxRadius * 2;
const imgW = Math.max(1, attachment.width || boxSize);
const imgH = Math.max(1, attachment.height || boxSize);
const scale = Math.min(boxSize / imgW, boxSize / imgH);
const renderW = imgW * scale;
const renderH = imgH * scale;
const rotationDeg = this._getBoxRotationValue(point) * (180 / Math.PI);
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
// Use cached URL to prevent blinking during timeline scrub
const imageHref = this._getRefImageUrl(widget, attachment);
if (!imageHref) return; // Skip if no valid image source
// Force image reload by setting href to empty first, then to actual URL
// This prevents browser from using stale cached image data
image.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '');
image.setAttributeNS('http://www.w3.org/1999/xlink', 'href', imageHref);
image.setAttribute('width', renderW);
image.setAttribute('height', renderH);
image.setAttribute('x', -renderW / 2);
image.setAttribute('y', -renderH / 2);
image.setAttribute('preserveAspectRatio', 'xMidYMid meet');
image.setAttribute('transform', `translate(${point.x},${point.y}) rotate(${rotationDeg})`);
image.style.pointerEvents = 'none';
image.style.opacity = '0.6';
// Store cache bust version to track if image needs updating
image.dataset.cacheBustVersion = this._cacheBustCounter;
this._pendingShapes.boxImages.push(image);
}
}
_drawCurrentBoxRotationHandle(pointGetter, styles = {}) {
// 1. Draw handle line (Stem)
this.activeLayerPanel.add(pv.Line)
.data(() => {
// Only render if current layer is still a box layer
const activeWidget = this.node.layerManager.getActiveWidget();
if (this._getLayerType(activeWidget) !== 'box') return [];
const point = pointGetter(); // <--- Dynamic lookup
const geom = this._computeBoxHandleGeometry(point);
return geom ? [geom.base, geom.tip] : [];
})
.left(d => d.x)
.top(d => d.y)
.strokeStyle(styles.pointStroke || '#2df26d')
.lineWidth(styles.lineWidth || 2);
// 2. Draw handle tip (Circle)
this.activeLayerPanel.add(pv.Dot)
.data(() => {
// Only render if current layer is still a box layer
const activeWidget = this.node.layerManager.getActiveWidget();
if (this._getLayerType(activeWidget) !== 'box') return [];
const point = pointGetter(); // <--- Dynamic lookup
const geom = this._computeBoxHandleGeometry(point);
return geom ? [{ point, tip: geom.tip }] : [];
})
.left(d => d.tip.x)
.top(d => d.tip.y)
.shape('circle')
.radius(5)
.fillStyle(styles.pointFill || 'rgba(45, 242, 109, 0.9)')
.strokeStyle(styles.pointStroke || '#064f1c')
.lineWidth(1.5)
.cursor('grab')
.event("mousedown", (d) => {
const idx = this.splineEditor.resolvePointIndex?.(d.point) ?? this.splineEditor.points.indexOf(d.point);
if (idx >= 0 && this.splineEditor.startBoxRotationDrag) {
this.splineEditor.startBoxRotationDrag(idx, pv.event);
}
});
}
_drawCurrentBoxCenterDot(pointGetter, styles = {}) {
this.activeLayerPanel.add(pv.Dot)
.data(() => {
// Only render if current layer is still a box layer
const activeWidget = this.node.layerManager.getActiveWidget();
if (this._getLayerType(activeWidget) !== 'box') return [];
const point = pointGetter(); // <--- Dynamic lookup
return point ? [point] : [];
})
.left(d => d.x)
.top(d => d.y)
.shape('circle')
.radius(3)
.fillStyle(styles.pointFill || '#2df26d')
.strokeStyle(styles.pointStroke || '#064f1c')
.lineWidth(1)
.events('none');
}
_queueBoxManipulator(widget) {
if (!widget || widget?.value?.type !== 'box_layer') return;
const editor = this.splineEditor;
const maxFrames = editor?._getMaxFrames ? editor._getMaxFrames() : BOX_TIMELINE_MAX_POINTS;
const frame = Math.max(1, Math.min(maxFrames, Math.round(widget.value?.box_timeline_point || 1)));
const normalized = editor?._computeBoxLayerPosition
? editor._computeBoxLayerPosition(widget, frame)
: { x: 0.5, y: 0.5, scale: 1, rotation: 0 };
const denorm = editor?.denormalizePoints
? editor.denormalizePoints([{ x: normalized.x ?? 0.5, y: normalized.y ?? 0.5 }])
: [{ x: 0, y: 0 }];
const point = denorm[0] || { x: 0, y: 0 };
point.boxScale = this.clampPointScale(normalized.scale ?? 1);
point.scale = point.boxScale;
point.boxRotation = (typeof normalized.rotation === 'number' && !Number.isNaN(normalized.rotation)) ? normalized.rotation : 0;
const styles = this._getLayerStyles('box', 'active', true, false);
const radius = this.getScaledBoxRadius(point);
const svgStyles = {
radius,
stroke: styles.pointStroke || "#139613",
strokeWidth: styles.lineWidth || 2,
fill: styles.pointFill || "rgba(19, 150, 19, 0.1)",
cursor: "default"
};
const attachment = this._getSelectedRefAttachment(widget);
// During preview mode (_boxRefOnlyMode), don't render green boxes or rotation text
if (!this._boxRefOnlyMode) {
const element = this._createSVGPath('square', point, svgStyles, this._getBoxRotationValue(point));
if (element) {
element.style.pointerEvents = 'none';
this._pendingShapes.activeBoxDots.push(element);
}
// Add rotation text display (green with 60% opacity)
const rotationRad = this._getBoxRotationValue(point);
const rotationDeg = rotationRad * (180 / Math.PI);
// Create a group to hold the text so we can position it correctly
const textGroup = document.createElementNS('http://www.w3.org/2000/svg', 'g');
textGroup.setAttribute('data-rotation-text', 'true');
const text = document.createElementNS('http://www.w3.org/2000/svg', 'text');
// Position at top-left corner of the box (relative to box center)
const offsetX = -radius;
const offsetY = -radius - 5;
text.setAttribute('x', offsetX);
text.setAttribute('y', offsetY);
text.setAttribute('fill', 'rgba(19, 150, 19, 0.6)'); // Green with 60% opacity
text.setAttribute('font-size', '14');
text.setAttribute('font-family', 'monospace');
text.setAttribute('font-weight', 'bold');
text.setAttribute('text-anchor', 'start');
text.setAttribute('pointer-events', 'none');
text.textContent = `${rotationDeg.toFixed(1)}°`;
textGroup.appendChild(text);
textGroup.setAttribute('transform', `translate(${point.x},${point.y})`);
textGroup.style.pointerEvents = 'none';
this._pendingShapes.activeBoxDots.push(textGroup);
}
// Render attached reference image if it exists
if (attachment && (attachment.base64 || attachment.path)) {
const boxSize = radius * 2;
const imgW = Math.max(1, attachment.width || boxSize);
const imgH = Math.max(1, attachment.height || boxSize);
const scale = Math.min(boxSize / imgW, boxSize / imgH);
const renderW = imgW * scale;
const renderH = imgH * scale;
const rotationDeg = this._getBoxRotationValue(point) * (180 / Math.PI);
const image = document.createElementNS('http://www.w3.org/2000/svg', 'image');
// Use cached URL to prevent blinking during timeline scrub
const imageHref = this._getRefImageUrl(widget, attachment);
if (!imageHref) return; // Skip if no valid image source
// Force image reload by setting href to empty first, then to actual URL
// This prevents browser from using stale cached image data
image.setAttributeNS('http://www.w3.org/1999/xlink', 'href', '');
image.setAttributeNS('http://www.w3.org/1999/xlink', 'href', imageHref);
image.setAttribute('width', renderW);
image.setAttribute('height', renderH);
image.setAttribute('x', -renderW / 2);
image.setAttribute('y', -renderH / 2);
image.setAttribute('preserveAspectRatio', 'xMidYMid meet');
image.setAttribute('transform', `translate(${point.x},${point.y}) rotate(${rotationDeg})`);
image.style.pointerEvents = 'none';
image.style.opacity = '0.6';
// Store cache bust version to track if image needs updating
image.dataset.cacheBustVersion = this._cacheBustCounter;
this._pendingShapes.boxImages.push(image);
}
}
_drawActiveLine(interpolation, isPointMode, layerType, styles) {
if (layerType === 'box') return; // Box layers render via box helpers only
// We ignore the passed 'safePoints' and fetch fresh ones inside .data()
this.activeLayerPanel.add(pv.Line)
.data(() => {
const currentPoints = this.splineEditor.points || [];
const validPoints = currentPoints.filter(p => p && Number.isFinite(p.x) && Number.isFinite(p.y));
// If we don't have points, or if this is actually a box layer
// (but the renderer got confused), return empty to hide the line.
const activeWidget = this.node.layerManager.getActiveWidget();
const activeType = this._getLayerType(activeWidget);
if (activeType === 'box') return [];
return this._prepareLineData(validPoints, interpolation, isPointMode);
})
.left(d => d.x)
.top(d => d.y)
.interpolate(() => isPointMode ? 'linear' : interpolation)
.strokeStyle(() => styles.lineStroke)
.lineWidth(() => styles.lineWidth);
}
_prepareLineData(safePoints, interpolation, isPointMode) {
if (isPointMode || interpolation === 'linear') return safePoints;
const hasHighlighted = safePoints.some(p => p.highlighted);
if (!hasHighlighted) return safePoints;
// Insert helper points around highlighted points for linear segments
const result = [];
for (let i = 0; i < safePoints.length; i++) {
const point = safePoints[i];
if (point.highlighted) {
if (i > 0) {
const prev = safePoints[i - 1];
const dx = point.x - prev.x, dy = point.y - prev.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const offset = Math.min(0.01, 1 / dist);
result.push({ x: prev.x + dx * (1 - offset), y: prev.y + dy * (1 - offset) });
}
result.push(point);
if (i < safePoints.length - 1) {
const next = safePoints[i + 1];
const dx = next.x - point.x, dy = next.y - point.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const offset = Math.min(0.01, 1 / dist);
result.push({ x: point.x + dx * offset, y: point.y + dy * offset });
}
} else {
result.push(point);
}
}
return result;
}
_drawActiveDots(interpolation, isPointMode, layerType, styles, allPoints) {
if (layerType === 'box') return; // Box layers use box-specific drawing
// Get current points
const currentPoints = this.splineEditor.points || [];
const validPoints = currentPoints.filter(p => p && Number.isFinite(p.x) && Number.isFinite(p.y));
// Queue SVG elements for each point (will be rendered after vis.render())
validPoints.forEach((point, idx) => {
const shape = this._getDotShape(point, interpolation, layerType, idx);
const strokeStyle = this._getDotStrokeStyle(point, interpolation, isPointMode, layerType, styles, validPoints);
const fillStyle = this._getDotFillStyle(point, interpolation, isPointMode, layerType, styles, validPoints);
const svgStyles = {
radius: styles.pointRadius || 6,
stroke: strokeStyle,
strokeWidth: styles.strokeWidth || styles.lineWidth || 3,
fill: fillStyle || 'none',
cursor: isPointMode ? 'default' : 'move'
};
let element;
if (shape === 'circle') {
element = this._createSVGCircle(point, svgStyles);
} else if (shape === 'triangle' || shape === 'square') {
const rotation = this._getDotAngle(point, interpolation, layerType);
element = this._createSVGPath(shape, point, svgStyles, rotation);
}
if (element) {
// Add event handlers
element.addEventListener('mousedown', (e) => {
if (this.splineEditor.handlePointPointerDown) {
this.splineEditor.handlePointPointerDown(point, e);
}
});
element.addEventListener('mouseover', (e) => {
if (this.splineEditor.mouseOverHandler) {
this.splineEditor.mouseOverHandler.call(this.splineEditor, point);
}
});
element.addEventListener('mouseout', (e) => {
if (this.splineEditor.mouseOutHandler) {
this.splineEditor.mouseOutHandler.call(this.splineEditor, point);
}
});
// Queue for rendering after vis.render()
this._pendingShapes.activeDots.push(element);
}
});
}
_getDotRadius(dot, interpolation, layerType, styles) {
if (layerType === 'handdraw') return styles.pointRadius || 2;
if (interpolation === 'points') return POINT_BASE_RADIUS;
if (layerType === 'box') return 6; // Explicit radius for box layers
return styles.pointRadius || 5; // Slightly smaller default for normal layers
}
_getDotShape(dot, interpolation, layerType) {
// Complete separation for each layer type
if (layerType === 'box') {
return 'square'; // Box layers always use squares
}
if (layerType === 'handdraw') {
return 'circle'; // Handdraw layers always use circles
}
if (layerType === 'normal' && interpolation !== 'points') {
// Normal splines use triangle for first point
const allPoints = this.splineEditor.points || [];
const idx = this.splineEditor.resolvePointIndex?.(dot) ?? allPoints.indexOf(dot);
const shape = idx === 0 ? 'triangle' : 'circle';
return shape;
}
return 'circle'; // Default fallback
}
_getDotAngle(dot, interpolation, layerType) {
// Complete separation for each layer type
if (layerType === 'box') {
return this._getBoxRotationValue(dot); // Box layers use rotation
}
if (layerType === 'handdraw') {
return 0; // Handdraw layers don't use rotation
}
if (layerType === 'normal' && interpolation !== 'points') {
// Normal splines use rotation for first point
const allPoints = this.splineEditor.points || [];
const idx = this.splineEditor.resolvePointIndex?.(dot) ?? allPoints.indexOf(dot);
if (idx !== 0 || allPoints.length <= 1) return 0;
const dx = allPoints[1].x - allPoints[0].x;
const dy = allPoints[1].y - allPoints[0].y;
return (dx !== 0 || dy !== 0) ? (Math.atan2(dy, dx) - Math.PI / 2 + Math.PI + 2 * Math.PI) % (2 * Math.PI) : 0;
}
return 0; // Default fallback
}
_getDotStrokeStyle(dot, interpolation, isPointMode, layerType, styles, allPoints) {
const idx = this.splineEditor.resolvePointIndex?.(dot) ?? allPoints.indexOf(dot);
// Complete separation for each layer type
if (layerType === 'handdraw') return styles.pointStroke;
if (layerType === 'box') return styles.pointStroke || '#139613'; // Box layer specific
if (layerType === 'normal') {
if (isPointMode && styles.pointStrokePoint) return styles.pointStrokePoint;
return styles.pointStroke;
}
return styles.pointStroke; // Default fallback
}
_getDotFillStyle(dot, interpolation, isPointMode, layerType, styles, allPoints) {
const idx = this.splineEditor.resolvePointIndex?.(dot) ?? allPoints.indexOf(dot);
// Complete separation for each layer type
if (layerType === 'handdraw') return styles.pointFill;
if (layerType === 'box') return styles.pointFill || 'rgba(19, 150, 19, 0.1)'; // Box layer specific
if (layerType === 'normal') {
if (!isPointMode && idx === 0 && styles.firstFill) return styles.firstFill;
if (isPointMode && styles.pointFillPoint) return styles.pointFillPoint;
return styles.pointFill;
}
return styles.pointFill; // Default fallback
}
_drawBoxPreview(widget) {
const previewState = this.splineEditor._boxPreviewState;
if (!previewState || previewState.widget !== widget ||
!Number.isFinite(previewState.x) || !Number.isFinite(previewState.y)) return;
this.activeLayerPanel.add(pv.Dot)
.data(() => {
// Only render if current layer is still a box layer
const activeWidget = this.node.layerManager.getActiveWidget();
if (this._getLayerType(activeWidget) !== 'box') return [];
return [previewState];
})
.left(d => d.x)
.top(d => d.y)
.radius(() => POINT_BASE_RADIUS)
.fillStyle('rgba(44, 198, 255, 0.85)')
.strokeStyle('#0b3b4b')
.lineWidth(1.5)
.events('none');
}
// === INACTIVE LAYER DRAWING ===
drawInactiveLayer(widget) {
const layerType = this._getLayerType(widget);
const isOff = !widget.value.on;
const isBoxLayer = layerType === 'box';
let points;
try {
points = isBoxLayer ? this._getBoxLayerPoints(widget) : this._getRegularLayerPoints(widget);
} catch (e) {
console.error("Error parsing points for inactive layer:", e);
return;
}
if (!points?.length) return;
const layerPanel = this.vis.add(pv.Panel).events("none");
this.inactiveLayerPanels.push(layerPanel);
this.inactiveLayerMetadata.push({ widget, points, panel: layerPanel });
if (isBoxLayer) {
const boxStyles = this._getLayerStyles('box', 'inactive', true, isOff);
this._drawInactiveBoxLayer(layerPanel, points, widget, boxStyles);
return;
}
const interpolation = this._normalizeInterpolation(widget.value.interpolation, layerType);
const isPointMode = (interpolation === 'points');
const styles = this._getLayerStyles(layerType, 'inactive', isPointMode, isOff);
this._drawInactiveLine(layerPanel, points, interpolation, isPointMode, styles);
if (layerType !== 'handdraw') {
this._drawInactiveDots(layerPanel, points, interpolation, isPointMode, styles, layerType);
}
}
_getBoxLayerPoints(widget) {
const boxKeys = Array.isArray(widget.value.box_keys) ? widget.value.box_keys : [];
if (!boxKeys.length) return null;
const sortedKeys = boxKeys
.filter(k => typeof k?.x === 'number' && typeof k?.y === 'number')
.slice()
.sort((a, b) => (a.frame || 0) - (b.frame || 0));
if (!sortedKeys.length) return null;
const interpolationMode = widget.value.box_interpolation || 'linear';
let curvePoints;
if (interpolationMode === 'basis' && sortedKeys.length >= 3) {
curvePoints = this._generateBasisCurvePoints(sortedKeys);
} else {
curvePoints = sortedKeys.map(k => ({ x: k.x, y: k.y }));
}
return this.splineEditor.denormalizePoints(curvePoints);
}
_getRegularLayerPoints(widget) {
const storedPoints = JSON.parse(widget.value.points_store || '[]');
return this.splineEditor.denormalizePoints(storedPoints);
}
_drawInactiveLine(layerPanel, points, interpolation, isPointMode, styles) {
const renderPoints = this._prepareLineData(points, interpolation, isPointMode);
const lineWidth = styles.lineWidth ?? (isPointMode ? 1 : 3);
layerPanel.add(pv.Line).events("none")
.data(renderPoints)
.left(d => d.x)
.top(d => d.y)
.interpolate(isPointMode ? 'linear' : interpolation)
.strokeStyle(styles.lineStroke)
.lineWidth(lineWidth);
}
_drawInactiveDots(layerPanel, points, interpolation, isPointMode, styles, layerType) {
if (layerType === 'box') return;
// Create deep copy to prevent sharing with active layers
const orderedDots = [...points].map(p => ({...p})).sort((a, b) =>
this.splineEditor.getBoxPointRadius ? this.splineEditor.getBoxPointRadius(b) - this.splineEditor.getBoxPointRadius(a) : 0
);
const firstPointOriginal = points.length > 0 ? points[0] : null;
orderedDots.forEach((dot) => {
let shape = 'circle';
let isFirstPoint = false;
// Determine shape based on layer type and position
if (layerType === 'normal' && !isPointMode) {
// Check if this dot is the first point by comparing coordinates
if (firstPointOriginal &&
Math.abs(dot.x - firstPointOriginal.x) < 0.001 &&
Math.abs(dot.y - firstPointOriginal.y) < 0.001) {
shape = 'triangle';
isFirstPoint = true;
} else {
shape = 'circle';
}
} else if (layerType === 'handdraw') {
shape = 'circle';
}
const pointRadius = isPointMode ? POINT_BASE_RADIUS * 0.6 : 4.5;
const svgStyles = {
radius: pointRadius,
stroke: styles.pointStroke || 'rgb(31,119,180)',
strokeWidth: styles.strokeWidth || 3,
fill: styles.pointFill || 'none',
cursor: 'default'
};
let element;
if (shape === 'circle') {
element = this._createSVGCircle(dot, svgStyles);
} else if (shape === 'triangle') {
// Calculate rotation for the first point triangle
let rotationRad = 0;
if (isFirstPoint && points.length > 1) {
const dx = points[1].x - points[0].x;
const dy = points[1].y - points[0].y;
rotationRad = (dx !== 0 || dy !== 0) ? (Math.atan2(dy, dx) - Math.PI / 2 + Math.PI + 2 * Math.PI) % (2 * Math.PI) : 0;
}
element = this._createSVGPath(shape, dot, svgStyles, rotationRad);
}
if (element) {
// Inactive dots don't need event handlers
element.style.pointerEvents = 'none';
// Queue for rendering after vis.render()
this._pendingShapes.inactiveDots.push(element);
}
});
return; // Disable old Protovis code
}
// === INACTIVE BOX LAYER HELPERS ===
_drawInactiveBoxLayer(layerPanel, points, widget, styles) {
if (!points || !points.length) return;
// Only use the main keyframe points for the inactive circles
const sanitizedKeys = this._sanitizeBoxKeys(widget);
const keyframeNormPoints = sanitizedKeys.map(k => ({ x: k.x, y: k.y }));
const keyframeDenormPoints = this._safeDenormalizePoints(keyframeNormPoints) || [];
const orderedDots = [...keyframeDenormPoints].map(p => ({ ...p })).sort((a, b) =>
this.splineEditor.getBoxPointRadius ? this.splineEditor.getBoxPointRadius(b) - this.splineEditor.getBoxPointRadius(a) : 0
);
this._drawInactiveLine(layerPanel, points, 'linear', true, styles);
// Draw small red circles for inactive box layers instead of squares
orderedDots.forEach((dot) => {
const svgStyles = {
radius: 4, // Small radius for inactive box points
stroke: '#f04d3a', // Red to indicate inactive state
strokeWidth: 2,
fill: 'rgba(240, 77, 58, 0.25)', // Light red fill
cursor: 'default'
};
const element = this._createSVGCircle(dot, svgStyles);
if (element) {
element.style.pointerEvents = 'none';
// Queue for rendering after vis.render()
this._pendingShapes.inactiveBoxDots.push(element);
}
});
// Keyframe markers (uses Protovis)
this._drawBoxKeyPath(layerPanel, widget, false);
}
// === BOX LAYER HELPERS ===
_sanitizeBoxKeys(widget) {
if (!widget?.value) return [];
const rawKeys = Array.isArray(widget.value.box_keys) ? widget.value.box_keys : [];
const editorWidth = Math.max(1, Number(this.splineEditor?.width) || 1);
const editorHeight = Math.max(1, Number(this.splineEditor?.height) || 1);
return rawKeys
.map(key => {
if (!key) return null;
const frameVal = Number(key.frame);
const rawX = (typeof key.x === 'number' && !Number.isNaN(key.x)) ? key.x : 0.5;
const rawY = (typeof key.y === 'number' && !Number.isNaN(key.y)) ? key.y : 0.5;
// Normalize if needed (values >= 10 are assumed to be pixel coordinates)
const normX = Math.abs(rawX) >= 10 ? rawX / editorWidth : rawX;
const normY = Math.abs(rawY) >= 10 ? rawY / editorHeight : rawY;
const scaleVal = (typeof key.scale === 'number' && !Number.isNaN(key.scale)) ? key.scale : 1;
const rotationVal = (typeof key.rotation === 'number' && !Number.isNaN(key.rotation)) ? key.rotation : 0;
return {
frame: Number.isFinite(frameVal) ? Math.round(frameVal) : 1,
x: normX,
y: normY,
scale: this.splineEditor.clampScaleValue?.(scaleVal) ?? Math.max(0.2, Math.min(3, scaleVal)),
rotation: rotationVal,
};
})
.filter(Boolean)
.sort((a, b) => (a.frame || 0) - (b.frame || 0));
}
_safeDenormalizePoints(points) {
if (!points?.length) return null;
try {
return this.splineEditor.denormalizePoints(points);
} catch {
return null;
}
}
_drawBoxKeyPath(panel, widget, isActive) {
const sortedKeys = this._sanitizeBoxKeys(widget);
if (!sortedKeys.length) return;
// Check if we're currently manipulating box keyframes
const isManipulating = this.splineEditor._boxKeyframePoints &&
this.splineEditor._manipulatingBoxKeyframe &&
this.splineEditor._manipulatingBoxKeyframe.widget === widget;
let keyframeDenormPoints;
let keyframeNormPoints;
if (isManipulating) {
// Use the working points being manipulated for real-time updates
keyframeDenormPoints = this.splineEditor._boxKeyframePoints.filter(p =>
p && Number.isFinite(p.x) && Number.isFinite(p.y)
);
// Also create normalized points for path generation
keyframeNormPoints = this.splineEditor.normalizePoints ?
this.splineEditor.normalizePoints(keyframeDenormPoints) :
sortedKeys.map(k => ({ x: k.x, y: k.y }));
} else {
// Use the stored box_keys
keyframeNormPoints = sortedKeys.map(k => ({ x: k.x, y: k.y }));
keyframeDenormPoints = this._safeDenormalizePoints(keyframeNormPoints);
}
if (!keyframeDenormPoints || !keyframeDenormPoints.length) return;
const interpolationMode = widget.value.box_interpolation || 'linear';
// Validate denormalized points to prevent drawing artifacts
const validKeyframeDenormPoints = keyframeDenormPoints.filter(p =>
p && Number.isFinite(p.x) && Number.isFinite(p.y)
);
if (!validKeyframeDenormPoints.length) return;
// Generate path points for connecting line
let pathNormPoints = keyframeNormPoints;
if (interpolationMode === 'basis' && sortedKeys.length >= 3) {
pathNormPoints = this._generateBasisCurvePoints(sortedKeys);
}
let pathDenormPoints = validKeyframeDenormPoints;
if (pathNormPoints !== keyframeNormPoints) {
const tempPath = this._safeDenormalizePoints(pathNormPoints);
if (tempPath && tempPath.length >= 2) {
pathDenormPoints = tempPath;
}
}
const activeColor = '#2df26d';
const inactiveColor = '#f04d3a';
const color = isActive ? activeColor : inactiveColor;
const fillColor = isActive ? 'rgba(45, 242, 109, 0.3)' : 'rgba(240,77,58,0.25)';
// Draw connecting path (only if we have 2+ keys)
if (sortedKeys.length >= 2 && pathDenormPoints.length >= 2) {
panel.add(pv.Line).events("none")
.data(() => {
// Only render if current layer is still a box layer
const activeWidget = this.node.layerManager.getActiveWidget();
if (this._getLayerType(activeWidget) !== 'box') return [];
// Use working points during manipulation for real-time updates
const currentManipulating = this.splineEditor._boxKeyframePoints &&
this.splineEditor._manipulatingBoxKeyframe &&
this.splineEditor._manipulatingBoxKeyframe.widget === widget;
if (currentManipulating) {
// Generate path from working points
const workingPoints = this.splineEditor._boxKeyframePoints.filter(p =>
p && Number.isFinite(p.x) && Number.isFinite(p.y)
);
return workingPoints;
}
return pathDenormPoints;
})
.left(d => d.x)
.top(d => d.y)
.interpolate('linear')
.strokeStyle(color)
.lineWidth(isActive ? 2 : 1.5);
}
// Draw keyframe indicator circles with interaction support
// These show where keyframes exist on the timeline and can be manipulated
validKeyframeDenormPoints.forEach((point, index) => {
// Add frame info to the point so we can identify which keyframe it belongs to
const keyframeData = {
...point,
frame: sortedKeys[index]?.frame,
isBoxKeyframe: true, // Mark this as a box keyframe for special handling
boxKeyIndex: index,
widget: widget
};
if (isActive) {
// Active layer: make keyframes interactive with dynamic positioning
panel.add(pv.Dot)
.data(() => {
// Get real-time position during manipulation
const currentManipulating = this.splineEditor._boxKeyframePoints &&
this.splineEditor._manipulatingBoxKeyframe &&
this.splineEditor._manipulatingBoxKeyframe.widget === widget;
if (currentManipulating && this.splineEditor._boxKeyframePoints[index]) {
// Use working point position
return [{
...keyframeData,
x: this.splineEditor._boxKeyframePoints[index].x,
y: this.splineEditor._boxKeyframePoints[index].y
}];
}
// Use original position
return [keyframeData];
})
.left(d => d.x)
.top(d => d.y)
.shape('circle')
.radius(4)
.strokeStyle(color)
.lineWidth(2)
.fillStyle(fillColor) // Semi-transparent fill for better visibility
.event("mousedown", (d) => {
if (this.splineEditor.handleBoxKeyframePointerDown) {
this.splineEditor.handleBoxKeyframePointerDown(d, pv.event);
}
})
.event("mouseover", (d) => {
if (this.splineEditor.mouseOverHandler) {
this.splineEditor.mouseOverHandler(d);
}
})
.event("mouseout", (d) => {
if (this.splineEditor.mouseOutHandler) {
this.splineEditor.mouseOutHandler();
}
});
} else {
// Inactive layer: non-interactive
panel.add(pv.Dot).events("none")
.data([keyframeData])
.left(d => d.x)
.top(d => d.y)
.shape('circle')
.radius(3)
.strokeStyle(color)
.lineWidth(2)
.fillStyle(fillColor);
}
});
}
_generateBasisCurvePoints(sortedKeys) {
if (sortedKeys.length < 3) {
return sortedKeys.map(k => ({ x: k.x, y: k.y }));
}
const points = [];
const stepsPerSegment = 20;
const basisInterpolate = (p0, p1, p2, p3, t) => {
const t2 = t * t, t3 = t2 * t;
const b0 = (1 - t3 + 3 * t2 - 3 * t) / 6;
const b1 = (4 - 6 * t2 + 3 * t3) / 6;
const b2 = (1 + 3 * t + 3 * t2 - 3 * t3) / 6;
const b3 = t3 / 6;
return {
x: b0 * p0.x + b1 * p1.x + b2 * p2.x + b3 * p3.x,
y: b0 * p0.y + b1 * p1.y + b2 * p2.y + b3 * p3.y
};
};
for (let i = 0; i < sortedKeys.length - 1; i++) {
const p0 = sortedKeys[Math.max(0, i - 1)];
const p1 = sortedKeys[i];
const p2 = sortedKeys[i + 1];
const p3 = sortedKeys[Math.min(sortedKeys.length - 1, i + 2)];
if (i === 0) points.push({ x: p1.x, y: p1.y });
for (let step = 1; step < stepsPerSegment; step++) {
const t = step / stepsPerSegment;
points.push(basisInterpolate(p0, p1, p2, p3, t));
}
if (i === sortedKeys.length - 2) points.push({ x: p2.x, y: p2.y });
}
return points;
}
// === HIT DETECTION ===
findInactiveLayerAtPosition(mouseX, mouseY, threshold = 15) {
const firstPointThreshold = Math.max(threshold, 22);
let closestWidget = null;
let closestDistance = threshold;
for (const layerData of this.inactiveLayerMetadata) {
const points = layerData.points;
// Check distance to points
for (let idx = 0; idx < points.length; idx++) {
const point = points[idx];
const dx = mouseX - point.x;
const dy = mouseY - point.y;
const distance = Math.sqrt(dx * dx + dy * dy);
const localThreshold = idx === 0 ? firstPointThreshold : threshold;
if (distance < Math.min(closestDistance, localThreshold)) {
closestDistance = distance;
closestWidget = layerData.widget;
}
}
// Check distance to line segments
for (let i = 0; i < points.length - 1; i++) {
const p1 = points[i];
const p2 = points[i + 1];
const distance = this.distanceToLineSegment(mouseX, mouseY, p1.x, p1.y, p2.x, p2.y);
if (distance < closestDistance) {
closestDistance = distance;
closestWidget = layerData.widget;
}
}
}
return closestWidget;
}
distanceToLineSegment(px, py, x1, y1, x2, y2) {
const dx = x2 - x1;
const dy = y2 - y1;
const lengthSquared = dx * dx + dy * dy;
if (lengthSquared === 0) {
const dpx = px - x1;
const dpy = py - y1;
return Math.sqrt(dpx * dpx + dpy * dpy);
}
let t = ((px - x1) * dx + (py - y1) * dy) / lengthSquared;
t = Math.max(0, Math.min(1, t));
const closestX = x1 + t * dx;
const closestY = y1 + t * dy;
const distX = px - closestX;
const distY = py - closestY;
return Math.sqrt(distX * distX + distY * distY);
}
}