Compare commits
No commits in common. "a09e4f12089162f8d158658e2d0bc7601ba866a1" and "f9e02fc3e45798690233c156e94a0fbcd2d2c1de" have entirely different histories.
a09e4f1208
...
f9e02fc3e4
14 changed files with 105 additions and 555 deletions
|
|
@ -46,11 +46,6 @@ 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",
|
||||
|
|
@ -61,7 +56,6 @@ 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),
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -100,7 +94,6 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||
{
|
||||
"type": "clusters",
|
||||
"price": runtime.price,
|
||||
"session_open": session_open(runtime),
|
||||
"clusters": [cluster.to_dict() for cluster in clusters],
|
||||
}
|
||||
)
|
||||
|
|
@ -115,11 +108,7 @@ 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(),
|
||||
"session_open": session_open(runtime),
|
||||
}
|
||||
{"type": "bar", "tf": tf.value, "bar": event["bar"].to_dict()}
|
||||
)
|
||||
elif event["type"] == "levels":
|
||||
await websocket.send_json(
|
||||
|
|
@ -131,7 +120,6 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||
{
|
||||
"type": "clusters",
|
||||
"price": runtime.price,
|
||||
"session_open": session_open(runtime),
|
||||
"clusters": [cluster.to_dict() for cluster in clusters],
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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": "/ESsent", "Priority": "high", "Tags": "chart_with_upwards_trend"},
|
||||
headers={"Title": "/ES confluence", "Priority": "high", "Tags": "chart_with_upwards_trend"},
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
|
|
|||
|
|
@ -858,24 +858,6 @@ 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
|
||||
|
|
|
|||
|
|
@ -106,7 +106,6 @@ 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([]);
|
||||
|
|
@ -139,7 +138,6 @@ 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'] },
|
||||
|
|
@ -209,13 +207,11 @@ 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.
|
||||
|
|
@ -231,7 +227,6 @@ createApp({
|
|||
number: line.number,
|
||||
kind: line.slope === 0 ? 'level' : 'trendline',
|
||||
label: line.label || '',
|
||||
hidden: line.hidden,
|
||||
line,
|
||||
}));
|
||||
const notes = comments.value.map(comment => ({
|
||||
|
|
@ -239,7 +234,6 @@ 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);
|
||||
|
|
@ -252,14 +246,9 @@ 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.
|
||||
const timeframe = item.line?.tf || item.comment?.tf || '';
|
||||
return `${item.label} ${item.kind} ${timeframe} #${item.number}`.toLowerCase().includes(needle);
|
||||
return `${item.label} ${item.kind} #${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 = [
|
||||
|
|
@ -297,7 +286,6 @@ 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
|
||||
|
|
@ -308,7 +296,6 @@ 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
|
||||
|
|
@ -321,7 +308,6 @@ 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.
|
||||
|
|
@ -585,6 +571,12 @@ 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;
|
||||
|
|
@ -715,6 +707,13 @@ 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];
|
||||
|
|
@ -725,28 +724,15 @@ 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() {
|
||||
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])];
|
||||
selectedDrawings.value = allManualSelected.value ? [] : drawings.value.map(drawing => drawing.id);
|
||||
selectedDrawing.value = selectedDrawings.value.length === 1 ? selectedDrawings.value[0] : null;
|
||||
}
|
||||
|
||||
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 deleteSelectedLines() {
|
||||
await deleteLines([...selectedDrawings.value]);
|
||||
}
|
||||
|
||||
async function setArmed(line, armed) {
|
||||
|
|
@ -975,11 +961,6 @@ 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
|
||||
|
|
@ -997,7 +978,6 @@ 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();
|
||||
|
|
@ -1013,6 +993,6 @@ createApp({
|
|||
window.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
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 };
|
||||
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 };
|
||||
},
|
||||
}).mount('#app');
|
||||
|
|
|
|||
182
static/chart.js
182
static/chart.js
|
|
@ -7,10 +7,6 @@ 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 = [];
|
||||
|
|
@ -67,19 +63,6 @@ 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;
|
||||
|
|
@ -180,8 +163,6 @@ 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.
|
||||
|
|
@ -196,9 +177,6 @@ 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:
|
||||
|
|
@ -256,10 +234,6 @@ 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');
|
||||
|
|
@ -376,7 +350,6 @@ 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;
|
||||
|
|
@ -402,17 +375,12 @@ 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
|
||||
|
|
@ -470,7 +438,6 @@ 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);
|
||||
|
|
@ -484,152 +451,12 @@ 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() {
|
||||
|
|
@ -1689,7 +1516,7 @@ class ConfluenceChart {
|
|||
|
||||
renderAnchorHandles() {
|
||||
const level = this.levels.find(
|
||||
value => value.id === this.selectedLineId && value.kind === 'manual' && !value.hidden,
|
||||
value => value.id === this.selectedLineId && value.kind === 'manual',
|
||||
);
|
||||
if (!level || !this.anchorHandles.length) {
|
||||
this.anchorHandles.forEach(handle => handle.setAttribute('hidden', ''));
|
||||
|
|
@ -1928,13 +1755,6 @@ 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);
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@
|
|||
<main>
|
||||
<section class="chart-shell">
|
||||
<div class="chart-head">
|
||||
<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><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 id="chart" @dragover.prevent @drop.prevent="dropSymbol"></div>
|
||||
|
|
@ -57,70 +57,7 @@
|
|||
</details>
|
||||
<details class="sidebar-section tools-section" open>
|
||||
<summary>Tools</summary>
|
||||
<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'}">
|
||||
<div class="tool" :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>
|
||||
|
|
@ -157,7 +94,26 @@
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tool tool-comment" :class="{armed: armedTool === 'comment'}">
|
||||
<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'}">
|
||||
<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>
|
||||
|
|
@ -168,6 +124,46 @@
|
|||
<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>
|
||||
|
|
@ -182,20 +178,18 @@
|
|||
<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" :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 @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>
|
||||
<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), 'hidden-drawing': item.hidden}">
|
||||
:class="{selected: selectedDrawings.includes(item.id)}"
|
||||
@click="focusDrawing(item)">
|
||||
<input class="line-select" type="checkbox"
|
||||
:checked="selectedDrawings.includes(item.id)" :aria-label="`Select drawing ${item.number}`"
|
||||
@click.stop @change="toggleDrawingSelection(item.id)">
|
||||
|
|
|
|||
|
|
@ -9,19 +9,18 @@ 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); }
|
||||
.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); }
|
||||
.symbol { font-weight:700; margin-right:14px; }.price { color:var(--accent); font-size:19px; }
|
||||
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: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; }
|
||||
.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; }
|
||||
.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; }
|
||||
|
|
@ -51,7 +50,6 @@ 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; }
|
||||
|
|
|
|||
|
|
@ -179,33 +179,6 @@ 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 => {
|
||||
|
|
|
|||
|
|
@ -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 .line-select').check();
|
||||
await page.locator('.trendline-row').click();
|
||||
await page.keyboard.press('ArrowUp');
|
||||
await page.keyboard.press('Shift+ArrowRight');
|
||||
await page.waitForFunction(([text, original]) => {
|
||||
|
|
@ -88,11 +88,8 @@ 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 checkbox did not point out the comment on the chart');
|
||||
'selecting the comment row 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');
|
||||
|
|
@ -118,7 +115,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 .line-select').check();
|
||||
await page.locator('.trendline-row').click();
|
||||
await page.keyboard.press('ArrowRight');
|
||||
await page.waitForFunction(([text, left]) => {
|
||||
const node = [...document.querySelectorAll('.chart-comment')].find(value => value.title === text);
|
||||
|
|
@ -154,7 +151,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, 'Mark');
|
||||
await armTool(page, 'Symbol');
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -59,9 +59,6 @@ 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');
|
||||
|
|
@ -163,9 +160,6 @@ 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,
|
||||
|
|
@ -247,49 +241,3 @@ 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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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(options = {}) {
|
||||
export async function launch() {
|
||||
const browser = await chromium.launch({
|
||||
args: ['--lang=en-US'],
|
||||
env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8' },
|
||||
|
|
@ -30,7 +30,6 @@ export async function launch(options = {}) {
|
|||
viewport: { width: 1600, height: 1000 },
|
||||
locale: 'en-US',
|
||||
timezoneId: 'America/Chicago',
|
||||
...options,
|
||||
});
|
||||
const errors = [];
|
||||
page.on('pageerror', error => errors.push(String(error.message)));
|
||||
|
|
@ -59,8 +58,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, launchOptions = {}) {
|
||||
const { browser, page } = await launch(launchOptions);
|
||||
export async function withChart(body) {
|
||||
const { browser, page } = await launch();
|
||||
const created = new Set();
|
||||
const pendingTracking = new Set();
|
||||
const trackCreatedDrawing = async response => {
|
||||
|
|
|
|||
|
|
@ -1,119 +0,0 @@
|
|||
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 });
|
||||
});
|
||||
|
|
@ -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', 'Mark']) {
|
||||
for (const name of ['Price level', 'Comment', 'Symbol']) {
|
||||
await armTool(page, name);
|
||||
assert.ok(await page.evaluate(() => window.__chart.armedTool), `${name} did not arm`);
|
||||
await page.keyboard.press('Escape');
|
||||
|
|
|
|||
|
|
@ -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, snapshot
|
||||
from app.bars.models import Bar, Timeframe
|
||||
from app.api.ws import router as ws_router
|
||||
from app.bars.models import Timeframe
|
||||
from app.config import Settings
|
||||
from app.runtime import Runtime
|
||||
|
||||
|
|
@ -49,13 +49,3 @@ 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue