Five defects found by exercising the running app rather than reading it: - Backspace inside the sidebar rename field deleted the trendline instead of a character. The window keydown handler never checked what was focused, so correcting a typo in a line's name destroyed the line. - playAlert() built a new AudioContext per alert and never closed it. Browsers cap a document at roughly six, after which alerts stop making any sound. One shared context now, with nodes released on end and a resume() for the autoplay policy. - Stored layer preferences were used verbatim, so any key added to defaultPrefs later would be missing for existing visitors. A missing enabled.ma is a crash rather than a cosmetic gap. They are now deep-merged onto the defaults, and unparseable state falls back instead of throwing. - The alert log keyed rows on a second-resolution timestamp, so two alerts in the same second collided. - Clusters embedded whole Level objects, including a moving average's entire point history — hundreds of entries reaching back years. Because clusters are re-sent on every closed 1m bar, this shipped the whole levels payload once a minute. Members are now compact summaries and the client joins on id; Cluster.to_dict() also stops round-tripping through asdict(), which was deep-copying those arrays before discarding them. /api/confluence drops from 61,838 to 1,245 bytes with five clusters live. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
92 lines
6.2 KiB
HTML
92 lines
6.2 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>/ES Confluence</title>
|
||
<link rel="stylesheet" href="/static/style.css">
|
||
<script src="https://unpkg.com/vue@3/dist/vue.global.prod.js"></script>
|
||
<script src="https://unpkg.com/lightweight-charts@5.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||
</head>
|
||
<body>
|
||
<div id="app">
|
||
<header>
|
||
<div><span class="eyebrow">CME FUTURES</span><h1>/ES <strong>CONFLUENCE</strong></h1></div>
|
||
<div class="status" :class="status.stream"><i></i>{{ status.stream }}<span v-if="status.delay_minutes"> ({{ status.delay_minutes }}min delay)</span> · {{ status.source || 'source' }}</div>
|
||
</header>
|
||
<main>
|
||
<section class="chart-shell">
|
||
<div class="chart-head">
|
||
<div><span class="symbol">{{ status.symbol || 'ES=F' }}</span><span class="price">{{ price == null ? '—' : price.toFixed(2) }}</span></div>
|
||
<div class="timeframes"><button v-for="tf in timeframes" :key="tf" :class="{active: timeframe === tf}" @click="selectTimeframe(tf)">{{ tf }}</button></div>
|
||
</div>
|
||
<div class="drawing-tools">
|
||
<button :class="{active: drawMode}" @click="toggleDraw">Trendline</button>
|
||
<input class="line-name" v-model.trim="drawName" placeholder="Line name" aria-label="Trendline name">
|
||
<input type="color" v-model="drawColor" aria-label="New trendline color">
|
||
<select v-model.number="drawWidth" aria-label="New trendline width"><option v-for="width in [1,2,3,4]" :value="width">{{ width }}px</option></select>
|
||
<select v-model="drawSide" aria-label="Line side"><option value="support">Support</option><option value="resistance">Resistance</option></select>
|
||
<label><input type="checkbox" v-model="snap">Snap</label>
|
||
<button @click="deleteSelected" :disabled="!hasLineSelection">Delete</button>
|
||
<span v-if="drawMode">{{ drawPoints.length ? 'Place second point' : 'Place first point' }}</span>
|
||
<span v-else-if="selectedLine">Line selected</span>
|
||
</div>
|
||
<div id="chart"></div>
|
||
<div class="statusbar">
|
||
<span>FEED <b>{{ status.stream }}</b></span>
|
||
<span>LAST BAR <b>{{ barAge }}</b></span>
|
||
<span>HELD <b>{{ status.bars_held?.[timeframe] || 0 }} {{ timeframe }}</b></span>
|
||
</div>
|
||
</section>
|
||
<aside>
|
||
<h2>Layers</h2>
|
||
<div class="layer-group">
|
||
<label class="master"><input type="checkbox" :checked="allEnabled('1d')" @change="toggleGroup('1d', $event.target.checked)"><span class="swatch tf-1d"></span>Daily MAs</label>
|
||
<div class="periods"><label v-for="period in [10,20,50,100,200]" :key="period"><input type="checkbox" :value="period" v-model="prefs.enabled.ma['1d']">{{ period }}</label></div>
|
||
</div>
|
||
<div class="layer-group optional">
|
||
<label><input type="checkbox" :checked="allEnabled('1h')" @change="toggleGroup('1h', $event.target.checked)"><span class="swatch tf-1h"></span>1h MAs</label>
|
||
</div>
|
||
<div class="layer-group">
|
||
<label><input type="checkbox" v-model="prefs.enabled.manual"><span class="swatch manual"></span>Manual lines</label>
|
||
<label class="disabled"><input type="checkbox" disabled>Auto trendlines</label>
|
||
</div>
|
||
<label class="score-hidden"><input type="checkbox" v-model="prefs.hidden_levels_score">Hidden levels still count toward confluence</label>
|
||
<details class="sidebar-section" open>
|
||
<summary>Trendlines ({{ manualLines.length }})</summary>
|
||
<div class="trendline-actions" v-if="manualLines.length">
|
||
<button @click="toggleSelectAll">{{ allManualSelected ? 'Clear' : 'Select all' }}</button>
|
||
<button @click="deleteSelectedLines" :disabled="!selectedLines.length">Delete selected ({{ selectedLines.length }})</button>
|
||
</div>
|
||
<div v-if="!manualLines.length" class="empty">No manual trendlines.</div>
|
||
<div v-for="line in manualLines" :key="line.id" class="trendline-row" :class="{selected: selectedLines.includes(line.id)}" @click="toggleLineSelection(line.id)">
|
||
<input class="line-select" type="checkbox" :checked="selectedLines.includes(line.id)" :aria-label="`Select trendline ${line.number}`" @click.stop @change="toggleLineSelection(line.id)">
|
||
<input :value="line.label" aria-label="Trendline name" @click.stop @change="renameLine(line, $event.target.value)">
|
||
<span>#{{ line.number }} · {{ line.tf }} · {{ line.side }}</span>
|
||
<button @click.stop="deleteLine(line.id)" aria-label="Delete trendline">Delete</button>
|
||
<div class="line-style-controls" @click.stop>
|
||
<input type="color" :value="line.color || '#65b7cf'" aria-label="Trendline color" @change="updateLineStyle(line, { color: $event.target.value })">
|
||
<select :value="line.line_width || 2" aria-label="Trendline width" @change="updateLineStyle(line, { line_width: Number($event.target.value) })"><option v-for="width in [1,2,3,4]" :value="width">{{ width }}px</option></select>
|
||
</div>
|
||
</div>
|
||
</details>
|
||
<details class="sidebar-section">
|
||
<summary>Confluence zones</summary>
|
||
<div v-if="!clusters.length" class="empty">No active zones near current structure.</div>
|
||
<div v-for="cluster in clusters" :key="cluster.id" class="cluster" :class="cluster.side">
|
||
<div class="cluster-top"><b>{{ cluster.side }}</b><strong>{{ cluster.score.toFixed(1) }}</strong></div>
|
||
<div class="zone">{{ cluster.low.toFixed(2) }} – {{ cluster.high.toFixed(2) }}</div>
|
||
<div class="members">{{ cluster.members.map(member => member.label).join(' · ') }}</div>
|
||
<div class="distance">{{ cluster.distance > 0 ? '+' : '' }}{{ cluster.distance.toFixed(2) }} pts</div>
|
||
</div>
|
||
</details>
|
||
<h2>Alert log</h2>
|
||
<div v-if="!alerts.length" class="empty">No alerts fired.</div>
|
||
<div v-for="alert in alerts" :key="alert.key" class="alert-entry"><time>{{ alert.at }}</time>{{ alert.message }}</div>
|
||
</aside>
|
||
</main>
|
||
</div>
|
||
<script src="/static/chart.js"></script>
|
||
<script src="/static/app.js"></script>
|
||
</body>
|
||
</html>
|