Replace the drawing strip with an armed-tool sidebar and drag placement

The drawing controls lived in a strip above the chart while the lines they
created were listed in the sidebar — two places for one concern, and the strip
cost vertical space the chart wanted.

The sidebar now carries a tool per object, each with its own parameters. A tool
head arms the tool; the gesture happens on the chart:

- Price level: press anywhere, drag to fine-tune, release. A tag follows the
  cursor showing the price snapped to the 0.25 tick, and that snapped value is
  exactly what gets committed.
- Trendline: press at one end, drag, release at the other. One gesture where it
  used to be two separate clicks and a "place first point / place second point"
  prompt.

Dragging from the palette itself was the other candidate and was rejected: a
price level is a one-point object and drops cleanly, but a trendline needs two
points, so it would still have wanted a second click afterwards and the two
tools would have behaved differently for no visible reason.

Panning and scaling are suspended while a tool is armed, or the drag that draws
a line also drags the chart out from under it. Arming survives exactly one
placement, so a stray drag afterwards cannot create a second object.

Typing an exact price stays, next to the drag: "somewhere around here" and
"exactly 7800" are different intents and both are cheap to support.

Verified by driving headless Chrome over CDP rather than by reading: arming sets
the class, the live tag reads 7770.75 mid-drag, the committed level is 7770.75
with zero slope, the trendline rubber-bands and saves with a real slope, and
both tools disarm afterwards.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Chris Amow 2026-08-10 02:37:22 -05:00
parent 1a1514bc77
commit d5edb12171
4 changed files with 254 additions and 73 deletions

View file

@ -78,17 +78,19 @@ createApp({
const clusters = ref([]); const clusters = ref([]);
const alerts = ref([]); const alerts = ref([]);
let alertSequence = 0; let alertSequence = 0;
const drawMode = ref(false); // Which tool the next chart gesture creates. null = pan/select as normal.
const armedTool = ref(null);
const drawName = ref(''); const drawName = ref('');
const drawColor = ref('#65b7cf'); const drawColor = ref('#65b7cf');
const drawWidth = ref(2); const drawWidth = ref(2);
const drawSide = ref('support'); const drawSide = ref('support');
const snap = ref(true); const snap = ref(true);
const drawPoints = ref([]);
const selectedLine = ref(null); const selectedLine = ref(null);
const selectedLines = ref([]); const selectedLines = ref([]);
const alertPrice = ref(null); const alertPrice = ref(null);
const alertNote = ref(''); const alertNote = ref('');
const levelColor = ref('#e0a34a');
const levelWidth = ref(2);
const timeframes = ['1m', '5m', '15m', '30m', '1h', '1d']; const timeframes = ['1m', '5m', '15m', '30m', '1h', '1d'];
const now = ref(Date.now()); const now = ref(Date.now());
let chartApi = null; let chartApi = null;
@ -187,47 +189,46 @@ createApp({
oscillator.onended = () => { oscillator.disconnect(); gain.disconnect(); }; oscillator.onended = () => { oscillator.disconnect(); gain.disconnect(); };
} }
function toggleDraw() { function armTool(tool) {
drawMode.value = !drawMode.value; armedTool.value = armedTool.value === tool ? null : tool;
drawPoints.value = [];
selectedLine.value = null; selectedLine.value = null;
chartApi.clearLinePreview(); chartApi.armTool(armedTool.value);
} }
function handleChartMove(param) { function handleChartClick(param) {
if (!drawMode.value || drawPoints.value.length !== 1) return; selectedLine.value = chartApi.hitTest(param);
chartApi.setLinePreview(drawPoints.value[0], chartApi.pointFromClick(param, snap.value), drawColor.value, drawWidth.value); selectedLines.value = selectedLine.value ? [selectedLine.value] : [];
} }
async function handleChartClick(param) { async function handleToolComplete(result) {
if (!drawMode.value) { // One placement per arming, so a tool cannot keep firing on stray drags.
selectedLine.value = chartApi.hitTest(param); armedTool.value = null;
selectedLines.value = selectedLine.value ? [selectedLine.value] : []; chartApi.armTool(null);
if (result.tool === 'level') {
alertPrice.value = result.price;
await addPriceAlert();
return; return;
} }
const point = chartApi.pointFromClick(param, snap.value); await createTrendline(result.start, result.end);
if (point.snappedSide) drawSide.value = point.snappedSide; }
drawPoints.value.push(point);
if (drawPoints.value.length < 2) return; async function createTrendline(first, second) {
chartApi.clearLinePreview(); const [start, end] = [first, second].sort((a, b) => a.t - b.t);
const [start, end] = drawPoints.value.sort((a, b) => a.t - b.t); const side = start.snappedSide || end.snappedSide || drawSide.value;
if (start.t === end.t) { drawPoints.value = []; return; }
const temporaryId = `tmp_${Date.now()}`; const temporaryId = `tmp_${Date.now()}`;
const optimistic = { const optimistic = {
id: temporaryId, kind: 'manual', tf: timeframe.value, side: drawSide.value, id: temporaryId, kind: 'manual', tf: timeframe.value, side,
weight: 1, score: 1, label: drawName.value || `${timeframe.value} ${drawSide.value}`, weight: 1, score: 1, label: drawName.value || `${timeframe.value} ${side}`,
anchor_t: start.t, anchor_p: start.p, slope: (end.p - start.p) / (end.t - start.t), anchor_t: start.t, anchor_p: start.p, slope: (end.p - start.p) / (end.t - start.t),
points: null, first_t: start.t, last_t: end.t, provisional: false, hidden: false, points: null, first_t: start.t, last_t: end.t, provisional: false, hidden: false,
color: drawColor.value, line_width: drawWidth.value, color: drawColor.value, line_width: drawWidth.value,
}; };
levels.value.push(optimistic); levels.value.push(optimistic);
syncVisibleLevels(); syncVisibleLevels();
drawPoints.value = [];
drawMode.value = false;
try { try {
const response = await apiFetch('/api/lines', { const response = await apiFetch('/api/lines', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tf: timeframe.value, side: drawSide.value, anchor_t: start.t, anchor_p: start.p, end_t: end.t, end_p: end.p, note: drawName.value, color: drawColor.value, line_width: drawWidth.value }), body: JSON.stringify({ tf: timeframe.value, side, anchor_t: start.t, anchor_p: start.p, end_t: end.t, end_p: end.p, note: drawName.value, color: drawColor.value, line_width: drawWidth.value }),
}); });
if (!response.ok) throw new Error(`HTTP ${response.status}`); if (!response.ok) throw new Error(`HTTP ${response.status}`);
const saved = await response.json(); const saved = await response.json();
@ -246,7 +247,10 @@ createApp({
if (!alertPrice.value) return; if (!alertPrice.value) return;
const response = await apiFetch('/api/lines/price', { const response = await apiFetch('/api/lines/price', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ price: alertPrice.value, note: alertNote.value }), body: JSON.stringify({
price: alertPrice.value, note: alertNote.value,
color: levelColor.value, line_width: levelWidth.value,
}),
}); });
if (!response.ok) { console.error(`Unable to add price alert: HTTP ${response.status}`); return; } if (!response.ok) { console.error(`Unable to add price alert: HTTP ${response.status}`); return; }
const saved = await response.json(); const saved = await response.json();
@ -402,13 +406,18 @@ createApp({
if (chartApi) chartApi.setSelectedLine(id); if (chartApi) chartApi.setSelectedLine(id);
}); });
watch(snap, value => {
if (chartApi) chartApi.setSnap(value);
});
onMounted(() => { onMounted(() => {
chartApi = new ConfluenceChart(); chartApi = new ConfluenceChart();
chartApi.create(document.getElementById('chart')); chartApi.create(document.getElementById('chart'));
chartApi.setClickHandler(handleChartClick); chartApi.setClickHandler(handleChartClick);
chartApi.setMoveHandler(handleChartMove); chartApi.setToolCompleteHandler(handleToolComplete);
chartApi.setLineChangeHandler(updateLineGeometry); chartApi.setLineChangeHandler(updateLineGeometry);
chartApi.setLineEndHandler(endLineHere); chartApi.setLineEndHandler(endLineHere);
chartApi.setSnap(snap.value);
window.addEventListener('keydown', handleKeydown); window.addEventListener('keydown', handleKeydown);
refreshStatus(); refreshStatus();
connect(); connect();
@ -421,6 +430,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown); window.removeEventListener('keydown', handleKeydown);
}); });
return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, drawMode, drawName, drawColor, drawWidth, drawSide, snap, drawPoints, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, addPriceAlert, selectTimeframe, allEnabled, toggleGroup, toggleDraw, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle }; return { status, price, barAge, timeframe, timeframes, prefs, clusters, alerts, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedLine, selectedLines, manualLines, hasLineSelection, allManualSelected, alertPrice, alertNote, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, selectLine, toggleLineSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle };
}, },
}).mount('#app'); }).mount('#app');

View file

@ -9,7 +9,6 @@ class ConfluenceChart {
this.bars = []; this.bars = [];
this.levels = []; this.levels = [];
this.onChartClick = null; this.onChartClick = null;
this.onChartMove = null;
this.tooltip = null; this.tooltip = null;
this.chartEl = null; this.chartEl = null;
this.clickListener = null; this.clickListener = null;
@ -23,6 +22,21 @@ class ConfluenceChart {
this.contextCutoff = null; this.contextCutoff = null;
this.contextListener = null; this.contextListener = null;
this.onLineEnd = null; this.onLineEnd = null;
// Tool arming: the sidebar decides what the next chart gesture creates.
this.armedTool = null;
this.gesture = null;
this.onToolComplete = null;
this.snapToBars = true;
this.priceTag = null;
this.toolDownListener = null;
this.toolMoveListener = null;
this.toolUpListener = null;
}
static TICK = 0.25;
static snapToTick(price) {
return Math.round(price / ConfluenceChart.TICK) * ConfluenceChart.TICK;
} }
create(el) { create(el) {
@ -88,6 +102,16 @@ class ConfluenceChart {
this.contextMenu.addEventListener('click', event => event.stopPropagation()); this.contextMenu.addEventListener('click', event => event.stopPropagation());
this.contextMenu.appendChild(endHere); this.contextMenu.appendChild(endHere);
el.appendChild(this.contextMenu); el.appendChild(this.contextMenu);
this.priceTag = document.createElement('div');
this.priceTag.className = 'chart-price-tag';
this.priceTag.hidden = true;
el.appendChild(this.priceTag);
this.toolDownListener = event => this.startToolGesture(event);
this.toolMoveListener = event => this.moveToolGesture(event);
this.toolUpListener = event => this.finishToolGesture(event);
el.addEventListener('pointerdown', this.toolDownListener);
window.addEventListener('pointermove', this.toolMoveListener);
window.addEventListener('pointerup', this.toolUpListener);
this.anchorMoveListener = event => this.moveAnchor(event); this.anchorMoveListener = event => this.moveAnchor(event);
this.anchorUpListener = event => this.finishAnchorDrag(event); this.anchorUpListener = event => this.finishAnchorDrag(event);
window.addEventListener('pointermove', this.anchorMoveListener); window.addEventListener('pointermove', this.anchorMoveListener);
@ -96,6 +120,9 @@ class ConfluenceChart {
el.addEventListener('contextmenu', this.contextListener); el.addEventListener('contextmenu', this.contextListener);
this.clickListener = event => { this.clickListener = event => {
this.hideContextMenu(); this.hideContextMenu();
// A placement gesture ends in a click too; selecting on it would pick
// whatever the new line happens to overlap.
if (this.armedTool) return;
if (!this.onChartClick) return; if (!this.onChartClick) return;
const bounds = el.getBoundingClientRect(); const bounds = el.getBoundingClientRect();
const point = { x: event.clientX - bounds.left, y: event.clientY - bounds.top }; const point = { x: event.clientX - bounds.left, y: event.clientY - bounds.top };
@ -109,7 +136,6 @@ class ConfluenceChart {
return; return;
} }
this.updateLineTooltip(param); this.updateLineTooltip(param);
if (this.onChartMove) this.onChartMove(param);
}); });
this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => this.renderAnchorHandles()); this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => this.renderAnchorHandles());
} }
@ -214,9 +240,125 @@ class ConfluenceChart {
this.renderAnchorHandles(); this.renderAnchorHandles();
} }
setClickHandler(handler) { this.onChartClick = handler; } // --- tool arming and placement gestures ---------------------------------
setMoveHandler(handler) { this.onChartMove = handler; } setToolCompleteHandler(handler) { this.onToolComplete = handler; }
setSnap(enabled) { this.snapToBars = enabled; }
/**
* Arm a tool so the next chart gesture places one. Panning is suspended
* while armed, otherwise the drag that draws a line also drags the chart out
* from under it.
*/
armTool(tool) {
this.armedTool = tool;
this.chart.applyOptions({ handleScroll: !tool, handleScale: !tool });
this.chartEl.classList.toggle('armed', Boolean(tool));
this.clearGesture();
}
eventPoint(event) {
const bounds = this.chartEl.getBoundingClientRect();
const x = event.clientX - bounds.left;
const y = event.clientY - bounds.top;
const price = this.candles.coordinateToPrice(y);
if (price == null) return null;
const time = this.chart.timeScale().coordinateToTime(x);
return { x, y, p: price, t: time == null ? null : Number(time) };
}
startToolGesture(event) {
if (!this.armedTool || event.button !== 0) return;
const point = this.eventPoint(event);
if (!point) return;
event.preventDefault();
this.gesture = { start: point, end: point };
this.chartEl.setPointerCapture?.(event.pointerId);
this.renderGesture();
}
moveToolGesture(event) {
if (!this.armedTool || !this.gesture) return;
const point = this.eventPoint(event);
if (!point) return;
this.gesture.end = point;
this.renderGesture();
}
finishToolGesture(event) {
if (!this.armedTool || !this.gesture) return;
const tool = this.armedTool;
const { start, end } = this.gesture;
this.clearGesture();
this.chartEl.releasePointerCapture?.(event.pointerId);
if (tool === 'level') {
// A click with no drag is a valid placement; the drag is only there to
// let you fine-tune the price before committing.
this.onToolComplete?.({ tool, price: ConfluenceChart.snapToTick(end.p) });
return;
}
// A stray click should not create a zero-length trendline.
if (Math.hypot(end.x - start.x, end.y - start.y) < 5) return;
const a = this.snapPoint(start);
const b = this.snapPoint(end);
if (a.t == null || b.t == null || a.t === b.t) return;
this.onToolComplete?.({ tool, start: a, end: b });
}
/** Trendline anchors snap to bar extremes; levels snap to the tick grid. */
snapPoint(point) {
const fallbackT = point.t ?? this.bars[this.bars.length - 1]?.t ?? null;
const base = { t: fallbackT, p: point.p, snappedSide: null };
if (!this.snapToBars || !this.bars.length || fallbackT == null) return base;
const nearest = this.bars.reduce(
(best, bar) => (Math.abs(bar.t - fallbackT) < Math.abs(best.t - fallbackT) ? bar : best),
);
const candidates = [
{ p: nearest.h, side: 'resistance' },
{ p: nearest.l, side: 'support' },
];
const snapped = candidates
.map(value => ({ ...value, distance: Math.abs(this.candles.priceToCoordinate(value.p) - point.y) }))
.sort((a, b) => a.distance - b.distance)[0];
if (snapped.distance <= 8) return { t: nearest.t, p: snapped.p, snappedSide: snapped.side };
return base;
}
renderGesture() {
if (!this.gesture) return;
const { start, end } = this.gesture;
if (this.armedTool === 'level') {
const price = ConfluenceChart.snapToTick(end.p);
const y = this.candles.priceToCoordinate(price);
if (y == null) return;
this.previewLine.removeAttribute('hidden');
this.previewLine.setAttribute('x1', 0);
this.previewLine.setAttribute('y1', y);
this.previewLine.setAttribute('x2', this.chartEl.clientWidth);
this.previewLine.setAttribute('y2', y);
this.previewLine.setAttribute('stroke', '#e0a34a');
this.previewLine.setAttribute('stroke-width', 2);
this.previewLine.setAttribute('stroke-dasharray', '6 4');
this.priceTag.hidden = false;
this.priceTag.textContent = price.toFixed(2);
this.priceTag.style.top = `${y}px`;
this.priceTag.style.left = `${Math.min(end.x + 14, this.chartEl.clientWidth - 70)}px`;
return;
}
const a = this.snapPoint(start);
const b = this.snapPoint(end);
if (a.t != null && b.t != null) this.setLinePreview(a, b, '#65b7cf', 2);
}
clearGesture() {
this.gesture = null;
this.clearLinePreview();
if (this.priceTag) this.priceTag.hidden = true;
}
setClickHandler(handler) { this.onChartClick = handler; }
setLineChangeHandler(handler) { this.onLineChange = handler; } setLineChangeHandler(handler) { this.onLineChange = handler; }
@ -259,22 +401,6 @@ class ConfluenceChart {
this.tooltip.style.top = `${Math.max(8, param.point.y - 30)}px`; this.tooltip.style.top = `${Math.max(8, param.point.y - 30)}px`;
} }
pointFromClick(param, snap) {
const rawPrice = this.candles.coordinateToPrice(param.point.y);
let point = { t: Number(param.time), p: rawPrice, snappedSide: null };
if (!snap || !this.bars.length) return point;
const nearest = this.bars.reduce((best, bar) => Math.abs(bar.t - point.t) < Math.abs(best.t - point.t) ? bar : best);
const candidates = [
{ p: nearest.h, side: 'resistance' },
{ p: nearest.l, side: 'support' },
];
const snapped = candidates
.map(value => ({ ...value, distance: Math.abs(this.candles.priceToCoordinate(value.p) - param.point.y) }))
.sort((a, b) => a.distance - b.distance)[0];
if (snapped.distance <= 8) point = { t: nearest.t, p: snapped.p, snappedSide: snapped.side };
return point;
}
hitTest(param) { hitTest(param) {
let best = null; let best = null;
// Flat levels draw as price lines, which have no series geometry to hit — // Flat levels draw as price lines, which have no series geometry to hit —
@ -432,6 +558,9 @@ class ConfluenceChart {
if (this.chartEl && this.contextListener) this.chartEl.removeEventListener('contextmenu', this.contextListener); if (this.chartEl && this.contextListener) this.chartEl.removeEventListener('contextmenu', this.contextListener);
if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener); if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener);
if (this.anchorUpListener) window.removeEventListener('pointerup', this.anchorUpListener); if (this.anchorUpListener) window.removeEventListener('pointerup', this.anchorUpListener);
if (this.chartEl && this.toolDownListener) this.chartEl.removeEventListener('pointerdown', this.toolDownListener);
if (this.toolMoveListener) window.removeEventListener('pointermove', this.toolMoveListener);
if (this.toolUpListener) window.removeEventListener('pointerup', this.toolUpListener);
if (this.chart) this.chart.remove(); if (this.chart) this.chart.remove();
} }
} }

View file

@ -20,25 +20,53 @@
<div><span class="symbol">{{ status.symbol || 'ES=F' }}</span><span class="price">{{ price == null ? '—' : price.toFixed(2) }}</span></div> <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 class="timeframes"><button v-for="tf in timeframes" :key="tf" :class="{active: timeframe === tf}" @click="selectTimeframe(tf)">{{ tf }}</button></div>
</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 id="chart"></div>
<div class="statusbar"> <div class="statusbar">
<span>FEED <b>{{ status.stream }}</b></span> <span>FEED <b>{{ status.stream }}</b></span>
<span>LAST BAR <b>{{ barAge }}</b></span> <span>LAST BAR <b>{{ barAge }}</b></span>
<span>HELD <b>{{ status.bars_held?.[timeframe] || 0 }} {{ timeframe }}</b></span> <span>HELD <b>{{ status.bars_held?.[timeframe] || 0 }} {{ timeframe }}</b></span>
<span v-if="armedTool === 'trendline'" class="arm-hint">Drag on the chart from one point to the other</span>
<span v-else-if="armedTool === 'level'" class="arm-hint">Click or drag on the chart to set the price</span>
<span v-else-if="selectedLine" class="arm-hint">Line selected — Delete removes it</span>
</div> </div>
</section> </section>
<aside> <aside>
<h2>Tools</h2>
<div class="tool" :class="{armed: armedTool === 'trendline'}">
<button class="tool-head" @click="armTool('trendline')" :aria-pressed="armedTool === 'trendline'">
<span class="tool-glyph">╱</span>Trendline
<span class="tool-state">{{ armedTool === 'trendline' ? 'drag on chart' : '' }}</span>
</button>
<div class="tool-body">
<label>Label<input v-model.trim="drawName" placeholder="optional" aria-label="Trendline label"></label>
<div class="row">
<label>Colour<input type="color" v-model="drawColor" aria-label="Trendline colour"></label>
<label>Width<select v-model.number="drawWidth" aria-label="Trendline width"><option v-for="width in [1,2,3,4]" :value="width">{{ width }}px</option></select></label>
</div>
<div class="row">
<label>Side<select v-model="drawSide" aria-label="Trendline side"><option value="support">Support</option><option value="resistance">Resistance</option></select></label>
<label class="check"><input type="checkbox" v-model="snap">Snap to highs/lows</label>
</div>
</div>
</div>
<div class="tool" :class="{armed: armedTool === 'level'}">
<button class="tool-head" @click="armTool('level')" :aria-pressed="armedTool === 'level'">
<span class="tool-glyph">─</span>Price level
<span class="tool-state">{{ armedTool === 'level' ? 'click on chart' : '' }}</span>
</button>
<div class="tool-body">
<label>Label<input v-model.trim="alertNote" placeholder="optional" aria-label="Level label"></label>
<div class="row">
<label>Colour<input type="color" v-model="levelColor" aria-label="Level colour"></label>
<label>Width<select v-model.number="levelWidth" aria-label="Level width"><option v-for="width in [1,2,3,4]" :value="width">{{ width }}px</option></select></label>
</div>
<form class="row price-row" @submit.prevent="addPriceAlert">
<label>Price<input type="number" step="0.25" v-model.number="alertPrice" :placeholder="price == null ? '0.00' : price.toFixed(2)" aria-label="Level price"></label>
<button type="submit" :disabled="!alertPrice">Add</button>
</form>
<p class="hint">Drag on the chart, or type an exact price. Alerts whenever price reaches it, whatever the confluence score.</p>
</div>
</div>
<h2>Layers</h2> <h2>Layers</h2>
<div class="layer-group"> <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> <label class="master"><input type="checkbox" :checked="allEnabled('1d')" @change="toggleGroup('1d', $event.target.checked)"><span class="swatch tf-1d"></span>Daily MAs</label>
@ -57,16 +85,7 @@
</div> </div>
<label class="score-hidden"><input type="checkbox" v-model="prefs.hidden_levels_score">Hidden levels still count toward confluence</label> <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> <details class="sidebar-section" open>
<summary>Price alert</summary> <summary>Lines &amp; levels ({{ manualLines.length }})</summary>
<form class="price-alert" @submit.prevent="addPriceAlert">
<input type="number" step="0.25" v-model.number="alertPrice" :placeholder="price == null ? 'Price' : price.toFixed(2)" aria-label="Alert price" required>
<input v-model.trim="alertNote" placeholder="Label (optional)" aria-label="Alert label">
<button type="submit" :disabled="!alertPrice">Add</button>
</form>
<p class="hint">Alerts whenever price reaches it, whatever the confluence score.</p>
</details>
<details class="sidebar-section" open>
<summary>Trendlines ({{ manualLines.length }})</summary>
<div class="trendline-actions" v-if="manualLines.length"> <div class="trendline-actions" v-if="manualLines.length">
<button @click="toggleSelectAll">{{ allManualSelected ? 'Clear' : 'Select all' }}</button> <button @click="toggleSelectAll">{{ allManualSelected ? 'Clear' : 'Select all' }}</button>
<button @click="deleteSelectedLines" :disabled="!selectedLines.length">Delete selected ({{ selectedLines.length }})</button> <button @click="deleteSelectedLines" :disabled="!selectedLines.length">Delete selected ({{ selectedLines.length }})</button>

View file

@ -20,9 +20,33 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
.sidebar-section { margin-top:30px; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; } .sidebar-section { margin-top:30px; }.sidebar-section summary { margin-bottom:12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; cursor:pointer; user-select:none; }.sidebar-section:not([open]) summary { margin-bottom:0; }
.trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }.trendline-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:5px 8px; padding:7px; border:1px solid transparent; }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; accent-color:var(--accent); }.trendline-row>input:not(.line-select) { min-width:0; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:11px; }.trendline-row span { grid-column:2; color:var(--muted); font-size:9px; text-transform:uppercase; }.trendline-row button { grid-column:3; grid-row:1; padding:3px 6px; font-size:9px; }.line-style-controls { grid-column:3; display:flex; align-items:center; gap:4px; }.line-style-controls input { width:24px; height:20px; padding:0; border:0; background:transparent; }.line-style-controls select { border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:9px; } .trendline-actions { display:flex; gap:5px; margin-bottom:7px; }.trendline-actions button { flex:1; padding:4px; font-size:9px; }.trendline-row { display:grid; grid-template-columns:auto minmax(0,1fr) auto; gap:5px 8px; padding:7px; border:1px solid transparent; }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; accent-color:var(--accent); }.trendline-row>input:not(.line-select) { min-width:0; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:11px; }.trendline-row span { grid-column:2; color:var(--muted); font-size:9px; text-transform:uppercase; }.trendline-row button { grid-column:3; grid-row:1; padding:3px 6px; font-size:9px; }.line-style-controls { grid-column:3; display:flex; align-items:center; gap:4px; }.line-style-controls input { width:24px; height:20px; padding:0; border:0; background:transparent; }.line-style-controls select { border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:9px; }
.layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.periods { display:flex; flex-wrap:wrap; gap:10px; padding-left:22px; }.periods label { color:var(--muted); }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; } .layer-group { padding:9px 0; border-bottom:1px solid var(--line); display:grid; gap:7px; }.layer-group label,.score-hidden { display:flex; align-items:center; gap:7px; font-size:11px; cursor:pointer; }.layer-group input,.score-hidden input { accent-color:var(--accent); }.periods { display:flex; flex-wrap:wrap; gap:10px; padding-left:22px; }.periods label { color:var(--muted); }.swatch { width:13px; height:3px; display:inline-block; background:var(--muted); }.tf-1d { background:#d96073; }.tf-1h { background:#efb643; }.manual { background:#65b7cf; }.vwap { background:#b07ad6; }.horizontal { background:#9fb0c4; }
.price-alert { display:grid; grid-template-columns:1fr 1fr auto; gap:6px; margin:8px 0 4px; } .hint { margin:6px 0 2px; font-size:10px; color:var(--muted); line-height:1.35; }
.price-alert input { min-width:0; font:inherit; font-size:11px; padding:5px 7px; border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); }
.price-alert button { font-size:11px; padding:5px 11px; } /* Tool palette: the head arms the tool, the body configures what it creates. */
.hint { margin:0 0 4px; font-size:10px; color:var(--muted); line-height:1.35; }.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; } .tool { border:1px solid var(--line); border-radius:7px; margin-bottom:8px; overflow:hidden; }
.tool.armed { border-color:var(--accent); box-shadow:0 0 0 1px var(--accent) inset; }
.tool-head { display:flex; align-items:center; gap:8px; width:100%; padding:8px 10px; font:inherit; font-size:12px;
background:transparent; color:var(--fg); border:0; cursor:pointer; text-align:left; }
.tool-head:hover { background:color-mix(in srgb, var(--fg) 5%, transparent); }
.tool.armed .tool-head { background:color-mix(in srgb, var(--accent) 14%, transparent); }
.tool-glyph { display:inline-block; width:14px; color:var(--muted); font-size:14px; line-height:1; }
.tool.armed .tool-glyph { color:var(--accent); }
.tool-state { margin-left:auto; font-size:10px; color:var(--accent); }
.tool-body { padding:2px 10px 10px; display:grid; gap:7px; }
.tool-body label { display:grid; gap:3px; font-size:10px; color:var(--muted); }
.tool-body .row { display:grid; grid-template-columns:1fr 1fr; gap:7px; align-items:end; }
.tool-body input, .tool-body select { min-width:0; font:inherit; font-size:11px; padding:4px 6px;
border:1px solid var(--line); border-radius:5px; background:transparent; color:var(--fg); }
.tool-body input[type=color] { padding:2px; height:26px; }
.tool-body .check { display:flex; align-items:center; gap:5px; padding-bottom:5px; }
.tool-body .check input { width:auto; }
.price-row { grid-template-columns:1fr auto; }
.price-row button { font-size:11px; padding:5px 12px; align-self:end; }
/* Armed tools take over the pointer, so the chart must not look draggable. */
#chart.armed { cursor:crosshair; }
.chart-price-tag { position:absolute; z-index:5; transform:translateY(-50%); padding:2px 6px; border-radius:4px;
background:#e0a34a; color:#1a1206; font-size:11px; font-variant-numeric:tabular-nums; pointer-events:none; }
.arm-hint { color:var(--accent); }.optional { color:var(--muted); }.disabled { opacity:.45; }.score-hidden { margin-top:11px; color:var(--muted); line-height:1.25; }
.cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; } .cluster { margin:8px 0; padding:10px; border:1px solid var(--line); border-left:3px solid var(--green); background:var(--chart-bg); }.cluster.resistance { border-left-color:var(--red); }.cluster-top { display:flex; justify-content:space-between; text-transform:uppercase; font-size:10px; }.cluster-top strong { color:var(--accent); font-size:16px; }.zone { margin:4px 0; font-size:15px; }.members,.distance { color:var(--muted); font-size:9px; }.distance { margin-top:5px; }.alert-entry { white-space:pre-line; margin:8px 0; padding:9px; background:color-mix(in srgb,var(--accent) 8%,transparent); font-size:10px; }.alert-entry time { display:block; color:var(--accent); margin-bottom:4px; }
@media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:54px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } } @media (max-width:850px) { #app { padding:10px; }.chart-shell { min-width:0; }main { grid-template-columns:1fr; }.drawing-tools { flex-wrap:wrap; }.drawing-tools .line-name { width:110px; }#chart { height:55vh; min-height:360px; }aside { min-height:180px; }header { height:54px; }.chart-head { align-items:flex-start; flex-direction:column; }.timeframes { justify-content:flex-start; }.timeframes button { padding:5px 8px; } }