// Timeline V2 - New timeline implementation for AnimationData curve-based system import { backgroundColor, foregroundColor, shadow, labelColor, scrubberColor } from "./styles.js" /** * TimelineState - Global state for timeline display and interaction */ class TimelineState { constructor(framerate = 24) { // Time format settings this.timeFormat = 'frames' // 'frames' | 'seconds' | 'measures' this.framerate = framerate // Zoom and viewport this.pixelsPerSecond = 100 // Zoom level - how many pixels per second of animation this.viewportStartTime = 0 // Horizontal scroll position (in seconds) // Playhead this.currentTime = 0 // Current time (in seconds) // Ruler settings this.rulerHeight = 30 // Height of time ruler in pixels // Snapping (Phase 5) this.snapToFrames = false // Whether to snap keyframes to frame boundaries } /** * Convert time (seconds) to pixel position */ timeToPixel(time) { return (time - this.viewportStartTime) * this.pixelsPerSecond } /** * Convert pixel position to time (seconds) */ pixelToTime(pixel) { return (pixel / this.pixelsPerSecond) + this.viewportStartTime } /** * Convert time (seconds) to frame number */ timeToFrame(time) { return Math.floor(time * this.framerate) } /** * Convert frame number to time (seconds) */ frameToTime(frame) { return frame / this.framerate } /** * Calculate appropriate ruler interval based on zoom level * Returns interval in seconds that gives ~50-100px spacing */ getRulerInterval() { const targetPixelSpacing = 75 // Target pixels between major ticks const timeSpacing = targetPixelSpacing / this.pixelsPerSecond // In seconds // Standard interval options (in seconds) const intervals = [ 0.01, 0.02, 0.05, // 10ms, 20ms, 50ms 0.1, 0.2, 0.5, // 100ms, 200ms, 500ms 1, 2, 5, // 1s, 2s, 5s 10, 20, 30, 60, // 10s, 20s, 30s, 1min 120, 300, 600 // 2min, 5min, 10min ] // Find closest interval let bestInterval = intervals[0] let bestDiff = Math.abs(timeSpacing - bestInterval) for (let interval of intervals) { const diff = Math.abs(timeSpacing - interval) if (diff < bestDiff) { bestDiff = diff bestInterval = interval } } return bestInterval } /** * Calculate appropriate ruler interval for frame mode * Returns interval in frames that gives ~50-100px spacing */ getRulerIntervalFrames() { const targetPixelSpacing = 75 const pixelsPerFrame = this.pixelsPerSecond / this.framerate const frameSpacing = targetPixelSpacing / pixelsPerFrame // Standard frame intervals const intervals = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000] // Find closest interval let bestInterval = intervals[0] let bestDiff = Math.abs(frameSpacing - bestInterval) for (let interval of intervals) { const diff = Math.abs(frameSpacing - interval) if (diff < bestDiff) { bestDiff = diff bestInterval = interval } } return bestInterval } /** * Format time for display based on current format setting */ formatTime(time) { if (this.timeFormat === 'frames') { return `${this.timeToFrame(time)}` } else if (this.timeFormat === 'seconds') { const minutes = Math.floor(time / 60) const seconds = Math.floor(time % 60) const ms = Math.floor((time % 1) * 10) if (minutes > 0) { return `${minutes}:${seconds.toString().padStart(2, '0')}` } else { return `${seconds}.${ms}s` } } // measures format - TODO when DAW features added return `${time.toFixed(2)}` } /** * Zoom in (increase pixelsPerSecond) */ zoomIn(factor = 1.5) { this.pixelsPerSecond *= factor // Clamp to reasonable range this.pixelsPerSecond = Math.min(this.pixelsPerSecond, 10000) // Max zoom } /** * Zoom out (decrease pixelsPerSecond) */ zoomOut(factor = 1.5) { this.pixelsPerSecond /= factor // Clamp to reasonable range this.pixelsPerSecond = Math.max(this.pixelsPerSecond, 10) // Min zoom } /** * Snap time to nearest frame boundary (Phase 5) */ snapTime(time) { if (!this.snapToFrames) { return time } const frame = Math.round(time * this.framerate) return frame / this.framerate } } /** * TimeRuler - Widget for displaying time ruler with adaptive intervals */ class TimeRuler { constructor(timelineState) { this.state = timelineState this.height = timelineState.rulerHeight } /** * Draw the time ruler */ draw(ctx, width) { ctx.save() // Background ctx.fillStyle = backgroundColor ctx.fillRect(0, 0, width, this.height) // Determine interval based on current zoom and format let interval, isFrameMode if (this.state.timeFormat === 'frames') { interval = this.state.getRulerIntervalFrames() // In frames isFrameMode = true } else { interval = this.state.getRulerInterval() // In seconds isFrameMode = false } // Calculate visible time range const startTime = this.state.viewportStartTime const endTime = this.state.pixelToTime(width) // Draw tick marks and labels if (isFrameMode) { this.drawFrameTicks(ctx, width, interval, startTime, endTime) } else { this.drawSecondTicks(ctx, width, interval, startTime, endTime) } // Draw playhead (current time indicator) this.drawPlayhead(ctx, width) ctx.restore() } /** * Draw tick marks for frame mode */ drawFrameTicks(ctx, width, interval, startTime, endTime) { const startFrame = Math.floor(this.state.timeToFrame(startTime) / interval) * interval const endFrame = Math.ceil(this.state.timeToFrame(endTime) / interval) * interval ctx.fillStyle = labelColor ctx.font = '11px sans-serif' ctx.textAlign = 'center' ctx.textBaseline = 'top' for (let frame = startFrame; frame <= endFrame; frame += interval) { const time = this.state.frameToTime(frame) const x = this.state.timeToPixel(time) if (x < 0 || x > width) continue // Major tick ctx.strokeStyle = foregroundColor ctx.lineWidth = 1 ctx.beginPath() ctx.moveTo(x, this.height - 10) ctx.lineTo(x, this.height) ctx.stroke() // Label ctx.fillText(frame.toString(), x, 2) // Minor ticks (subdivisions) const minorInterval = interval / 5 if (minorInterval >= 1) { for (let i = 1; i < 5; i++) { const minorFrame = frame + (minorInterval * i) const minorTime = this.state.frameToTime(minorFrame) const minorX = this.state.timeToPixel(minorTime) if (minorX < 0 || minorX > width) continue ctx.strokeStyle = shadow ctx.beginPath() ctx.moveTo(minorX, this.height - 5) ctx.lineTo(minorX, this.height) ctx.stroke() } } } } /** * Draw tick marks for second mode */ drawSecondTicks(ctx, width, interval, startTime, endTime) { const startTick = Math.floor(startTime / interval) * interval const endTick = Math.ceil(endTime / interval) * interval ctx.fillStyle = labelColor ctx.font = '11px sans-serif' ctx.textAlign = 'center' ctx.textBaseline = 'top' for (let time = startTick; time <= endTick; time += interval) { const x = this.state.timeToPixel(time) if (x < 0 || x > width) continue // Major tick ctx.strokeStyle = foregroundColor ctx.lineWidth = 1 ctx.beginPath() ctx.moveTo(x, this.height - 10) ctx.lineTo(x, this.height) ctx.stroke() // Label ctx.fillText(this.state.formatTime(time), x, 2) // Minor ticks (subdivisions) const minorInterval = interval / 5 for (let i = 1; i < 5; i++) { const minorTime = time + (minorInterval * i) const minorX = this.state.timeToPixel(minorTime) if (minorX < 0 || minorX > width) continue ctx.strokeStyle = shadow ctx.beginPath() ctx.moveTo(minorX, this.height - 5) ctx.lineTo(minorX, this.height) ctx.stroke() } } } /** * Draw playhead (current time indicator) */ drawPlayhead(ctx, width) { const x = this.state.timeToPixel(this.state.currentTime) // Only draw if playhead is visible if (x < 0 || x > width) return ctx.strokeStyle = scrubberColor ctx.lineWidth = 2 ctx.beginPath() ctx.moveTo(x, 0) ctx.lineTo(x, this.height) ctx.stroke() // Playhead handle (triangle at top) ctx.fillStyle = scrubberColor ctx.beginPath() ctx.moveTo(x, 0) ctx.lineTo(x - 6, 8) ctx.lineTo(x + 6, 8) ctx.closePath() ctx.fill() } /** * Hit test for playhead dragging (no longer used, kept for potential future use) */ hitTestPlayhead(x, y) { const playheadX = this.state.timeToPixel(this.state.currentTime) const distance = Math.abs(x - playheadX) // 10px tolerance for hitting playhead return distance < 10 && y >= 0 && y <= this.height } /** * Handle mouse down - start dragging playhead */ mousedown(x, y) { // Clicking anywhere in the ruler moves the playhead there this.state.currentTime = this.state.pixelToTime(x) this.state.currentTime = Math.max(0, this.state.currentTime) this.draggingPlayhead = true return true } /** * Handle mouse move - drag playhead */ mousemove(x, y) { if (this.draggingPlayhead) { const newTime = this.state.pixelToTime(x); this.state.currentTime = Math.max(0, newTime); return true } return false } /** * Handle mouse up - stop dragging */ mouseup(x, y) { if (this.draggingPlayhead) { this.draggingPlayhead = false return true } return false } } /** * TrackHierarchy - Builds and manages hierarchical track structure from GraphicsObject * Phase 2: Track hierarchy display */ class TrackHierarchy { constructor() { this.tracks = [] // Flat list of tracks for rendering this.trackHeight = 30 // Default track height in pixels } /** * Build track list from GraphicsObject layers * Creates a flattened list of tracks for rendering, maintaining hierarchy info */ buildTracks(graphicsObject) { this.tracks = [] if (!graphicsObject || !graphicsObject.children) { return } // Iterate through layers (GraphicsObject.children are Layers) for (let layer of graphicsObject.children) { // Add layer track const layerTrack = { type: 'layer', object: layer, name: layer.name || 'Layer', indent: 0, collapsed: layer.collapsed || false, visible: layer.visible !== false } this.tracks.push(layerTrack) // If layer is not collapsed, add its children if (!layerTrack.collapsed) { // Add child GraphicsObjects (nested groups) if (layer.children) { for (let child of layer.children) { this.addObjectTrack(child, 1) } } // Add shapes (grouped by shapeId for shape tweening) if (layer.shapes) { // Group shapes by shapeId const shapesByShapeId = new Map(); for (let shape of layer.shapes) { if (!shapesByShapeId.has(shape.shapeId)) { shapesByShapeId.set(shape.shapeId, []); } shapesByShapeId.get(shape.shapeId).push(shape); } // Add one track per unique shapeId for (let [shapeId, shapes] of shapesByShapeId) { // Use the first shape as the representative for the track this.addShapeTrack(shapes[0], 1, shapeId, shapes) } } } } // Add audio tracks (after visual layers) if (graphicsObject.audioTracks) { for (let audioTrack of graphicsObject.audioTracks) { const audioTrackItem = { type: 'audio', object: audioTrack, name: audioTrack.name || 'Audio', indent: 0, collapsed: audioTrack.collapsed || false, visible: audioTrack.audible !== false } this.tracks.push(audioTrackItem) } } } /** * Recursively add object track and its children */ addObjectTrack(obj, indent) { const track = { type: 'object', object: obj, name: obj.name || obj.idx, indent: indent, collapsed: obj.trackCollapsed || false } this.tracks.push(track) // If object is not collapsed, add its children if (!track.collapsed && obj.children) { for (let layer of obj.children) { // Nested object's layers const nestedLayerTrack = { type: 'layer', object: layer, name: layer.name || 'Layer', indent: indent + 1, collapsed: layer.collapsed || false, visible: layer.visible !== false } this.tracks.push(nestedLayerTrack) if (!nestedLayerTrack.collapsed) { // Add nested layer's children if (layer.children) { for (let child of layer.children) { this.addObjectTrack(child, indent + 2) } } if (layer.shapes) { // Group shapes by shapeId const shapesByShapeId = new Map(); for (let shape of layer.shapes) { if (!shapesByShapeId.has(shape.shapeId)) { shapesByShapeId.set(shape.shapeId, []); } shapesByShapeId.get(shape.shapeId).push(shape); } // Add one track per unique shapeId for (let [shapeId, shapes] of shapesByShapeId) { this.addShapeTrack(shapes[0], indent + 2, shapeId, shapes) } } } } } } /** * Add shape track (grouped by shapeId for shape tweening) */ addShapeTrack(shape, indent, shapeId, shapes) { const track = { type: 'shape', object: shape, // Representative shape for display shapeId: shapeId, // The shared shapeId shapes: shapes, // All shape versions with this shapeId name: shape.constructor.name || 'Shape', indent: indent } this.tracks.push(track) } /** * Calculate height for a specific track based on its curves mode (Phase 4) */ getTrackHeight(track) { const baseHeight = this.trackHeight // Only objects, shapes, and audio tracks can have curves if (track.type !== 'object' && track.type !== 'shape' && track.type !== 'audio') { return baseHeight } const obj = track.object // Calculate additional height needed for curves if (obj.curvesMode === 'keyframe') { // Phase 6: Minimized mode should be compact - no extra height // Keyframes are overlaid on the segment bar return baseHeight } else if (obj.curvesMode === 'curve') { // Use the object's curvesHeight property return baseHeight + (obj.curvesHeight || 150) + 10 // +10 for padding } return baseHeight } /** * Calculate total height needed for all tracks */ getTotalHeight() { let totalHeight = 0 for (let track of this.tracks) { totalHeight += this.getTrackHeight(track) } return totalHeight } /** * Get track at a given Y position */ getTrackAtY(y) { let currentY = 0 for (let i = 0; i < this.tracks.length; i++) { const track = this.tracks[i] const trackHeight = this.getTrackHeight(track) if (y >= currentY && y < currentY + trackHeight) { return track } currentY += trackHeight } return null } /** * Get Y position for a specific track index (Phase 4) */ getTrackY(trackIndex) { let y = 0 for (let i = 0; i < trackIndex && i < this.tracks.length; i++) { y += this.getTrackHeight(this.tracks[i]) } return y } } export { TimelineState, TimeRuler, TrackHierarchy }