diff --git a/app/analysis/confluence.py b/app/analysis/confluence.py index 78ee959..3528dae 100644 --- a/app/analysis/confluence.py +++ b/app/analysis/confluence.py @@ -1,4 +1,4 @@ -from dataclasses import asdict, dataclass +from dataclasses import dataclass from hashlib import sha1 from typing import Any @@ -17,10 +17,18 @@ class Cluster: distance: float def to_dict(self) -> dict[str, Any]: - value = asdict(self) - value["side"] = self.side.value - value["members"] = [member.to_dict() for member in self.members] - return value + # Built field by field rather than via asdict(), which would deep-copy + # every member's point history before we replaced it with summaries. + return { + "id": self.id, + "side": self.side.value, + "low": self.low, + "high": self.high, + "center": self.center, + "score": self.score, + "members": [member.summary() for member in self.members], + "distance": self.distance, + } def cluster_levels( diff --git a/app/analysis/levels.py b/app/analysis/levels.py index 7358796..3475312 100644 --- a/app/analysis/levels.py +++ b/app/analysis/levels.py @@ -50,3 +50,20 @@ class Level: value["tf"] = self.tf.value value["side"] = self.side.value return value + + def summary(self) -> dict[str, Any]: + """Compact form for embedding inside a cluster. + + Clusters go out on every closed 1m bar, and a moving average carries its + whole point history — hundreds of entries reaching back years. Embedding + the full level duplicated the entire levels payload once a minute. The + client already holds the full levels and joins on id. + """ + return { + "id": self.id, + "kind": self.kind.value, + "tf": self.tf.value, + "side": self.side.value, + "weight": self.weight, + "label": self.label, + } diff --git a/static/app.js b/static/app.js index 08c3f19..770328b 100644 --- a/static/app.js +++ b/static/app.js @@ -40,16 +40,44 @@ const defaultPrefs = { hidden_levels_score: false, }; +// Stored preferences outlive the shape they were written in. Anything added to +// defaultPrefs later would otherwise be missing for every existing visitor — +// and a missing `enabled.ma` is a crash, not a cosmetic gap. +function mergePrefs(defaults, stored) { + if (!stored || typeof stored !== 'object' || Array.isArray(stored)) return structuredClone(defaults); + const merged = structuredClone(defaults); + for (const [key, value] of Object.entries(stored)) { + if (!(key in merged)) continue; + const fallback = merged[key]; + if (fallback && typeof fallback === 'object' && !Array.isArray(fallback)) { + merged[key] = mergePrefs(fallback, value); + } else if (Array.isArray(fallback)) { + if (Array.isArray(value)) merged[key] = value; + } else if (value !== null && typeof value !== 'object') { + merged[key] = value; + } + } + return merged; +} + +function loadPrefs() { + try { + return mergePrefs(defaultPrefs, JSON.parse(localStorage.getItem('chart-layer-prefs'))); + } catch { + return structuredClone(defaultPrefs); + } +} + createApp({ setup() { const status = ref({ stream: 'disconnected', bars_held: {} }); const price = ref(null); - const storedPrefs = localStorage.getItem('chart-layer-prefs'); - const prefs = ref(storedPrefs ? JSON.parse(storedPrefs) : structuredClone(defaultPrefs)); + const prefs = ref(loadPrefs()); const timeframe = ref(prefs.value.base_tf || '1m'); const levels = ref([]); const clusters = ref([]); const alerts = ref([]); + let alertSequence = 0; const drawMode = ref(false); const drawName = ref(''); const drawColor = ref('#65b7cf'); @@ -107,8 +135,12 @@ createApp({ clusters.value = message.clusters; price.value = message.price; } else if (message.type === 'alert') { - alerts.value.unshift({ at: new Date().toLocaleTimeString(), message: message.message }); - alerts.value = alerts.value.slice(0, 20); + // Keyed on a counter, not the timestamp: two alerts inside the same + // second would collide and Vue would reuse the wrong row. + alerts.value = [ + { key: ++alertSequence, at: new Date().toLocaleTimeString(), message: message.message }, + ...alerts.value, + ].slice(0, 20); playAlert(); } }; @@ -126,16 +158,26 @@ createApp({ }; } + // One context for the page, not one per alert. Browsers cap how many a + // document may hold (~6), after which alerts silently stop making a sound. + let audioContext = null; + function playAlert() { - const context = new (window.AudioContext || window.webkitAudioContext)(); - const oscillator = context.createOscillator(); - const gain = context.createGain(); + const Context = window.AudioContext || window.webkitAudioContext; + if (!Context) return; + if (!audioContext) audioContext = new Context(); + // Autoplay policy suspends a context created before any user gesture. + if (audioContext.state === 'suspended') audioContext.resume(); + const oscillator = audioContext.createOscillator(); + const gain = audioContext.createGain(); oscillator.frequency.value = 740; - gain.gain.setValueAtTime(0.12, context.currentTime); - gain.gain.exponentialRampToValueAtTime(0.001, context.currentTime + 0.35); - oscillator.connect(gain).connect(context.destination); + gain.gain.setValueAtTime(0.12, audioContext.currentTime); + gain.gain.exponentialRampToValueAtTime(0.001, audioContext.currentTime + 0.35); + oscillator.connect(gain).connect(audioContext.destination); oscillator.start(); - oscillator.stop(context.currentTime + 0.35); + oscillator.stop(audioContext.currentTime + 0.35); + // Nodes are single-use; release them rather than letting them pile up. + oscillator.onended = () => { oscillator.disconnect(); gain.disconnect(); }; } function toggleDraw() { @@ -279,8 +321,16 @@ createApp({ syncVisibleLevels(); } + // Backspace is a normal editing key inside a field. Without this guard, + // fixing a typo in a trendline's name deletes the trendline. + function isEditing(target) { + if (!target) return false; + return target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName); + } + function handleKeydown(event) { - if ((event.key === 'Delete' || event.key === 'Backspace') && selectedLine.value) { + if (isEditing(event.target)) return; + if ((event.key === 'Delete' || event.key === 'Backspace') && hasLineSelection.value) { event.preventDefault(); deleteSelected(); } diff --git a/static/index.html b/static/index.html index b548930..bacd0dc 100644 --- a/static/index.html +++ b/static/index.html @@ -82,7 +82,7 @@