Compare commits

...

2 commits

Author SHA1 Message Date
a09e4f1208 better select and visibility 2026-08-14 03:33:53 -05:00
087d9d9d2a changed ntfy string 2026-08-14 02:44:10 -05:00
14 changed files with 555 additions and 105 deletions

View file

@ -46,6 +46,11 @@ def connection_clusters(runtime, prefs: dict | None):
)
def session_open(runtime) -> float | None:
bars = runtime.store.get(Timeframe.D1, 1)
return bars[-1].o if bars else None
def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
return {
"type": "snapshot",
@ -56,6 +61,7 @@ def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
"price": runtime.store.get(Timeframe.M1, 1)[-1].c
if runtime.store.get(Timeframe.M1, 1)
else None,
"session_open": session_open(runtime),
}
@ -94,6 +100,7 @@ async def websocket_endpoint(websocket: WebSocket):
{
"type": "clusters",
"price": runtime.price,
"session_open": session_open(runtime),
"clusters": [cluster.to_dict() for cluster in clusters],
}
)
@ -108,7 +115,11 @@ async def websocket_endpoint(websocket: WebSocket):
break
if event["type"] == "bar" and event["bar"].tf is tf:
await websocket.send_json(
{"type": "bar", "tf": tf.value, "bar": event["bar"].to_dict()}
{
"type": "bar", "tf": tf.value,
"bar": event["bar"].to_dict(),
"session_open": session_open(runtime),
}
)
elif event["type"] == "levels":
await websocket.send_json(
@ -120,6 +131,7 @@ async def websocket_endpoint(websocket: WebSocket):
{
"type": "clusters",
"price": runtime.price,
"session_open": session_open(runtime),
"clusters": [cluster.to_dict() for cluster in clusters],
}
)

View file

@ -8,6 +8,6 @@ async def send_ntfy(server: str, topic: str, message: str) -> None:
response = await client.post(
f"{server.rstrip('/')}/{topic}",
content=message,
headers={"Title": "/ES confluence", "Priority": "high", "Tags": "chart_with_upwards_trend"},
headers={"Title": "/ESsent", "Priority": "high", "Tags": "chart_with_upwards_trend"},
)
response.raise_for_status()

View file

@ -858,6 +858,24 @@ which clears a trendline's pending first anchor, preview and active gesture;
Symbol's separate open panel is closed too. A palette no longer consumes the
first Escape while leaving a half-drawn tool active behind it.
### 2026-08-13 — touch pinch scales both chart axes
Lightweight Charts already scaled time for a two-finger pane pinch and scaled
price when the gesture began on the right axis, but the browser sometimes took
the latter as page zoom. The app now captures only an active two-finger chart
gesture, prevents its browser default, leaves the library's horizontal scaling
intact, and applies the same pinch ratio around the gesture's center price.
Single-finger page scrolling and every mouse path remain native and untouched.
The manual price range resets on a timeframe change or a double click/tap on the
right axis. A separate touch tap path was required for selection: after chart
gesture handling, mobile browsers do not reliably synthesize the DOM click the
desktop selection path expects. A short stationary tap now hit-tests and selects
the drawing and opens its tooltip; moved touches, annotations, armed tools and
two-finger gestures are excluded. Verified with Chromium touch events: logical
time span and visible price span both changed while `visualViewport.scale`
remained constant.
### 2026-08-11 — a time axis past the last bar, and the Yahoo bar it exposed
Trendlines project into the whitespace right of the last candle, but the axis

View file

@ -106,6 +106,7 @@ createApp({
const status = ref({ stream: 'disconnected', bars_held: {} });
const dataReceivedAt = ref(null);
const price = ref(null);
const sessionOpen = ref(null);
const prefs = ref(loadPrefs());
const timeframe = ref(prefs.value.base_tf || '1m');
const levels = ref([]);
@ -138,6 +139,7 @@ createApp({
const levelColor = ref('#e0a34a');
const levelWidth = ref(2);
const alertEarlyPoints = ref(null);
const animateCurrentPrice = ref(localStorage.getItem('chart-animate-current-price') !== 'false');
const timeframes = ['1m', '5m', '15m', '30m', '1h', '1d'];
const drawingColorRows = [
{ id: 'green', colors: ['#A6D8AA', '#4DB155', '#258F33', '#006D09'] },
@ -207,11 +209,13 @@ createApp({
hour: 'numeric', minute: '2-digit', second: '2-digit',
}).format(new Date(dataReceivedAt.value));
});
const quoteChange = computed(() => {
if (price.value == null || !(sessionOpen.value > 0)) return null;
const points = price.value - sessionOpen.value;
return { points, percent: points / sessionOpen.value * 100 };
});
const manualLines = computed(() => levels.value.filter(level => level.kind === 'manual'));
const hasDrawingSelection = computed(() => selectedDrawings.value.length > 0 || selectedDrawing.value != null);
const allManualSelected = computed(() => manualLines.value.length + comments.value.length > 0
&& [...manualLines.value, ...comments.value]
.every(drawing => selectedDrawings.value.includes(drawing.id)));
// One list for everything drawn. Lines come from the levels socket, which
// keeps them live; comments come from the drawings endpoint.
@ -227,6 +231,7 @@ createApp({
number: line.number,
kind: line.slope === 0 ? 'level' : 'trendline',
label: line.label || '',
hidden: line.hidden,
line,
}));
const notes = comments.value.map(comment => ({
@ -234,6 +239,7 @@ createApp({
number: comment.number,
kind: comment.kind === 'symbol' ? 'symbol' : 'comment',
label: comment.note || '',
hidden: comment.hidden,
comment,
}));
return [...lines, ...notes].sort((a, b) => a.number - b.number);
@ -246,9 +252,14 @@ createApp({
if (!needle) return true;
// Matched against what is on screen — the label or comment text, the
// kind, and the drawing number, so "comment", "cpi" and "7" all work.
return `${item.label} ${item.kind} #${item.number}`.toLowerCase().includes(needle);
const timeframe = item.line?.tf || item.comment?.tf || '';
return `${item.label} ${item.kind} ${timeframe} #${item.number}`.toLowerCase().includes(needle);
});
});
const allShownSelected = computed(() => filteredDrawings.value.length > 0
&& filteredDrawings.value.every(drawing => selectedDrawings.value.includes(drawing.id)));
const selectedAreHidden = computed(() => selectedDrawings.value.length > 0
&& selectedDrawings.value.every(id => drawings.value.find(drawing => drawing.id === id)?.hidden));
function addEvent(message, kind = 'info', url = null, number = null) {
events.value = [
@ -286,6 +297,7 @@ createApp({
}
syncVisibleLevels();
price.value = message.price;
sessionOpen.value = message.session_open ?? sessionOpen.value;
} else if (message.type === 'bar') {
// Switching timeframe races: the server answers `subscribe` with a
// fresh snapshot from one coroutine while another is still draining
@ -296,6 +308,7 @@ createApp({
dataReceivedAt.value = Date.now();
chartApi.updateBar(message.bar);
price.value = message.bar.c;
sessionOpen.value = message.session_open ?? sessionOpen.value;
status.value.last_bar_t = message.bar.t;
} else if (message.type === 'levels') {
// A delta, not a replacement: VWAP changes every minute while the
@ -308,6 +321,7 @@ createApp({
} else if (message.type === 'clusters') {
clusters.value = message.clusters;
price.value = message.price;
sessionOpen.value = message.session_open ?? sessionOpen.value;
} else if (message.type === 'alert') {
// Keyed on a counter, not the timestamp: two alerts inside the same
// second would collide and Vue would reuse the wrong row.
@ -571,12 +585,6 @@ createApp({
syncVisibleLevels();
}
async function deleteFilteredDrawings() {
const doomed = [...filteredDrawings.value];
if (!doomed.length) return;
for (const item of doomed) await deleteDrawing(item);
}
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;
@ -707,13 +715,6 @@ createApp({
});
}
function focusDrawing(item) {
selectedDrawing.value = item.id;
selectedDrawings.value = [item.id];
if (item.comment) chartApi?.focusComment(item.id);
else chartApi?.focusLine(item.id);
}
function selectComment(comment) {
selectedDrawing.value = comment.id;
selectedDrawings.value = [comment.id];
@ -724,15 +725,28 @@ createApp({
? selectedDrawings.value.filter(value => value !== id)
: [...selectedDrawings.value, id];
selectedDrawing.value = selectedDrawings.value.length === 1 ? selectedDrawings.value[0] : null;
if (selectedDrawing.value === id) {
const item = drawings.value.find(drawing => drawing.id === id);
if (item?.comment) chartApi?.focusComment(id);
else chartApi?.focusLine(id);
}
}
function toggleSelectAll() {
selectedDrawings.value = allManualSelected.value ? [] : drawings.value.map(drawing => drawing.id);
const shown = new Set(filteredDrawings.value.map(drawing => drawing.id));
selectedDrawings.value = allShownSelected.value
? selectedDrawings.value.filter(id => !shown.has(id))
: [...new Set([...selectedDrawings.value, ...shown])];
selectedDrawing.value = selectedDrawings.value.length === 1 ? selectedDrawings.value[0] : null;
}
async function deleteSelectedLines() {
await deleteLines([...selectedDrawings.value]);
async function toggleSelectedVisibility() {
const hidden = !selectedAreHidden.value;
for (const id of [...selectedDrawings.value]) {
const item = drawings.value.find(drawing => drawing.id === id);
const drawing = item?.line || item?.comment;
if (drawing) await updateLineStyle(drawing, { hidden });
}
}
async function setArmed(line, armed) {
@ -961,6 +975,11 @@ createApp({
localStorage.setItem('chart-color-row-labels', JSON.stringify(value));
}, { deep: true });
watch(animateCurrentPrice, value => {
localStorage.setItem('chart-animate-current-price', String(value));
if (chartApi) chartApi.setCurrentPriceAnimation(value);
});
onMounted(async () => {
chartApi = new ConfluenceChart();
// Deliberate debug handle. Chart geometry bugs are invisible from the
@ -978,6 +997,7 @@ createApp({
chartApi.setLineDuplicateHandler(duplicateLine);
chartApi.setLineDeleteHandler(deleteLine);
chartApi.setSnap(snap.value);
chartApi.setCurrentPriceAnimation(animateCurrentPrice.value);
chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment, onSelect: selectComment });
window.addEventListener('keydown', handleKeydown);
await migrateLegacyToken();
@ -993,6 +1013,6 @@ createApp({
window.removeEventListener('keydown', handleKeydown);
});
return { status, price, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allManualSelected, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, focusDrawing, toggleDrawingSelection, toggleSelectAll, deleteSelectedLines, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, deleteFilteredDrawings, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol };
return { status, price, sessionOpen, quoteChange, animateCurrentPrice, barAge, dataUpdatedAt, timeframe, timeframes, drawingColors, drawingColorRows, drawingColorName, colorRowLabels, symbolChoices, selectedSymbol, symbolColor, symbolPanelOpen, prefs, clusters, clustersByPrice, events, diagnosticMode, captureBusy, captureDiagnostic, armedTool, drawName, drawColor, drawWidth, drawSide, snap, selectedDrawing, selectedDrawings, manualLines, hasDrawingSelection, allShownSelected, selectedAreHidden, alertPrice, alertNote, alertEarlyPoints, levelColor, levelWidth, addPriceAlert, armTool, selectTimeframe, allEnabled, toggleGroup, deleteSelected, deleteLine, toggleDrawingSelection, toggleSelectAll, toggleSelectedVisibility, renameLine, updateLineStyle, updateLevelNumber, setArmed, commentText, commentFloat, comments, drawings, filteredDrawings, drawingFilter, drawingKind, deleteDrawing, toggleComment, togglePinned, chooseSymbol, toggleSymbolPanel, startSymbolDrag, dropSymbol };
},
}).mount('#app');

View file

@ -7,6 +7,10 @@ class ConfluenceChart {
this.observedPlotCanvas = null;
this.levelSeries = new Map();
this.priceLines = new Map();
this.currentPriceLine = null;
this.currentPricePulse = null;
this.animateCurrentPrice = true;
this.lastCurrentPrice = null;
this.previewLine = null;
this.projectionLayer = null;
this.bars = [];
@ -63,6 +67,19 @@ class ConfluenceChart {
this.onCommentSelect = null;
this.describeDrawingColor = null;
this.lastSnapDiagnostic = null;
this.manualPriceRange = null;
this.pricePinch = null;
this.touchStartListener = null;
this.touchMoveListener = null;
this.touchEndListener = null;
this.priceResetListener = null;
this.touchTap = null;
this.suppressClickUntil = 0;
this.candleAutoscaleProvider = original => {
const info = original();
if (!info || !this.manualPriceRange) return info;
return { ...info, priceRange: { ...this.manualPriceRange } };
};
}
static TICK = 0.25;
@ -163,6 +180,8 @@ class ConfluenceChart {
},
},
rightPriceScale: { borderVisible: false },
handleScale: { pinch: false },
handleScroll: { horzTouchDrag: true, vertTouchDrag: false },
// Every price-bearing series shares this scale. A second autoscaled price
// axis can place the same numeric price at a different y, which makes
// daily context look comparable to intraday price when it is not.
@ -177,6 +196,9 @@ class ConfluenceChart {
upColor: '#20b8a6', downColor: '#ef5b3f', borderVisible: true,
borderUpColor: '#087f76', borderDownColor: '#b9342d',
wickUpColor: '#087f76', wickDownColor: '#b9342d',
autoscaleInfoProvider: this.candleAutoscaleProvider,
lastValueVisible: false,
priceLineVisible: false,
});
// Volume as an overlay on its own hidden scale, confined to the bottom
// fifth. An overlay rather than a pane so it cannot alter the price scale:
@ -234,6 +256,10 @@ class ConfluenceChart {
this.tooltip = document.createElement('div');
this.tooltip.className = 'chart-tooltip';
this.overlayLayer.appendChild(this.tooltip);
this.currentPricePulse = document.createElement('div');
this.currentPricePulse.className = 'current-price-pulse';
this.currentPricePulse.hidden = true;
this.overlayLayer.appendChild(this.currentPricePulse);
const preview = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
preview.classList.add('chart-preview');
preview.setAttribute('aria-hidden', 'true');
@ -350,6 +376,7 @@ class ConfluenceChart {
el.addEventListener('contextmenu', this.contextListener);
this.clickListener = event => {
this.hideContextMenu();
if (Date.now() < this.suppressClickUntil) return;
// A placement gesture ends in a click too; selecting on it would pick
// whatever the new line happens to overlap.
if (this.armedTool) return;
@ -375,12 +402,17 @@ class ConfluenceChart {
this.renderComments();
this.renderContextLabels();
});
this.installPinchZoom();
}
setBars(bars) {
if (this.bars[0]?.tf && bars[0]?.tf && this.bars[0].tf !== bars[0].tf) {
this.manualPriceRange = null;
}
this.bars = bars;
const candleData = bars.map(this.toCandle);
this.candles.setData(candleData);
this.updateCurrentPrice(bars[bars.length - 1]?.c, false);
this.volume.setData(bars.map(ConfluenceChart.toVolume));
this.futureSpace.setData(this.futureTimes(bars));
// Anchored by time, not by logical index. A logical index addresses the
@ -438,6 +470,7 @@ class ConfluenceChart {
const last = this.bars[this.bars.length - 1];
if (last && bar.t < last.t) return;
this.candles.update(this.toCandle(bar));
this.updateCurrentPrice(bar.c);
this.volume.update(ConfluenceChart.toVolume(bar));
if (this.bars.length && this.bars[this.bars.length - 1].t === bar.t) this.bars[this.bars.length - 1] = bar;
else this.bars.push(bar);
@ -451,12 +484,152 @@ class ConfluenceChart {
this.renderComments();
}
setCurrentPriceAnimation(enabled) { this.animateCurrentPrice = Boolean(enabled); }
updateCurrentPrice(price, pulse = true) {
if (price == null) return;
const options = {
price,
color: '#b7771d',
lineWidth: 3,
lineStyle: LightweightCharts.LineStyle.Solid,
axisLabelVisible: true,
title: '',
};
if (this.currentPriceLine) this.currentPriceLine.applyOptions(options);
else this.currentPriceLine = this.candles.createPriceLine(options);
const changed = this.lastCurrentPrice != null && price !== this.lastCurrentPrice;
this.lastCurrentPrice = price;
if (!pulse || !changed || !this.animateCurrentPrice || !this.currentPricePulse) return;
requestAnimationFrame(() => {
const y = this.candles.priceToCoordinate(price);
if (y == null) return;
this.currentPricePulse.hidden = false;
this.currentPricePulse.style.top = `${Math.round(y)}px`;
this.currentPricePulse.classList.remove('active');
void this.currentPricePulse.offsetWidth;
this.currentPricePulse.classList.add('active');
});
}
setCommentHandlers({ onToggle, onMove, onSelect }) {
this.onCommentToggle = onToggle;
this.onCommentMove = onMove;
this.onCommentSelect = onSelect;
}
installPinchZoom() {
if (!this.chartEl) return;
const point = touch => ({ x: touch.clientX, y: touch.clientY });
const separation = touches => ({
x: Math.abs(touches[1].clientX - touches[0].clientX),
y: Math.abs(touches[1].clientY - touches[0].clientY),
});
this.touchStartListener = event => {
if (event.touches.length === 1) {
const touch = event.touches[0];
this.touchTap = {
x: touch.clientX,
y: touch.clientY,
at: Date.now(),
annotation: Boolean(event.target.closest?.('.chart-comment')),
};
return;
}
this.touchTap = null;
if (event.touches.length !== 2) return;
const plot = this.plotCanvas()?.getBoundingClientRect();
if (!plot) return;
const touches = [...event.touches];
const insideChart = touches.every(touch => {
const p = point(touch);
return p.x >= plot.left && p.x <= this.chartEl.getBoundingClientRect().right
&& p.y >= plot.top && p.y <= plot.bottom;
});
if (!insideChart) return;
const distances = separation(touches);
const mode = distances.x >= distances.y ? 'time' : 'price';
const centerX = (touches[0].clientX + touches[1].clientX) / 2 - plot.left;
const centerY = (touches[0].clientY + touches[1].clientY) / 2 - plot.top;
const margins = this.chart.priceScale('right').options().scaleMargins;
const top = this.candles.coordinateToPrice(plot.height * margins.top);
const bottom = this.candles.coordinateToPrice(plot.height * (1 - margins.bottom));
const anchor = this.candles.coordinateToPrice(centerY);
const timeRange = this.chart.timeScale().getVisibleLogicalRange();
if ([top, bottom, anchor].some(value => value == null) || !timeRange) return;
this.pricePinch = {
mode,
distance: Math.max(distances[mode === 'time' ? 'x' : 'y'], 1),
anchor,
minValue: Math.min(top, bottom),
maxValue: Math.max(top, bottom),
timeRange,
timeAnchor: timeRange.from + (timeRange.to - timeRange.from) * centerX / plot.width,
};
// Prevent browser page zoom only after a two-finger chart gesture begins.
event.preventDefault();
};
this.touchMoveListener = event => {
if (event.touches.length === 1 && this.touchTap) {
const touch = event.touches[0];
if (Math.hypot(touch.clientX - this.touchTap.x, touch.clientY - this.touchTap.y) > 10) {
this.touchTap = null;
}
}
if (!this.pricePinch || event.touches.length !== 2) return;
event.preventDefault();
const distances = separation([...event.touches]);
const { mode, distance, anchor, minValue, maxValue, timeRange, timeAnchor } = this.pricePinch;
const ratio = Math.max(0.1, distances[mode === 'time' ? 'x' : 'y'] / distance);
if (mode === 'time') {
// Hold the price range while changing the visible bars; otherwise the
// chart's autoscale makes a horizontal-only gesture look linked to Y.
this.manualPriceRange = { minValue, maxValue };
this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider });
this.chart.timeScale().setVisibleLogicalRange({
from: timeAnchor + (timeRange.from - timeAnchor) / ratio,
to: timeAnchor + (timeRange.to - timeAnchor) / ratio,
});
} else {
this.manualPriceRange = {
minValue: anchor + (minValue - anchor) / ratio,
maxValue: anchor + (maxValue - anchor) / ratio,
};
this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider });
}
this.renderAnchorHandles();
this.renderComments();
this.renderContextLabels();
};
this.touchEndListener = event => {
if (event.touches.length < 2) this.pricePinch = null;
if (event.touches.length || !this.touchTap || this.armedTool) return;
const tap = this.touchTap;
this.touchTap = null;
if (tap.annotation || Date.now() - tap.at > 500) return;
const touch = event.changedTouches[0];
const chartPoint = touch && this.eventPoint({ clientX: touch.clientX, clientY: touch.clientY });
if (!chartPoint || !this.withinPlot(chartPoint) || chartPoint.t == null) return;
this.suppressClickUntil = Date.now() + 500;
const param = { point: chartPoint, time: chartPoint.t };
if (this.onChartClick) this.onChartClick(param);
this.updateLineTooltip(param);
};
this.chartEl.addEventListener('touchstart', this.touchStartListener, { passive: false, capture: true });
this.chartEl.addEventListener('touchmove', this.touchMoveListener, { passive: false, capture: true });
this.chartEl.addEventListener('touchend', this.touchEndListener, { passive: true, capture: true });
this.chartEl.addEventListener('touchcancel', this.touchEndListener, { passive: true, capture: true });
this.priceResetListener = event => {
if (!this.manualPriceRange) return;
const plot = this.plotCanvas()?.getBoundingClientRect();
if (!plot || event.clientX < plot.right) return;
this.manualPriceRange = null;
this.candles.applyOptions({ autoscaleInfoProvider: this.candleAutoscaleProvider });
};
this.chartEl.addEventListener('dblclick', this.priceResetListener);
}
// Comments are DOM, not canvas: they hold arbitrary text, need to collapse,
// and a floating one must ignore the time scale entirely.
renderComments() {
@ -1516,7 +1689,7 @@ class ConfluenceChart {
renderAnchorHandles() {
const level = this.levels.find(
value => value.id === this.selectedLineId && value.kind === 'manual',
value => value.id === this.selectedLineId && value.kind === 'manual' && !value.hidden,
);
if (!level || !this.anchorHandles.length) {
this.anchorHandles.forEach(handle => handle.setAttribute('hidden', ''));
@ -1755,6 +1928,13 @@ 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.touchStartListener) {
this.chartEl.removeEventListener('touchstart', this.touchStartListener, true);
this.chartEl.removeEventListener('touchmove', this.touchMoveListener, true);
this.chartEl.removeEventListener('touchend', this.touchEndListener, true);
this.chartEl.removeEventListener('touchcancel', this.touchEndListener, true);
this.chartEl.removeEventListener('dblclick', this.priceResetListener);
}
if (this.lineMoveListener) window.removeEventListener('pointermove', this.lineMoveListener);
if (this.lineUpListener) window.removeEventListener('pointerup', this.lineUpListener);
if (this.chartEl && this.toolDownListener) this.chartEl.removeEventListener('pointerdown', this.toolDownListener);

View file

@ -21,7 +21,7 @@
<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="quote"><span class="symbol">{{ status.symbol || 'ES=F' }}</span><span class="price">{{ price == null ? '—' : price.toFixed(2) }}</span><span class="quote-change" :class="quoteChange == null ? '' : quoteChange.points > 0 ? 'positive' : quoteChange.points < 0 ? 'negative' : 'flat'"><template v-if="quoteChange">{{ quoteChange.points >= 0 ? '+' : '' }}{{ quoteChange.points.toFixed(2) }} ({{ quoteChange.percent >= 0 ? '+' : '' }}{{ quoteChange.percent.toFixed(2) }}%)</template><template v-else>— (—%)</template></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 id="chart" @dragover.prevent @drop.prevent="dropSymbol"></div>
@ -57,7 +57,70 @@
</details>
<details class="sidebar-section tools-section" open>
<summary>Tools</summary>
<div class="tool" :class="{armed: armedTool === 'trendline'}">
<details class="tool-config">
<summary>Config</summary>
<label><input type="checkbox" v-model="animateCurrentPrice"> Animate current price</label>
</details>
<div class="tool tool-mark" :class="{armed: armedTool === 'symbol'}">
<div class="tool-head symbol-head" title="Choose a mark and click the chart, or drag a mark button onto it. Marks pin to that bar and price and can be dragged later with their grip.">
<button class="symbol-arm" @click="toggleSymbolPanel" :aria-expanded="symbolPanelOpen" :aria-pressed="armedTool === 'symbol'">
<span class="tool-glyph"><i class="fa-solid fa-icons"></i></span>Mark
</button>
<details class="color-picker symbol-head-color" @click.stop>
<summary :title="drawingColorName(symbolColor)"
:aria-label="`Choose symbol color; current color ${drawingColorName(symbolColor)}`"
:style="{backgroundColor: symbolColor}"></summary>
<div class="color-popover">
<div v-for="row in drawingColorRows" :key="row.id" class="color-family-row">
<input v-model="colorRowLabels[row.id]" maxlength="24" class="color-family-input"
:aria-label="`${row.id} row annotation`" @click.stop
@blur="colorRowLabels[row.id] = colorRowLabels[row.id].trim() || row.id">
<div class="color-family-swatches">
<button v-for="color in row.colors" :key="color" type="button"
:class="{selected: color === symbolColor}" :style="{backgroundColor: color}"
:title="drawingColorName(color)" :aria-label="`Use ${drawingColorName(color)}`"
@click.stop="symbolColor = color; $event.currentTarget.closest('details').open = false"></button>
</div>
</div>
<div class="color-popover-footer">
<label class="custom-color" title="Custom color"><i class="fa-solid fa-palette"></i><input type="color" v-model="symbolColor" aria-label="Custom symbol color"></label>
<button type="button" class="palette-close" aria-label="Cancel color selection" title="Cancel"
@click.stop="$event.currentTarget.closest('details').open = false"><i class="fa-solid fa-xmark"></i></button>
</div>
</div>
</details>
<span class="tool-state">{{ armedTool === 'symbol' ? 'click chart' : '' }}</span>
</div>
<div class="tool-body" v-show="symbolPanelOpen">
<div class="symbol-palette" aria-label="Mark choices">
<button v-for="symbol in symbolChoices" :key="symbol.icon" type="button" draggable="true"
:class="{active: selectedSymbol === symbol.icon}" :title="symbol.name" :aria-label="symbol.name"
@click="chooseSymbol(symbol)" @dragstart="startSymbolDrag($event, symbol)">
<i :class="`fa-solid fa-${symbol.icon}`"></i>
</button>
</div>
</div>
</div>
<div class="tool tool-level" :class="{armed: armedTool === 'level'}">
<button class="tool-head" @click="armTool('level')" :aria-pressed="armedTool === 'level'">
<span class="tool-glyph"><i class="fa-solid fa-minus"></i></span>Price level
<span class="tool-state">{{ armedTool === 'level' ? 'click on chart' : '' }}</span>
</button>
<div class="tool-body" v-show="armedTool === 'level'">
<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 9" :value="width">{{ width }}px</option></select></label>
</div>
<label>Alert early (pts)<input type="number" min="0" step="0.25" v-model.number="alertEarlyPoints" placeholder="ATR default" aria-label="Alert early points"></label>
<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>
<div class="tool tool-trendline" :class="{armed: armedTool === 'trendline'}">
<button class="tool-head" @click="armTool('trendline')" :aria-pressed="armedTool === 'trendline'">
<span class="tool-glyph"><i class="fa-solid fa-arrow-trend-up"></i></span>Trendline
<span class="tool-state">{{ armedTool === 'trendline' ? 'drag on chart' : '' }}</span>
@ -94,26 +157,7 @@
</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"><i class="fa-solid fa-minus"></i></span>Price level
<span class="tool-state">{{ armedTool === 'level' ? 'click on chart' : '' }}</span>
</button>
<div class="tool-body" v-show="armedTool === 'level'">
<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 9" :value="width">{{ width }}px</option></select></label>
</div>
<label>Alert early (pts)<input type="number" min="0" step="0.25" v-model.number="alertEarlyPoints" placeholder="ATR default" aria-label="Alert early points"></label>
<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>
<div class="tool" :class="{armed: armedTool === 'comment'}">
<div class="tool tool-comment" :class="{armed: armedTool === 'comment'}">
<button class="tool-head" @click="armTool('comment')" :aria-pressed="armedTool === 'comment'">
<span class="tool-glyph"><i class="fa-solid fa-note-sticky"></i></span>Comment
<span class="tool-state">{{ armedTool === 'comment' ? 'click on chart' : '' }}</span>
@ -124,46 +168,6 @@
<p class="hint">Type the text, then click the chart to place it. Pinned comments sit on that bar; floating ones stay put as you scroll and can be dragged. Click one to collapse it.</p>
</div>
</div>
<div class="tool" :class="{armed: armedTool === 'symbol'}">
<div class="tool-head symbol-head" title="Choose a symbol and click the chart, or drag a symbol button onto it. Symbols pin to that bar and price and can be dragged later with their grip.">
<button class="symbol-arm" @click="toggleSymbolPanel" :aria-expanded="symbolPanelOpen" :aria-pressed="armedTool === 'symbol'">
<span class="tool-glyph"><i class="fa-solid fa-icons"></i></span>Symbol
</button>
<details class="color-picker symbol-head-color" @click.stop>
<summary :title="drawingColorName(symbolColor)"
:aria-label="`Choose symbol color; current color ${drawingColorName(symbolColor)}`"
:style="{backgroundColor: symbolColor}"></summary>
<div class="color-popover">
<div v-for="row in drawingColorRows" :key="row.id" class="color-family-row">
<input v-model="colorRowLabels[row.id]" maxlength="24" class="color-family-input"
:aria-label="`${row.id} row annotation`" @click.stop
@blur="colorRowLabels[row.id] = colorRowLabels[row.id].trim() || row.id">
<div class="color-family-swatches">
<button v-for="color in row.colors" :key="color" type="button"
:class="{selected: color === symbolColor}" :style="{backgroundColor: color}"
:title="drawingColorName(color)" :aria-label="`Use ${drawingColorName(color)}`"
@click.stop="symbolColor = color; $event.currentTarget.closest('details').open = false"></button>
</div>
</div>
<div class="color-popover-footer">
<label class="custom-color" title="Custom color"><i class="fa-solid fa-palette"></i><input type="color" v-model="symbolColor" aria-label="Custom symbol color"></label>
<button type="button" class="palette-close" aria-label="Cancel color selection" title="Cancel"
@click.stop="$event.currentTarget.closest('details').open = false"><i class="fa-solid fa-xmark"></i></button>
</div>
</div>
</details>
<span class="tool-state">{{ armedTool === 'symbol' ? 'click chart' : '' }}</span>
</div>
<div class="tool-body" v-show="symbolPanelOpen">
<div class="symbol-palette" aria-label="Symbol choices">
<button v-for="symbol in symbolChoices" :key="symbol.icon" type="button" draggable="true"
:class="{active: selectedSymbol === symbol.icon}" :title="symbol.name" :aria-label="symbol.name"
@click="chooseSymbol(symbol)" @dragstart="startSymbolDrag($event, symbol)">
<i :class="`fa-solid fa-${symbol.icon}`"></i>
</button>
</div>
</div>
</div>
</details>
<details class="sidebar-section" open>
<summary>Drawings ({{ filteredDrawings.length }}<span v-if="filteredDrawings.length !== drawings.length"> of {{ drawings.length }}</span>)</summary>
@ -178,18 +182,20 @@
<input v-model="drawingFilter" placeholder="Filter by text…" aria-label="Filter drawings by text">
</div>
<div class="trendline-actions" v-if="drawings.length">
<button @click="toggleSelectAll">{{ allManualSelected ? 'Clear' : 'Select all' }}</button>
<button @click="deleteFilteredDrawings" :disabled="!filteredDrawings.length"
:title="`Delete the ${filteredDrawings.length} drawing(s) matching this filter`">
Delete shown ({{ filteredDrawings.length }})
<button @click="toggleSelectAll" :disabled="!filteredDrawings.length">{{ allShownSelected ? 'Clear shown' : 'Select shown' }}</button>
<span class="selection-count">{{ selectedDrawings.length }} selected</span>
<button @click="toggleSelectedVisibility" :disabled="!selectedDrawings.length"
:aria-label="selectedAreHidden ? 'Show selected drawings' : 'Hide selected drawings'">
<i :class="selectedAreHidden ? 'fa-solid fa-eye' : 'fa-solid fa-eye-slash'"></i>
{{ selectedAreHidden ? 'Show' : 'Hide' }}
</button>
<button @click="deleteSelected" :disabled="!selectedDrawings.length" aria-label="Delete selected drawings">Delete</button>
</div>
<div v-if="!drawings.length" class="empty">Nothing drawn yet.</div>
<div v-else-if="!filteredDrawings.length" class="empty">No drawings match this filter.</div>
<div v-for="item in filteredDrawings" :key="item.id" class="trendline-row"
:class="{selected: selectedDrawings.includes(item.id)}"
@click="focusDrawing(item)">
:class="{selected: selectedDrawings.includes(item.id), 'hidden-drawing': item.hidden}">
<input class="line-select" type="checkbox"
:checked="selectedDrawings.includes(item.id)" :aria-label="`Select drawing ${item.number}`"
@click.stop @change="toggleDrawingSelection(item.id)">

View file

@ -9,18 +9,19 @@ h1 { margin:0; font-size:22px; letter-spacing:-1px; } h1 strong { color:var(--ac
main { display:grid; grid-template-columns:minmax(0, 1fr) 300px; gap:16px; }
.chart-shell,aside { background:var(--panel); border:1px solid var(--line); }
.chart-head { min-height:56px; padding:10px 14px; display:flex; align-items:center; justify-content:space-between; gap:12px; border-bottom:1px solid var(--line); }
.symbol { font-weight:700; margin-right:14px; }.price { color:var(--accent); font-size:19px; }
.quote { display:flex; align-items:baseline; gap:9px; flex-wrap:wrap; }.symbol { font-weight:700; margin-right:5px; }.price { color:var(--accent); font-size:19px; }.quote-change { color:var(--muted); font-size:11px; }.quote-change.positive { color:var(--green); }.quote-change.negative { color:var(--red); }
button { border:1px solid var(--line); background:transparent; color:var(--muted); padding:6px 11px; font:inherit; cursor:pointer; }button.active { color:var(--bg); background:var(--accent); border-color:var(--accent); }
.timeframes { display:flex; flex-wrap:wrap; justify-content:flex-end; }.timeframes button+button { border-left:0; }
.drawing-tools { min-height:38px; padding:5px 12px; display:flex; align-items:center; gap:9px; border-bottom:1px solid var(--line); color:var(--muted); font-size:10px; }.drawing-tools button,.drawing-tools select,.drawing-tools .line-name { padding:4px 8px; font-size:10px; }.drawing-tools select,.drawing-tools .line-name { background:var(--panel); color:var(--fg); border:1px solid var(--line); }.drawing-tools .line-name { width:130px; font:inherit; }.drawing-tools label { display:flex; gap:4px; align-items:center; }.drawing-tools input { accent-color:var(--accent); }
#chart { position:relative; height:calc(100vh - 190px); min-height:420px; }.chart-preview,.chart-projections,.chart-handles { position:absolute; inset:0; width:100%; height:100%; overflow:hidden; pointer-events:none; }.chart-preview,.chart-projections { z-index:4; }.chart-projections line { stroke-linecap:round; }.chart-handles { z-index:6; }.chart-preview line[hidden],.chart-anchor[hidden],.chart-line-hit[hidden],.chart-line-focus[hidden] { display:none; }.chart-line-focus { stroke-linecap:round; pointer-events:none; animation:line-focus .75s ease-out forwards; }.chart-line-hit { stroke:transparent; stroke-width:16px; pointer-events:stroke; cursor:move; touch-action:none; }.chart-anchor { stroke:var(--panel); stroke-width:2px; cursor:grab; pointer-events:all; touch-action:none; }.chart-anchor:active { cursor:grabbing; }.chart-tooltip { position:absolute; z-index:5; padding:4px 7px; border:1px solid var(--line); background:var(--panel); color:var(--fg); font-size:10px; white-space:pre-line; pointer-events:none; }.chart-tooltip[hidden] { display:none; }
.current-price-pulse { position:absolute; left:0; right:0; z-index:5; height:2px; transform:translateY(-1px); opacity:0; background:repeating-linear-gradient(90deg,#b7771d 0 6px,transparent 6px 12px); pointer-events:none; }.current-price-pulse.active { animation:current-price-ripple .65s ease-out; }@keyframes current-price-ripple { 0% { opacity:.95; background-position-x:0; } 100% { opacity:0; background-position-x:36px; } }
@keyframes line-focus { 0% { opacity:.85; stroke-width:var(--line-focus-start-width,12px); } 80% { opacity:.85; } 100% { opacity:0; stroke-width:var(--line-focus-width,2px); } }
.chart-context-menu { position:absolute; z-index:8; width:165px; padding:4px; border:1px solid var(--line); background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 15%,transparent); pointer-events:auto; }.chart-context-menu[hidden] { display:none; }.chart-context-menu button { width:100%; padding:6px 8px; text-align:left; color:var(--fg); font-size:10px; }
.statusbar { min-height:34px; display:flex; align-items:center; gap:24px; padding:6px 13px; border-top:1px solid var(--line); color:var(--muted); font-size:10px; }.statusbar b { color:var(--fg); text-transform:uppercase; }.data-freshness { margin-left:auto; white-space:nowrap; }.diag-capture { padding:3px 7px; border-color:var(--accent); color:var(--accent); font-size:9px; white-space:nowrap; }.diag-capture:disabled { opacity:.55; cursor:wait; }
aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:1.3px; }h2:not(:first-child) { margin-top:30px; }.empty { border-left:2px solid var(--line); padding:10px 12px; color:var(--muted); font-size:11px; }
.sidebar-section { margin-top:30px; }.sidebar-section:first-of-type { margin-top:0; }.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:14px minmax(0,1fr); gap:4px; padding:2px 4px; border:1px solid transparent; border-bottom-color:var(--line); }.trendline-row.selected { border-color:var(--accent); }.trendline-row>.line-select { align-self:center; width:12px; height:12px; margin:0; accent-color:var(--accent); }.trendline-row>.drawing-icon { align-self:center; }
.trendline-actions { display:grid; grid-template-columns:auto 1fr auto auto; align-items:center; gap:4px; margin-bottom:7px; }.trendline-actions button { padding:3px 5px; font-size:8px; white-space:nowrap; }.selection-count { color:var(--muted); font-size:8px; text-align:center; white-space:nowrap; }
.trendline-row { display:grid; grid-template-columns:14px minmax(0,1fr); gap:4px; padding:2px 4px; border:1px solid transparent; border-bottom-color:var(--line); }.trendline-row.selected { border-color:var(--accent); }.trendline-row.hidden-drawing .drawing-content { opacity:.48; }.trendline-row.hidden-drawing .drawing-secondary>span::before { content:'HIDDEN · '; }.trendline-row>.line-select { align-self:center; width:12px; height:12px; margin:0; accent-color:var(--accent); }.trendline-row>.drawing-icon { align-self:center; }
.drawing-content { min-width:0; display:grid; gap:1px; }.drawing-primary,.drawing-secondary { display:flex; align-items:center; min-width:0; }.drawing-primary { gap:3px; }.drawing-primary>input { flex:1; min-width:0; height:20px; padding:1px 3px; border:0; border-bottom:1px solid var(--line); background:transparent; color:var(--fg); font:inherit; font-size:10px; }.drawing-secondary { justify-content:space-between; gap:5px; min-height:19px; }.drawing-secondary>span { overflow:hidden; color:var(--muted); font-size:8px; letter-spacing:.25px; text-transform:uppercase; white-space:nowrap; text-overflow:ellipsis; }
.drawing-state { position:relative; display:grid; place-items:center; flex:none; width:20px; height:20px; color:var(--accent); cursor:pointer; }.drawing-state.off { color:var(--muted); }.drawing-state input { position:absolute; opacity:0; pointer-events:none; }.drawing-delete,.collapse-toggle { display:grid; place-items:center; flex:none; width:20px; height:20px; padding:0; border:0; background:transparent; color:var(--muted); font-size:9px; cursor:pointer; }.drawing-delete:hover { color:var(--red); }
.drawing-controls { display:flex; align-items:center; gap:3px; flex:none; }.drawing-controls select { width:31px; height:19px; padding:0 2px; border:1px solid var(--line); border-radius:3px; background:var(--panel); color:var(--fg); font:inherit; font-size:8px; }.color-picker { position:relative; height:19px; }.color-picker>summary { width:19px; height:19px; border:1px solid var(--line); border-radius:3px; cursor:pointer; list-style:none; }.color-picker>summary::-webkit-details-marker { display:none; }.color-picker:not([open])>.color-popover { display:none; }.color-popover { position:absolute; right:0; bottom:24px; z-index:20; display:grid; grid-template-columns:repeat(4,18px); gap:3px; width:95px; padding:6px; border:1px solid var(--line); border-radius:5px; background:var(--panel); box-shadow:0 5px 18px color-mix(in srgb,var(--fg) 18%,transparent); }.color-popover>button,.custom-color { width:18px; height:18px; padding:0; border:1px solid color-mix(in srgb,var(--fg) 20%,transparent); border-radius:2px; cursor:pointer; }.color-popover>button.selected { outline:2px solid var(--fg); outline-offset:1px; }.color-popover>.palette-close { grid-column:4; background:var(--chart-bg); color:var(--muted); }.color-popover>.palette-close:hover { color:var(--fg); }.custom-color { position:relative; display:grid; place-items:center; background:var(--chart-bg); color:var(--muted); font-size:9px; }.custom-color input { position:absolute; inset:0; width:100%; height:100%; opacity:0; cursor:pointer; }
@ -50,6 +51,7 @@ aside { padding:16px; }h2 { margin:0 0 12px; color:var(--muted); font-size:11px;
.tool-body input[type=color] { padding:2px; height:26px; }
.symbol-palette { display:grid; grid-template-columns:repeat(7,1fr); gap:3px; }.symbol-palette button { display:grid; place-items:center; min-width:0; height:30px; padding:0; font-size:14px; }.symbol-palette button.active { color:var(--bg); }
.tool-body .check { display:flex; align-items:center; gap:5px; padding-bottom:5px; }
.tool-config { margin-top:8px; padding:7px 9px; border:1px solid var(--line); border-radius:7px; }.tool-config>summary { margin:0; color:var(--muted); font-size:10px; text-transform:uppercase; cursor:pointer; }.tool-config label { display:flex; align-items:center; gap:6px; margin-top:8px; color:var(--muted); font-size:10px; }.tool-config input { accent-color:var(--accent); }
.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; }

View file

@ -179,6 +179,33 @@ test('the status row shows the exact local time data last arrived',
});
});
test('the quote, current-price marker, and tool order retain their trading context',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const state = await page.evaluate(() => ({
quote: document.querySelector('.quote-change').textContent.trim(),
tools: [...document.querySelectorAll('.tools-section > .tool-config, .tools-section > .tool')]
.map(node => node.matches('.tool-config')
? node.querySelector('summary').textContent.trim()
: node.querySelector('.tool-head').textContent.trim().replace(/\s+/g, ' ')),
candleOptions: window.__chart.candles.options(),
markerOptions: window.__chart.currentPriceLine?.options(),
pulseExists: Boolean(document.querySelector('.current-price-pulse')),
configOpen: document.querySelector('.tool-config').open,
}));
assert.match(state.quote, /^[+-]?\d+\.\d{2} \([+-]?\d+\.\d{2}%\)$/);
assert.deepEqual(state.tools, ['Config', 'Mark', 'Price level', 'Trendline', 'Comment']);
assert.equal(state.candleOptions.lastValueVisible, false);
assert.equal(state.candleOptions.priceLineVisible, false);
assert.equal(state.markerOptions.lineWidth, 3);
assert.equal(state.markerOptions.axisLabelVisible, true);
assert.equal(state.pulseExists, true);
assert.equal(state.configOpen, false);
assertNoPageErrors(page, assert);
});
});
test('a daily crosshair shows the session date, not the previous evening',
{ timeout: 180000 }, async () => {
await withChart(async page => {

View file

@ -54,7 +54,7 @@ test('a pinned comment keeps its bar across timeframes', { timeout: 300000 }, as
assert.ok(anchor, 'the comment was not created');
await page.fill('input[aria-label="Filter drawings by text"]', TEXT);
await page.locator('.trendline-row').click();
await page.locator('.trendline-row .line-select').check();
await page.keyboard.press('ArrowUp');
await page.keyboard.press('Shift+ArrowRight');
await page.waitForFunction(([text, original]) => {
@ -88,8 +88,11 @@ test('clicking a comment collapses it', { timeout: 180000 }, async () => {
assert.deepEqual(controls, ['collapse-toggle', 'color'],
'the comment color picker is not the rightmost control');
await row.locator('.drawing-secondary>span').click();
assert.equal(await page.locator(`.chart-comment[title="${TEXT}"]`).evaluate(node => node.classList.contains('focus')), false,
'clicking drawing-list content selected the comment');
await row.locator('.line-select').check();
assert.equal(await page.locator(`.chart-comment[title="${TEXT}"]`).evaluate(node => node.classList.contains('focus')), true,
'selecting the comment row did not point out the comment on the chart');
'selecting the comment checkbox did not point out the comment on the chart');
await page.keyboard.press('Escape');
assert.equal(await row.evaluate(node => node.classList.contains('selected')), false,
'Escape did not clear comment selection');
@ -115,7 +118,7 @@ test('a floating comment holds its position while the chart scrolls', { timeout:
const before = await commentNode(page);
assert.ok(before, 'the floating comment was not created');
await page.fill('input[aria-label="Filter drawings by text"]', TEXT);
await page.locator('.trendline-row').click();
await page.locator('.trendline-row .line-select').check();
await page.keyboard.press('ArrowRight');
await page.waitForFunction(([text, left]) => {
const node = [...document.querySelectorAll('.chart-comment')].find(value => value.title === text);
@ -151,7 +154,7 @@ test('a symbol can be dropped at a price and dragged to a new one', {
}, async () => {
await withChart(async page => {
const box = await chartBox(page);
await armTool(page, 'Symbol');
await armTool(page, 'Mark');
assert.equal(await page.locator('.symbol-palette>button').count(), 12,
'the two-row symbol palette is incomplete');
assert.equal(await page.locator('.symbol-palette>button[title="Go"] .fa-play').count(), 1,

View file

@ -59,6 +59,9 @@ test('editing a drawing name with Backspace or Delete cannot delete the drawing'
const checkbox = row.locator('.line-select');
if (await checkbox.isChecked()) await checkbox.click();
await row.locator('.drawing-secondary>span').click();
assert.equal(await page.evaluate(() => window.__chart.selectedLineId), null,
'clicking drawing-list content selected the trendline');
await checkbox.check();
await page.waitForFunction(id => window.__chart.selectedLineId === id, created);
assert.equal(await page.locator('.chart-line-focus').isVisible(), true,
'selecting the drawing did not point out its trendline on the chart');
@ -160,6 +163,9 @@ test('a price level can be edited from the list and adjusted on the chart',
'an unselected price level exposed its full name on the price axis');
await row.locator('.drawing-secondary>span').click();
assert.equal(await page.evaluate(() => window.__chart.selectedLineId), null,
'clicking drawing-list content selected the drawing');
await row.locator('.line-select').check();
await page.waitForFunction(levelId => window.__chart.selectedLineId === levelId, id);
assert.equal(await page.evaluate(levelId =>
window.__chart.priceLines.get(levelId).line.options().title, id), label,
@ -241,3 +247,49 @@ test('Delete removes a drawing selected through its checkbox',
assertNoPageErrors(page, assert);
});
});
test('filtered selections can hide and show drawings without deleting them',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const label = `bulk visibility ${Date.now()}`;
const created = await page.evaluate(async name => {
const chart = window.__chart;
const start = chart.bars.at(-30);
const end = chart.bars.at(-10);
const response = await fetch('/api/lines', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tf: '1m', side: 'support',
anchor_t: start.t, anchor_p: start.l,
end_t: end.t, end_p: end.l,
note: name,
}),
});
return response.json();
}, label);
await page.waitForFunction(id => window.__chart.levels.some(level => level.id === id), created.id);
const filter = page.locator('input[aria-label="Filter drawings by text"]');
await filter.fill('1m');
assert.equal(await page.locator(`input[aria-label="Drawing name"][value="${label}"]`).count(), 1,
'the drawing filter did not match the timeframe');
await filter.fill(label);
const row = page.locator('.trendline-row');
await page.getByRole('button', { name: 'Select shown' }).click();
assert.equal(await page.locator('.selection-count').textContent(), '1 selected');
await page.getByRole('button', { name: 'Hide selected drawings' }).click();
await page.waitForFunction(id => window.__chart.levels.find(level => level.id === id)?.hidden, created.id);
assert.equal(await row.evaluate(node => node.classList.contains('hidden-drawing')), true);
assert.equal(await page.evaluate(id => window.__chart.levelSeries.has(id), created.id), false,
'the hidden drawing remained on the chart');
await page.getByRole('button', { name: 'Show selected drawings' }).click();
await page.waitForFunction(id => !window.__chart.levels.find(level => level.id === id)?.hidden, created.id);
assert.equal(await row.evaluate(node => node.classList.contains('hidden-drawing')), false);
assert.equal(await page.evaluate(id => window.__chart.levelSeries.has(id), created.id), true,
'the shown drawing did not return to the chart');
assertNoPageErrors(page, assert);
});
});

View file

@ -21,7 +21,7 @@ const TOKEN_KEY = 'chart-token';
* Chromium reports `en-US@posix`, Intl throws inside the charting library, and
* every test sees a blank canvas that looks exactly like a broken app.
*/
export async function launch() {
export async function launch(options = {}) {
const browser = await chromium.launch({
args: ['--lang=en-US'],
env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8' },
@ -30,6 +30,7 @@ export async function launch() {
viewport: { width: 1600, height: 1000 },
locale: 'en-US',
timezoneId: 'America/Chicago',
...options,
});
const errors = [];
page.on('pageerror', error => errors.push(String(error.message)));
@ -58,8 +59,8 @@ export async function openChart(page) {
* outcome. The dev stack shares one drawing store with whoever is using the
* app, so a test that leaves debris leaves it in someone's sidebar.
*/
export async function withChart(body) {
const { browser, page } = await launch();
export async function withChart(body, launchOptions = {}) {
const { browser, page } = await launch(launchOptions);
const created = new Set();
const pendingTracking = new Set();
const trackCreatedDrawing = async response => {

119
tests/e2e/touch.test.mjs Normal file
View file

@ -0,0 +1,119 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { withChart, assertNoPageErrors } from './helpers.mjs';
test('touch tap selects a drawing and horizontal and vertical pinches scale only their own axes',
{ timeout: 180000 }, async () => {
await withChart(async page => {
const label = `e2e touch ${Date.now()}`;
const created = await page.evaluate(async name => {
const price = window.__chart.bars.at(-1).c;
const response = await fetch('/api/lines/price', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ price, note: name }),
});
return response.json();
}, label);
await page.waitForFunction(id =>
window.__chart.levels.some(level => level.id === id), created.id);
const cdp = await page.context().newCDPSession(page);
const geometry = await page.evaluate(id => {
const chart = window.__chart;
const level = chart.levels.find(value => value.id === id);
const plot = chart.plotCanvas().getBoundingClientRect();
return {
tap: { x: plot.left + plot.width * 0.5, y: plot.top + chart.candles.priceToCoordinate(level.anchor_p) },
horizontal: {
x1: plot.left + plot.width * 0.35, y1: plot.top + plot.height * 0.5,
x2: plot.left + plot.width * 0.65, y2: plot.top + plot.height * 0.5,
},
vertical: {
x1: plot.left + plot.width * 0.5, y1: plot.top + plot.height * 0.35,
x2: plot.left + plot.width * 0.5, y2: plot.top + plot.height * 0.65,
},
};
}, created.id);
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart', touchPoints: [{ ...geometry.tap, id: 1 }],
});
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await page.waitForFunction(id => window.__chart.selectedLineId === id, created.id);
assert.match(await page.locator('.chart-tooltip').textContent(), new RegExp(label));
const state = () => page.evaluate(() => {
const chart = window.__chart;
const range = chart.chart.timeScale().getVisibleLogicalRange();
return {
timeFrom: range.from,
timeTo: range.to,
timeSpan: range.to - range.from,
priceSpan: Math.abs(chart.candles.coordinateToPrice(80) - chart.candles.coordinateToPrice(500)),
pageScale: window.visualViewport?.scale || 1,
};
});
const pinch = async (points, axis) => {
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [
{ x: points.x1, y: points.y1, id: 1 },
{ x: points.x2, y: points.y2, id: 2 },
],
});
for (let step = 1; step <= 8; step += 1) {
const delta = step * 8;
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [
{ x: points.x1 - (axis === 'x' ? delta : 0), y: points.y1 - (axis === 'y' ? delta : 0), id: 1 },
{ x: points.x2 + (axis === 'x' ? delta : 0), y: points.y2 + (axis === 'y' ? delta : 0), id: 2 },
],
});
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await page.waitForTimeout(300);
};
const before = await state();
await pinch(geometry.horizontal, 'x');
const horizontal = await state();
assert.notEqual(Math.round(horizontal.timeSpan), Math.round(before.timeSpan),
'horizontal pinch did not change the time scale');
assert.ok(Math.abs(horizontal.priceSpan - before.priceSpan) < 0.1,
`horizontal pinch changed the price scale (${before.priceSpan} to ${horizontal.priceSpan})`);
await pinch(geometry.vertical, 'y');
const vertical = await state();
assert.ok(Math.abs(vertical.priceSpan - horizontal.priceSpan) > 0.1,
'vertical pinch did not change the price scale');
assert.ok(Math.abs(vertical.timeSpan - horizontal.timeSpan) < 0.1,
'vertical pinch changed the time scale');
assert.equal(vertical.pageScale, before.pageScale, 'pinch zoomed the browser page');
const plot = await page.evaluate(() => {
const rect = window.__chart.plotCanvas().getBoundingClientRect();
return { left: rect.left, top: rect.top, width: rect.width, height: rect.height };
});
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [{ x: plot.left + plot.width * 0.5, y: plot.top + plot.height * 0.5, id: 1 }],
});
for (let step = 1; step <= 8; step += 1) {
await cdp.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: plot.left + plot.width * 0.5 + step * 10, y: plot.top + plot.height * 0.5, id: 1 }],
});
}
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await page.waitForTimeout(300);
const panned = await state();
assert.ok(Math.abs(panned.timeSpan - vertical.timeSpan) < 0.1,
'one-finger pan changed the time zoom');
assert.ok(Math.abs(panned.timeFrom - vertical.timeFrom) > 0.5,
'one-finger horizontal drag did not pan the chart');
assertNoPageErrors(page, assert);
}, { viewport: { width: 1200, height: 800 }, hasTouch: true, isMobile: true });
});

View file

@ -145,7 +145,7 @@ test('Escape cancels a pending trendline anchor and disarms the tool',
test('Escape cancels every armed drawing tool', { timeout: 180000 }, async () => {
await withChart(async page => {
for (const name of ['Price level', 'Comment', 'Symbol']) {
for (const name of ['Price level', 'Comment', 'Mark']) {
await armTool(page, name);
assert.ok(await page.evaluate(() => window.__chart.armedTool), `${name} did not arm`);
await page.keyboard.press('Escape');

View file

@ -3,8 +3,8 @@ from fastapi.testclient import TestClient
from app.analysis.confluence import cluster_levels
from app.analysis.levels import Level, LevelKind, Side
from app.api.ws import router as ws_router
from app.bars.models import Timeframe
from app.api.ws import router as ws_router, snapshot
from app.bars.models import Bar, Timeframe
from app.config import Settings
from app.runtime import Runtime
@ -49,3 +49,13 @@ def test_websocket_layer_preferences_are_isolated_per_connection(tmp_path):
assert runtime.levels == original_levels
assert runtime.clusters == original_clusters
def test_quote_change_always_uses_the_daily_session_open(tmp_path):
runtime = Runtime(Settings(manual_lines_path=tmp_path / "manual_lines.json"))
runtime.store.put(Bar(Timeframe.D1, 1000, 6123.25, 6140, 6100, 6130, 1, False, "/ES", "test"))
runtime.store.put(Bar(Timeframe.H1, 2000, 6130, 6150, 6120, 6145, 1, False, "/ES", "test"))
message = snapshot(runtime, Timeframe.H1)
assert message["session_open"] == 6123.25