Compare commits
2 commits
f9e02fc3e4
...
a09e4f1208
| Author | SHA1 | Date | |
|---|---|---|---|
| a09e4f1208 | |||
| 087d9d9d2a |
14 changed files with 555 additions and 105 deletions
|
|
@ -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:
|
def snapshot(runtime, tf: Timeframe, prefs: dict | None = None) -> dict:
|
||||||
return {
|
return {
|
||||||
"type": "snapshot",
|
"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
|
"price": runtime.store.get(Timeframe.M1, 1)[-1].c
|
||||||
if runtime.store.get(Timeframe.M1, 1)
|
if runtime.store.get(Timeframe.M1, 1)
|
||||||
else None,
|
else None,
|
||||||
|
"session_open": session_open(runtime),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -94,6 +100,7 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||||
{
|
{
|
||||||
"type": "clusters",
|
"type": "clusters",
|
||||||
"price": runtime.price,
|
"price": runtime.price,
|
||||||
|
"session_open": session_open(runtime),
|
||||||
"clusters": [cluster.to_dict() for cluster in clusters],
|
"clusters": [cluster.to_dict() for cluster in clusters],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
@ -108,7 +115,11 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||||
break
|
break
|
||||||
if event["type"] == "bar" and event["bar"].tf is tf:
|
if event["type"] == "bar" and event["bar"].tf is tf:
|
||||||
await websocket.send_json(
|
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":
|
elif event["type"] == "levels":
|
||||||
await websocket.send_json(
|
await websocket.send_json(
|
||||||
|
|
@ -120,6 +131,7 @@ async def websocket_endpoint(websocket: WebSocket):
|
||||||
{
|
{
|
||||||
"type": "clusters",
|
"type": "clusters",
|
||||||
"price": runtime.price,
|
"price": runtime.price,
|
||||||
|
"session_open": session_open(runtime),
|
||||||
"clusters": [cluster.to_dict() for cluster in clusters],
|
"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(
|
response = await client.post(
|
||||||
f"{server.rstrip('/')}/{topic}",
|
f"{server.rstrip('/')}/{topic}",
|
||||||
content=message,
|
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()
|
response.raise_for_status()
|
||||||
|
|
|
||||||
|
|
@ -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
|
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.
|
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
|
### 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
|
Trendlines project into the whitespace right of the last candle, but the axis
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,7 @@ createApp({
|
||||||
const status = ref({ stream: 'disconnected', bars_held: {} });
|
const status = ref({ stream: 'disconnected', bars_held: {} });
|
||||||
const dataReceivedAt = ref(null);
|
const dataReceivedAt = ref(null);
|
||||||
const price = ref(null);
|
const price = ref(null);
|
||||||
|
const sessionOpen = ref(null);
|
||||||
const prefs = ref(loadPrefs());
|
const prefs = ref(loadPrefs());
|
||||||
const timeframe = ref(prefs.value.base_tf || '1m');
|
const timeframe = ref(prefs.value.base_tf || '1m');
|
||||||
const levels = ref([]);
|
const levels = ref([]);
|
||||||
|
|
@ -138,6 +139,7 @@ createApp({
|
||||||
const levelColor = ref('#e0a34a');
|
const levelColor = ref('#e0a34a');
|
||||||
const levelWidth = ref(2);
|
const levelWidth = ref(2);
|
||||||
const alertEarlyPoints = ref(null);
|
const alertEarlyPoints = ref(null);
|
||||||
|
const animateCurrentPrice = ref(localStorage.getItem('chart-animate-current-price') !== 'false');
|
||||||
const timeframes = ['1m', '5m', '15m', '30m', '1h', '1d'];
|
const timeframes = ['1m', '5m', '15m', '30m', '1h', '1d'];
|
||||||
const drawingColorRows = [
|
const drawingColorRows = [
|
||||||
{ id: 'green', colors: ['#A6D8AA', '#4DB155', '#258F33', '#006D09'] },
|
{ id: 'green', colors: ['#A6D8AA', '#4DB155', '#258F33', '#006D09'] },
|
||||||
|
|
@ -207,11 +209,13 @@ createApp({
|
||||||
hour: 'numeric', minute: '2-digit', second: '2-digit',
|
hour: 'numeric', minute: '2-digit', second: '2-digit',
|
||||||
}).format(new Date(dataReceivedAt.value));
|
}).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 manualLines = computed(() => levels.value.filter(level => level.kind === 'manual'));
|
||||||
const hasDrawingSelection = computed(() => selectedDrawings.value.length > 0 || selectedDrawing.value != null);
|
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
|
// One list for everything drawn. Lines come from the levels socket, which
|
||||||
// keeps them live; comments come from the drawings endpoint.
|
// keeps them live; comments come from the drawings endpoint.
|
||||||
|
|
@ -227,6 +231,7 @@ createApp({
|
||||||
number: line.number,
|
number: line.number,
|
||||||
kind: line.slope === 0 ? 'level' : 'trendline',
|
kind: line.slope === 0 ? 'level' : 'trendline',
|
||||||
label: line.label || '',
|
label: line.label || '',
|
||||||
|
hidden: line.hidden,
|
||||||
line,
|
line,
|
||||||
}));
|
}));
|
||||||
const notes = comments.value.map(comment => ({
|
const notes = comments.value.map(comment => ({
|
||||||
|
|
@ -234,6 +239,7 @@ createApp({
|
||||||
number: comment.number,
|
number: comment.number,
|
||||||
kind: comment.kind === 'symbol' ? 'symbol' : 'comment',
|
kind: comment.kind === 'symbol' ? 'symbol' : 'comment',
|
||||||
label: comment.note || '',
|
label: comment.note || '',
|
||||||
|
hidden: comment.hidden,
|
||||||
comment,
|
comment,
|
||||||
}));
|
}));
|
||||||
return [...lines, ...notes].sort((a, b) => a.number - b.number);
|
return [...lines, ...notes].sort((a, b) => a.number - b.number);
|
||||||
|
|
@ -246,9 +252,14 @@ createApp({
|
||||||
if (!needle) return true;
|
if (!needle) return true;
|
||||||
// Matched against what is on screen — the label or comment text, the
|
// Matched against what is on screen — the label or comment text, the
|
||||||
// kind, and the drawing number, so "comment", "cpi" and "7" all work.
|
// 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) {
|
function addEvent(message, kind = 'info', url = null, number = null) {
|
||||||
events.value = [
|
events.value = [
|
||||||
|
|
@ -286,6 +297,7 @@ createApp({
|
||||||
}
|
}
|
||||||
syncVisibleLevels();
|
syncVisibleLevels();
|
||||||
price.value = message.price;
|
price.value = message.price;
|
||||||
|
sessionOpen.value = message.session_open ?? sessionOpen.value;
|
||||||
} else if (message.type === 'bar') {
|
} else if (message.type === 'bar') {
|
||||||
// Switching timeframe races: the server answers `subscribe` with a
|
// Switching timeframe races: the server answers `subscribe` with a
|
||||||
// fresh snapshot from one coroutine while another is still draining
|
// fresh snapshot from one coroutine while another is still draining
|
||||||
|
|
@ -296,6 +308,7 @@ createApp({
|
||||||
dataReceivedAt.value = Date.now();
|
dataReceivedAt.value = Date.now();
|
||||||
chartApi.updateBar(message.bar);
|
chartApi.updateBar(message.bar);
|
||||||
price.value = message.bar.c;
|
price.value = message.bar.c;
|
||||||
|
sessionOpen.value = message.session_open ?? sessionOpen.value;
|
||||||
status.value.last_bar_t = message.bar.t;
|
status.value.last_bar_t = message.bar.t;
|
||||||
} else if (message.type === 'levels') {
|
} else if (message.type === 'levels') {
|
||||||
// A delta, not a replacement: VWAP changes every minute while the
|
// A delta, not a replacement: VWAP changes every minute while the
|
||||||
|
|
@ -308,6 +321,7 @@ createApp({
|
||||||
} else if (message.type === 'clusters') {
|
} else if (message.type === 'clusters') {
|
||||||
clusters.value = message.clusters;
|
clusters.value = message.clusters;
|
||||||
price.value = message.price;
|
price.value = message.price;
|
||||||
|
sessionOpen.value = message.session_open ?? sessionOpen.value;
|
||||||
} else if (message.type === 'alert') {
|
} else if (message.type === 'alert') {
|
||||||
// Keyed on a counter, not the timestamp: two alerts inside the same
|
// Keyed on a counter, not the timestamp: two alerts inside the same
|
||||||
// second would collide and Vue would reuse the wrong row.
|
// second would collide and Vue would reuse the wrong row.
|
||||||
|
|
@ -571,12 +585,6 @@ createApp({
|
||||||
syncVisibleLevels();
|
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) {
|
async function createTrendline(first, second) {
|
||||||
const [start, end] = [first, second].sort((a, b) => a.t - b.t);
|
const [start, end] = [first, second].sort((a, b) => a.t - b.t);
|
||||||
const side = start.snappedSide || end.snappedSide || drawSide.value;
|
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) {
|
function selectComment(comment) {
|
||||||
selectedDrawing.value = comment.id;
|
selectedDrawing.value = comment.id;
|
||||||
selectedDrawings.value = [comment.id];
|
selectedDrawings.value = [comment.id];
|
||||||
|
|
@ -724,15 +725,28 @@ createApp({
|
||||||
? selectedDrawings.value.filter(value => value !== id)
|
? selectedDrawings.value.filter(value => value !== id)
|
||||||
: [...selectedDrawings.value, id];
|
: [...selectedDrawings.value, id];
|
||||||
selectedDrawing.value = selectedDrawings.value.length === 1 ? selectedDrawings.value[0] : null;
|
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() {
|
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;
|
selectedDrawing.value = selectedDrawings.value.length === 1 ? selectedDrawings.value[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSelectedLines() {
|
async function toggleSelectedVisibility() {
|
||||||
await deleteLines([...selectedDrawings.value]);
|
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) {
|
async function setArmed(line, armed) {
|
||||||
|
|
@ -961,6 +975,11 @@ createApp({
|
||||||
localStorage.setItem('chart-color-row-labels', JSON.stringify(value));
|
localStorage.setItem('chart-color-row-labels', JSON.stringify(value));
|
||||||
}, { deep: true });
|
}, { deep: true });
|
||||||
|
|
||||||
|
watch(animateCurrentPrice, value => {
|
||||||
|
localStorage.setItem('chart-animate-current-price', String(value));
|
||||||
|
if (chartApi) chartApi.setCurrentPriceAnimation(value);
|
||||||
|
});
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
chartApi = new ConfluenceChart();
|
chartApi = new ConfluenceChart();
|
||||||
// Deliberate debug handle. Chart geometry bugs are invisible from the
|
// Deliberate debug handle. Chart geometry bugs are invisible from the
|
||||||
|
|
@ -978,6 +997,7 @@ createApp({
|
||||||
chartApi.setLineDuplicateHandler(duplicateLine);
|
chartApi.setLineDuplicateHandler(duplicateLine);
|
||||||
chartApi.setLineDeleteHandler(deleteLine);
|
chartApi.setLineDeleteHandler(deleteLine);
|
||||||
chartApi.setSnap(snap.value);
|
chartApi.setSnap(snap.value);
|
||||||
|
chartApi.setCurrentPriceAnimation(animateCurrentPrice.value);
|
||||||
chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment, onSelect: selectComment });
|
chartApi.setCommentHandlers({ onToggle: toggleComment, onMove: moveComment, onSelect: selectComment });
|
||||||
window.addEventListener('keydown', handleKeydown);
|
window.addEventListener('keydown', handleKeydown);
|
||||||
await migrateLegacyToken();
|
await migrateLegacyToken();
|
||||||
|
|
@ -993,6 +1013,6 @@ createApp({
|
||||||
window.removeEventListener('keydown', handleKeydown);
|
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');
|
}).mount('#app');
|
||||||
|
|
|
||||||
182
static/chart.js
182
static/chart.js
|
|
@ -7,6 +7,10 @@ class ConfluenceChart {
|
||||||
this.observedPlotCanvas = null;
|
this.observedPlotCanvas = null;
|
||||||
this.levelSeries = new Map();
|
this.levelSeries = new Map();
|
||||||
this.priceLines = new Map();
|
this.priceLines = new Map();
|
||||||
|
this.currentPriceLine = null;
|
||||||
|
this.currentPricePulse = null;
|
||||||
|
this.animateCurrentPrice = true;
|
||||||
|
this.lastCurrentPrice = null;
|
||||||
this.previewLine = null;
|
this.previewLine = null;
|
||||||
this.projectionLayer = null;
|
this.projectionLayer = null;
|
||||||
this.bars = [];
|
this.bars = [];
|
||||||
|
|
@ -63,6 +67,19 @@ class ConfluenceChart {
|
||||||
this.onCommentSelect = null;
|
this.onCommentSelect = null;
|
||||||
this.describeDrawingColor = null;
|
this.describeDrawingColor = null;
|
||||||
this.lastSnapDiagnostic = 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;
|
static TICK = 0.25;
|
||||||
|
|
@ -163,6 +180,8 @@ class ConfluenceChart {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
rightPriceScale: { borderVisible: false },
|
rightPriceScale: { borderVisible: false },
|
||||||
|
handleScale: { pinch: false },
|
||||||
|
handleScroll: { horzTouchDrag: true, vertTouchDrag: false },
|
||||||
// Every price-bearing series shares this scale. A second autoscaled price
|
// Every price-bearing series shares this scale. A second autoscaled price
|
||||||
// axis can place the same numeric price at a different y, which makes
|
// axis can place the same numeric price at a different y, which makes
|
||||||
// daily context look comparable to intraday price when it is not.
|
// daily context look comparable to intraday price when it is not.
|
||||||
|
|
@ -177,6 +196,9 @@ class ConfluenceChart {
|
||||||
upColor: '#20b8a6', downColor: '#ef5b3f', borderVisible: true,
|
upColor: '#20b8a6', downColor: '#ef5b3f', borderVisible: true,
|
||||||
borderUpColor: '#087f76', borderDownColor: '#b9342d',
|
borderUpColor: '#087f76', borderDownColor: '#b9342d',
|
||||||
wickUpColor: '#087f76', wickDownColor: '#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
|
// 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:
|
// 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 = document.createElement('div');
|
||||||
this.tooltip.className = 'chart-tooltip';
|
this.tooltip.className = 'chart-tooltip';
|
||||||
this.overlayLayer.appendChild(this.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');
|
const preview = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
||||||
preview.classList.add('chart-preview');
|
preview.classList.add('chart-preview');
|
||||||
preview.setAttribute('aria-hidden', 'true');
|
preview.setAttribute('aria-hidden', 'true');
|
||||||
|
|
@ -350,6 +376,7 @@ class ConfluenceChart {
|
||||||
el.addEventListener('contextmenu', this.contextListener);
|
el.addEventListener('contextmenu', this.contextListener);
|
||||||
this.clickListener = event => {
|
this.clickListener = event => {
|
||||||
this.hideContextMenu();
|
this.hideContextMenu();
|
||||||
|
if (Date.now() < this.suppressClickUntil) return;
|
||||||
// A placement gesture ends in a click too; selecting on it would pick
|
// A placement gesture ends in a click too; selecting on it would pick
|
||||||
// whatever the new line happens to overlap.
|
// whatever the new line happens to overlap.
|
||||||
if (this.armedTool) return;
|
if (this.armedTool) return;
|
||||||
|
|
@ -375,12 +402,17 @@ class ConfluenceChart {
|
||||||
this.renderComments();
|
this.renderComments();
|
||||||
this.renderContextLabels();
|
this.renderContextLabels();
|
||||||
});
|
});
|
||||||
|
this.installPinchZoom();
|
||||||
}
|
}
|
||||||
|
|
||||||
setBars(bars) {
|
setBars(bars) {
|
||||||
|
if (this.bars[0]?.tf && bars[0]?.tf && this.bars[0].tf !== bars[0].tf) {
|
||||||
|
this.manualPriceRange = null;
|
||||||
|
}
|
||||||
this.bars = bars;
|
this.bars = bars;
|
||||||
const candleData = bars.map(this.toCandle);
|
const candleData = bars.map(this.toCandle);
|
||||||
this.candles.setData(candleData);
|
this.candles.setData(candleData);
|
||||||
|
this.updateCurrentPrice(bars[bars.length - 1]?.c, false);
|
||||||
this.volume.setData(bars.map(ConfluenceChart.toVolume));
|
this.volume.setData(bars.map(ConfluenceChart.toVolume));
|
||||||
this.futureSpace.setData(this.futureTimes(bars));
|
this.futureSpace.setData(this.futureTimes(bars));
|
||||||
// Anchored by time, not by logical index. A logical index addresses the
|
// 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];
|
const last = this.bars[this.bars.length - 1];
|
||||||
if (last && bar.t < last.t) return;
|
if (last && bar.t < last.t) return;
|
||||||
this.candles.update(this.toCandle(bar));
|
this.candles.update(this.toCandle(bar));
|
||||||
|
this.updateCurrentPrice(bar.c);
|
||||||
this.volume.update(ConfluenceChart.toVolume(bar));
|
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;
|
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);
|
else this.bars.push(bar);
|
||||||
|
|
@ -451,12 +484,152 @@ class ConfluenceChart {
|
||||||
this.renderComments();
|
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 }) {
|
setCommentHandlers({ onToggle, onMove, onSelect }) {
|
||||||
this.onCommentToggle = onToggle;
|
this.onCommentToggle = onToggle;
|
||||||
this.onCommentMove = onMove;
|
this.onCommentMove = onMove;
|
||||||
this.onCommentSelect = onSelect;
|
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,
|
// Comments are DOM, not canvas: they hold arbitrary text, need to collapse,
|
||||||
// and a floating one must ignore the time scale entirely.
|
// and a floating one must ignore the time scale entirely.
|
||||||
renderComments() {
|
renderComments() {
|
||||||
|
|
@ -1516,7 +1689,7 @@ class ConfluenceChart {
|
||||||
|
|
||||||
renderAnchorHandles() {
|
renderAnchorHandles() {
|
||||||
const level = this.levels.find(
|
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) {
|
if (!level || !this.anchorHandles.length) {
|
||||||
this.anchorHandles.forEach(handle => handle.setAttribute('hidden', ''));
|
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.chartEl && this.contextListener) this.chartEl.removeEventListener('contextmenu', this.contextListener);
|
||||||
if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener);
|
if (this.anchorMoveListener) window.removeEventListener('pointermove', this.anchorMoveListener);
|
||||||
if (this.anchorUpListener) window.removeEventListener('pointerup', this.anchorUpListener);
|
if (this.anchorUpListener) window.removeEventListener('pointerup', this.anchorUpListener);
|
||||||
|
if (this.chartEl && this.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.lineMoveListener) window.removeEventListener('pointermove', this.lineMoveListener);
|
||||||
if (this.lineUpListener) window.removeEventListener('pointerup', this.lineUpListener);
|
if (this.lineUpListener) window.removeEventListener('pointerup', this.lineUpListener);
|
||||||
if (this.chartEl && this.toolDownListener) this.chartEl.removeEventListener('pointerdown', this.toolDownListener);
|
if (this.chartEl && this.toolDownListener) this.chartEl.removeEventListener('pointerdown', this.toolDownListener);
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@
|
||||||
<main>
|
<main>
|
||||||
<section class="chart-shell">
|
<section class="chart-shell">
|
||||||
<div class="chart-head">
|
<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 class="timeframes"><button v-for="tf in timeframes" :key="tf" :class="{active: timeframe === tf}" @click="selectTimeframe(tf)">{{ tf }}</button></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="chart" @dragover.prevent @drop.prevent="dropSymbol"></div>
|
<div id="chart" @dragover.prevent @drop.prevent="dropSymbol"></div>
|
||||||
|
|
@ -57,7 +57,70 @@
|
||||||
</details>
|
</details>
|
||||||
<details class="sidebar-section tools-section" open>
|
<details class="sidebar-section tools-section" open>
|
||||||
<summary>Tools</summary>
|
<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'">
|
<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-glyph"><i class="fa-solid fa-arrow-trend-up"></i></span>Trendline
|
||||||
<span class="tool-state">{{ armedTool === 'trendline' ? 'drag on chart' : '' }}</span>
|
<span class="tool-state">{{ armedTool === 'trendline' ? 'drag on chart' : '' }}</span>
|
||||||
|
|
@ -94,26 +157,7 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="tool" :class="{armed: armedTool === 'level'}">
|
<div class="tool tool-comment" :class="{armed: armedTool === 'comment'}">
|
||||||
<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'">
|
<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-glyph"><i class="fa-solid fa-note-sticky"></i></span>Comment
|
||||||
<span class="tool-state">{{ armedTool === 'comment' ? 'click on chart' : '' }}</span>
|
<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>
|
<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>
|
</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>
|
||||||
<details class="sidebar-section" open>
|
<details class="sidebar-section" open>
|
||||||
<summary>Drawings ({{ filteredDrawings.length }}<span v-if="filteredDrawings.length !== drawings.length"> of {{ drawings.length }}</span>)</summary>
|
<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">
|
<input v-model="drawingFilter" placeholder="Filter by text…" aria-label="Filter drawings by text">
|
||||||
</div>
|
</div>
|
||||||
<div class="trendline-actions" v-if="drawings.length">
|
<div class="trendline-actions" v-if="drawings.length">
|
||||||
<button @click="toggleSelectAll">{{ allManualSelected ? 'Clear' : 'Select all' }}</button>
|
<button @click="toggleSelectAll" :disabled="!filteredDrawings.length">{{ allShownSelected ? 'Clear shown' : 'Select shown' }}</button>
|
||||||
<button @click="deleteFilteredDrawings" :disabled="!filteredDrawings.length"
|
<span class="selection-count">{{ selectedDrawings.length }} selected</span>
|
||||||
:title="`Delete the ${filteredDrawings.length} drawing(s) matching this filter`">
|
<button @click="toggleSelectedVisibility" :disabled="!selectedDrawings.length"
|
||||||
Delete shown ({{ filteredDrawings.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>
|
||||||
|
<button @click="deleteSelected" :disabled="!selectedDrawings.length" aria-label="Delete selected drawings">Delete</button>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="!drawings.length" class="empty">Nothing drawn yet.</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-else-if="!filteredDrawings.length" class="empty">No drawings match this filter.</div>
|
||||||
|
|
||||||
<div v-for="item in filteredDrawings" :key="item.id" class="trendline-row"
|
<div v-for="item in filteredDrawings" :key="item.id" class="trendline-row"
|
||||||
:class="{selected: selectedDrawings.includes(item.id)}"
|
:class="{selected: selectedDrawings.includes(item.id), 'hidden-drawing': item.hidden}">
|
||||||
@click="focusDrawing(item)">
|
|
||||||
<input class="line-select" type="checkbox"
|
<input class="line-select" type="checkbox"
|
||||||
:checked="selectedDrawings.includes(item.id)" :aria-label="`Select drawing ${item.number}`"
|
:checked="selectedDrawings.includes(item.id)" :aria-label="`Select drawing ${item.number}`"
|
||||||
@click.stop @change="toggleDrawingSelection(item.id)">
|
@click.stop @change="toggleDrawingSelection(item.id)">
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
main { display:grid; grid-template-columns:minmax(0, 1fr) 300px; gap:16px; }
|
||||||
.chart-shell,aside { background:var(--panel); border:1px solid var(--line); }
|
.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); }
|
.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); }
|
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; }
|
.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); }
|
.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; }
|
#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); } }
|
@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; }
|
.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; }
|
.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; }
|
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; }
|
.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-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>.line-select { align-self:center; width:12px; height:12px; margin:0; accent-color:var(--accent); }.trendline-row>.drawing-icon { align-self:center; }
|
.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-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-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; }
|
.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; }
|
.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); }
|
.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-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; }
|
.tool-body .check input { width:auto; }
|
||||||
.price-row { grid-template-columns:1fr auto; }
|
.price-row { grid-template-columns:1fr auto; }
|
||||||
.price-row button { font-size:11px; padding:5px 12px; align-self:end; }
|
.price-row button { font-size:11px; padding:5px 12px; align-self:end; }
|
||||||
|
|
|
||||||
|
|
@ -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',
|
test('a daily crosshair shows the session date, not the previous evening',
|
||||||
{ timeout: 180000 }, async () => {
|
{ timeout: 180000 }, async () => {
|
||||||
await withChart(async page => {
|
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');
|
assert.ok(anchor, 'the comment was not created');
|
||||||
|
|
||||||
await page.fill('input[aria-label="Filter drawings by text"]', TEXT);
|
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('ArrowUp');
|
||||||
await page.keyboard.press('Shift+ArrowRight');
|
await page.keyboard.press('Shift+ArrowRight');
|
||||||
await page.waitForFunction(([text, original]) => {
|
await page.waitForFunction(([text, original]) => {
|
||||||
|
|
@ -88,8 +88,11 @@ test('clicking a comment collapses it', { timeout: 180000 }, async () => {
|
||||||
assert.deepEqual(controls, ['collapse-toggle', 'color'],
|
assert.deepEqual(controls, ['collapse-toggle', 'color'],
|
||||||
'the comment color picker is not the rightmost control');
|
'the comment color picker is not the rightmost control');
|
||||||
await row.locator('.drawing-secondary>span').click();
|
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,
|
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');
|
await page.keyboard.press('Escape');
|
||||||
assert.equal(await row.evaluate(node => node.classList.contains('selected')), false,
|
assert.equal(await row.evaluate(node => node.classList.contains('selected')), false,
|
||||||
'Escape did not clear comment selection');
|
'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);
|
const before = await commentNode(page);
|
||||||
assert.ok(before, 'the floating comment was not created');
|
assert.ok(before, 'the floating comment was not created');
|
||||||
await page.fill('input[aria-label="Filter drawings by text"]', TEXT);
|
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.keyboard.press('ArrowRight');
|
||||||
await page.waitForFunction(([text, left]) => {
|
await page.waitForFunction(([text, left]) => {
|
||||||
const node = [...document.querySelectorAll('.chart-comment')].find(value => value.title === text);
|
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 () => {
|
}, async () => {
|
||||||
await withChart(async page => {
|
await withChart(async page => {
|
||||||
const box = await chartBox(page);
|
const box = await chartBox(page);
|
||||||
await armTool(page, 'Symbol');
|
await armTool(page, 'Mark');
|
||||||
assert.equal(await page.locator('.symbol-palette>button').count(), 12,
|
assert.equal(await page.locator('.symbol-palette>button').count(), 12,
|
||||||
'the two-row symbol palette is incomplete');
|
'the two-row symbol palette is incomplete');
|
||||||
assert.equal(await page.locator('.symbol-palette>button[title="Go"] .fa-play').count(), 1,
|
assert.equal(await page.locator('.symbol-palette>button[title="Go"] .fa-play').count(), 1,
|
||||||
|
|
|
||||||
|
|
@ -59,6 +59,9 @@ test('editing a drawing name with Backspace or Delete cannot delete the drawing'
|
||||||
const checkbox = row.locator('.line-select');
|
const checkbox = row.locator('.line-select');
|
||||||
if (await checkbox.isChecked()) await checkbox.click();
|
if (await checkbox.isChecked()) await checkbox.click();
|
||||||
await row.locator('.drawing-secondary>span').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);
|
await page.waitForFunction(id => window.__chart.selectedLineId === id, created);
|
||||||
assert.equal(await page.locator('.chart-line-focus').isVisible(), true,
|
assert.equal(await page.locator('.chart-line-focus').isVisible(), true,
|
||||||
'selecting the drawing did not point out its trendline on the chart');
|
'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');
|
'an unselected price level exposed its full name on the price axis');
|
||||||
|
|
||||||
await row.locator('.drawing-secondary>span').click();
|
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);
|
await page.waitForFunction(levelId => window.__chart.selectedLineId === levelId, id);
|
||||||
assert.equal(await page.evaluate(levelId =>
|
assert.equal(await page.evaluate(levelId =>
|
||||||
window.__chart.priceLines.get(levelId).line.options().title, id), label,
|
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);
|
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
|
* 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.
|
* 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({
|
const browser = await chromium.launch({
|
||||||
args: ['--lang=en-US'],
|
args: ['--lang=en-US'],
|
||||||
env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8' },
|
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 },
|
viewport: { width: 1600, height: 1000 },
|
||||||
locale: 'en-US',
|
locale: 'en-US',
|
||||||
timezoneId: 'America/Chicago',
|
timezoneId: 'America/Chicago',
|
||||||
|
...options,
|
||||||
});
|
});
|
||||||
const errors = [];
|
const errors = [];
|
||||||
page.on('pageerror', error => errors.push(String(error.message)));
|
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
|
* 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.
|
* app, so a test that leaves debris leaves it in someone's sidebar.
|
||||||
*/
|
*/
|
||||||
export async function withChart(body) {
|
export async function withChart(body, launchOptions = {}) {
|
||||||
const { browser, page } = await launch();
|
const { browser, page } = await launch(launchOptions);
|
||||||
const created = new Set();
|
const created = new Set();
|
||||||
const pendingTracking = new Set();
|
const pendingTracking = new Set();
|
||||||
const trackCreatedDrawing = async response => {
|
const trackCreatedDrawing = async response => {
|
||||||
|
|
|
||||||
119
tests/e2e/touch.test.mjs
Normal file
119
tests/e2e/touch.test.mjs
Normal 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 });
|
||||||
|
});
|
||||||
|
|
@ -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 () => {
|
test('Escape cancels every armed drawing tool', { timeout: 180000 }, async () => {
|
||||||
await withChart(async page => {
|
await withChart(async page => {
|
||||||
for (const name of ['Price level', 'Comment', 'Symbol']) {
|
for (const name of ['Price level', 'Comment', 'Mark']) {
|
||||||
await armTool(page, name);
|
await armTool(page, name);
|
||||||
assert.ok(await page.evaluate(() => window.__chart.armedTool), `${name} did not arm`);
|
assert.ok(await page.evaluate(() => window.__chart.armedTool), `${name} did not arm`);
|
||||||
await page.keyboard.press('Escape');
|
await page.keyboard.press('Escape');
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,8 @@ from fastapi.testclient import TestClient
|
||||||
|
|
||||||
from app.analysis.confluence import cluster_levels
|
from app.analysis.confluence import cluster_levels
|
||||||
from app.analysis.levels import Level, LevelKind, Side
|
from app.analysis.levels import Level, LevelKind, Side
|
||||||
from app.api.ws import router as ws_router
|
from app.api.ws import router as ws_router, snapshot
|
||||||
from app.bars.models import Timeframe
|
from app.bars.models import Bar, Timeframe
|
||||||
from app.config import Settings
|
from app.config import Settings
|
||||||
from app.runtime import Runtime
|
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.levels == original_levels
|
||||||
assert runtime.clusters == original_clusters
|
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