/****************************************************************************** Copyright (c) Microsoft Corporation. Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ***************************************************************************** */ /* global Reflect, Promise, SuppressedError, Symbol, Iterator */ function __decorate(decorators, target, key, desc) { var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d; if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc); else for (var i = decorators.length - 1; i >= 0; i--) if (d = decorators[i]) r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r; return c > 3 && r && Object.defineProperty(target, key, r), r; } typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { var e = new Error(message); return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; }; /** * @license * Copyright 2019 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ const t$2=globalThis,e$2=t$2.ShadowRoot&&(void 0===t$2.ShadyCSS||t$2.ShadyCSS.nativeShadow)&&"adoptedStyleSheets"in Document.prototype&&"replace"in CSSStyleSheet.prototype,s$2=Symbol(),o$4=new WeakMap;let n$3 = class n{constructor(t,e,o){if(this._$cssResult$=true,o!==s$2)throw Error("CSSResult is not constructable. Use `unsafeCSS` or `css` instead.");this.cssText=t,this.t=e;}get styleSheet(){let t=this.o;const s=this.t;if(e$2&&void 0===t){const e=void 0!==s&&1===s.length;e&&(t=o$4.get(s)),void 0===t&&((this.o=t=new CSSStyleSheet).replaceSync(this.cssText),e&&o$4.set(s,t));}return t}toString(){return this.cssText}};const r$4=t=>new n$3("string"==typeof t?t:t+"",void 0,s$2),i$3=(t,...e)=>{const o=1===t.length?t[0]:e.reduce((e,s,o)=>e+(t=>{if(true===t._$cssResult$)return t.cssText;if("number"==typeof t)return t;throw Error("Value passed to 'css' function must be a 'css' function result: "+t+". Use 'unsafeCSS' to pass non-literal values, but take care to ensure page security.")})(s)+t[o+1],t[0]);return new n$3(o,t,s$2)},S$1=(s,o)=>{if(e$2)s.adoptedStyleSheets=o.map(t=>t instanceof CSSStyleSheet?t:t.styleSheet);else for(const e of o){const o=document.createElement("style"),n=t$2.litNonce;void 0!==n&&o.setAttribute("nonce",n),o.textContent=e.cssText,s.appendChild(o);}},c$2=e$2?t=>t:t=>t instanceof CSSStyleSheet?(t=>{let e="";for(const s of t.cssRules)e+=s.cssText;return r$4(e)})(t):t; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */const{is:i$2,defineProperty:e$1,getOwnPropertyDescriptor:h$1,getOwnPropertyNames:r$3,getOwnPropertySymbols:o$3,getPrototypeOf:n$2}=Object,a$1=globalThis,c$1=a$1.trustedTypes,l$1=c$1?c$1.emptyScript:"",p$1=a$1.reactiveElementPolyfillSupport,d$1=(t,s)=>t,u$1={toAttribute(t,s){switch(s){case Boolean:t=t?l$1:null;break;case Object:case Array:t=null==t?t:JSON.stringify(t);}return t},fromAttribute(t,s){let i=t;switch(s){case Boolean:i=null!==t;break;case Number:i=null===t?null:Number(t);break;case Object:case Array:try{i=JSON.parse(t);}catch(t){i=null;}}return i}},f$1=(t,s)=>!i$2(t,s),b$1={attribute:true,type:String,converter:u$1,reflect:false,useDefault:false,hasChanged:f$1};Symbol.metadata??=Symbol("metadata"),a$1.litPropertyMetadata??=new WeakMap;let y$1 = class y extends HTMLElement{static addInitializer(t){this._$Ei(),(this.l??=[]).push(t);}static get observedAttributes(){return this.finalize(),this._$Eh&&[...this._$Eh.keys()]}static createProperty(t,s=b$1){if(s.state&&(s.attribute=false),this._$Ei(),this.prototype.hasOwnProperty(t)&&((s=Object.create(s)).wrapped=true),this.elementProperties.set(t,s),!s.noAccessor){const i=Symbol(),h=this.getPropertyDescriptor(t,i,s);void 0!==h&&e$1(this.prototype,t,h);}}static getPropertyDescriptor(t,s,i){const{get:e,set:r}=h$1(this.prototype,t)??{get(){return this[s]},set(t){this[s]=t;}};return {get:e,set(s){const h=e?.call(this);r?.call(this,s),this.requestUpdate(t,h,i);},configurable:true,enumerable:true}}static getPropertyOptions(t){return this.elementProperties.get(t)??b$1}static _$Ei(){if(this.hasOwnProperty(d$1("elementProperties")))return;const t=n$2(this);t.finalize(),void 0!==t.l&&(this.l=[...t.l]),this.elementProperties=new Map(t.elementProperties);}static finalize(){if(this.hasOwnProperty(d$1("finalized")))return;if(this.finalized=true,this._$Ei(),this.hasOwnProperty(d$1("properties"))){const t=this.properties,s=[...r$3(t),...o$3(t)];for(const i of s)this.createProperty(i,t[i]);}const t=this[Symbol.metadata];if(null!==t){const s=litPropertyMetadata.get(t);if(void 0!==s)for(const[t,i]of s)this.elementProperties.set(t,i);}this._$Eh=new Map;for(const[t,s]of this.elementProperties){const i=this._$Eu(t,s);void 0!==i&&this._$Eh.set(i,t);}this.elementStyles=this.finalizeStyles(this.styles);}static finalizeStyles(s){const i=[];if(Array.isArray(s)){const e=new Set(s.flat(1/0).reverse());for(const s of e)i.unshift(c$2(s));}else void 0!==s&&i.push(c$2(s));return i}static _$Eu(t,s){const i=s.attribute;return false===i?void 0:"string"==typeof i?i:"string"==typeof t?t.toLowerCase():void 0}constructor(){super(),this._$Ep=void 0,this.isUpdatePending=false,this.hasUpdated=false,this._$Em=null,this._$Ev();}_$Ev(){this._$ES=new Promise(t=>this.enableUpdating=t),this._$AL=new Map,this._$E_(),this.requestUpdate(),this.constructor.l?.forEach(t=>t(this));}addController(t){(this._$EO??=new Set).add(t),void 0!==this.renderRoot&&this.isConnected&&t.hostConnected?.();}removeController(t){this._$EO?.delete(t);}_$E_(){const t=new Map,s=this.constructor.elementProperties;for(const i of s.keys())this.hasOwnProperty(i)&&(t.set(i,this[i]),delete this[i]);t.size>0&&(this._$Ep=t);}createRenderRoot(){const t=this.shadowRoot??this.attachShadow(this.constructor.shadowRootOptions);return S$1(t,this.constructor.elementStyles),t}connectedCallback(){this.renderRoot??=this.createRenderRoot(),this.enableUpdating(true),this._$EO?.forEach(t=>t.hostConnected?.());}enableUpdating(t){}disconnectedCallback(){this._$EO?.forEach(t=>t.hostDisconnected?.());}attributeChangedCallback(t,s,i){this._$AK(t,i);}_$ET(t,s){const i=this.constructor.elementProperties.get(t),e=this.constructor._$Eu(t,i);if(void 0!==e&&true===i.reflect){const h=(void 0!==i.converter?.toAttribute?i.converter:u$1).toAttribute(s,i.type);this._$Em=t,null==h?this.removeAttribute(e):this.setAttribute(e,h),this._$Em=null;}}_$AK(t,s){const i=this.constructor,e=i._$Eh.get(t);if(void 0!==e&&this._$Em!==e){const t=i.getPropertyOptions(e),h="function"==typeof t.converter?{fromAttribute:t.converter}:void 0!==t.converter?.fromAttribute?t.converter:u$1;this._$Em=e;const r=h.fromAttribute(s,t.type);this[e]=r??this._$Ej?.get(e)??r,this._$Em=null;}}requestUpdate(t,s,i,e=false,h){if(void 0!==t){const r=this.constructor;if(false===e&&(h=this[t]),i??=r.getPropertyOptions(t),!((i.hasChanged??f$1)(h,s)||i.useDefault&&i.reflect&&h===this._$Ej?.get(t)&&!this.hasAttribute(r._$Eu(t,i))))return;this.C(t,s,i);} false===this.isUpdatePending&&(this._$ES=this._$EP());}C(t,s,{useDefault:i,reflect:e,wrapped:h},r){i&&!(this._$Ej??=new Map).has(t)&&(this._$Ej.set(t,r??s??this[t]),true!==h||void 0!==r)||(this._$AL.has(t)||(this.hasUpdated||i||(s=void 0),this._$AL.set(t,s)),true===e&&this._$Em!==t&&(this._$Eq??=new Set).add(t));}async _$EP(){this.isUpdatePending=true;try{await this._$ES;}catch(t){Promise.reject(t);}const t=this.scheduleUpdate();return null!=t&&await t,!this.isUpdatePending}scheduleUpdate(){return this.performUpdate()}performUpdate(){if(!this.isUpdatePending)return;if(!this.hasUpdated){if(this.renderRoot??=this.createRenderRoot(),this._$Ep){for(const[t,s]of this._$Ep)this[t]=s;this._$Ep=void 0;}const t=this.constructor.elementProperties;if(t.size>0)for(const[s,i]of t){const{wrapped:t}=i,e=this[s];true!==t||this._$AL.has(s)||void 0===e||this.C(s,void 0,i,e);}}let t=false;const s=this._$AL;try{t=this.shouldUpdate(s),t?(this.willUpdate(s),this._$EO?.forEach(t=>t.hostUpdate?.()),this.update(s)):this._$EM();}catch(s){throw t=false,this._$EM(),s}t&&this._$AE(s);}willUpdate(t){}_$AE(t){this._$EO?.forEach(t=>t.hostUpdated?.()),this.hasUpdated||(this.hasUpdated=true,this.firstUpdated(t)),this.updated(t);}_$EM(){this._$AL=new Map,this.isUpdatePending=false;}get updateComplete(){return this.getUpdateComplete()}getUpdateComplete(){return this._$ES}shouldUpdate(t){return true}update(t){this._$Eq&&=this._$Eq.forEach(t=>this._$ET(t,this[t])),this._$EM();}updated(t){}firstUpdated(t){}};y$1.elementStyles=[],y$1.shadowRootOptions={mode:"open"},y$1[d$1("elementProperties")]=new Map,y$1[d$1("finalized")]=new Map,p$1?.({ReactiveElement:y$1}),(a$1.reactiveElementVersions??=[]).push("2.1.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ const t$1=globalThis,i$1=t=>t,s$1=t$1.trustedTypes,e=s$1?s$1.createPolicy("lit-html",{createHTML:t=>t}):void 0,h="$lit$",o$2=`lit$${Math.random().toFixed(9).slice(2)}$`,n$1="?"+o$2,r$2=`<${n$1}>`,l=document,c=()=>l.createComment(""),a=t=>null===t||"object"!=typeof t&&"function"!=typeof t,u=Array.isArray,d=t=>u(t)||"function"==typeof t?.[Symbol.iterator],f="[ \t\n\f\r]",v=/<(?:(!--|\/[^a-zA-Z])|(\/?[a-zA-Z][^>\s]*)|(\/?$))/g,_=/-->/g,m=/>/g,p=RegExp(`>|${f}(?:([^\\s"'>=/]+)(${f}*=${f}*(?:[^ \t\n\f\r"'\`<>=]|("|')|))|$)`,"g"),g=/'/g,$=/"/g,y=/^(?:script|style|textarea|title)$/i,x=t=>(i,...s)=>({_$litType$:t,strings:i,values:s}),b=x(1),E=Symbol.for("lit-noChange"),A=Symbol.for("lit-nothing"),C=new WeakMap,P=l.createTreeWalker(l,129);function V(t,i){if(!u(t)||!t.hasOwnProperty("raw"))throw Error("invalid template strings array");return void 0!==e?e.createHTML(i):i}const N=(t,i)=>{const s=t.length-1,e=[];let n,l=2===i?"":3===i?"":"",c=v;for(let i=0;i"===u[0]?(c=n??v,d=-1):void 0===u[1]?d=-2:(d=c.lastIndex-u[2].length,a=u[1],c=void 0===u[3]?p:'"'===u[3]?$:g):c===$||c===g?c=p:c===_||c===m?c=v:(c=p,n=void 0);const x=c===p&&t[i+1].startsWith("/>")?" ":"";l+=c===v?s+r$2:d>=0?(e.push(a),s.slice(0,d)+h+s.slice(d)+o$2+x):s+o$2+(-2===d?i:x);}return [V(t,l+(t[s]||"")+(2===i?"":3===i?"":"")),e]};class S{constructor({strings:t,_$litType$:i},e){let r;this.parts=[];let l=0,a=0;const u=t.length-1,d=this.parts,[f,v]=N(t,i);if(this.el=S.createElement(f,e),P.currentNode=this.el.content,2===i||3===i){const t=this.el.content.firstChild;t.replaceWith(...t.childNodes);}for(;null!==(r=P.nextNode())&&d.length0){r.textContent=s$1?s$1.emptyScript:"";for(let s=0;s2||""!==s[0]||""!==s[1]?(this._$AH=Array(s.length-1).fill(new String),this.strings=s):this._$AH=A;}_$AI(t,i=this,s,e){const h=this.strings;let o=false;if(void 0===h)t=M(this,t,i,0),o=!a(t)||t!==this._$AH&&t!==E,o&&(this._$AH=t);else {const e=t;let n,r;for(t=h[0],n=0;n{const e=s?.renderBefore??i;let h=e._$litPart$;if(void 0===h){const t=s?.renderBefore??null;e._$litPart$=h=new k(i.insertBefore(c(),t),t,void 0,s??{});}return h._$AI(t),h}; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */const s=globalThis;class i extends y$1{constructor(){super(...arguments),this.renderOptions={host:this},this._$Do=void 0;}createRenderRoot(){const t=super.createRenderRoot();return this.renderOptions.renderBefore??=t.firstChild,t}update(t){const r=this.render();this.hasUpdated||(this.renderOptions.isConnected=this.isConnected),super.update(t),this._$Do=D(r,this.renderRoot,this.renderOptions);}connectedCallback(){super.connectedCallback(),this._$Do?.setConnected(true);}disconnectedCallback(){super.disconnectedCallback(),this._$Do?.setConnected(false);}render(){return E}}i._$litElement$=true,i["finalized"]=true,s.litElementHydrateSupport?.({LitElement:i});const o$1=s.litElementPolyfillSupport;o$1?.({LitElement:i});(s.litElementVersions??=[]).push("4.2.2"); /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */ const t=t=>(e,o)=>{ void 0!==o?o.addInitializer(()=>{customElements.define(t,e);}):customElements.define(t,e);}; /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */const o={attribute:true,type:String,converter:u$1,reflect:false,hasChanged:f$1},r$1=(t=o,e,r)=>{const{kind:n,metadata:i}=r;let s=globalThis.litPropertyMetadata.get(i);if(void 0===s&&globalThis.litPropertyMetadata.set(i,s=new Map),"setter"===n&&((t=Object.create(t)).wrapped=true),s.set(r.name,t),"accessor"===n){const{name:o}=r;return {set(r){const n=e.get.call(this);e.set.call(this,r),this.requestUpdate(o,n,t,true,r);},init(e){return void 0!==e&&this.C(o,void 0,t,e),e}}}if("setter"===n){const{name:o}=r;return function(r){const n=this[o];e.call(this,r),this.requestUpdate(o,n,t,true,r);}}throw Error("Unsupported decorator location: "+n)};function n(t){return (e,o)=>"object"==typeof o?r$1(t,e,o):((t,e,o)=>{const r=e.hasOwnProperty(o);return e.constructor.createProperty(o,t),r?Object.getOwnPropertyDescriptor(e,o):void 0})(t,e,o)} /** * @license * Copyright 2017 Google LLC * SPDX-License-Identifier: BSD-3-Clause */function r(r){return n({...r,state:true,attribute:false})} // Color palette for automatic background graph coloring const BACKGROUND_GRAPH_COLORS = [ '#f44336', // Red '#9c27b0', // Purple '#3f51b5', // Indigo '#00bcd4', // Cyan '#009688', // Teal '#4caf50', // Green '#8bc34a', // Light Green '#cddc39', // Lime '#ffeb3b', // Yellow '#ffc107', // Amber '#ff9800', // Orange '#ff5722', // Deep Orange '#795548', // Brown '#607d8b', // Blue Grey ]; let KeyframeTimeline = class KeyframeTimeline extends i { constructor() { super(...arguments); this.duration = 24; // hours this.slots = 96; // time divisions (e.g., 15-min intervals in 24h) this.keyframes = []; this.minValue = 5; // Minimum Y axis value (default: 5°C) this.maxValue = 30; // Maximum Y axis value (default: 30°C) this.snapValue = 0; // Y axis snap step (0 = no snapping) this.xAxisLabel = ''; // X axis label this.yAxisLabel = ''; // Y axis label this.title = ''; // Title displayed in top left this.showHeader = true; // Show header with controls this.allowCollapse = true; // Allow collapsing the timeline this.readonly = false; // Disable all interactions this.showCurrentTime = false; // Automatically show indicator at current time this.backgroundGraphs = []; // Background reference graphs this.advanceHistory = []; // Array of {activated_at, target_time, cancelled_at, target_node} this.tooltipMode = 'cursor'; // Tooltip display mode (set from global settings) this.canvasWidth = 0; this.canvasHeight = 600; this.showConfig = false; this.draggingIndex = null; this.draggingSegment = null; this.selectedKeyframeIndex = null; this.collapsed = false; this.showScrollNavLeft = false; this.showScrollNavRight = false; this.undoStack = []; // Changed to store full keyframe arrays this.legendCollapsed = true; this.isDragging = false; this.hasMoved = false; this.dragStartX = 0; this.dragStartY = 0; this.isPanning = false; this.panStartX = 0; this.panStartScrollLeft = 0; this.lastClickTime = 0; this.lastClickIndex = -1; // Track which keyframe was last clicked this.lastClickX = 0; this.instanceId = Math.random().toString(36).substring(7); // Unique instance ID for debugging this.lastClickY = 0; this.justDeletedKeyframe = false; // Prevent dblclick from adding after delete this.holdStartX = 0; this.holdStartY = 0; this.hoverRenderPending = false; this.hoverX = 0; this.hoverY = 0; } willUpdate(changedProperties) { // Property change tracking } firstUpdated() { const canvasEl = this.shadowRoot?.querySelector('canvas'); if (canvasEl) { this.canvas = canvasEl; this.ctx = canvasEl.getContext('2d') || undefined; this.updateCanvasSize(); } this.tooltipEl = this.shadowRoot?.querySelector('.cursor-tooltip'); this.wrapperEl = this.shadowRoot?.querySelector('.timeline-canvas-wrapper'); this.checkScrollVisibility(); // Listen for scroll events to update button visibility if (this.wrapperEl) { this.wrapperEl.addEventListener('scroll', () => this.checkScrollVisibility()); } // Update canvas size on window resize window.addEventListener('resize', () => { this.updateCanvasSize(); this.drawTimeline(); this.checkScrollVisibility(); }); // Draw immediately - CSS variables should now be properly set via styles.css requestAnimationFrame(() => { this.updateCanvasSize(); this.drawTimeline(); }); // Setup current time indicator if enabled if (this.showCurrentTime) { this.updateCurrentTime(); this.startCurrentTimeTimer(); } // Setup keyboard shortcuts this.keyboardHandler = (e) => { if ((e.ctrlKey || e.metaKey) && e.key === 'z' && !e.shiftKey) { e.preventDefault(); this.undo(); } }; document.addEventListener('keydown', this.keyboardHandler); } disconnectedCallback() { super.disconnectedCallback(); this.stopCurrentTimeTimer(); // Remove keyboard listener if (this.keyboardHandler) { document.removeEventListener('keydown', this.keyboardHandler); } } updated(changedProperties) { super.updated(changedProperties); // Redraw when keyframes, backgroundGraphs or advanceHistory changes if (changedProperties.has('keyframes')) { this.drawTimeline(); this.updateNavigationButtonsState(); } // Redraw when backgroundGraphs or advanceHistory changes if (changedProperties.has('backgroundGraphs') || changedProperties.has('advanceHistory')) { this.drawTimeline(); } // Start/stop timer when showCurrentTime changes if (changedProperties.has('showCurrentTime')) { if (this.showCurrentTime) { this.updateCurrentTime(); this.startCurrentTimeTimer(); } else { this.stopCurrentTimeTimer(); } } } updateCurrentTime() { const now = new Date(); const hours = now.getHours(); const minutes = now.getMinutes(); const seconds = now.getSeconds(); // Calculate time as decimal hours const currentTime = hours + (minutes / 60) + (seconds / 3600); // Only update if within the timeline duration if (currentTime <= this.duration) { this.indicatorTime = currentTime; this.drawTimeline(); } } startCurrentTimeTimer() { // Update every 10 seconds for smooth movement this.currentTimeTimer = window.setInterval(() => { this.updateCurrentTime(); }, 10000); } stopCurrentTimeTimer() { if (this.currentTimeTimer) { window.clearInterval(this.currentTimeTimer); this.currentTimeTimer = undefined; } } updateCanvasSize() { if (!this.canvas) return; // Use offsetWidth to get actual canvas width (not clipped by scroll container) const canvasCSSWidth = this.canvas.offsetWidth; this.canvasWidth = canvasCSSWidth * window.devicePixelRatio; // Read height from CSS variables const computedStyle = getComputedStyle(this); const cssHeightVar = this.collapsed ? '--timeline-height-collapsed' : '--timeline-height'; const cssHeight = computedStyle.getPropertyValue(cssHeightVar).trim(); const baseHeight = parseInt(cssHeight); this.canvasHeight = baseHeight * window.devicePixelRatio; this.canvas.width = this.canvasWidth; this.canvas.height = this.canvasHeight; } normalizeValue(value) { // Convert value from minValue-maxValue range to 0-1 range return (value - this.minValue) / (this.maxValue - this.minValue); } denormalizeValue(normalized) { // Convert value from 0-1 range to minValue-maxValue range return normalized * (this.maxValue - this.minValue) + this.minValue; } getThemeColor(cssVar) { return getComputedStyle(this).getPropertyValue(cssVar).trim(); } getBackgroundGraphColor(bgGraph, graphIndex) { return bgGraph.color || BACKGROUND_GRAPH_COLORS[graphIndex % BACKGROUND_GRAPH_COLORS.length]; } toggleLegend(e) { e.stopPropagation(); this.legendCollapsed = !this.legendCollapsed; } getBaseFontSize() { // Get computed font size from host element to respect browser/accessibility settings const computedStyle = getComputedStyle(this); return parseFloat(computedStyle.fontSize); } snapValueToGrid(value) { // Snap value to grid if snapValue is set if (this.snapValue > 0) { const snapped = Math.round(value / this.snapValue) * this.snapValue; return this.roundToStepPrecision(snapped, this.snapValue); } return this.roundToPrecision(value, 4); } roundToStepPrecision(value, step) { return this.roundToPrecision(value, this.getStepPrecision(step)); } roundToPrecision(value, precision) { const safePrecision = Math.max(0, Math.min(precision, 10)); const factor = 10 ** safePrecision; return Math.round((value + Number.EPSILON) * factor) / factor; } getStepPrecision(step) { if (!Number.isFinite(step) || step <= 0) { return 3; } const stepString = step.toString().toLowerCase(); if (stepString.includes('e-')) { const exponent = Number(stepString.split('e-')[1]); return Number.isFinite(exponent) ? exponent : 3; } const decimalPart = stepString.split('.')[1]; return decimalPart ? decimalPart.length : 0; } getGraphDimensions(rect) { const labelHeight = 30; const leftMargin = 35; const yAxisWidth = 35; const rightMargin = 35; const topMargin = 25; const bottomMargin = 25; const graphHeight = rect.height - labelHeight - topMargin - bottomMargin; // Use canvas width (not rect width which is clipped by scroll container) const canvasWidthCSS = this.canvas.width / (window.devicePixelRatio || 1); const graphWidth = canvasWidthCSS - leftMargin - yAxisWidth - rightMargin; return { labelHeight, leftMargin, yAxisWidth, rightMargin, topMargin, bottomMargin, graphHeight, graphWidth, canvasWidthCSS }; } sortKeyframes() { // Keep track of what was selected/dragging before sort const selectedKeyframe = this.selectedKeyframeIndex !== null ? this.keyframes[this.selectedKeyframeIndex] : null; const draggingKeyframe = this.draggingIndex !== null ? this.keyframes[this.draggingIndex] : null; // Sort keyframes by time this.keyframes = [...this.keyframes].sort((a, b) => a.time - b.time); // Update indices to point to same keyframes after sort if (selectedKeyframe) { this.selectedKeyframeIndex = this.keyframes.findIndex(kf => kf === selectedKeyframe); } if (draggingKeyframe) { this.draggingIndex = this.keyframes.findIndex(kf => kf === draggingKeyframe); } } drawTimeline() { if (!this.ctx || !this.canvas) return; this.canvas.getBoundingClientRect(); const dpr = window.devicePixelRatio; this.ctx.clearRect(0, 0, this.canvasWidth, this.canvasHeight); this.canvasWidth / this.slots; // Reserve space at bottom for labels, left for Y axis, right margin, and top for margin const labelHeight = 30 * dpr; const leftMargin = 35 * dpr; const yAxisWidth = 35 * dpr; const rightMargin = 35 * dpr; const topMargin = 45 * dpr; const bottomMargin = 25 * dpr; const graphHeight = this.canvasHeight - labelHeight - topMargin - bottomMargin; const graphWidth = this.canvasWidth - leftMargin - yAxisWidth - rightMargin; // Draw Y axis labels and horizontal grid lines const numYLabels = this.collapsed ? 2 : 5; const baseFontSize = this.getBaseFontSize(); const yLabelOffset = 5 * dpr; // Distance from Y-axis to label text const yAxisLabelOffset = -10 * dpr; // Distance from left edge to vertical Y-axis label const xAxisLabelOffset = 25 * dpr; // Distance from bottom to horizontal X-axis label this.ctx.fillStyle = this.getThemeColor('--canvas-text-secondary'); this.ctx.font = `${baseFontSize * dpr}px sans-serif`; this.ctx.textAlign = 'right'; this.ctx.textBaseline = 'middle'; this.ctx.strokeStyle = this.getThemeColor('--canvas-grid-line'); this.ctx.lineWidth = 1 * dpr; for (let i = 0; i < numYLabels; i++) { const ratio = i / (numYLabels - 1); const value = this.minValue + ratio * (this.maxValue - this.minValue); const y = topMargin + (graphHeight * (1 - ratio)); // Draw label this.ctx.fillText(value.toFixed(1), leftMargin + yAxisWidth - yLabelOffset, y); // Draw horizontal grid line this.ctx.beginPath(); this.ctx.moveTo(leftMargin + yAxisWidth, y); this.ctx.lineTo(leftMargin + yAxisWidth + graphWidth, y); this.ctx.stroke(); } // Draw Y axis label (vertical text on left side) if (this.yAxisLabel && !this.collapsed) { this.ctx.save(); this.ctx.translate(leftMargin + yAxisLabelOffset, this.canvasHeight / 2); this.ctx.rotate(-Math.PI / 2); this.ctx.fillStyle = this.getThemeColor('--canvas-text-primary'); this.ctx.font = `${baseFontSize * dpr}px sans-serif`; this.ctx.textAlign = 'center'; this.ctx.textBaseline = 'middle'; this.ctx.fillText(this.yAxisLabel, 0, 0); this.ctx.restore(); } // Adjust slot width for graph area graphWidth / this.slots; // Draw hour markers (full height) and labels this.ctx.fillStyle = this.getThemeColor('--canvas-text-secondary'); this.ctx.font = `${baseFontSize * dpr}px sans-serif`; this.ctx.textAlign = 'center'; this.ctx.textBaseline = 'middle'; const hoursToShow = Math.ceil(this.duration); const labelInterval = this.collapsed ? 3 : 1; // Show labels every 3 hours when collapsed for (let i = 0; i <= hoursToShow; i++) { const x = leftMargin + yAxisWidth + ((i / this.duration) * graphWidth); // Draw hour marker line with thicker lines every 6 hours const isMajorLine = i % 6 === 0; this.ctx.strokeStyle = isMajorLine ? this.getThemeColor('--canvas-grid-line-major') : this.getThemeColor('--canvas-grid-line'); this.ctx.lineWidth = isMajorLine ? 2 * dpr : 1 * dpr; this.ctx.beginPath(); this.ctx.moveTo(x, topMargin); this.ctx.lineTo(x, topMargin + graphHeight); this.ctx.stroke(); // Draw hour label only at specified intervals (below graph area) if (i % labelInterval === 0) { const hour = i % 24; const label = hour === 0 ? '00' : hour.toString().padStart(2, '0'); this.ctx.fillText(label, x, topMargin + graphHeight + (15 * dpr)); } } // Draw axis lines (like SVG graph) this.ctx.strokeStyle = this.getThemeColor('--canvas-text-primary'); this.ctx.lineWidth = 2 * dpr; // X-axis (bottom) this.ctx.beginPath(); this.ctx.moveTo(leftMargin + yAxisWidth, topMargin + graphHeight); this.ctx.lineTo(leftMargin + yAxisWidth + graphWidth, topMargin + graphHeight); this.ctx.stroke(); // Y-axis (left) this.ctx.beginPath(); this.ctx.moveTo(leftMargin + yAxisWidth, topMargin); this.ctx.lineTo(leftMargin + yAxisWidth, topMargin + graphHeight); this.ctx.stroke(); // Current time indicator (green dashed line like SVG) const now = new Date(); const currentHours = now.getHours() + now.getMinutes() / 60; const currentX = leftMargin + yAxisWidth + ((currentHours / this.duration) * graphWidth); this.ctx.strokeStyle = '#00ff00'; this.ctx.lineWidth = 2 * dpr; this.ctx.setLineDash([5 * dpr, 5 * dpr]); this.ctx.globalAlpha = 0.7; this.ctx.beginPath(); this.ctx.moveTo(currentX, topMargin); this.ctx.lineTo(currentX, topMargin + graphHeight); this.ctx.stroke(); this.ctx.setLineDash([]); this.ctx.globalAlpha = 1.0; // Current time label at top const hours = Math.floor(currentHours); const minutes = Math.floor((currentHours - hours) * 60); const timeLabel = `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}`; this.ctx.fillStyle = this.getThemeColor('--canvas-text-primary'); this.ctx.font = `bold ${baseFontSize * dpr}px sans-serif`; this.ctx.textAlign = 'center'; this.ctx.textBaseline = 'bottom'; this.ctx.fillText(timeLabel, currentX, topMargin - (5 * dpr)); // Draw X axis label (below time labels) if (this.xAxisLabel && !this.collapsed) { this.ctx.fillStyle = this.getThemeColor('--canvas-text-primary'); this.ctx.font = `${baseFontSize * dpr}px sans-serif`; this.ctx.textAlign = 'center'; this.ctx.textBaseline = 'top'; this.ctx.fillText(this.xAxisLabel, leftMargin + yAxisWidth + (graphWidth / 2), this.canvasHeight - xAxisLabelOffset); } // Draw background graphs (reference data) const ctx = this.ctx; this.backgroundGraphs.forEach((bgGraph, graphIndex) => { if (bgGraph.keyframes.length === 0) return; // Sort keyframes by time for proper line drawing const sortedKeyframes = [...bgGraph.keyframes].sort((a, b) => a.time - b.time); // Use specified color or cycle through palette const color = this.getBackgroundGraphColor(bgGraph, graphIndex); const rgbaMatch = color.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); if (rgbaMatch) { ctx.strokeStyle = `rgba(${rgbaMatch[1]}, ${rgbaMatch[2]}, ${rgbaMatch[3]}, 0.5)`; } else { // Try hex to rgba conversion const hexMatch = color.match(/#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i); if (hexMatch) { const r = parseInt(hexMatch[1], 16); const g = parseInt(hexMatch[2], 16); const b = parseInt(hexMatch[3], 16); ctx.strokeStyle = `rgba(${r}, ${g}, ${b}, 0.5)`; } else { ctx.strokeStyle = color; } } ctx.lineWidth = 1.5 * dpr; ctx.setLineDash([3 * dpr, 3 * dpr]); // Dashed line for background // Draw lines between keyframes for (let i = 0; i < sortedKeyframes.length - 1; i++) { const kf1 = sortedKeyframes[i]; const kf2 = sortedKeyframes[i + 1]; const x1 = leftMargin + yAxisWidth + ((kf1.time / this.duration) * graphWidth); const y1 = topMargin + ((1 - this.normalizeValue(kf1.value)) * graphHeight); const x2 = leftMargin + yAxisWidth + ((kf2.time / this.duration) * graphWidth); const y2 = topMargin + ((1 - this.normalizeValue(kf2.value)) * graphHeight); // Draw line between points ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); } ctx.setLineDash([]); // Reset dash // Draw small circle markers at each keyframe (smaller than main graph) sortedKeyframes.forEach(kf => { const x = leftMargin + yAxisWidth + ((kf.time / this.duration) * graphWidth); const y = topMargin + ((1 - this.normalizeValue(kf.value)) * graphHeight); this.ctx.fillStyle = color; this.ctx.beginPath(); this.ctx.arc(x, y, 2 * dpr, 0, Math.PI * 2); this.ctx.fill(); }); }); // Draw FLAT/STEP lines between keyframes (like scheduler-card) if (this.keyframes.length > 0) { this.ctx.strokeStyle = this.getThemeColor('--keyframe-color'); this.ctx.lineWidth = 3 * dpr; // Keyframes are already sorted by time for (let i = 0; i < this.keyframes.length - 1; i++) { const kf1 = this.keyframes[i]; const kf2 = this.keyframes[i + 1]; const x1 = leftMargin + yAxisWidth + ((kf1.time / this.duration) * graphWidth); const y1 = topMargin + ((1 - this.normalizeValue(kf1.value)) * graphHeight); const x2 = leftMargin + yAxisWidth + ((kf2.time / this.duration) * graphWidth); const y2 = topMargin + ((1 - this.normalizeValue(kf2.value)) * graphHeight); // Draw flat line (hold value until next keyframe) this.ctx.beginPath(); this.ctx.moveTo(x1, y1); this.ctx.lineTo(x2, y1); // Horizontal to next time this.ctx.lineTo(x2, y2); // Vertical step to new value this.ctx.stroke(); } // Wraparound: extend last keyframe to end, then wrap to first keyframe if (this.keyframes.length > 0) { const lastKf = this.keyframes[this.keyframes.length - 1]; const firstKf = this.keyframes[0]; const lastX = leftMargin + yAxisWidth + ((lastKf.time / this.duration) * graphWidth); const lastY = topMargin + ((1 - this.normalizeValue(lastKf.value)) * graphHeight); const firstY = topMargin + ((1 - this.normalizeValue(firstKf.value)) * graphHeight); // Determine the starting value (from previous day or from last keyframe) const startValue = this.previousDayEndValue !== undefined ? this.previousDayEndValue : lastKf.value; topMargin + ((1 - this.normalizeValue(startValue)) * graphHeight); // Calculate the position of the last hour marker (24:00/00:00) const endX = leftMargin + yAxisWidth + ((this.duration / this.duration) * graphWidth); // Extend last keyframe to right edge this.ctx.beginPath(); this.ctx.moveTo(lastX, lastY); this.ctx.lineTo(endX, lastY); this.ctx.stroke(); // Extend from left edge to first keyframe // In multi-day mode, use previousDayEndValue; in 24hr mode, wrap to own last value const wrapValue = this.previousDayEndValue !== undefined && this.previousDayEndValue !== null ? this.previousDayEndValue : lastKf.value; const wrapY = topMargin + ((1 - this.normalizeValue(wrapValue)) * graphHeight); if (firstKf.time > 0) { // Hold wrap value horizontally, then step to first keyframe const firstX = leftMargin + yAxisWidth + ((firstKf.time / this.duration) * graphWidth); this.ctx.beginPath(); this.ctx.moveTo(leftMargin + yAxisWidth, wrapY); this.ctx.lineTo(firstX, wrapY); this.ctx.lineTo(firstX, firstY); this.ctx.stroke(); } else { // First keyframe at 0, just draw vertical step this.ctx.beginPath(); this.ctx.moveTo(leftMargin + yAxisWidth, wrapY); this.ctx.lineTo(leftMargin + yAxisWidth, firstY); this.ctx.stroke(); } } } // Draw advance history markers if (this.advanceHistory && this.advanceHistory.length > 0) { this.advanceHistory.forEach(event => { if (event.target_node && event.target_node.temp !== null && event.target_node.temp !== undefined) { // Parse activated_at time to get hour position const activatedDate = new Date(event.activated_at); const activatedHours = activatedDate.getHours() + (activatedDate.getMinutes() / 60); // Only draw if within today's 24-hour range if (activatedHours >= 0 && activatedHours < 24) { const x = leftMargin + yAxisWidth + ((activatedHours / this.duration) * graphWidth); const y = topMargin + ((1 - this.normalizeValue(event.target_node.temp)) * graphHeight); // Draw diamond marker for advance activation this.ctx.save(); this.ctx.fillStyle = '#00ff00'; this.ctx.strokeStyle = '#00aa00'; this.ctx.lineWidth = 2 * dpr; this.ctx.translate(x, y); this.ctx.rotate(Math.PI / 4); const markerSize = 8 * dpr; this.ctx.fillRect(-markerSize, -markerSize, markerSize * 2, markerSize * 2); this.ctx.strokeRect(-markerSize, -markerSize, markerSize * 2, markerSize * 2); this.ctx.restore(); } } }); } // Draw current time indicator (vertical bar) if set if (this.indicatorTime !== undefined && this.indicatorTime >= 0 && this.indicatorTime <= this.duration) { const indicatorX = leftMargin + yAxisWidth + ((this.indicatorTime / this.duration) * graphWidth); this.ctx.strokeStyle = '#00ff00'; this.ctx.lineWidth = 2 * dpr; this.ctx.setLineDash([5 * dpr, 5 * dpr]); // Dashed line // Draw vertical line through graph area this.ctx.beginPath(); this.ctx.moveTo(indicatorX, topMargin); this.ctx.lineTo(indicatorX, topMargin + graphHeight); this.ctx.stroke(); this.ctx.setLineDash([]); // Reset dash } // Draw keyframe markers this.keyframes.forEach((kf, index) => { const x = leftMargin + yAxisWidth + ((kf.time / this.duration) * graphWidth); const y = topMargin + ((1 - this.normalizeValue(kf.value)) * graphHeight); // Highlight if being dragged or selected const isDragging = this.draggingIndex === index; const isSelected = this.selectedKeyframeIndex === index; if (isDragging) { this.ctx.fillStyle = this.getThemeColor('--keyframe-dragging-color'); } else if (isSelected) { this.ctx.fillStyle = this.getThemeColor('--keyframe-selected-color'); } else { this.ctx.fillStyle = this.getThemeColor('--keyframe-color'); } // Draw diamond marker (smaller when collapsed) this.ctx.save(); this.ctx.translate(x, y); this.ctx.rotate(Math.PI / 4); const baseSize = this.collapsed ? 4 : 6; const size = (isDragging || isSelected) ? baseSize + 2 : baseSize; this.ctx.fillRect(-size * dpr, -size * dpr, size * 2 * dpr, size * 2 * dpr); this.ctx.restore(); // Draw selection ring for selected keyframe (hide when collapsed) if (isSelected && !isDragging && !this.collapsed) { this.ctx.strokeStyle = this.getThemeColor('--keyframe-selected-color'); this.ctx.lineWidth = 2; this.ctx.beginPath(); this.ctx.arc(x, y, 12 * dpr, 0, Math.PI * 2); this.ctx.stroke(); } // Draw hover ring for draggable indication (hide when collapsed) if (!isDragging && !isSelected && !this.collapsed) { const textColor = this.getThemeColor('--canvas-text-secondary'); // Apply opacity to text color for hover ring const rgbaMatch = textColor.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); if (rgbaMatch) { this.ctx.strokeStyle = `rgba(${rgbaMatch[1]}, ${rgbaMatch[2]}, ${rgbaMatch[3]}, 0.3)`; } else { this.ctx.strokeStyle = textColor; } this.ctx.lineWidth = 1; this.ctx.beginPath(); this.ctx.arc(x, y, 10 * dpr, 0, Math.PI * 2); this.ctx.stroke(); } // Draw value label above each keyframe (hide when collapsed) if (!this.collapsed) { const valueText = kf.value.toFixed(this.snapValue < 1 ? 1 : 0); this.ctx.font = `${baseFontSize * dpr}px system-ui, -apple-system, sans-serif`; this.ctx.textAlign = 'center'; this.ctx.textBaseline = 'bottom'; // Position label above the keyframe with some padding const labelY = y - 18 * dpr; // Draw background for better readability const textMetrics = this.ctx.measureText(valueText); const textWidth = textMetrics.width; const textHeight = 14 * dpr; const padding = 4 * dpr; this.ctx.fillStyle = this.getThemeColor('--canvas-label-bg'); this.ctx.fillRect(x - textWidth / 2 - padding, labelY - textHeight - padding, textWidth + padding * 2, textHeight + padding * 2); // Draw text - use keyframe color if (isDragging) { this.ctx.fillStyle = this.getThemeColor('--keyframe-dragging-color'); } else if (isSelected) { this.ctx.fillStyle = this.getThemeColor('--keyframe-selected-color'); } else { this.ctx.fillStyle = this.getThemeColor('--keyframe-color'); } this.ctx.fillText(valueText, x, labelY); } }); // Tooltip is rendered as a lightweight DOM overlay to avoid canvas redraw on hover. } buildHoverTooltip(x, y, rect) { const { leftMargin, yAxisWidth, rightMargin, topMargin, graphHeight, graphWidth } = this.getGraphDimensions(rect); // Check if in graph area if (x < leftMargin + yAxisWidth || x > rect.width - rightMargin || y < topMargin || y > topMargin + graphHeight) { return null; } const adjustedX = x - leftMargin - yAxisWidth; const time = (adjustedX / graphWidth) * this.duration; let tooltipLines = []; if (this.tooltipMode === 'cursor') { // Show interpolated value at cursor position on schedule line const tooltipValue = this.getInterpolatedValue(time); if (tooltipValue !== null) { const hours = Math.floor(time); const minutes = Math.round((time - hours) * 60); tooltipLines.push({ text: `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')} - ${tooltipValue.toFixed(1)}°`, color: this.getThemeColor('--keyframe-color') }); } } else { // Get current time in decimal hours const now = new Date(); const currentTime = now.getHours() + now.getMinutes() / 60; // Only show history if hovering over past time or close to current time (within 30 minutes) const timeDiff = time - currentTime; const showHistory = timeDiff <= 0.5; // Show if in past or within 30 minutes of now if (showHistory && this.backgroundGraphs && this.backgroundGraphs.length > 0) { this.backgroundGraphs.forEach((historyGraph, graphIndex) => { if (!historyGraph || !historyGraph.keyframes || historyGraph.keyframes.length === 0) return; let displayPoint = null; if (time >= currentTime) { // At or past current time (but close to now): show latest temperature displayPoint = historyGraph.keyframes[historyGraph.keyframes.length - 1]; } else { // Before current time: find closest data point to hover time let closestPoint = null; let closestDist = Infinity; for (const point of historyGraph.keyframes) { const dist = Math.abs(point.time - time); if (dist < closestDist) { closestDist = dist; closestPoint = point; } } if (closestPoint && closestDist < 0.5) { // Within 30 minutes displayPoint = closestPoint; } } if (displayPoint) { const label = historyGraph.label || 'Temperature'; const color = historyGraph.color || BACKGROUND_GRAPH_COLORS[graphIndex % BACKGROUND_GRAPH_COLORS.length]; tooltipLines.push({ text: `${label}: ${displayPoint.value.toFixed(1)}°`, color }); } }); } } if (tooltipLines.length === 0) return null; return { lines: tooltipLines }; } hideHoverTooltip() { if (!this.tooltipEl) return; this.tooltipEl.hidden = true; } getOpaqueColor(color) { if (!this.shadowRoot) return color; if (!this.colorProbeEl) { this.colorProbeEl = document.createElement('span'); this.colorProbeEl.style.position = 'absolute'; this.colorProbeEl.style.visibility = 'hidden'; this.colorProbeEl.style.pointerEvents = 'none'; this.colorProbeEl.style.width = '0'; this.colorProbeEl.style.height = '0'; this.colorProbeEl.style.overflow = 'hidden'; this.shadowRoot.appendChild(this.colorProbeEl); } this.colorProbeEl.style.color = color; const resolved = getComputedStyle(this.colorProbeEl).color; const rgbaMatch = resolved.match(/rgba\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*,\s*([0-9]*\.?[0-9]+)\s*\)/i); if (rgbaMatch) { return `rgb(${rgbaMatch[1]}, ${rgbaMatch[2]}, ${rgbaMatch[3]})`; } return resolved || color; } renderHoverTooltip() { if (!this.canvas || !this.tooltipEl || this.collapsed || this.isDragging || this.isPanning) { this.hideHoverTooltip(); return; } const rect = this.canvas.getBoundingClientRect(); const tooltip = this.buildHoverTooltip(this.hoverX, this.hoverY, rect); if (!tooltip) { this.hideHoverTooltip(); return; } this.tooltipEl.replaceChildren(); tooltip.lines.forEach(line => { const lineEl = document.createElement('div'); lineEl.className = 'cursor-tooltip-line'; lineEl.textContent = line.text; if (line.color) { lineEl.style.color = this.getOpaqueColor(line.color); } this.tooltipEl.appendChild(lineEl); }); this.tooltipEl.hidden = false; // Stable anchor: above-left by default; flip horizontally to the right when near left edge. const cursorOffsetX = 32; const cursorOffsetY = 10; const margin = 10; const tooltipWidth = this.tooltipEl.offsetWidth; const tooltipHeight = this.tooltipEl.offsetHeight; const maxX = Math.max(margin, rect.width - tooltipWidth - margin); const maxY = Math.max(margin, rect.height - tooltipHeight - margin); let tooltipX = this.hoverX - tooltipWidth - cursorOffsetX; if (tooltipX < margin) { tooltipX = this.hoverX + cursorOffsetX; } tooltipX = Math.max(margin, Math.min(tooltipX, maxX)); const tooltipY = Math.max(margin, Math.min(this.hoverY - tooltipHeight - cursorOffsetY, maxY)); this.tooltipEl.style.left = `${Math.round(tooltipX)}px`; this.tooltipEl.style.top = `${Math.round(tooltipY)}px`; } queueHoverTooltipRender(x, y) { this.hoverX = x; this.hoverY = y; if (this.hoverRenderPending) return; this.hoverRenderPending = true; requestAnimationFrame(() => { this.hoverRenderPending = false; this.renderHoverTooltip(); }); } getInterpolatedValue(time) { if (this.keyframes.length === 0) return null; if (this.keyframes.length === 1) return this.keyframes[0].value; // Find surrounding keyframes let beforeKf = this.keyframes[0]; let afterKf = this.keyframes[this.keyframes.length - 1]; for (let i = 0; i < this.keyframes.length - 1; i++) { if (this.keyframes[i].time <= time && this.keyframes[i + 1].time >= time) { beforeKf = this.keyframes[i]; afterKf = this.keyframes[i + 1]; break; } } // Linear interpolation if (beforeKf.time === afterKf.time) return beforeKf.value; const ratio = (time - beforeKf.time) / (afterKf.time - beforeKf.time); return beforeKf.value + (afterKf.value - beforeKf.value) * ratio; } handleCanvasMouseDown(e) { if (!this.canvas || this.collapsed || this.readonly) return; const rect = this.canvas.getBoundingClientRect(); const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX; const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY; const x = clientX - rect.left; const y = clientY - rect.top; const { leftMargin, yAxisWidth, rightMargin, topMargin, bottomMargin, graphHeight, graphWidth } = this.getGraphDimensions(rect); // Check if scrollable (only in expanded mode) const isScrollable = !this.collapsed && this.wrapperEl && this.wrapperEl.scrollWidth > this.wrapperEl.clientWidth; // For touch devices, set up long-press detection FIRST (before any returns) if (e instanceof TouchEvent) { const currentTime = Date.now(); const timeSinceLastClick = currentTime - this.lastClickTime; const dx = x - this.lastClickX; const dy = y - this.lastClickY; const distance = Math.sqrt(dx * dx + dy * dy); // Double-tap detected (within 300ms and 30px) if (timeSinceLastClick < 300 && distance < 30) { this.handleDoubleClick(x, y, rect); this.lastClickTime = 0; // Reset to prevent triple-tap return; } this.lastClickTime = currentTime; this.lastClickX = x; this.lastClickY = y; // Start long-press timer for touch devices (600ms) this.holdStartX = x; this.holdStartY = y; this.clearHoldTimer(); this.holdTimer = window.setTimeout(() => { this.handleContextMenu(x, y, rect); // Clear dragging state if long press completes this.isDragging = false; this.draggingIndex = null; }, 600); } // Check if clicking on existing keyframe const clickedIndex = this.keyframes.findIndex(kf => { const kfX = leftMargin + yAxisWidth + ((kf.time / this.duration) * graphWidth); const kfY = topMargin + ((1 - this.normalizeValue(kf.value)) * graphHeight); const distance = Math.sqrt(Math.pow(x - kfX, 2) + Math.pow(y - kfY, 2)); return distance < 20; }); if (clickedIndex >= 0) { // Check for double-click to delete (desktop) if (e instanceof MouseEvent) { const currentTime = Date.now(); const timeSinceLastClick = currentTime - this.lastClickTime; // Double-click detected on same keyframe (within 300ms) if (timeSinceLastClick < 300 && this.lastClickIndex === clickedIndex) { this.deleteKeyframe(clickedIndex); this.lastClickTime = 0; // Reset this.lastClickIndex = -1; this.draggingIndex = null; // Clear dragging state this.isDragging = false; this.justDeletedKeyframe = true; // Prevent dblclick handler from adding setTimeout(() => this.justDeletedKeyframe = false, 100); // Reset after event propagation e.preventDefault(); e.stopPropagation(); return; } this.lastClickTime = currentTime; this.lastClickIndex = clickedIndex; } // Prepare to drag existing keyframe (but wait to see if it's a long press or just a click) this.draggingIndex = clickedIndex; this.hasMoved = false; // Reset movement tracking this.dragStartX = x; this.dragStartY = y; // Don't set isDragging yet - we'll set it on first move // This allows clicks to work properly e.preventDefault(); return; } // Check if clicking on a segment (line between two keyframes) const segmentIndex = this.findSegmentAtPoint(x, y, rect); if (segmentIndex >= 0) { // Prepare to drag segment this.draggingSegment = { startIndex: segmentIndex, endIndex: segmentIndex + 1, initialStartTime: this.keyframes[segmentIndex].time, initialEndTime: this.keyframes[segmentIndex + 1].time, initialPointerX: x }; this.hasMoved = false; this.dragStartX = x; e.preventDefault(); return; } // If scrollable and not clicking on keyframe, prepare for panning if (isScrollable) { this.isPanning = true; this.panStartX = clientX; this.panStartScrollLeft = this.wrapperEl.scrollLeft; e.preventDefault(); } } handleCanvasMouseMove(e) { // Update hover position for tooltip (only for mouse, not touch) if (e instanceof MouseEvent && !this.isDragging && !this.isPanning && this.canvas) { this.queueHoverTooltipRender(e.offsetX, e.offsetY); } else if (e instanceof TouchEvent) { this.hideHoverTooltip(); } // Handle panning first (takes priority when active) if (this.isPanning && this.wrapperEl) { const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX; const deltaX = clientX - this.panStartX; this.wrapperEl.scrollLeft = this.panStartScrollLeft - deltaX; e.preventDefault(); return; } // Cancel long-press if moving during touch hold if (e instanceof TouchEvent && this.holdTimer) { const rect = this.canvas?.getBoundingClientRect(); if (rect) { this.wrapperEl?.scrollLeft || 0; const x = e.touches[0].clientX - rect.left; const y = e.touches[0].clientY - rect.top; const dx = x - this.holdStartX; const dy = y - this.holdStartY; const distance = Math.sqrt(dx * dx + dy * dy); // Cancel hold if moved more than 5px - user is dragging (match threshold below) if (distance > 5) { this.clearHoldTimer(); } } } if (!this.canvas) return; // Handle segment dragging if (this.draggingSegment !== null) { const rect = this.canvas.getBoundingClientRect(); const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX; this.wrapperEl?.scrollLeft || 0; const currentX = clientX - rect.left; const dx = currentX - this.dragStartX; const distance = Math.abs(dx); if (distance < 5) return; // Minimum movement threshold if (!this.hasMoved) { this.saveUndoState(); this.hasMoved = true; } const { leftMargin, yAxisWidth, graphWidth } = this.getGraphDimensions(rect); // Calculate time delta const pixelDelta = currentX - this.draggingSegment.initialPointerX; const timeDelta = (pixelDelta / graphWidth) * this.duration; // Apply delta to both keyframes let newStartTime = this.draggingSegment.initialStartTime + timeDelta; let newEndTime = this.draggingSegment.initialEndTime + timeDelta; // Constrain to not pass adjacent keyframes const { startIndex, endIndex } = this.draggingSegment; if (startIndex > 0) { const prevTime = this.keyframes[startIndex - 1].time; const minGap = this.duration / this.slots; if (newStartTime < prevTime + minGap) { const shift = (prevTime + minGap) - newStartTime; newStartTime += shift; newEndTime += shift; } } if (endIndex < this.keyframes.length - 1) { const nextTime = this.keyframes[endIndex + 1].time; const minGap = this.duration / this.slots; if (newEndTime > nextTime - minGap) { const shift = newEndTime - (nextTime - minGap); newStartTime -= shift; newEndTime -= shift; } } // Clamp to boundaries (max 23:59 to prevent 24:00) newStartTime = Math.max(0, newStartTime); newEndTime = Math.min(23 + (59 / 60), newEndTime); // Snap times to nearest slot (15-minute intervals) const slotDuration = this.duration / this.slots; newStartTime = Math.round(newStartTime / slotDuration) * slotDuration; newEndTime = Math.round(newEndTime / slotDuration) * slotDuration; // Update keyframes this.keyframes[startIndex].time = newStartTime; this.keyframes[endIndex].time = newEndTime; // Fire update event for settings panel this.dispatchEvent(new CustomEvent('nodeSettingsUpdate', { detail: { index: startIndex, keyframe: this.keyframes[startIndex] }, bubbles: true, composed: true })); this.drawTimeline(); return; } // Handle single keyframe dragging if (this.draggingIndex === null) return; const rect = this.canvas.getBoundingClientRect(); const clientX = e instanceof MouseEvent ? e.clientX : e.touches[0].clientX; const clientY = e instanceof MouseEvent ? e.clientY : e.touches[0].clientY; this.wrapperEl?.scrollLeft || 0; const currentX = clientX - rect.left; const currentY = clientY - rect.top; // Calculate distance from drag start const dx = currentX - this.dragStartX; const dy = currentY - this.dragStartY; const distance = Math.sqrt(dx * dx + dy * dy); // Only consider it a move if moved more than 5 pixels if (distance < 5) return; // Start dragging on first move if (!this.isDragging) { this.isDragging = true; // Save undo state when starting to drag this.saveUndoState(); } this.hasMoved = true; // Track that movement occurred if (!this.isDragging) return; let x = currentX; let y = currentY; const { leftMargin, yAxisWidth, rightMargin, topMargin, bottomMargin, graphHeight, graphWidth, canvasWidthCSS } = this.getGraphDimensions(rect); // Clamp to canvas bounds (graph area only) x = Math.max(leftMargin + yAxisWidth, Math.min(x, canvasWidthCSS - rightMargin)); y = Math.max(topMargin, Math.min(y, topMargin + graphHeight)); // Snap time to nearest slot (adjust for Y axis offset) const adjustedX = x - leftMargin - yAxisWidth; const slotWidth = graphWidth / this.slots; const slotIndex = Math.round(adjustedX / slotWidth); // Clamp to 23:59 (23.9833 hours) to prevent 24:00 let time = Math.min((slotIndex / this.slots) * this.duration, 23 + (59 / 60)); // Constrain time to not pass adjacent keyframes (array is already sorted) if (this.draggingIndex > 0) { const prevTime = this.keyframes[this.draggingIndex - 1].time; const minTime = prevTime + (this.duration / this.slots); // At least one slot apart time = Math.max(time, minTime); } if (this.draggingIndex < this.keyframes.length - 1) { const nextTime = this.keyframes[this.draggingIndex + 1].time; const maxTime = nextTime - (this.duration / this.slots); // At least one slot apart time = Math.min(time, maxTime); } // Value is in minValue-maxValue range (adjust for top margin) const adjustedY = y - topMargin; const normalizedValue = Math.max(0, Math.min(1, 1 - (adjustedY / graphHeight))); let value = Math.max(this.minValue, Math.min(this.maxValue, this.denormalizeValue(normalizedValue))); // Apply snapping if configured value = this.snapValueToGrid(value); // Update keyframe position const oldTime = this.keyframes[this.draggingIndex].time; this.keyframes = this.keyframes.map((kf, i) => i === this.draggingIndex ? { time, value } : kf); // Re-sort if time changed (might change position in array) if (Math.abs(time - oldTime) > 0.01) { this.sortKeyframes(); } // Fire update event for settings panel this.dispatchEvent(new CustomEvent('nodeSettingsUpdate', { detail: { index: this.draggingIndex, keyframe: this.keyframes[this.draggingIndex] }, bubbles: true, composed: true })); this.drawTimeline(); e.preventDefault(); } handleCanvasMouseUp(e) { this.clearHoldTimer(); this.hideHoverTooltip(); // Handle segment dragging completion if (this.draggingSegment !== null && this.hasMoved) { this.dispatchEvent(new CustomEvent('segment-moved', { detail: { startIndex: this.draggingSegment.startIndex, endIndex: this.draggingSegment.endIndex }, bubbles: true, composed: true })); this.draggingSegment = null; this.hasMoved = false; return; } // Handle single keyframe dragging completion if (this.draggingIndex !== null && this.hasMoved) { this.dispatchEvent(new CustomEvent('keyframe-moved', { detail: { index: this.draggingIndex, keyframe: this.keyframes[this.draggingIndex] }, bubbles: true, composed: true })); } // Handle tap selection for touch devices (when not dragging) if (e instanceof TouchEvent && this.draggingIndex !== null && !this.hasMoved) { // Select the keyframe this.selectedKeyframeIndex = this.draggingIndex; this.drawTimeline(); this.dispatchEvent(new CustomEvent('keyframe-clicked', { detail: { index: this.draggingIndex, keyframe: this.keyframes[this.draggingIndex] }, bubbles: true, composed: true })); } this.draggingIndex = null; this.draggingSegment = null; this.isDragging = false; this.hasMoved = false; this.isPanning = false; // Reset panning state } handleCanvasMouseLeave(e) { // Clear hover tooltip overlay this.hideHoverTooltip(); // Also handle mouseup logic if needed this.handleCanvasMouseUp(e); } handleCanvasClick(e) { if (!this.canvas || this.collapsed || this.readonly) return; // If we just finished dragging, don't process click if (this.hasMoved) return; const rect = this.canvas.getBoundingClientRect(); this.wrapperEl?.scrollLeft || 0; const x = e.clientX - rect.left; const y = e.clientY - rect.top; // Use CSS pixels for click detection (canvas drawing uses device pixels with dpr scaling) const { leftMargin, yAxisWidth, topMargin, graphHeight, graphWidth } = this.getGraphDimensions(rect); // Check if clicking on existing keyframe const clickedIndex = this.keyframes.findIndex(kf => { const kfX = leftMargin + yAxisWidth + ((kf.time / this.duration) * graphWidth); const kfY = topMargin + ((1 - this.normalizeValue(kf.value)) * graphHeight); const distance = Math.sqrt(Math.pow(x - kfX, 2) + Math.pow(y - kfY, 2)); return distance < 20; }); if (clickedIndex >= 0) { // Select the keyframe this.selectedKeyframeIndex = clickedIndex; this.drawTimeline(); this.dispatchEvent(new CustomEvent('keyframe-clicked', { detail: { index: clickedIndex, keyframe: this.keyframes[clickedIndex] }, bubbles: true, composed: true })); } else { // Clicked on empty area - deselect this.selectedKeyframeIndex = null; this.drawTimeline(); } } handleCanvasContextMenu(e) { if (!this.canvas || this.collapsed || this.readonly) return; e.preventDefault(); // Prevent default context menu const rect = this.canvas.getBoundingClientRect(); this.wrapperEl?.scrollLeft || 0; const x = e.clientX - rect.left; const y = e.clientY - rect.top; this.handleContextMenu(x, y, rect); } handleContextMenu(x, y, rect) { const { leftMargin, yAxisWidth, topMargin, graphHeight, graphWidth } = this.getGraphDimensions(rect); // Check if clicking on existing keyframe const clickedIndex = this.keyframes.findIndex(kf => { const kfX = leftMargin + yAxisWidth + ((kf.time / this.duration) * graphWidth); const kfY = topMargin + ((1 - this.normalizeValue(kf.value)) * graphHeight); const distance = Math.sqrt(Math.pow(x - kfX, 2) + Math.pow(y - kfY, 2)); return distance < 20; }); // Delete keyframe if clicked on one if (clickedIndex >= 0) { this.deleteKeyframe(clickedIndex); } } clearHoldTimer() { if (this.holdTimer) { window.clearTimeout(this.holdTimer); this.holdTimer = undefined; } } handleCanvasDoubleClick(e) { if (!this.canvas || this.collapsed || this.readonly || this.justDeletedKeyframe) return; const rect = this.canvas.getBoundingClientRect(); this.wrapperEl?.scrollLeft || 0; const x = e.clientX - rect.left; const y = e.clientY - rect.top; this.handleDoubleClick(x, y, rect); } handleDoubleClick(x, y, rect) { const { leftMargin, yAxisWidth, topMargin, graphHeight, graphWidth } = this.getGraphDimensions(rect); // Check if clicking on existing keyframe const clickedIndex = this.keyframes.findIndex(kf => { const kfX = leftMargin + yAxisWidth + ((kf.time / this.duration) * graphWidth); const kfY = topMargin + ((1 - this.normalizeValue(kf.value)) * graphHeight); const distance = Math.sqrt(Math.pow(x - kfX, 2) + Math.pow(y - kfY, 2)); return distance < 20; }); if (clickedIndex >= 0) return; // Don't add if clicking on existing // Check if click is within graph area if (x < leftMargin + yAxisWidth || y < topMargin || y > topMargin + graphHeight) return; // Snap time to nearest slot (adjust for Y axis offset) const adjustedX = x - leftMargin - yAxisWidth; const slotWidth = graphWidth / this.slots; const slotIndex = Math.round(adjustedX / slotWidth); // Clamp to 23:59 (23.9833 hours) to prevent 24:00 const time = Math.min((slotIndex / this.slots) * this.duration, 23 + (59 / 60)); // Value is in minValue-maxValue range (adjust for top margin) const adjustedY = y - topMargin; const normalizedValue = 1 - (adjustedY / graphHeight); let value = this.denormalizeValue(normalizedValue); // Apply snapping if configured value = this.snapValueToGrid(value); // Save state before adding this.saveUndoState(); this.keyframes = [...this.keyframes, { time, value }]; this.sortKeyframes(); // Keep array sorted this.drawTimeline(); this.dispatchEvent(new CustomEvent('keyframe-added', { detail: { time, value }, bubbles: true, composed: true })); } // Helper method to delete a keyframe deleteKeyframe(index) { if (index < 0 || index >= this.keyframes.length) return; // Save state before deleting this.saveUndoState(); const deletedKeyframe = this.keyframes[index]; // Remove from keyframes this.keyframes = this.keyframes.filter((_, i) => i !== index); // Clear selection if deleting selected keyframe if (this.selectedKeyframeIndex === index) { this.selectedKeyframeIndex = null; } else if (this.selectedKeyframeIndex !== null && this.selectedKeyframeIndex > index) { // Adjust selection index if deleting a keyframe before selected one this.selectedKeyframeIndex--; } this.drawTimeline(); this.dispatchEvent(new CustomEvent('keyframe-deleted', { detail: { index, keyframe: deletedKeyframe }, bubbles: true, composed: true })); } // Find segment (line between keyframes) at given point findSegmentAtPoint(x, y, rect) { const { leftMargin, yAxisWidth, topMargin, graphHeight, graphWidth } = this.getGraphDimensions(rect); // Check each segment for (let i = 0; i < this.keyframes.length - 1; i++) { const kf1 = this.keyframes[i]; const kf2 = this.keyframes[i + 1]; const x1 = leftMargin + yAxisWidth + ((kf1.time / this.duration) * graphWidth); const y1 = topMargin + ((1 - this.normalizeValue(kf1.value)) * graphHeight); const x2 = leftMargin + yAxisWidth + ((kf2.time / this.duration) * graphWidth); const y2 = topMargin + ((1 - this.normalizeValue(kf2.value)) * graphHeight); // Check distance to horizontal line segment (flat hold) if (x >= x1 && x <= x2) { const distanceToHorizontal = Math.abs(y - y1); if (distanceToHorizontal < 10) return i; } // Check distance to vertical line segment (step) if (Math.abs(x - x2) < 10 && ((y >= y1 && y <= y2) || (y >= y2 && y <= y1))) { return i; } } return -1; } clearKeyframes() { // Save state before clearing this.saveUndoState(); this.keyframes = []; this.selectedKeyframeIndex = null; // Clear selection this.drawTimeline(); this.dispatchEvent(new CustomEvent('keyframes-cleared', { bubbles: true, composed: true })); } // Save current state to undo stack emitUndoStackChanged(action, size, removedCount = 0) { this.dispatchEvent(new CustomEvent('undo-stack-changed', { detail: { action, size, removedCount, keyframeCount: this.keyframes.length }, bubbles: true, composed: true })); } saveUndoState() { // Deep copy current keyframes const stateCopy = this.keyframes.map(kf => ({ ...kf })); this.undoStack = [...this.undoStack, stateCopy]; this.emitUndoStackChanged('push', this.undoStack.length); // Limit undo stack to 50 entries if (this.undoStack.length > 50) { const removedCount = this.undoStack.length - 50; this.undoStack = this.undoStack.slice(-50); this.emitUndoStackChanged('trim', this.undoStack.length, removedCount); } this.updateUndoButtonState(); this.updateNavigationButtonsState(); } // Public undo method (can be called externally or via Ctrl+Z) undo() { if (this.undoStack.length === 0) return; // Pop last state from undo stack const previousState = this.undoStack[this.undoStack.length - 1]; this.undoStack = this.undoStack.slice(0, -1); this.emitUndoStackChanged('pop', this.undoStack.length, 1); // Restore previous state this.keyframes = previousState.map(kf => ({ ...kf })); this.drawTimeline(); this.updateUndoButtonState(); this.updateNavigationButtonsState(); this.dispatchEvent(new CustomEvent('keyframe-restored', { detail: { keyframes: this.keyframes }, bubbles: true, composed: true })); } // Set external undo button reference setUndoButton(buttonElement) { this.undoButton = buttonElement; // Add click handler if (this.undoButton) { this.undoButton.addEventListener('click', () => this.undo()); this.updateUndoButtonState(); } } // Set external previous button reference setPreviousButton(buttonElement) { this.previousButton = buttonElement; if (this.previousButton) { this.previousButton.addEventListener('click', () => this.selectPrevious()); this.updateNavigationButtonsState(); } } // Set external next button reference setNextButton(buttonElement) { this.nextButton = buttonElement; if (this.nextButton) { this.nextButton.addEventListener('click', () => this.selectNext()); this.updateNavigationButtonsState(); } } // Set external clear button reference setClearButton(buttonElement) { if (buttonElement) { buttonElement.addEventListener('click', () => this.clearKeyframes()); } } // Update navigation buttons disabled state updateNavigationButtonsState() { const canNavigate = this.keyframes.length >= 2; if (this.previousButton) { this.previousButton.disabled = !canNavigate; } if (this.nextButton) { this.nextButton.disabled = !canNavigate; } } // Update undo button disabled state updateUndoButtonState() { if (this.undoButton) { if (this.undoStack.length > 0) { this.undoButton.disabled = false; this.undoButton.style.opacity = '1'; } else { this.undoButton.disabled = true; this.undoButton.style.opacity = '0.5'; } } } selectPrevious() { if (this.keyframes.length === 0) return; // Array is already sorted by time if (this.selectedKeyframeIndex === null || this.selectedKeyframeIndex === 0) { // No selection or at start - select last keyframe this.selectedKeyframeIndex = this.keyframes.length - 1; } else { // Select previous this.selectedKeyframeIndex--; } this.drawTimeline(); this.dispatchEvent(new CustomEvent('keyframe-selected', { detail: { index: this.selectedKeyframeIndex, keyframe: this.keyframes[this.selectedKeyframeIndex] }, bubbles: true, composed: true })); } selectNext() { if (this.keyframes.length === 0) return; // Array is already sorted by time if (this.selectedKeyframeIndex === null || this.selectedKeyframeIndex >= this.keyframes.length - 1) { // No selection or at end - select first keyframe this.selectedKeyframeIndex = 0; } else { // Select next this.selectedKeyframeIndex++; } this.drawTimeline(); this.dispatchEvent(new CustomEvent('keyframe-selected', { detail: { index: this.selectedKeyframeIndex, keyframe: this.keyframes[this.selectedKeyframeIndex] }, bubbles: true, composed: true })); } toggleConfig() { this.showConfig = !this.showConfig; } toggleCollapse() { if (!this.allowCollapse) return; // Don't allow collapsing if disabled this.hideHoverTooltip(); this.collapsed = !this.collapsed; // Update canvas height after state change setTimeout(() => { this.updateCanvasSize(); this.drawTimeline(); this.checkScrollVisibility(); }, 50); } checkScrollVisibility() { if (!this.wrapperEl) return; const isScrollable = !this.collapsed && this.wrapperEl.scrollWidth > this.wrapperEl.clientWidth; if (!isScrollable) { this.showScrollNavLeft = false; this.showScrollNavRight = false; return; } const scrollLeft = this.wrapperEl.scrollLeft; const maxScroll = this.wrapperEl.scrollWidth - this.wrapperEl.clientWidth; // Show left button if not at start (with 1px tolerance) this.showScrollNavLeft = scrollLeft > 1; // Show right button if not at end (with 1px tolerance) this.showScrollNavRight = scrollLeft < maxScroll - 1; } scrollToStart() { if (this.wrapperEl) { this.wrapperEl.scrollTo({ left: 0, behavior: 'smooth' }); } } scrollToEnd() { if (this.wrapperEl) { this.wrapperEl.scrollTo({ left: this.wrapperEl.scrollWidth, behavior: 'smooth' }); } } updateSlots(e) { const input = e.target; const newSlots = Math.max(1, Math.min(288, parseInt(input.value) || 1)); if (newSlots !== this.slots) { this.slots = newSlots; this.updateCanvasSize(); this.drawTimeline(); } } updateDuration(e) { const input = e.target; const newDuration = Math.max(1, Math.min(168, parseInt(input.value) || 1)); if (newDuration !== this.duration) { this.duration = newDuration; this.drawTimeline(); } } updatePreviousDayEnd(e) { const input = e.target; if (input.value === '') { this.previousDayEndValue = undefined; } else { const value = parseFloat(input.value); this.previousDayEndValue = Math.max(this.minValue, Math.min(this.maxValue, value)); } this.drawTimeline(); } updateMinValue(e) { const input = e.target; const newMin = parseFloat(input.value); if (!isNaN(newMin) && newMin < this.maxValue) { this.minValue = newMin; this.drawTimeline(); } } updateMaxValue(e) { const input = e.target; const newMax = parseFloat(input.value); if (!isNaN(newMax) && newMax > this.minValue) { this.maxValue = newMax; this.drawTimeline(); } } updateSnapValue(e) { const input = e.target; if (input.value === '') { this.snapValue = 0; } else { const newSnap = parseFloat(input.value); this.snapValue = !isNaN(newSnap) && newSnap >= 0 ? newSnap : 0; } } updateXAxisLabel(e) { const input = e.target; this.xAxisLabel = input.value; this.drawTimeline(); } updateYAxisLabel(e) { const input = e.target; this.yAxisLabel = input.value; this.drawTimeline(); } render() { const slotDuration = this.duration / this.slots; const slotMinutes = slotDuration * 60; return b `
${!this.showHeader && this.title && this.allowCollapse ? b `
${this.title}
` : !this.showHeader && this.title ? b `
${this.title}
` : ''} ${this.showHeader ? b `
${this.title || `Timeline Editor (${this.duration}h • ${this.slots} slots @ ${slotMinutes.toFixed(0)}min)`}
${this.allowCollapse ? b ` ` : ''}
` : ''} ${this.showConfig ? b `
0 ? this.snapValue.toString() : ''} @change=${this.updateSnapValue} />
` : ''}
${this.backgroundGraphs.length > 0 ? b `
Legend
${!this.legendCollapsed ? b `
${this.backgroundGraphs.map((bgGraph, graphIndex) => b `
${bgGraph.label || `Entity ${graphIndex + 1}`}
`)}
` : ''}
` : ''}
${this.collapsed ? b `
Click to expand
` : ''}
${this.showScrollNavLeft ? b `
` : ''} ${this.showScrollNavRight ? b `
` : ''}
Double-click to add • Drag to move • Right-click/hold to delete • ${this.keyframes.length} keyframes
`; } }; KeyframeTimeline.styles = i$3 ` :host { display: block; width: 100%; --timeline-height: 400px; --timeline-height-collapsed: 100px; --timeline-bg: var(--card-background-color, #1c1c1c); --timeline-track: var(--secondary-background-color, #2c2c2c); --timeline-ruler: var(--divider-color, rgba(255, 255, 255, 0.12)); --keyframe-color: var(--accent-color, var(--primary-color, #03a9f4)); --keyframe-selected-color: var(--success-color, #4caf50); --keyframe-dragging-color: var(--warning-color, #ff9800); --canvas-text-primary: var(--primary-text-color, rgba(255, 255, 255, 0.9)); --canvas-text-secondary: var(--secondary-text-color, rgba(255, 255, 255, 0.7)); --canvas-grid-line: rgba(68, 68, 68, 0.3); --canvas-grid-line-major: rgba(68, 68, 68, 0.5); --canvas-label-bg: var(--card-background-color, rgba(0, 0, 0, 0.7)); --indicator-color: var(--accent-color, #ff9800); } .timeline-container { background: var(--background); border-radius: 4px; padding: 0; user-select: none; } .timeline-title { font-size: 16px; font-weight: 600; color: var(--primary-text-color, #e1e1e1); cursor: pointer; user-select: none; margin-bottom: 12px; } .timeline-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; color: var(--primary-text-color, #e1e1e1); } .timeline-header span { cursor: pointer; user-select: none; } .timeline-controls { display: flex; gap: 8px; } button { background: var(--primary-color, #03a9f4); color: var(--primary-text-color, white); border: none; padding: 6px 12px; border-radius: 4px; cursor: pointer; font-size: 14px; } button:hover { opacity: 0.9; } button:disabled { opacity: 0.5; cursor: not-allowed; } button.secondary { background: var(--secondary-background-color, #2c2c2c); border: 1px solid var(--divider-color, rgba(255, 255, 255, 0.12)); } .config-panel { background: var(--secondary-background-color, #2c2c2c); border-radius: 4px; padding: 12px; margin-bottom: 12px; } .config-row { display: flex; align-items: center; gap: 8px; margin-bottom: 8px; } .config-row label { min-width: 80px; font-size: 14px; } input[type="number"], input[type="text"] { background: var(--timeline-track); color: var(--primary-text-color, #e1e1e1); border: 1px solid var(--divider-color, rgba(255, 255, 255, 0.12)); border-radius: 4px; padding: 4px 8px; font-size: 14px; } .slot-list { display: flex; flex-direction: column; gap: 6px; margin-top: 8px; } .slot-item { display: flex; align-items: center; gap: 8px; padding: 4px; background: var(--timeline-track); border-radius: 4px; } .slot-color { width: 20px; height: 20px; border-radius: 3px; cursor: pointer; border: 1px solid var(--divider-color, rgba(255, 255, 255, 0.12)); } .timeline-canvas-wrapper { width: 100%; overflow-x: auto; overflow-y: hidden; background: var(--timeline-track); border-radius: 4px; transition: height 0.3s ease; cursor: pointer; scrollbar-width: thin; scrollbar-color: var(--scrollbar-thumb-color, rgba(128, 128, 128, 0.4)) transparent; } .timeline-canvas-wrapper::-webkit-scrollbar { height: 8px; } .timeline-canvas-wrapper::-webkit-scrollbar-track { background: transparent; } .timeline-canvas-wrapper::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb-color, rgba(128, 128, 128, 0.4)); border-radius: 4px; } .timeline-canvas-wrapper::-webkit-scrollbar-thumb:hover { background: var(--scrollbar-thumb-color-hover, rgba(128, 128, 128, 0.6)); } .timeline-canvas-wrapper.expanded { cursor: default; } .timeline-canvas-wrapper:not(.expanded) { overflow-x: hidden; } .timeline-canvas { min-width: max(100%, 800px); height: var(--timeline-height); background: var(--timeline-track); cursor: crosshair; position: relative; touch-action: none; transition: height 0.3s ease; } .timeline-canvas.collapsed { min-width: 100%; height: var(--timeline-height-collapsed); cursor: pointer; } .timeline-canvas.dragging { cursor: grabbing; } canvas { display: block; width: 100%; height: 100%; } @media (max-width: 800px) { .timeline-canvas:not(.collapsed) { min-width: 800px; } } .expand-hint { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); background: var(--card-background-color, rgba(0, 0, 0, 0.7)); color: var(--primary-text-color, white); padding: 8px 16px; border-radius: 4px; font-size: 12px; pointer-events: none; opacity: 0; transition: opacity 0.2s ease; border: 1px solid var(--divider-color, rgba(255, 255, 255, 0.2)); } .timeline-canvas-wrapper:hover .expand-hint { opacity: 1; } .timeline-canvas-wrapper.expanded .expand-hint { display: none; } .scroll-nav { position: absolute; top: 10px; transform: none; background: var(--card-background-color, rgba(0, 0, 0, 0.6)); color: var(--primary-text-color, white); border: 1px solid var(--divider-color, rgba(255, 255, 255, 0.2)); border-radius: 4px; width: 32px; height: 32px; display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 10; transition: background 0.2s ease; user-select: none; pointer-events: auto; /* Ensure button is clickable but doesn't block canvas unnecessarily */ } .scroll-nav:hover { background: var(--secondary-background-color, rgba(0, 0, 0, 0.8)); } .scroll-nav.left { left: -10px; } .scroll-nav.right { right: -10px; } .graph-legend { position: absolute; top: 10px; left: 70px; z-index: 12; min-width: 160px; max-width: 240px; background: rgba(0, 0, 0, 0.5); border: 1px solid var(--divider-color, rgba(255, 255, 255, 0.2)); border-radius: 6px; color: var(--canvas-text-primary); font-size: 12px; } .graph-legend-header { display: flex; align-items: center; gap: 6px; padding: 6px 8px; cursor: pointer; user-select: none; font-weight: 600; } .graph-legend-toggle { display: inline-block; font-size: 11px; transform: rotate(90deg); transition: transform 0.2s ease; } .graph-legend.collapsed .graph-legend-toggle { transform: rotate(0deg); } .graph-legend-items { padding: 0 8px 8px 8px; display: flex; flex-direction: column; gap: 6px; max-height: 180px; overflow-y: auto; } .graph-legend-item { display: flex; align-items: center; gap: 8px; min-width: 0; } .graph-legend-swatch { width: 10px; height: 10px; border-radius: 2px; border: 1px solid var(--divider-color, rgba(255, 255, 255, 0.3)); flex: 0 0 auto; } .graph-legend-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--canvas-text-secondary); } .cursor-tooltip { position: absolute; z-index: 20; pointer-events: none; background-color: rgba(var(--rgb-card-background-color, 224, 224, 224), 0.9); background-image: none; border: 1px solid var(--primary-color); border-radius: 4px; padding: 8px; font-size: 13px; line-height: 18px; white-space: nowrap; } .cursor-tooltip-line { color: var(--keyframe-color); font-weight: 600; opacity: 1; } .info { margin-top: 12px; font-size: 12px; color: var(--secondary-text-color, #9e9e9e); } `; __decorate([ n({ type: Number }) ], KeyframeTimeline.prototype, "duration", void 0); __decorate([ n({ type: Number }) ], KeyframeTimeline.prototype, "slots", void 0); __decorate([ n({ type: Array }) ], KeyframeTimeline.prototype, "keyframes", void 0); __decorate([ n({ type: Number }) ], KeyframeTimeline.prototype, "previousDayEndValue", void 0); __decorate([ n({ type: Number }) ], KeyframeTimeline.prototype, "minValue", void 0); __decorate([ n({ type: Number }) ], KeyframeTimeline.prototype, "maxValue", void 0); __decorate([ n({ type: Number }) ], KeyframeTimeline.prototype, "snapValue", void 0); __decorate([ n({ type: String }) ], KeyframeTimeline.prototype, "xAxisLabel", void 0); __decorate([ n({ type: String }) ], KeyframeTimeline.prototype, "yAxisLabel", void 0); __decorate([ n({ type: String }) ], KeyframeTimeline.prototype, "title", void 0); __decorate([ n({ type: Boolean }) ], KeyframeTimeline.prototype, "showHeader", void 0); __decorate([ n({ type: Boolean }) ], KeyframeTimeline.prototype, "allowCollapse", void 0); __decorate([ n({ type: Boolean }) ], KeyframeTimeline.prototype, "readonly", void 0); __decorate([ n({ type: Number }) ], KeyframeTimeline.prototype, "indicatorTime", void 0); __decorate([ n({ type: Boolean }) ], KeyframeTimeline.prototype, "showCurrentTime", void 0); __decorate([ n({ type: Array }) ], KeyframeTimeline.prototype, "backgroundGraphs", void 0); __decorate([ n({ type: Array }) ], KeyframeTimeline.prototype, "advanceHistory", void 0); __decorate([ n({ type: String, attribute: false }) ], KeyframeTimeline.prototype, "tooltipMode", void 0); __decorate([ r() ], KeyframeTimeline.prototype, "canvasWidth", void 0); __decorate([ r() ], KeyframeTimeline.prototype, "canvasHeight", void 0); __decorate([ r() ], KeyframeTimeline.prototype, "showConfig", void 0); __decorate([ r() ], KeyframeTimeline.prototype, "draggingIndex", void 0); __decorate([ r() ], KeyframeTimeline.prototype, "draggingSegment", void 0); __decorate([ r() ], KeyframeTimeline.prototype, "selectedKeyframeIndex", void 0); __decorate([ r() ], KeyframeTimeline.prototype, "collapsed", void 0); __decorate([ r() ], KeyframeTimeline.prototype, "showScrollNavLeft", void 0); __decorate([ r() ], KeyframeTimeline.prototype, "showScrollNavRight", void 0); __decorate([ r() ], KeyframeTimeline.prototype, "undoStack", void 0); __decorate([ r() ], KeyframeTimeline.prototype, "legendCollapsed", void 0); KeyframeTimeline = __decorate([ t('keyframe-timeline') ], KeyframeTimeline); export { KeyframeTimeline }; //# sourceMappingURL=keyframe-timeline.js.map