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:
parent
1a1514bc77
commit
d5edb12171
4 changed files with 254 additions and 73 deletions
|
|
@ -78,17 +78,19 @@ createApp({
|
|||
const clusters = ref([]);
|
||||
const alerts = ref([]);
|
||||
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 drawColor = ref('#65b7cf');
|
||||
const drawWidth = ref(2);
|
||||
const drawSide = ref('support');
|
||||
const snap = ref(true);
|
||||
const drawPoints = ref([]);
|
||||
const selectedLine = ref(null);
|
||||
const selectedLines = ref([]);
|
||||
const alertPrice = ref(null);
|
||||
const alertNote = ref('');
|
||||
const levelColor = ref('#e0a34a');
|
||||
const levelWidth = ref(2);
|
||||
const timeframes = ['1m', '5m', '15m', '30m', '1h', '1d'];
|
||||
const now = ref(Date.now());
|
||||
let chartApi = null;
|
||||
|
|
@ -187,47 +189,46 @@ createApp({
|
|||
oscillator.onended = () => { oscillator.disconnect(); gain.disconnect(); };
|
||||
}
|
||||
|
||||
function toggleDraw() {
|
||||
drawMode.value = !drawMode.value;
|
||||
drawPoints.value = [];
|
||||
function armTool(tool) {
|
||||
armedTool.value = armedTool.value === tool ? null : tool;
|
||||
selectedLine.value = null;
|
||||
chartApi.clearLinePreview();
|
||||
chartApi.armTool(armedTool.value);
|
||||
}
|
||||
|
||||
function handleChartMove(param) {
|
||||
if (!drawMode.value || drawPoints.value.length !== 1) return;
|
||||
chartApi.setLinePreview(drawPoints.value[0], chartApi.pointFromClick(param, snap.value), drawColor.value, drawWidth.value);
|
||||
function handleChartClick(param) {
|
||||
selectedLine.value = chartApi.hitTest(param);
|
||||
selectedLines.value = selectedLine.value ? [selectedLine.value] : [];
|
||||
}
|
||||
|
||||
async function handleChartClick(param) {
|
||||
if (!drawMode.value) {
|
||||
selectedLine.value = chartApi.hitTest(param);
|
||||
selectedLines.value = selectedLine.value ? [selectedLine.value] : [];
|
||||
async function handleToolComplete(result) {
|
||||
// One placement per arming, so a tool cannot keep firing on stray drags.
|
||||
armedTool.value = null;
|
||||
chartApi.armTool(null);
|
||||
if (result.tool === 'level') {
|
||||
alertPrice.value = result.price;
|
||||
await addPriceAlert();
|
||||
return;
|
||||
}
|
||||
const point = chartApi.pointFromClick(param, snap.value);
|
||||
if (point.snappedSide) drawSide.value = point.snappedSide;
|
||||
drawPoints.value.push(point);
|
||||
if (drawPoints.value.length < 2) return;
|
||||
chartApi.clearLinePreview();
|
||||
const [start, end] = drawPoints.value.sort((a, b) => a.t - b.t);
|
||||
if (start.t === end.t) { drawPoints.value = []; return; }
|
||||
await createTrendline(result.start, result.end);
|
||||
}
|
||||
|
||||
async function createTrendline(first, second) {
|
||||
const [start, end] = [first, second].sort((a, b) => a.t - b.t);
|
||||
const side = start.snappedSide || end.snappedSide || drawSide.value;
|
||||
const temporaryId = `tmp_${Date.now()}`;
|
||||
const optimistic = {
|
||||
id: temporaryId, kind: 'manual', tf: timeframe.value, side: drawSide.value,
|
||||
weight: 1, score: 1, label: drawName.value || `${timeframe.value} ${drawSide.value}`,
|
||||
id: temporaryId, kind: 'manual', tf: timeframe.value, side,
|
||||
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),
|
||||
points: null, first_t: start.t, last_t: end.t, provisional: false, hidden: false,
|
||||
color: drawColor.value, line_width: drawWidth.value,
|
||||
};
|
||||
levels.value.push(optimistic);
|
||||
syncVisibleLevels();
|
||||
drawPoints.value = [];
|
||||
drawMode.value = false;
|
||||
try {
|
||||
const response = await apiFetch('/api/lines', {
|
||||
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}`);
|
||||
const saved = await response.json();
|
||||
|
|
@ -246,7 +247,10 @@ createApp({
|
|||
if (!alertPrice.value) return;
|
||||
const response = await apiFetch('/api/lines/price', {
|
||||
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; }
|
||||
const saved = await response.json();
|
||||
|
|
@ -402,13 +406,18 @@ createApp({
|
|||
if (chartApi) chartApi.setSelectedLine(id);
|
||||
});
|
||||
|
||||
watch(snap, value => {
|
||||
if (chartApi) chartApi.setSnap(value);
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
chartApi = new ConfluenceChart();
|
||||
chartApi.create(document.getElementById('chart'));
|
||||
chartApi.setClickHandler(handleChartClick);
|
||||
chartApi.setMoveHandler(handleChartMove);
|
||||
chartApi.setToolCompleteHandler(handleToolComplete);
|
||||
chartApi.setLineChangeHandler(updateLineGeometry);
|
||||
chartApi.setLineEndHandler(endLineHere);
|
||||
chartApi.setSnap(snap.value);
|
||||
window.addEventListener('keydown', handleKeydown);
|
||||
refreshStatus();
|
||||
connect();
|
||||
|
|
@ -421,6 +430,6 @@ createApp({
|
|||
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');
|
||||
|
|
|
|||
169
static/chart.js
169
static/chart.js
|
|
@ -9,7 +9,6 @@ class ConfluenceChart {
|
|||
this.bars = [];
|
||||
this.levels = [];
|
||||
this.onChartClick = null;
|
||||
this.onChartMove = null;
|
||||
this.tooltip = null;
|
||||
this.chartEl = null;
|
||||
this.clickListener = null;
|
||||
|
|
@ -23,6 +22,21 @@ class ConfluenceChart {
|
|||
this.contextCutoff = null;
|
||||
this.contextListener = 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) {
|
||||
|
|
@ -88,6 +102,16 @@ class ConfluenceChart {
|
|||
this.contextMenu.addEventListener('click', event => event.stopPropagation());
|
||||
this.contextMenu.appendChild(endHere);
|
||||
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.anchorUpListener = event => this.finishAnchorDrag(event);
|
||||
window.addEventListener('pointermove', this.anchorMoveListener);
|
||||
|
|
@ -96,6 +120,9 @@ class ConfluenceChart {
|
|||
el.addEventListener('contextmenu', this.contextListener);
|
||||
this.clickListener = event => {
|
||||
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;
|
||||
const bounds = el.getBoundingClientRect();
|
||||
const point = { x: event.clientX - bounds.left, y: event.clientY - bounds.top };
|
||||
|
|
@ -109,7 +136,6 @@ class ConfluenceChart {
|
|||
return;
|
||||
}
|
||||
this.updateLineTooltip(param);
|
||||
if (this.onChartMove) this.onChartMove(param);
|
||||
});
|
||||
this.chart.timeScale().subscribeVisibleLogicalRangeChange(() => this.renderAnchorHandles());
|
||||
}
|
||||
|
|
@ -214,9 +240,125 @@ class ConfluenceChart {
|
|||
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; }
|
||||
|
||||
|
|
@ -259,22 +401,6 @@ class ConfluenceChart {
|
|||
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) {
|
||||
let best = null;
|
||||
// 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.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener);
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,25 +20,53 @@
|
|||
<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>
|
||||
<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>
|
||||
</section>
|
||||
<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>
|
||||
<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>
|
||||
|
|
@ -57,16 +85,7 @@
|
|||
</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>Price alert</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>
|
||||
<summary>Lines & levels ({{ 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>
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
.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; }
|
||||
.price-alert { display:grid; grid-template-columns:1fr 1fr auto; gap:6px; margin:8px 0 4px; }
|
||||
.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; }
|
||||
.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; }
|
||||
.hint { margin:6px 0 2px; font-size:10px; color:var(--muted); line-height:1.35; }
|
||||
|
||||
/* Tool palette: the head arms the tool, the body configures what it creates. */
|
||||
.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; }
|
||||
@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; } }
|
||||
|
|
|
|||
Loading…
Reference in a new issue